feat: sharepoint for storing files #1
Vendored
+2
-3
@@ -13,7 +13,7 @@
|
|||||||
"pipeCwd": "${workspaceFolder}",
|
"pipeCwd": "${workspaceFolder}",
|
||||||
"quoteArgs": false
|
"quoteArgs": false
|
||||||
},
|
},
|
||||||
"preLaunchTask": "docker-compose up (backend only)",
|
"preLaunchTask": "docker-compose up (debug backend only)",
|
||||||
"sourceFileMap": {
|
"sourceFileMap": {
|
||||||
"/src/src/ParagonPlayground.Api": "${workspaceFolder}/src/ParagonPlayground/backend/src/ParagonPlayground.Api",
|
"/src/src/ParagonPlayground.Api": "${workspaceFolder}/src/ParagonPlayground/backend/src/ParagonPlayground.Api",
|
||||||
"/src/src/ParagonPlayground.Domain": "${workspaceFolder}/src/ParagonPlayground/backend/src/ParagonPlayground.Domain",
|
"/src/src/ParagonPlayground.Domain": "${workspaceFolder}/src/ParagonPlayground/backend/src/ParagonPlayground.Domain",
|
||||||
@@ -39,8 +39,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Attach to All",
|
"name": "Attach to All",
|
||||||
"type": "compound",
|
"preLaunchTask": "docker-compose up (debug)",
|
||||||
"preLaunchTask": "docker-compose up",
|
|
||||||
"configurations": ["Attach to Backend", "Attach to Frontend"]
|
"configurations": ["Attach to Backend", "Attach to Frontend"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
Vendored
+4
-1
@@ -1,3 +1,6 @@
|
|||||||
{
|
{
|
||||||
"FSharp.suggestGitignore": false
|
"FSharp.suggestGitignore": false,
|
||||||
|
"cSpell.words": [
|
||||||
|
"useparagon"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
Vendored
+22
@@ -45,6 +45,28 @@
|
|||||||
"group": "none",
|
"group": "none",
|
||||||
"detail": "Restart all services"
|
"detail": "Restart all services"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"label": "docker-compose up (debug)",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "docker compose -f docker-compose.debug.yml up -d --build",
|
||||||
|
"options": {
|
||||||
|
"cwd": "${workspaceFolder}/src/ParagonPlayground"
|
||||||
|
},
|
||||||
|
"problemMatcher": [],
|
||||||
|
"group": "none",
|
||||||
|
"detail": "Build and start all services using the debug stage (compiled binary, debugger-ready)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "docker-compose up (debug backend only)",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "docker compose -f docker-compose.debug.yml up -d --build mongodb backend nginx",
|
||||||
|
"options": {
|
||||||
|
"cwd": "${workspaceFolder}/src/ParagonPlayground"
|
||||||
|
},
|
||||||
|
"problemMatcher": [],
|
||||||
|
"group": "none",
|
||||||
|
"detail": "Backend only in debug mode (no frontend container)"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"label": "build backend",
|
"label": "build backend",
|
||||||
"type": "shell",
|
"type": "shell",
|
||||||
|
|||||||
@@ -1,22 +1,8 @@
|
|||||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
# === Development stage ===
|
||||||
|
# Hot reload via dotnet watch with source volume mount
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS development
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
COPY ParagonPlayground.slnx ./
|
|
||||||
COPY Directory.Build.props ./
|
|
||||||
COPY src/Directory.Build.props src/
|
|
||||||
COPY src/Directory.Packages.props src/
|
|
||||||
COPY src/ParagonPlayground.Domain/ParagonPlayground.Domain.csproj src/ParagonPlayground.Domain/
|
|
||||||
COPY src/ParagonPlayground.Infrastructure/ParagonPlayground.Infrastructure.csproj src/ParagonPlayground.Infrastructure/
|
|
||||||
COPY src/ParagonPlayground.Api/ParagonPlayground.Api.csproj src/ParagonPlayground.Api/
|
|
||||||
RUN dotnet restore src/ParagonPlayground.Api/ParagonPlayground.Api.csproj
|
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN dotnet publish src/ParagonPlayground.Api/ParagonPlayground.Api.csproj -c Debug -o /app
|
|
||||||
|
|
||||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
|
|
||||||
WORKDIR /app
|
|
||||||
EXPOSE 8080
|
|
||||||
COPY --from=build /app .
|
|
||||||
|
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
apt-get install -y unzip curl && \
|
apt-get install -y unzip curl && \
|
||||||
@@ -24,4 +10,30 @@ RUN apt-get update && \
|
|||||||
apt-get clean && \
|
apt-get clean && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
ENV ASPNETCORE_URLS=http://+:8080
|
||||||
|
ENV ASPNETCORE_ENVIRONMENT=Development
|
||||||
|
CMD ["dotnet", "watch", "run", "--project", "src/ParagonPlayground.Api/ParagonPlayground.Api.csproj", "--no-launch-profile"]
|
||||||
|
|
||||||
|
# === Build stage ===
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY . .
|
||||||
|
RUN dotnet publish src/ParagonPlayground.Api/ParagonPlayground.Api.csproj -c Debug -o /app
|
||||||
|
|
||||||
|
# === Debug stage ===
|
||||||
|
# Compiled binary with vsdbg for debugger attachment
|
||||||
|
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS debug
|
||||||
|
WORKDIR /app
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y unzip curl && \
|
||||||
|
curl -sSL https://aka.ms/getvsdbgsh | /bin/sh /dev/stdin -v latest -l /vsdbg && \
|
||||||
|
apt-get clean && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=build /app .
|
||||||
|
ENV ASPNETCORE_URLS=http://+:8080
|
||||||
|
ENV ASPNETCORE_ENVIRONMENT=Development
|
||||||
ENTRYPOINT ["dotnet", "ParagonPlayground.Api.dll"]
|
ENTRYPOINT ["dotnet", "ParagonPlayground.Api.dll"]
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
<PackageVersion Include="BCrypt.Net-Next" Version="4.2.0" />
|
<PackageVersion Include="BCrypt.Net-Next" Version="4.2.0" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.11" />
|
<PackageVersion Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.11" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.10" />
|
||||||
|
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.4.0" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.1.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
namespace ParagonPlayground.Api.Auth;
|
||||||
|
|
||||||
|
internal static class PolicyNames
|
||||||
|
{
|
||||||
|
internal const string AdminOnly = "AdminOnly";
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class RoleNames
|
||||||
|
{
|
||||||
|
internal const string Admin = "admin";
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class SessionAuthDefaults
|
||||||
|
{
|
||||||
|
internal const string Scheme = "Session";
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class ClaimNames
|
||||||
|
{
|
||||||
|
internal const string OrganizationId = "OrganizationId";
|
||||||
|
|
||||||
|
internal const string OrganizationName = "OrganizationName";
|
||||||
|
|
||||||
|
internal const string OrganizationSlug = "OrganizationSlug";
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class EndpointAuthorizationExtensions
|
||||||
|
{
|
||||||
|
internal static RouteHandlerBuilder RequireAdmin(this RouteHandlerBuilder builder)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(builder);
|
||||||
|
|
||||||
|
_ = builder.RequireAuthorization(PolicyNames.AdminOnly);
|
||||||
|
return builder;
|
||||||
|
}
|
||||||
|
}
|
||||||
+80
@@ -0,0 +1,80 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text.Encodings.Web;
|
||||||
|
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
using ParagonPlayground.Api.Context;
|
||||||
|
using ParagonPlayground.Api.Infrastructure;
|
||||||
|
using ParagonPlayground.Infrastructure.Data;
|
||||||
|
using ParagonPlayground.Infrastructure.Services;
|
||||||
|
|
||||||
|
namespace ParagonPlayground.Api.Auth;
|
||||||
|
|
||||||
|
internal sealed class SessionAuthenticationHandler(
|
||||||
|
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||||
|
ILoggerFactory logger,
|
||||||
|
UrlEncoder encoder,
|
||||||
|
CookieService cookieService,
|
||||||
|
SessionRepository sessionRepository,
|
||||||
|
UserRepository userRepository,
|
||||||
|
OrganizationRepository organizationRepository
|
||||||
|
) : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
|
||||||
|
{
|
||||||
|
private readonly CookieService _cookieService = cookieService;
|
||||||
|
private readonly SessionRepository _sessionRepository = sessionRepository;
|
||||||
|
private readonly UserRepository _userRepository = userRepository;
|
||||||
|
private readonly OrganizationRepository _organizationRepository = organizationRepository;
|
||||||
|
|
||||||
|
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||||
|
{
|
||||||
|
var sessionToken = _cookieService.GetSessionToken(Context);
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(sessionToken))
|
||||||
|
{
|
||||||
|
return AuthenticateResult.NoResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokenHash = TokenHelper.HashToken(sessionToken);
|
||||||
|
var session = await _sessionRepository.GetByTokenHashAsync(tokenHash, Context.RequestAborted);
|
||||||
|
|
||||||
|
if (session is null || session.ExpiresAt <= DateTime.UtcNow)
|
||||||
|
{
|
||||||
|
return AuthenticateResult.Fail("Session token is invalid or expired.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await _userRepository.GetByIdAsync(session.UserId, Context.RequestAborted);
|
||||||
|
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
return AuthenticateResult.Fail("The user associated with this session no longer exists.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var organization = await _organizationRepository.GetByIdAsync(user.OrganizationId, Context.RequestAborted);
|
||||||
|
|
||||||
|
if (organization is null)
|
||||||
|
{
|
||||||
|
return AuthenticateResult.Fail("The organization associated with this session no longer exists.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Context.SetSession(session);
|
||||||
|
Context.SetSessionToken(sessionToken);
|
||||||
|
Context.SetUser(user);
|
||||||
|
Context.SetOrganization(organization);
|
||||||
|
|
||||||
|
var identity = new ClaimsIdentity(
|
||||||
|
[
|
||||||
|
new Claim(ClaimTypes.NameIdentifier, user.Id),
|
||||||
|
new Claim(ClaimTypes.Name, user.DisplayName),
|
||||||
|
new Claim(ClaimTypes.Email, user.Email),
|
||||||
|
new Claim(ClaimTypes.Role, user.Role),
|
||||||
|
new Claim(ClaimNames.OrganizationId, organization.Id),
|
||||||
|
new Claim(ClaimNames.OrganizationName, organization.Name),
|
||||||
|
new Claim(ClaimNames.OrganizationSlug, organization.Slug),
|
||||||
|
],
|
||||||
|
Scheme.Name
|
||||||
|
);
|
||||||
|
|
||||||
|
return AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(identity), Scheme.Name));
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
-4
@@ -29,9 +29,16 @@ internal static class HttpContextExtensions
|
|||||||
context.Items[ContextKeys.User] = user;
|
context.Items[ContextKeys.User] = user;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static User? GetUser(this HttpContext context)
|
internal static User GetUser(this HttpContext context)
|
||||||
{
|
{
|
||||||
return context.Items[ContextKeys.User] as User;
|
return context.Items[ContextKeys.User] as User
|
||||||
|
?? throw new NotAuthenticatedException("The authenticated user is not available for this request.");
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static bool TryGetUser(this HttpContext context, out User? user)
|
||||||
|
{
|
||||||
|
user = context.Items[ContextKeys.User] as User;
|
||||||
|
return user is not null;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static void SetOrganization(this HttpContext context, Organization? org)
|
internal static void SetOrganization(this HttpContext context, Organization? org)
|
||||||
@@ -39,8 +46,15 @@ internal static class HttpContextExtensions
|
|||||||
context.Items[ContextKeys.Organization] = org;
|
context.Items[ContextKeys.Organization] = org;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static Organization? GetOrganization(this HttpContext context)
|
internal static Organization GetOrganization(this HttpContext context)
|
||||||
{
|
{
|
||||||
return context.Items[ContextKeys.Organization] as Organization;
|
return context.Items[ContextKeys.Organization] as Organization
|
||||||
|
?? throw new NotAuthenticatedException("The authenticated organization is not available for this request.");
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static bool TryGetOrganization(this HttpContext context, out Organization? organization)
|
||||||
|
{
|
||||||
|
organization = context.Items[ContextKeys.Organization] as Organization;
|
||||||
|
return organization is not null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
namespace ParagonPlayground.Api.Context;
|
||||||
|
|
||||||
|
internal sealed class NotAuthenticatedException : Exception
|
||||||
|
{
|
||||||
|
public NotAuthenticatedException()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public NotAuthenticatedException(string message) : base(message)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public NotAuthenticatedException(string message, Exception innerException) : base(message, innerException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,8 +16,8 @@ internal static class AuthEndpoints
|
|||||||
internal static RouteGroupBuilder MapAuthEndpoints(this RouteGroupBuilder group)
|
internal static RouteGroupBuilder MapAuthEndpoints(this RouteGroupBuilder group)
|
||||||
{
|
{
|
||||||
_ = group.MapPost("/login", LoginAsync);
|
_ = group.MapPost("/login", LoginAsync);
|
||||||
_ = group.MapPost("/logout", LogoutAsync);
|
_ = group.MapPost("/logout", LogoutAsync).RequireAuthorization();
|
||||||
_ = group.MapGet("/me", Me);
|
_ = group.MapGet("/me", Me).RequireAuthorization();
|
||||||
return group;
|
return group;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ internal static class AuthEndpoints
|
|||||||
|
|
||||||
if (user is null || passwordService.Verify(request.Password, user.PasswordHash) is false)
|
if (user is null || passwordService.Verify(request.Password, user.PasswordHash) is false)
|
||||||
{
|
{
|
||||||
return Results.Json(new { error = "Invalid email or password" }, statusCode: StatusCodes.Status401Unauthorized);
|
return Results.Problem(detail: "Invalid email or password", statusCode: StatusCodes.Status401Unauthorized);
|
||||||
}
|
}
|
||||||
|
|
||||||
var org = await orgRepo.GetByIdAsync(user.OrganizationId, ct);
|
var org = await orgRepo.GetByIdAsync(user.OrganizationId, ct);
|
||||||
@@ -65,6 +65,7 @@ internal static class AuthEndpoints
|
|||||||
Id = user.Id,
|
Id = user.Id,
|
||||||
Email = user.Email,
|
Email = user.Email,
|
||||||
DisplayName = user.DisplayName,
|
DisplayName = user.DisplayName,
|
||||||
|
Role = user.Role,
|
||||||
OrganizationId = org?.Id ?? string.Empty,
|
OrganizationId = org?.Id ?? string.Empty,
|
||||||
OrganizationName = org?.Name ?? string.Empty,
|
OrganizationName = org?.Name ?? string.Empty,
|
||||||
OrganizationSlug = org?.Slug ?? string.Empty,
|
OrganizationSlug = org?.Slug ?? string.Empty,
|
||||||
@@ -88,18 +89,13 @@ internal static class AuthEndpoints
|
|||||||
cookieService.ClearSessionCookie(context);
|
cookieService.ClearSessionCookie(context);
|
||||||
cookieService.ClearXsrfCookie(context);
|
cookieService.ClearXsrfCookie(context);
|
||||||
|
|
||||||
return Results.Ok(new { message = "Logged out" });
|
return Results.NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IResult Me(HttpContext context)
|
private static IResult Me(HttpContext context)
|
||||||
{
|
{
|
||||||
var user = context.GetUser();
|
var user = context.GetUser();
|
||||||
|
|
||||||
if (user is null)
|
|
||||||
{
|
|
||||||
return Results.Json(new { error = "Not authenticated" }, statusCode: StatusCodes.Status401Unauthorized);
|
|
||||||
}
|
|
||||||
|
|
||||||
var org = context.GetOrganization();
|
var org = context.GetOrganization();
|
||||||
|
|
||||||
return Results.Ok(new UserResponse
|
return Results.Ok(new UserResponse
|
||||||
@@ -107,6 +103,7 @@ internal static class AuthEndpoints
|
|||||||
Id = user.Id,
|
Id = user.Id,
|
||||||
Email = user.Email,
|
Email = user.Email,
|
||||||
DisplayName = user.DisplayName,
|
DisplayName = user.DisplayName,
|
||||||
|
Role = user.Role,
|
||||||
OrganizationId = org?.Id ?? string.Empty,
|
OrganizationId = org?.Id ?? string.Empty,
|
||||||
OrganizationName = org?.Name ?? string.Empty,
|
OrganizationName = org?.Name ?? string.Empty,
|
||||||
OrganizationSlug = org?.Slug ?? string.Empty,
|
OrganizationSlug = org?.Slug ?? string.Empty,
|
||||||
|
|||||||
+214
@@ -0,0 +1,214 @@
|
|||||||
|
using MongoDB.Bson;
|
||||||
|
|
||||||
|
using ParagonPlayground.Api.Auth;
|
||||||
|
using ParagonPlayground.Api.Context;
|
||||||
|
using ParagonPlayground.Api.Services;
|
||||||
|
using ParagonPlayground.Domain.DTOs;
|
||||||
|
using ParagonPlayground.Domain.Entities;
|
||||||
|
using ParagonPlayground.Infrastructure.Data;
|
||||||
|
|
||||||
|
namespace ParagonPlayground.Api.Endpoints;
|
||||||
|
|
||||||
|
internal static class IntegrationEndpoints
|
||||||
|
{
|
||||||
|
internal static RouteGroupBuilder MapIntegrationEndpoints(this RouteGroupBuilder group)
|
||||||
|
{
|
||||||
|
_ = group.MapGet("/config", GetConfig);
|
||||||
|
_ = group.MapPut("/config", PutConfig).RequireAdmin();
|
||||||
|
_ = group.MapGet("/credentials", GetCredentials);
|
||||||
|
_ = group.MapGet("/credentials/org", GetOrgCredentials).RequireAdmin();
|
||||||
|
_ = group.MapPost("/credentials", PostCredential);
|
||||||
|
_ = group.MapDelete("/credentials/org", PurgeOrgCredentials).RequireAdmin();
|
||||||
|
_ = group.MapDelete("/credentials/{credentialId}", DeleteCredential);
|
||||||
|
_ = group.RequireAuthorization();
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GetConfig(
|
||||||
|
HttpContext context,
|
||||||
|
OrganizationIntegrationRepository repo,
|
||||||
|
CancellationToken ct
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var org = context.GetOrganization();
|
||||||
|
|
||||||
|
var config = await repo.GetByOrganizationIdAsync(org.Id, ct);
|
||||||
|
|
||||||
|
if (config is null)
|
||||||
|
{
|
||||||
|
return Results.Problem(detail: "Integration configuration not found", statusCode: StatusCodes.Status404NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Ok(new IntegrationConfigResponse
|
||||||
|
{
|
||||||
|
Id = config.Id,
|
||||||
|
OrganizationId = config.OrganizationId,
|
||||||
|
ConnectionMode = config.ConnectionMode,
|
||||||
|
SharePointSiteUrl = config.SharePointSiteUrl,
|
||||||
|
SharePointSiteId = config.SharePointSiteId,
|
||||||
|
SharePointFolderPath = config.SharePointFolderPath,
|
||||||
|
UpdatedAt = config.UpdatedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> PutConfig(
|
||||||
|
IntegrationConfigRequest request,
|
||||||
|
HttpContext context,
|
||||||
|
OrganizationIntegrationRepository repo,
|
||||||
|
UserCredentialRepository credRepo,
|
||||||
|
ParagonService paragon,
|
||||||
|
CancellationToken ct
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var user = context.GetUser();
|
||||||
|
var org = context.GetOrganization();
|
||||||
|
|
||||||
|
var config = await repo.GetByOrganizationIdAsync(org.Id, ct);
|
||||||
|
|
||||||
|
config ??= new OrganizationIntegration
|
||||||
|
{
|
||||||
|
Id = ObjectId.GenerateNewId().ToString(),
|
||||||
|
OrganizationId = org.Id,
|
||||||
|
};
|
||||||
|
|
||||||
|
config.ConnectionMode = request.ConnectionMode;
|
||||||
|
config.SharePointFolderPath = request.SharePointFolderPath?.Trim();
|
||||||
|
config.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(request.SharePointSiteUrl) is false)
|
||||||
|
{
|
||||||
|
config.SharePointSiteUrl = request.SharePointSiteUrl.Trim();
|
||||||
|
|
||||||
|
var credentials = await credRepo.GetByUserIdAsync(user.Id, ct);
|
||||||
|
var spCredential = credentials.FirstOrDefault(c =>
|
||||||
|
c.IntegrationType.Equals("sharepoint", StringComparison.OrdinalIgnoreCase)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (spCredential is not null && paragon.IsConfigured)
|
||||||
|
{
|
||||||
|
var jwt = paragon.GenerateToken(org.Id, spCredential.CredentialId);
|
||||||
|
|
||||||
|
config.SharePointSiteId = await paragon.ResolveSiteUrlAsync(
|
||||||
|
jwt,
|
||||||
|
spCredential.CredentialId,
|
||||||
|
config.SharePointSiteUrl,
|
||||||
|
ct
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await repo.UpsertAsync(config, ct);
|
||||||
|
|
||||||
|
return Results.Ok(new IntegrationConfigResponse
|
||||||
|
{
|
||||||
|
Id = config.Id,
|
||||||
|
OrganizationId = config.OrganizationId,
|
||||||
|
ConnectionMode = config.ConnectionMode,
|
||||||
|
SharePointSiteUrl = config.SharePointSiteUrl,
|
||||||
|
SharePointSiteId = config.SharePointSiteId,
|
||||||
|
SharePointFolderPath = config.SharePointFolderPath,
|
||||||
|
UpdatedAt = config.UpdatedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GetCredentials(
|
||||||
|
HttpContext context,
|
||||||
|
UserCredentialRepository repo,
|
||||||
|
CancellationToken ct
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var user = context.GetUser();
|
||||||
|
|
||||||
|
var credentials = await repo.GetByUserIdAsync(user.Id, ct);
|
||||||
|
|
||||||
|
return Results.Ok(credentials.Select(c => new CredentialResponse
|
||||||
|
{
|
||||||
|
Id = c.Id,
|
||||||
|
CredentialId = c.CredentialId,
|
||||||
|
IntegrationType = c.IntegrationType,
|
||||||
|
ConnectedAt = c.ConnectedAt,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GetOrgCredentials(
|
||||||
|
HttpContext context,
|
||||||
|
UserCredentialRepository repo,
|
||||||
|
CancellationToken ct
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var org = context.GetOrganization();
|
||||||
|
|
||||||
|
var credentials = await repo.GetByOrganizationIdAsync(org.Id, ct);
|
||||||
|
|
||||||
|
return Results.Ok(credentials.Select(c => new CredentialResponse
|
||||||
|
{
|
||||||
|
Id = c.Id,
|
||||||
|
CredentialId = c.CredentialId,
|
||||||
|
IntegrationType = c.IntegrationType,
|
||||||
|
ConnectedAt = c.ConnectedAt,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> PostCredential(
|
||||||
|
CredentialRequest request,
|
||||||
|
HttpContext context,
|
||||||
|
UserCredentialRepository repo,
|
||||||
|
CancellationToken ct
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var user = context.GetUser();
|
||||||
|
var org = context.GetOrganization();
|
||||||
|
|
||||||
|
var credential = new UserCredential
|
||||||
|
{
|
||||||
|
Id = ObjectId.GenerateNewId().ToString(),
|
||||||
|
UserId = user.Id,
|
||||||
|
OrganizationId = org.Id,
|
||||||
|
CredentialId = request.CredentialId,
|
||||||
|
IntegrationType = request.IntegrationType,
|
||||||
|
ConnectedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
|
||||||
|
await repo.CreateAsync(credential, ct);
|
||||||
|
|
||||||
|
return Results.Created($"/api/integration/credentials/{credential.Id}", new CredentialResponse
|
||||||
|
{
|
||||||
|
Id = credential.Id,
|
||||||
|
CredentialId = credential.CredentialId,
|
||||||
|
IntegrationType = credential.IntegrationType,
|
||||||
|
ConnectedAt = credential.ConnectedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> PurgeOrgCredentials(
|
||||||
|
HttpContext context,
|
||||||
|
UserCredentialRepository repo,
|
||||||
|
CancellationToken ct
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var org = context.GetOrganization();
|
||||||
|
|
||||||
|
_ = await repo.DeleteByOrganizationIdAsync(org.Id, ct);
|
||||||
|
|
||||||
|
return Results.NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> DeleteCredential(
|
||||||
|
string credentialId,
|
||||||
|
HttpContext context,
|
||||||
|
UserCredentialRepository repo,
|
||||||
|
CancellationToken ct
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var user = context.GetUser();
|
||||||
|
|
||||||
|
var deleted = await repo.DeleteByCredentialIdAsync(credentialId, user.Id, ct);
|
||||||
|
|
||||||
|
if (deleted is false)
|
||||||
|
{
|
||||||
|
return Results.Problem(detail: "Credential not found", statusCode: StatusCodes.Status404NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using ParagonPlayground.Api.Context;
|
||||||
|
using ParagonPlayground.Api.Services;
|
||||||
|
using ParagonPlayground.Domain.DTOs;
|
||||||
|
using ParagonPlayground.Infrastructure.Data;
|
||||||
|
|
||||||
|
namespace ParagonPlayground.Api.Endpoints;
|
||||||
|
|
||||||
|
internal static class ParagonEndpoints
|
||||||
|
{
|
||||||
|
internal static RouteGroupBuilder MapParagonEndpoints(this RouteGroupBuilder group)
|
||||||
|
{
|
||||||
|
_ = group.MapGet("/token", GenerateToken);
|
||||||
|
_ = group.RequireAuthorization();
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GenerateToken(
|
||||||
|
HttpContext context,
|
||||||
|
ParagonService paragon,
|
||||||
|
UserCredentialRepository credRepo,
|
||||||
|
CancellationToken ct
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var user = context.GetUser();
|
||||||
|
var org = context.GetOrganization();
|
||||||
|
|
||||||
|
if (paragon.IsConfigured is false)
|
||||||
|
{
|
||||||
|
return Results.Problem(
|
||||||
|
detail: "Paragon integration is not configured. Set Paragon:ProjectId and Paragon:SigningKey.",
|
||||||
|
statusCode: StatusCodes.Status400BadRequest
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
var credentials = await credRepo.GetByUserIdAsync(user.Id, ct);
|
||||||
|
var spCredential = credentials.FirstOrDefault(c => c.IntegrationType.Equals("sharepoint", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
var jwt = paragon.GenerateToken(org.Id, spCredential?.CredentialId);
|
||||||
|
|
||||||
|
return Results.Ok(new ParagonTokenResponse
|
||||||
|
{
|
||||||
|
ParagonJwt = jwt,
|
||||||
|
ProjectId = paragon.ProjectId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+329
@@ -0,0 +1,329 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
using MongoDB.Bson;
|
||||||
|
|
||||||
|
using ParagonPlayground.Api.Context;
|
||||||
|
using ParagonPlayground.Api.Services;
|
||||||
|
using ParagonPlayground.Domain.DTOs;
|
||||||
|
using ParagonPlayground.Domain.Entities;
|
||||||
|
using ParagonPlayground.Infrastructure.Data;
|
||||||
|
|
||||||
|
namespace ParagonPlayground.Api.Endpoints;
|
||||||
|
|
||||||
|
internal static class StorageEndpoints
|
||||||
|
{
|
||||||
|
internal static RouteGroupBuilder MapStorageEndpoints(this RouteGroupBuilder group)
|
||||||
|
{
|
||||||
|
_ = group.MapGet("/", ListItems);
|
||||||
|
_ = group.MapPost("/folders", CreateFolder);
|
||||||
|
_ = group.MapPost("/files", UploadFile);
|
||||||
|
_ = group.MapDelete("/{id}", DeleteItem);
|
||||||
|
_ = group.MapGet("/{id}/download", GetDownloadUrls);
|
||||||
|
_ = group.MapGet("/{id}/content", ProxyContent);
|
||||||
|
_ = group.RequireAuthorization();
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> ListItems(
|
||||||
|
string? parentId,
|
||||||
|
HttpContext context,
|
||||||
|
StorageItemRepository repo,
|
||||||
|
UserRepository userRepo,
|
||||||
|
CancellationToken ct
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var org = context.GetOrganization();
|
||||||
|
|
||||||
|
var items = await repo.GetByParentIdAsync(org.Id, parentId, ct);
|
||||||
|
var userIds = items.Select(i => i.CreatedByUserId).Distinct().ToList();
|
||||||
|
var users = new Dictionary<string, string>();
|
||||||
|
|
||||||
|
foreach (var uid in userIds)
|
||||||
|
{
|
||||||
|
var u = await userRepo.GetByIdAsync(uid, ct);
|
||||||
|
users[uid] = u?.DisplayName ?? "Unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Ok(items.Select(i => new StorageItemResponse
|
||||||
|
{
|
||||||
|
Id = i.Id,
|
||||||
|
Name = i.Name,
|
||||||
|
IsFolder = i.IsFolder,
|
||||||
|
ParentId = i.ParentId,
|
||||||
|
ContentType = i.ContentType,
|
||||||
|
FileSize = i.FileSize,
|
||||||
|
SharePointWebUrl = i.SharePointWebUrl,
|
||||||
|
CreatedByUserId = i.CreatedByUserId,
|
||||||
|
CreatedByDisplayName = users.GetValueOrDefault(i.CreatedByUserId, "Unknown"),
|
||||||
|
CreatedAt = i.CreatedAt,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> CreateFolder(
|
||||||
|
CreateFolderRequest request,
|
||||||
|
HttpContext context,
|
||||||
|
StorageItemRepository repo,
|
||||||
|
CancellationToken ct
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var user = context.GetUser();
|
||||||
|
var org = context.GetOrganization();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Name))
|
||||||
|
{
|
||||||
|
return Results.Problem(detail: "Folder name is required", statusCode: StatusCodes.Status400BadRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
var item = new StorageItem
|
||||||
|
{
|
||||||
|
Id = ObjectId.GenerateNewId().ToString(),
|
||||||
|
OrganizationId = org.Id,
|
||||||
|
Name = request.Name.Trim(),
|
||||||
|
IsFolder = true,
|
||||||
|
ParentId = request.ParentId,
|
||||||
|
CreatedByUserId = user.Id,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
|
||||||
|
await repo.CreateAsync(item, ct);
|
||||||
|
|
||||||
|
return Results.Created($"/api/storage/{item.Id}", new StorageItemResponse
|
||||||
|
{
|
||||||
|
Id = item.Id,
|
||||||
|
Name = item.Name,
|
||||||
|
IsFolder = true,
|
||||||
|
ParentId = item.ParentId,
|
||||||
|
CreatedByUserId = item.CreatedByUserId,
|
||||||
|
CreatedByDisplayName = user.DisplayName,
|
||||||
|
CreatedAt = item.CreatedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> UploadFile(
|
||||||
|
HttpContext context,
|
||||||
|
StorageItemRepository storageRepo,
|
||||||
|
UserCredentialRepository credRepo,
|
||||||
|
OrganizationIntegrationRepository configRepo,
|
||||||
|
ParagonService paragon,
|
||||||
|
UserRepository userRepo,
|
||||||
|
CancellationToken ct
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var user = context.GetUser();
|
||||||
|
var org = context.GetOrganization();
|
||||||
|
|
||||||
|
var file = context.Request.Form.Files.Count > 0 ? context.Request.Form.Files[0] : null;
|
||||||
|
|
||||||
|
if (file is null || file.Length is 0)
|
||||||
|
{
|
||||||
|
return Results.Problem(detail: "File is required", statusCode: StatusCodes.Status400BadRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
var parentId = (string?)context.Request.Form["parentId"];
|
||||||
|
|
||||||
|
var config = await configRepo.GetByOrganizationIdAsync(org.Id, ct);
|
||||||
|
|
||||||
|
if (config is null || string.IsNullOrWhiteSpace(config.SharePointSiteId))
|
||||||
|
{
|
||||||
|
return Results.Problem(
|
||||||
|
detail: "SharePoint integration is not configured. Ask an admin to set it up.",
|
||||||
|
statusCode: StatusCodes.Status400BadRequest
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
var credentials = await credRepo.GetByUserIdAsync(user.Id, ct);
|
||||||
|
var spCredential = credentials.FirstOrDefault(c =>
|
||||||
|
c.IntegrationType.Equals("sharepoint", StringComparison.OrdinalIgnoreCase)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (spCredential is null)
|
||||||
|
{
|
||||||
|
return Results.Problem(
|
||||||
|
detail: "No SharePoint credential found. Connect to SharePoint first.",
|
||||||
|
statusCode: StatusCodes.Status400BadRequest
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (paragon.IsConfigured is false)
|
||||||
|
{
|
||||||
|
return Results.Problem(
|
||||||
|
detail: "Paragon integration is not configured on the server.",
|
||||||
|
statusCode: StatusCodes.Status500InternalServerError
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
var jwt = paragon.GenerateToken(org.Id, spCredential.CredentialId);
|
||||||
|
|
||||||
|
string sharePointResponse;
|
||||||
|
|
||||||
|
await using (var stream = file.OpenReadStream())
|
||||||
|
{
|
||||||
|
sharePointResponse = await paragon.UploadFileAsync(
|
||||||
|
jwt,
|
||||||
|
spCredential.CredentialId,
|
||||||
|
config.SharePointSiteId,
|
||||||
|
config.SharePointFolderPath ?? "",
|
||||||
|
file.FileName,
|
||||||
|
stream,
|
||||||
|
file.ContentType,
|
||||||
|
ct
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
string? driveItemId = null;
|
||||||
|
string? webUrl = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(sharePointResponse);
|
||||||
|
|
||||||
|
if (doc.RootElement.TryGetProperty("output", out var output))
|
||||||
|
{
|
||||||
|
driveItemId = output.TryGetProperty("id", out var id) ? id.GetString() : null;
|
||||||
|
webUrl = output.TryGetProperty("webUrl", out var wu) ? wu.GetString() : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
// Response parsing is best-effort; store what we have
|
||||||
|
}
|
||||||
|
|
||||||
|
var storageItem = new StorageItem
|
||||||
|
{
|
||||||
|
Id = ObjectId.GenerateNewId().ToString(),
|
||||||
|
OrganizationId = org.Id,
|
||||||
|
Name = file.FileName,
|
||||||
|
IsFolder = false,
|
||||||
|
ParentId = parentId,
|
||||||
|
ContentType = file.ContentType,
|
||||||
|
FileSize = file.Length,
|
||||||
|
SharePointSiteId = config.SharePointSiteId,
|
||||||
|
SharePointDriveItemId = driveItemId,
|
||||||
|
SharePointWebUrl = webUrl,
|
||||||
|
CreatedByUserId = user.Id,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
|
||||||
|
await storageRepo.CreateAsync(storageItem, ct);
|
||||||
|
|
||||||
|
return Results.Created($"/api/storage/{storageItem.Id}", new StorageItemResponse
|
||||||
|
{
|
||||||
|
Id = storageItem.Id,
|
||||||
|
Name = storageItem.Name,
|
||||||
|
IsFolder = false,
|
||||||
|
ParentId = storageItem.ParentId,
|
||||||
|
ContentType = storageItem.ContentType,
|
||||||
|
FileSize = storageItem.FileSize,
|
||||||
|
SharePointWebUrl = storageItem.SharePointWebUrl,
|
||||||
|
CreatedByUserId = storageItem.CreatedByUserId,
|
||||||
|
CreatedByDisplayName = user.DisplayName,
|
||||||
|
CreatedAt = storageItem.CreatedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> DeleteItem(
|
||||||
|
string id,
|
||||||
|
HttpContext context,
|
||||||
|
StorageItemRepository repo,
|
||||||
|
CancellationToken ct
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var org = context.GetOrganization();
|
||||||
|
|
||||||
|
var item = await repo.GetByIdAsync(id, ct);
|
||||||
|
|
||||||
|
if (item is null || item.OrganizationId != org.Id)
|
||||||
|
{
|
||||||
|
return Results.Problem(detail: "Item not found", statusCode: StatusCodes.Status404NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
await repo.DeleteAsync(id, ct);
|
||||||
|
|
||||||
|
return Results.NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GetDownloadUrls(
|
||||||
|
string id,
|
||||||
|
HttpContext context,
|
||||||
|
StorageItemRepository repo,
|
||||||
|
CancellationToken ct
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var org = context.GetOrganization();
|
||||||
|
|
||||||
|
var item = await repo.GetByIdAsync(id, ct);
|
||||||
|
|
||||||
|
if (item is null || item.OrganizationId != org.Id || item.IsFolder)
|
||||||
|
{
|
||||||
|
return Results.Problem(detail: "Item not found", statusCode: StatusCodes.Status404NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
var baseUrl = $"{context.Request.Scheme}://{context.Request.Host}";
|
||||||
|
|
||||||
|
return Results.Ok(new DownloadResponse
|
||||||
|
{
|
||||||
|
SharePointUrl = item.SharePointWebUrl,
|
||||||
|
ProxyUrl = $"{baseUrl}/api/storage/{item.Id}/content",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> ProxyContent(
|
||||||
|
string id,
|
||||||
|
HttpContext context,
|
||||||
|
StorageItemRepository storageRepo,
|
||||||
|
UserCredentialRepository credRepo,
|
||||||
|
ParagonService paragon,
|
||||||
|
CancellationToken ct
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var user = context.GetUser();
|
||||||
|
var org = context.GetOrganization();
|
||||||
|
|
||||||
|
var item = await storageRepo.GetByIdAsync(id, ct);
|
||||||
|
|
||||||
|
if (item is null || item.OrganizationId != org.Id || item.IsFolder)
|
||||||
|
{
|
||||||
|
return Results.Problem(detail: "Item not found", statusCode: StatusCodes.Status404NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(item.SharePointDriveItemId) || string.IsNullOrEmpty(item.SharePointSiteId))
|
||||||
|
{
|
||||||
|
return Results.Problem(
|
||||||
|
detail: "No SharePoint reference available for this file.",
|
||||||
|
statusCode: StatusCodes.Status400BadRequest
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
var credentials = await credRepo.GetByUserIdAsync(user.Id, ct);
|
||||||
|
var spCredential = credentials.FirstOrDefault(c =>
|
||||||
|
c.IntegrationType.Equals("sharepoint", StringComparison.OrdinalIgnoreCase)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (spCredential is null)
|
||||||
|
{
|
||||||
|
return Results.Problem(
|
||||||
|
detail: "No SharePoint credential found.",
|
||||||
|
statusCode: StatusCodes.Status400BadRequest
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (paragon.IsConfigured is false)
|
||||||
|
{
|
||||||
|
return Results.Problem(
|
||||||
|
detail: "Paragon integration is not configured.",
|
||||||
|
statusCode: StatusCodes.Status500InternalServerError
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
var jwt = paragon.GenerateToken(org.Id, spCredential.CredentialId);
|
||||||
|
var fileStream = await paragon.DownloadFileAsync(
|
||||||
|
jwt,
|
||||||
|
spCredential.CredentialId,
|
||||||
|
item.SharePointSiteId,
|
||||||
|
item.SharePointDriveItemId,
|
||||||
|
ct
|
||||||
|
);
|
||||||
|
|
||||||
|
return Results.Stream(fileStream, item.ContentType ?? "application/octet-stream", item.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
using Microsoft.AspNetCore.Diagnostics;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
using ParagonPlayground.Api.Context;
|
||||||
|
|
||||||
|
namespace ParagonPlayground.Api.Middleware;
|
||||||
|
|
||||||
|
internal sealed class NotAuthenticatedExceptionHandler(ILogger<NotAuthenticatedExceptionHandler> logger) : IExceptionHandler
|
||||||
|
{
|
||||||
|
private static readonly Action<ILogger, string, Exception> LogUnauthorizedAccess =
|
||||||
|
LoggerMessage.Define<string>(
|
||||||
|
LogLevel.Warning,
|
||||||
|
new EventId(1, "NotAuthenticatedException"),
|
||||||
|
"Session state was accessed without an authenticated request. Path: {Path}"
|
||||||
|
);
|
||||||
|
|
||||||
|
private readonly ILogger<NotAuthenticatedExceptionHandler> _logger = logger;
|
||||||
|
|
||||||
|
public ValueTask<bool> TryHandleAsync(
|
||||||
|
HttpContext httpContext,
|
||||||
|
Exception exception,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (exception is not NotAuthenticatedException)
|
||||||
|
{
|
||||||
|
return ValueTask.FromResult(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
LogUnauthorizedAccess(_logger, httpContext.Request.Path.ToString(), exception);
|
||||||
|
|
||||||
|
httpContext.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||||
|
|
||||||
|
if (httpContext.RequestServices.GetService<IProblemDetailsService>() is { } problemDetailsService)
|
||||||
|
{
|
||||||
|
return problemDetailsService.TryWriteAsync(new ProblemDetailsContext
|
||||||
|
{
|
||||||
|
HttpContext = httpContext,
|
||||||
|
ProblemDetails = new ProblemDetails
|
||||||
|
{
|
||||||
|
Status = StatusCodes.Status401Unauthorized,
|
||||||
|
Title = "Unauthorized",
|
||||||
|
Detail = exception.Message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return ValueTask.FromResult(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
-52
@@ -1,52 +0,0 @@
|
|||||||
using ParagonPlayground.Api.Context;
|
|
||||||
using ParagonPlayground.Api.Infrastructure;
|
|
||||||
using ParagonPlayground.Infrastructure.Data;
|
|
||||||
using ParagonPlayground.Infrastructure.Services;
|
|
||||||
|
|
||||||
namespace ParagonPlayground.Api.Middleware;
|
|
||||||
|
|
||||||
internal class SessionAuthMiddleware(RequestDelegate next)
|
|
||||||
{
|
|
||||||
private readonly RequestDelegate _next = next;
|
|
||||||
|
|
||||||
public async Task InvokeAsync(
|
|
||||||
HttpContext context,
|
|
||||||
SessionRepository sessionRepo,
|
|
||||||
UserRepository userRepo,
|
|
||||||
OrganizationRepository orgRepo,
|
|
||||||
CookieService cookieService
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var ct = context.RequestAborted;
|
|
||||||
var sessionToken = cookieService.GetSessionToken(context);
|
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(sessionToken) is false)
|
|
||||||
{
|
|
||||||
var tokenHash = TokenHelper.HashToken(sessionToken);
|
|
||||||
var session = await sessionRepo.GetByTokenHashAsync(tokenHash, ct);
|
|
||||||
|
|
||||||
if (session is not null && session.ExpiresAt > DateTime.UtcNow)
|
|
||||||
{
|
|
||||||
context.SetSession(session);
|
|
||||||
context.SetSessionToken(sessionToken);
|
|
||||||
|
|
||||||
var user = await userRepo.GetByIdAsync(session.UserId, ct);
|
|
||||||
|
|
||||||
if (user is not null)
|
|
||||||
{
|
|
||||||
context.SetUser(user);
|
|
||||||
|
|
||||||
var org = await orgRepo.GetByIdAsync(user.OrganizationId, ct);
|
|
||||||
|
|
||||||
if (org is not null)
|
|
||||||
{
|
|
||||||
context.SetOrganization(org);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await _next(context);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace ParagonPlayground.Api.Options;
|
||||||
|
|
||||||
|
internal class ParagonOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "Paragon";
|
||||||
|
|
||||||
|
public string ProjectId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string SigningKey { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string ProxyBaseUrl { get; set; } = "https://proxy.useparagon.com";
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="MongoDB.Driver" />
|
<PackageReference Include="MongoDB.Driver" />
|
||||||
|
<PackageReference Include="System.IdentityModel.Tokens.Jwt" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Http.Resilience" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.AspNetCore.HttpOverrides;
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
using ParagonPlayground.Api.Auth;
|
||||||
using ParagonPlayground.Api.Endpoints;
|
using ParagonPlayground.Api.Endpoints;
|
||||||
using ParagonPlayground.Api.Middleware;
|
using ParagonPlayground.Api.Middleware;
|
||||||
using ParagonPlayground.Api.Options;
|
using ParagonPlayground.Api.Options;
|
||||||
|
using ParagonPlayground.Api.Services;
|
||||||
using ParagonPlayground.Infrastructure.Data;
|
using ParagonPlayground.Infrastructure.Data;
|
||||||
using ParagonPlayground.Infrastructure.Services;
|
using ParagonPlayground.Infrastructure.Services;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
builder.Services.Configure<MongoDbOptions>(builder.Configuration.GetSection(MongoDbOptions.SectionName));
|
builder.Services.Configure<MongoDbOptions>(builder.Configuration.GetSection(MongoDbOptions.SectionName));
|
||||||
|
|
||||||
builder.Services.AddSingleton(static sp =>
|
builder.Services.AddSingleton(static sp =>
|
||||||
{
|
{
|
||||||
var opts = sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<MongoDbOptions>>().Value;
|
var opts = sp.GetRequiredService<IOptions<MongoDbOptions>>().Value;
|
||||||
return new MongoDbContext(opts.ConnectionString, opts.DatabaseName);
|
return new MongoDbContext(opts.ConnectionString, opts.DatabaseName);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -20,6 +25,29 @@ builder.Services.AddSingleton<CookieService>();
|
|||||||
builder.Services.AddSingleton<OrganizationRepository>();
|
builder.Services.AddSingleton<OrganizationRepository>();
|
||||||
builder.Services.AddSingleton<UserRepository>();
|
builder.Services.AddSingleton<UserRepository>();
|
||||||
builder.Services.AddSingleton<SessionRepository>();
|
builder.Services.AddSingleton<SessionRepository>();
|
||||||
|
builder.Services.AddSingleton<StorageItemRepository>();
|
||||||
|
builder.Services.AddSingleton<UserCredentialRepository>();
|
||||||
|
builder.Services.AddSingleton<OrganizationIntegrationRepository>();
|
||||||
|
builder.Services.AddSingleton<ParagonService>();
|
||||||
|
|
||||||
|
builder.Services.Configure<ParagonOptions>(builder.Configuration.GetSection(ParagonOptions.SectionName));
|
||||||
|
|
||||||
|
builder.Services.AddHttpClient<ParagonApiClient>()
|
||||||
|
.AddStandardResilienceHandler();
|
||||||
|
|
||||||
|
builder.Services.AddProblemDetails();
|
||||||
|
builder.Services.AddExceptionHandler<NotAuthenticatedExceptionHandler>();
|
||||||
|
|
||||||
|
builder.Services
|
||||||
|
.AddAuthentication(SessionAuthDefaults.Scheme)
|
||||||
|
.AddScheme<AuthenticationSchemeOptions, SessionAuthenticationHandler>(
|
||||||
|
SessionAuthDefaults.Scheme,
|
||||||
|
_ => { }
|
||||||
|
);
|
||||||
|
|
||||||
|
builder.Services
|
||||||
|
.AddAuthorizationBuilder()
|
||||||
|
.AddPolicy(PolicyNames.AdminOnly, policy => policy.RequireRole(RoleNames.Admin));
|
||||||
|
|
||||||
builder.Services.Configure<ForwardedHeadersOptions>(static options =>
|
builder.Services.Configure<ForwardedHeadersOptions>(static options =>
|
||||||
{
|
{
|
||||||
@@ -29,8 +57,14 @@ builder.Services.Configure<ForwardedHeadersOptions>(static options =>
|
|||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
app.UseForwardedHeaders();
|
app.UseForwardedHeaders();
|
||||||
|
app.UseExceptionHandler();
|
||||||
|
app.UseStatusCodePages();
|
||||||
app.UseMiddleware<TenantResolutionMiddleware>();
|
app.UseMiddleware<TenantResolutionMiddleware>();
|
||||||
app.UseMiddleware<SessionAuthMiddleware>();
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
app.MapGroup("/api/auth").MapAuthEndpoints();
|
app.MapGroup("/api/auth").MapAuthEndpoints();
|
||||||
|
app.MapGroup("/api/paragon").MapParagonEndpoints();
|
||||||
|
app.MapGroup("/api/integration").MapIntegrationEndpoints();
|
||||||
|
app.MapGroup("/api/storage").MapStorageEndpoints();
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
using ParagonPlayground.Api.Options;
|
||||||
|
|
||||||
|
namespace ParagonPlayground.Api.Services;
|
||||||
|
|
||||||
|
internal sealed class ParagonApiClient
|
||||||
|
{
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly string _projectId;
|
||||||
|
|
||||||
|
public ParagonApiClient(HttpClient httpClient, IOptions<ParagonOptions> options)
|
||||||
|
{
|
||||||
|
_httpClient = httpClient;
|
||||||
|
_projectId = options.Value.ProjectId;
|
||||||
|
_httpClient.BaseAddress = new Uri(options.Value.ProxyBaseUrl.TrimEnd('/') + "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> UploadFileAsync(
|
||||||
|
string jwt,
|
||||||
|
string credentialId,
|
||||||
|
string siteId,
|
||||||
|
string folderPath,
|
||||||
|
string fileName,
|
||||||
|
Stream fileStream,
|
||||||
|
string contentType,
|
||||||
|
CancellationToken ct
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var url = $"projects/{_projectId}/sdk/proxy/sharepoint"
|
||||||
|
+ $"/sites/{siteId}/drive/root:/{folderPath.Trim('/')}/{fileName}:/content";
|
||||||
|
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
await fileStream.CopyToAsync(ms, ct).ConfigureAwait(false);
|
||||||
|
ms.Position = 0;
|
||||||
|
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Put, url)
|
||||||
|
{
|
||||||
|
Content = new StreamContent(ms),
|
||||||
|
};
|
||||||
|
|
||||||
|
_ = request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {jwt}");
|
||||||
|
_ = request.Headers.TryAddWithoutValidation("X-Paragon-Credential", credentialId);
|
||||||
|
request.Content.Headers.ContentType = new MediaTypeHeaderValue(contentType);
|
||||||
|
|
||||||
|
using var response = await _httpClient.SendAsync(request, ct).ConfigureAwait(false);
|
||||||
|
_ = response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
return await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Stream> DownloadFileAsync(
|
||||||
|
string jwt,
|
||||||
|
string credentialId,
|
||||||
|
string siteId,
|
||||||
|
string driveItemId,
|
||||||
|
CancellationToken ct
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var url = $"projects/{_projectId}/sdk/proxy/sharepoint"
|
||||||
|
+ $"/sites/{siteId}/drive/items/{driveItemId}/content";
|
||||||
|
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||||
|
|
||||||
|
_ = request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {jwt}");
|
||||||
|
_ = request.Headers.TryAddWithoutValidation("X-Paragon-Credential", credentialId);
|
||||||
|
|
||||||
|
var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
|
||||||
|
_ = response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
return await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> ResolveSiteUrlAsync(
|
||||||
|
string jwt,
|
||||||
|
string credentialId,
|
||||||
|
string siteUrl,
|
||||||
|
CancellationToken ct
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var uri = new Uri(siteUrl);
|
||||||
|
var segments = uri.AbsolutePath.TrimEnd('/')
|
||||||
|
.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
var encodedPath = segments.Length > 0
|
||||||
|
? ":/" + string.Join("/", segments.Select(Uri.EscapeDataString))
|
||||||
|
: "";
|
||||||
|
|
||||||
|
var proxyUrl = $"projects/{_projectId}/sdk/proxy/sharepoint/sites/{uri.Host}{encodedPath}";
|
||||||
|
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Get, proxyUrl);
|
||||||
|
_ = request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {jwt}");
|
||||||
|
_ = request.Headers.TryAddWithoutValidation("X-Paragon-Credential", credentialId);
|
||||||
|
|
||||||
|
using var response = await _httpClient.SendAsync(request, ct).ConfigureAwait(false);
|
||||||
|
_ = response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||||
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
|
||||||
|
var output = doc.RootElement.GetProperty("output");
|
||||||
|
var idProp = output.GetProperty("id");
|
||||||
|
return idProp.GetString() ?? "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
|
||||||
|
using ParagonPlayground.Api.Options;
|
||||||
|
|
||||||
|
namespace ParagonPlayground.Api.Services;
|
||||||
|
|
||||||
|
internal sealed class ParagonService(IOptions<ParagonOptions> options, ParagonApiClient apiClient)
|
||||||
|
{
|
||||||
|
private readonly ParagonOptions _options = options.Value;
|
||||||
|
private readonly ParagonApiClient _apiClient = apiClient;
|
||||||
|
|
||||||
|
public string ProjectId => _options.ProjectId;
|
||||||
|
|
||||||
|
public bool IsConfigured =>
|
||||||
|
string.IsNullOrEmpty(_options.ProjectId) is false
|
||||||
|
&& string.IsNullOrEmpty(_options.SigningKey) is false;
|
||||||
|
|
||||||
|
public string GenerateToken(string organizationId, string? credentialId = null)
|
||||||
|
{
|
||||||
|
if (IsConfigured is false)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Paragon is not configured. Set Paragon:ProjectId and Paragon:SigningKey.");
|
||||||
|
}
|
||||||
|
|
||||||
|
using var rsa = RSA.Create();
|
||||||
|
rsa.ImportFromPem(_options.SigningKey);
|
||||||
|
|
||||||
|
var key = new RsaSecurityKey(rsa.ExportParameters(true)) { KeyId = "paragon" };
|
||||||
|
var signingCredentials = new SigningCredentials(key, SecurityAlgorithms.RsaSha256);
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
|
var permissions = credentialId is not null
|
||||||
|
? new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["integration:sharepoint"] = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
[$"credential:{credentialId}"] = true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: (object)new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["integration:sharepoint"] = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["credential:*"] = new[] { "credential:write" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var claims = new[]
|
||||||
|
{
|
||||||
|
new Claim("sub", $"org:{organizationId}"),
|
||||||
|
new Claim("aud", $"useparagon.com/{_options.ProjectId}"),
|
||||||
|
new Claim("urn:useparagon:connect:permissions", JsonSerializer.Serialize(permissions)),
|
||||||
|
};
|
||||||
|
|
||||||
|
var token = new JwtSecurityToken(
|
||||||
|
claims: claims,
|
||||||
|
notBefore: now,
|
||||||
|
expires: now.AddHours(1),
|
||||||
|
signingCredentials: signingCredentials
|
||||||
|
);
|
||||||
|
|
||||||
|
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<string> UploadFileAsync(
|
||||||
|
string jwt,
|
||||||
|
string credentialId,
|
||||||
|
string siteId,
|
||||||
|
string folderPath,
|
||||||
|
string fileName,
|
||||||
|
Stream fileStream,
|
||||||
|
string contentType,
|
||||||
|
CancellationToken ct
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return _apiClient.UploadFileAsync(jwt, credentialId, siteId, folderPath, fileName, fileStream, contentType, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<string> ResolveSiteUrlAsync(
|
||||||
|
string jwt,
|
||||||
|
string credentialId,
|
||||||
|
string siteUrl,
|
||||||
|
CancellationToken ct
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return _apiClient.ResolveSiteUrlAsync(jwt, credentialId, siteUrl, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<Stream> DownloadFileAsync(
|
||||||
|
string jwt,
|
||||||
|
string credentialId,
|
||||||
|
string siteId,
|
||||||
|
string driveItemId,
|
||||||
|
CancellationToken ct
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return _apiClient.DownloadFileAsync(jwt, credentialId, siteId, driveItemId, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-1
@@ -14,7 +14,8 @@ namespace ParagonPlayground.Cli.Commands;
|
|||||||
internal class ProvisionUserCommand(
|
internal class ProvisionUserCommand(
|
||||||
OrganizationRepository orgRepo,
|
OrganizationRepository orgRepo,
|
||||||
UserRepository userRepo,
|
UserRepository userRepo,
|
||||||
PasswordService passwordService) : AsyncCommand<ProvisionUserCommand.Settings>
|
PasswordService passwordService
|
||||||
|
) : AsyncCommand<ProvisionUserCommand.Settings>
|
||||||
{
|
{
|
||||||
internal class Settings : CommandSettings
|
internal class Settings : CommandSettings
|
||||||
{
|
{
|
||||||
@@ -33,6 +34,10 @@ internal class ProvisionUserCommand(
|
|||||||
[Description("Organization slug")]
|
[Description("Organization slug")]
|
||||||
[CommandOption("-o|--org-slug")]
|
[CommandOption("-o|--org-slug")]
|
||||||
public required string OrgSlug { get; set; }
|
public required string OrgSlug { get; set; }
|
||||||
|
|
||||||
|
[Description("User role (admin or member)")]
|
||||||
|
[CommandOption("-r|--role")]
|
||||||
|
public string Role { get; set; } = "member";
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async Task<int> ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken)
|
protected override async Task<int> ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken)
|
||||||
@@ -60,6 +65,7 @@ internal class ProvisionUserCommand(
|
|||||||
DisplayName = settings.Name,
|
DisplayName = settings.Name,
|
||||||
PasswordHash = passwordService.Hash(settings.Password),
|
PasswordHash = passwordService.Hash(settings.Password),
|
||||||
OrganizationId = org.Id,
|
OrganizationId = org.Id,
|
||||||
|
Role = settings.Role,
|
||||||
CreatedAt = DateTime.UtcNow,
|
CreatedAt = DateTime.UtcNow,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -41,11 +41,11 @@ internal class SeedCommand(
|
|||||||
|
|
||||||
var users = new[]
|
var users = new[]
|
||||||
{
|
{
|
||||||
(Email: "alice@acme.com", Name: "Alice", Password: "password123"),
|
(Email: "alice@acme.com", Name: "Alice", Password: "password123", Role: "admin"),
|
||||||
(Email: "bob@acme.com", Name: "Bob", Password: "password123"),
|
(Email: "bob@acme.com", Name: "Bob", Password: "password123", Role: "member"),
|
||||||
};
|
};
|
||||||
|
|
||||||
foreach (var (email, name, password) in users)
|
foreach (var (email, name, password, role) in users)
|
||||||
{
|
{
|
||||||
var existing = await userRepo.GetByEmailAsync(email, cancellationToken);
|
var existing = await userRepo.GetByEmailAsync(email, cancellationToken);
|
||||||
|
|
||||||
@@ -58,6 +58,7 @@ internal class SeedCommand(
|
|||||||
DisplayName = name,
|
DisplayName = name,
|
||||||
PasswordHash = passwordService.Hash(password),
|
PasswordHash = passwordService.Hash(password),
|
||||||
OrganizationId = org.Id,
|
OrganizationId = org.Id,
|
||||||
|
Role = role,
|
||||||
CreatedAt = DateTime.UtcNow,
|
CreatedAt = DateTime.UtcNow,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -66,10 +67,19 @@ internal class SeedCommand(
|
|||||||
AnsiConsole.MarkupLine($"[green]Created user: {user.Email} ({user.DisplayName})[/]");
|
AnsiConsole.MarkupLine($"[green]Created user: {user.Email} ({user.DisplayName})[/]");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
{
|
||||||
|
if (existing.Role != role)
|
||||||
|
{
|
||||||
|
existing.Role = role;
|
||||||
|
await userRepo.ReplaceAsync(existing, cancellationToken);
|
||||||
|
AnsiConsole.MarkupLine($"[yellow]User '{email}' role updated to '{role}'[/]");
|
||||||
|
}
|
||||||
|
else
|
||||||
{
|
{
|
||||||
AnsiConsole.MarkupLine($"[yellow]User '{email}' already exists[/]");
|
AnsiConsole.MarkupLine($"[yellow]User '{email}' already exists[/]");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
AnsiConsole.MarkupLine("[bold green]Seed complete![/]");
|
AnsiConsole.MarkupLine("[bold green]Seed complete![/]");
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[*.{cs,vb}]
|
||||||
|
dotnet_diagnostic.CA1056.severity = none
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
namespace ParagonPlayground.Domain.DTOs;
|
||||||
|
|
||||||
|
/// <summary>Request to create a new virtual folder.</summary>
|
||||||
|
public class CreateFolderRequest
|
||||||
|
{
|
||||||
|
/// <summary>Folder name.</summary>
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Optional parent folder ID (null for root).</summary>
|
||||||
|
public string? ParentId { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace ParagonPlayground.Domain.DTOs;
|
||||||
|
|
||||||
|
/// <summary>Request to store a new Paragon credential mapping.</summary>
|
||||||
|
public class CredentialRequest
|
||||||
|
{
|
||||||
|
/// <summary>Paragon credential ID from the integration install flow.</summary>
|
||||||
|
public string CredentialId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Integration type (e.g. "sharepoint").</summary>
|
||||||
|
public string IntegrationType { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace ParagonPlayground.Domain.DTOs;
|
||||||
|
|
||||||
|
/// <summary>Stored Paragon credential for the current user.</summary>
|
||||||
|
public class CredentialResponse
|
||||||
|
{
|
||||||
|
/// <summary>Unique identifier.</summary>
|
||||||
|
public string Id { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Paragon credential ID.</summary>
|
||||||
|
public string CredentialId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Integration type (e.g. "sharepoint").</summary>
|
||||||
|
public string IntegrationType { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Timestamp when the credential was connected.</summary>
|
||||||
|
public DateTime ConnectedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace ParagonPlayground.Domain.DTOs;
|
||||||
|
|
||||||
|
/// <summary>Download URLs for a stored file.</summary>
|
||||||
|
public class DownloadResponse
|
||||||
|
{
|
||||||
|
/// <summary>Direct SharePoint web URL (opens in SharePoint).</summary>
|
||||||
|
public string? SharePointUrl { get; set; }
|
||||||
|
|
||||||
|
/// <summary>App-proxied download URL (streams through the backend via Paragon).</summary>
|
||||||
|
public string? ProxyUrl { get; set; }
|
||||||
|
}
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
namespace ParagonPlayground.Domain.DTOs;
|
||||||
|
|
||||||
|
/// <summary>Request to create or update the organization's integration configuration.</summary>
|
||||||
|
public class IntegrationConfigRequest
|
||||||
|
{
|
||||||
|
/// <summary>Connection mode: "default" (ISV-provided app) or "byo" (user-configured OAuth).</summary>
|
||||||
|
public string ConnectionMode { get; set; } = "default";
|
||||||
|
|
||||||
|
/// <summary>Target SharePoint site URL (e.g. https://contoso.sharepoint.com/sites/MySite).</summary>
|
||||||
|
public string? SharePointSiteUrl { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Target folder path within the SharePoint site.</summary>
|
||||||
|
public string? SharePointFolderPath { get; set; }
|
||||||
|
}
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
namespace ParagonPlayground.Domain.DTOs;
|
||||||
|
|
||||||
|
/// <summary>Organization's Paragon/SharePoint integration configuration.</summary>
|
||||||
|
public class IntegrationConfigResponse
|
||||||
|
{
|
||||||
|
/// <summary>Unique identifier.</summary>
|
||||||
|
public string Id { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Organization this config belongs to.</summary>
|
||||||
|
public string OrganizationId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Connection mode: "default" (ISV-provided app) or "byo" (user-configured OAuth).</summary>
|
||||||
|
public string ConnectionMode { get; set; } = "default";
|
||||||
|
|
||||||
|
/// <summary>Full SharePoint site URL (e.g. https://contoso.sharepoint.com/sites/MySite).</summary>
|
||||||
|
public string? SharePointSiteUrl { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Resolved SharePoint site ID (e.g. contoso.sharepoint.com,guid,guid).</summary>
|
||||||
|
public string? SharePointSiteId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Target folder path within SharePoint.</summary>
|
||||||
|
public string? SharePointFolderPath { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Timestamp of last update.</summary>
|
||||||
|
public DateTime UpdatedAt { get; set; }
|
||||||
|
}
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
namespace ParagonPlayground.Domain.DTOs;
|
||||||
|
|
||||||
|
/// <summary>Response containing a signed Paragon JWT and project ID.</summary>
|
||||||
|
public class ParagonTokenResponse
|
||||||
|
{
|
||||||
|
/// <summary>Signed JWT for authenticating with the Paragon SDK.</summary>
|
||||||
|
public string ParagonJwt { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Paragon project ID for SDK initialization.</summary>
|
||||||
|
public string ProjectId { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
namespace ParagonPlayground.Domain.DTOs;
|
||||||
|
|
||||||
|
/// <summary>File or folder returned by the storage API.</summary>
|
||||||
|
public class StorageItemResponse
|
||||||
|
{
|
||||||
|
/// <summary>Unique identifier.</summary>
|
||||||
|
public string Id { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Display name.</summary>
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>True for folders, false for files.</summary>
|
||||||
|
public bool IsFolder { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Parent folder ID (null for root items).</summary>
|
||||||
|
public string? ParentId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>MIME type (null for folders).</summary>
|
||||||
|
public string? ContentType { get; set; }
|
||||||
|
|
||||||
|
/// <summary>File size in bytes (0 for folders).</summary>
|
||||||
|
public long FileSize { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Direct SharePoint web URL (null for folders).</summary>
|
||||||
|
public string? SharePointWebUrl { get; set; }
|
||||||
|
|
||||||
|
/// <summary>User who created this item.</summary>
|
||||||
|
public string CreatedByUserId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Display name of the creator.</summary>
|
||||||
|
public string CreatedByDisplayName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Timestamp when the item was created.</summary>
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -20,4 +20,7 @@ public class UserResponse
|
|||||||
|
|
||||||
/// <summary>URL-friendly slug of the user's organization.</summary>
|
/// <summary>URL-friendly slug of the user's organization.</summary>
|
||||||
public string OrganizationSlug { get; set; } = string.Empty;
|
public string OrganizationSlug { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>User's role within the organization ("admin" or "member").</summary>
|
||||||
|
public string Role { get; set; } = "member";
|
||||||
}
|
}
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
namespace ParagonPlayground.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>Per-organization Paragon/SharePoint integration configuration.</summary>
|
||||||
|
public class OrganizationIntegration
|
||||||
|
{
|
||||||
|
/// <summary>Unique identifier (MongoDB ObjectId).</summary>
|
||||||
|
public string Id { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Organization this config belongs to.</summary>
|
||||||
|
public string OrganizationId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Connection mode: "default" (ISV-provided app) or "byo" (user-configured OAuth).</summary>
|
||||||
|
public string ConnectionMode { get; set; } = "default";
|
||||||
|
|
||||||
|
/// <summary>Full SharePoint site URL (e.g. https://contoso.sharepoint.com/sites/MySite).</summary>
|
||||||
|
public string? SharePointSiteUrl { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Resolved SharePoint site ID (e.g. contoso.sharepoint.com,guid,guid).</summary>
|
||||||
|
public string? SharePointSiteId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Target folder path within the SharePoint site.</summary>
|
||||||
|
public string? SharePointFolderPath { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Timestamp of the last configuration update.</summary>
|
||||||
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
namespace ParagonPlayground.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>Represents a file or folder in the virtual storage tree (independent of SharePoint structure).</summary>
|
||||||
|
public class StorageItem
|
||||||
|
{
|
||||||
|
/// <summary>Unique identifier (MongoDB ObjectId).</summary>
|
||||||
|
public string Id { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Organization this item belongs to.</summary>
|
||||||
|
public string OrganizationId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Display name of the file or folder.</summary>
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>True if this is a folder, false if it's a file.</summary>
|
||||||
|
public bool IsFolder { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Parent folder ID (null for root-level items).</summary>
|
||||||
|
public string? ParentId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>MIME type of the file (null for folders).</summary>
|
||||||
|
public string? ContentType { get; set; }
|
||||||
|
|
||||||
|
/// <summary>File size in bytes (0 for folders).</summary>
|
||||||
|
public long FileSize { get; set; }
|
||||||
|
|
||||||
|
/// <summary>SharePoint site ID where the file was uploaded (null for folders).</summary>
|
||||||
|
public string? SharePointSiteId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>SharePoint drive item ID (null for folders).</summary>
|
||||||
|
public string? SharePointDriveItemId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>SharePoint web URL for direct access (null for folders).</summary>
|
||||||
|
public string? SharePointWebUrl { get; set; }
|
||||||
|
|
||||||
|
/// <summary>User who created this item.</summary>
|
||||||
|
public string CreatedByUserId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Timestamp when the item was created.</summary>
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
@@ -18,6 +18,9 @@ public class User
|
|||||||
/// <summary>Identifier of the organization this user belongs to.</summary>
|
/// <summary>Identifier of the organization this user belongs to.</summary>
|
||||||
public string OrganizationId { get; set; } = string.Empty;
|
public string OrganizationId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Role within the organization: "admin" or "member".</summary>
|
||||||
|
public string Role { get; set; } = "member";
|
||||||
|
|
||||||
/// <summary>Timestamp when the user was created.</summary>
|
/// <summary>Timestamp when the user was created.</summary>
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
namespace ParagonPlayground.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>Maps an app user to their Paragon integration credential.</summary>
|
||||||
|
public class UserCredential
|
||||||
|
{
|
||||||
|
/// <summary>Unique identifier (MongoDB ObjectId).</summary>
|
||||||
|
public string Id { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>App user who owns this credential.</summary>
|
||||||
|
public string UserId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Organization the user belongs to.</summary>
|
||||||
|
public string OrganizationId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Paragon credential ID from the integration install flow.</summary>
|
||||||
|
public string CredentialId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Integration type (e.g. "sharepoint").</summary>
|
||||||
|
public string IntegrationType { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Timestamp when the credential was connected.</summary>
|
||||||
|
public DateTime ConnectedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
+12
@@ -22,6 +22,18 @@ public sealed class MongoDbContext : IDisposable
|
|||||||
public IMongoCollection<Session> Sessions =>
|
public IMongoCollection<Session> Sessions =>
|
||||||
_database.GetCollection<Session>("Sessions");
|
_database.GetCollection<Session>("Sessions");
|
||||||
|
|
||||||
|
/// <summary>Storage items collection.</summary>
|
||||||
|
public IMongoCollection<StorageItem> StorageItems =>
|
||||||
|
_database.GetCollection<StorageItem>("StorageItems");
|
||||||
|
|
||||||
|
/// <summary>User credentials collection.</summary>
|
||||||
|
public IMongoCollection<UserCredential> UserCredentials =>
|
||||||
|
_database.GetCollection<UserCredential>("UserCredentials");
|
||||||
|
|
||||||
|
/// <summary>Organization integrations collection.</summary>
|
||||||
|
public IMongoCollection<OrganizationIntegration> OrganizationIntegrations =>
|
||||||
|
_database.GetCollection<OrganizationIntegration>("OrganizationIntegrations");
|
||||||
|
|
||||||
/// <summary>Initializes a new MongoDbContext and connects to the specified database.</summary>
|
/// <summary>Initializes a new MongoDbContext and connects to the specified database.</summary>
|
||||||
public MongoDbContext(string connectionString, string databaseName)
|
public MongoDbContext(string connectionString, string databaseName)
|
||||||
{
|
{
|
||||||
|
|||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
using MongoDB.Driver;
|
||||||
|
|
||||||
|
using ParagonPlayground.Domain.Entities;
|
||||||
|
|
||||||
|
namespace ParagonPlayground.Infrastructure.Data;
|
||||||
|
|
||||||
|
/// <summary>Repository for organization integration configuration data access.</summary>
|
||||||
|
public class OrganizationIntegrationRepository(MongoDbContext context)
|
||||||
|
{
|
||||||
|
private readonly MongoDbContext _context = context;
|
||||||
|
|
||||||
|
/// <summary>Finds the integration config for an organization.</summary>
|
||||||
|
public async Task<OrganizationIntegration?> GetByOrganizationIdAsync(
|
||||||
|
string organizationId,
|
||||||
|
CancellationToken ct
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return await _context.OrganizationIntegrations
|
||||||
|
.Find(c => c.OrganizationId == organizationId)
|
||||||
|
.FirstOrDefaultAsync(ct)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Creates or replaces the integration config for an organization.</summary>
|
||||||
|
public async Task UpsertAsync(OrganizationIntegration config, CancellationToken ct)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(config);
|
||||||
|
|
||||||
|
var filter = Builders<OrganizationIntegration>.Filter
|
||||||
|
.Eq(c => c.OrganizationId, config.OrganizationId);
|
||||||
|
|
||||||
|
var options = new ReplaceOptions() { IsUpsert = true };
|
||||||
|
|
||||||
|
_ = await _context.OrganizationIntegrations
|
||||||
|
.ReplaceOneAsync(filter, config, options, ct)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
-3
@@ -12,18 +12,26 @@ public class OrganizationRepository(MongoDbContext context)
|
|||||||
/// <summary>Finds an organization by its URL slug.</summary>
|
/// <summary>Finds an organization by its URL slug.</summary>
|
||||||
public async Task<Organization?> GetBySlugAsync(string slug, CancellationToken cancellationToken)
|
public async Task<Organization?> GetBySlugAsync(string slug, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return await _context.Organizations.Find(o => o.Slug == slug).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
return await _context.Organizations
|
||||||
|
.Find(o => o.Slug == slug)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Finds an organization by its unique identifier.</summary>
|
/// <summary>Finds an organization by its unique identifier.</summary>
|
||||||
public async Task<Organization?> GetByIdAsync(string id, CancellationToken cancellationToken)
|
public async Task<Organization?> GetByIdAsync(string id, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return await _context.Organizations.Find(o => o.Id == id).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
return await _context.Organizations
|
||||||
|
.Find(o => o.Id == id)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Creates a new organization in the database.</summary>
|
/// <summary>Creates a new organization in the database.</summary>
|
||||||
public async Task CreateAsync(Organization organization, CancellationToken cancellationToken)
|
public async Task CreateAsync(Organization organization, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await _context.Organizations.InsertOneAsync(organization, cancellationToken: cancellationToken).ConfigureAwait(false);
|
await _context.Organizations
|
||||||
|
.InsertOneAsync(organization, cancellationToken: cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+13
-4
@@ -12,24 +12,33 @@ public class SessionRepository(MongoDbContext context)
|
|||||||
/// <summary>Finds a session by its token hash.</summary>
|
/// <summary>Finds a session by its token hash.</summary>
|
||||||
public async Task<Session?> GetByTokenHashAsync(string tokenHash, CancellationToken cancellationToken)
|
public async Task<Session?> GetByTokenHashAsync(string tokenHash, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return await _context.Sessions.Find(s => s.TokenHash == tokenHash).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
return await _context.Sessions
|
||||||
|
.Find(s => s.TokenHash == tokenHash)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Creates a new session in the database.</summary>
|
/// <summary>Creates a new session in the database.</summary>
|
||||||
public async Task CreateAsync(Session session, CancellationToken cancellationToken)
|
public async Task CreateAsync(Session session, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await _context.Sessions.InsertOneAsync(session, new InsertOneOptions(), cancellationToken).ConfigureAwait(false);
|
await _context.Sessions
|
||||||
|
.InsertOneAsync(session, new InsertOneOptions(), cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Deletes a session by its unique identifier.</summary>
|
/// <summary>Deletes a session by its unique identifier.</summary>
|
||||||
public async Task DeleteAsync(string id, CancellationToken cancellationToken)
|
public async Task DeleteAsync(string id, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
_ = await _context.Sessions.DeleteOneAsync(s => s.Id == id, cancellationToken).ConfigureAwait(false);
|
_ = await _context.Sessions
|
||||||
|
.DeleteOneAsync(s => s.Id == id, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Deletes all sessions for a given user.</summary>
|
/// <summary>Deletes all sessions for a given user.</summary>
|
||||||
public async Task DeleteByUserIdAsync(string userId, CancellationToken cancellationToken)
|
public async Task DeleteByUserIdAsync(string userId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
_ = await _context.Sessions.DeleteManyAsync(s => s.UserId == userId, cancellationToken).ConfigureAwait(false);
|
_ = await _context.Sessions
|
||||||
|
.DeleteManyAsync(s => s.UserId == userId, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
using MongoDB.Driver;
|
||||||
|
|
||||||
|
using ParagonPlayground.Domain.Entities;
|
||||||
|
|
||||||
|
namespace ParagonPlayground.Infrastructure.Data;
|
||||||
|
|
||||||
|
/// <summary>Repository for storage item (file/folder) data access.</summary>
|
||||||
|
public class StorageItemRepository(MongoDbContext context)
|
||||||
|
{
|
||||||
|
private readonly MongoDbContext _context = context;
|
||||||
|
|
||||||
|
/// <summary>Lists items in a folder (or root items when parentId is null).</summary>
|
||||||
|
public async Task<List<StorageItem>> GetByParentIdAsync(
|
||||||
|
string organizationId, string? parentId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var filter = Builders<StorageItem>.Filter.Eq(i => i.OrganizationId, organizationId)
|
||||||
|
& Builders<StorageItem>.Filter.Eq(i => i.ParentId, parentId);
|
||||||
|
|
||||||
|
return await _context.StorageItems.Find(filter)
|
||||||
|
.SortByDescending(i => i.IsFolder)
|
||||||
|
.ThenBy(i => i.Name)
|
||||||
|
.ToListAsync(ct)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Finds a storage item by ID.</summary>
|
||||||
|
public async Task<StorageItem?> GetByIdAsync(string id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
return await _context.StorageItems.Find(i => i.Id == id)
|
||||||
|
.FirstOrDefaultAsync(ct)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Creates a new storage item.</summary>
|
||||||
|
public async Task CreateAsync(StorageItem item, CancellationToken ct)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(item);
|
||||||
|
|
||||||
|
await _context.StorageItems
|
||||||
|
.InsertOneAsync(item, cancellationToken: ct)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Deletes a storage item by ID.</summary>
|
||||||
|
public async Task DeleteAsync(string id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
_ = await _context.StorageItems
|
||||||
|
.DeleteOneAsync(i => i.Id == id, ct)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
using MongoDB.Driver;
|
||||||
|
|
||||||
|
using ParagonPlayground.Domain.Entities;
|
||||||
|
|
||||||
|
namespace ParagonPlayground.Infrastructure.Data;
|
||||||
|
|
||||||
|
/// <summary>Repository for user credential data access.</summary>
|
||||||
|
public class UserCredentialRepository(MongoDbContext context)
|
||||||
|
{
|
||||||
|
private readonly MongoDbContext _context = context;
|
||||||
|
|
||||||
|
/// <summary>Finds all credentials for a given user.</summary>
|
||||||
|
public async Task<List<UserCredential>> GetByUserIdAsync(string userId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
return await _context.UserCredentials
|
||||||
|
.Find(c => c.UserId == userId)
|
||||||
|
.ToListAsync(ct)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Finds all credentials for a given organization.</summary>
|
||||||
|
public async Task<List<UserCredential>> GetByOrganizationIdAsync(string organizationId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
return await _context.UserCredentials
|
||||||
|
.Find(c => c.OrganizationId == organizationId)
|
||||||
|
.ToListAsync(ct)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Stores a new credential mapping.</summary>
|
||||||
|
public async Task CreateAsync(UserCredential credential, CancellationToken ct)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(credential);
|
||||||
|
|
||||||
|
await _context.UserCredentials
|
||||||
|
.InsertOneAsync(credential, cancellationToken: ct)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Deletes all credentials for a given organization.</summary>
|
||||||
|
public async Task<long> DeleteByOrganizationIdAsync(string organizationId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _context.UserCredentials
|
||||||
|
.DeleteManyAsync(c => c.OrganizationId == organizationId, ct)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return result.DeletedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Deletes a credential by its Paragon credential ID for a given user.</summary>
|
||||||
|
public async Task<bool> DeleteByCredentialIdAsync(string credentialId, string userId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var filter = Builders<UserCredential>.Filter.And(
|
||||||
|
Builders<UserCredential>.Filter.Eq(c => c.CredentialId, credentialId),
|
||||||
|
Builders<UserCredential>.Filter.Eq(c => c.UserId, userId)
|
||||||
|
);
|
||||||
|
|
||||||
|
var result = await _context.UserCredentials
|
||||||
|
.DeleteOneAsync(filter, ct)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return result.DeletedCount > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
-2
@@ -18,12 +18,27 @@ public class UserRepository(MongoDbContext context)
|
|||||||
/// <summary>Finds a user by their unique identifier.</summary>
|
/// <summary>Finds a user by their unique identifier.</summary>
|
||||||
public async Task<User?> GetByIdAsync(string id, CancellationToken cancellationToken)
|
public async Task<User?> GetByIdAsync(string id, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return await _context.Users.Find(u => u.Id == id).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
return await _context.Users
|
||||||
|
.Find(u => u.Id == id)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Creates a new user in the database.</summary>
|
/// <summary>Creates a new user in the database.</summary>
|
||||||
public async Task CreateAsync(User user, CancellationToken cancellationToken)
|
public async Task CreateAsync(User user, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await _context.Users.InsertOneAsync(user, new InsertOneOptions(), cancellationToken).ConfigureAwait(false);
|
await _context.Users
|
||||||
|
.InsertOneAsync(user, new InsertOneOptions(), cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Replaces an existing user document.</summary>
|
||||||
|
public async Task ReplaceAsync(User user, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(user);
|
||||||
|
|
||||||
|
_ = await _context.Users
|
||||||
|
.ReplaceOneAsync(u => u.Id == user.Id, user, cancellationToken: cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
services:
|
||||||
|
mongodb:
|
||||||
|
image: mongo:7
|
||||||
|
ports:
|
||||||
|
- "27017:27017"
|
||||||
|
volumes:
|
||||||
|
- mongo_data:/data/db
|
||||||
|
healthcheck:
|
||||||
|
test: echo 'db.runCommand("ping").ok' | mongosh --quiet
|
||||||
|
interval: 10s
|
||||||
|
retries: 5
|
||||||
|
start_period: 20s
|
||||||
|
backend:
|
||||||
|
container_name: paragonplayground-backend
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
target: debug
|
||||||
|
ports:
|
||||||
|
- "5000:8080"
|
||||||
|
environment:
|
||||||
|
- MongoDb__ConnectionString=mongodb://mongodb:27017
|
||||||
|
- MongoDb__DatabaseName=paragon_playground
|
||||||
|
- ASPNETCORE_ENVIRONMENT=Development
|
||||||
|
- ASPNETCORE_URLS=http://+:8080
|
||||||
|
depends_on:
|
||||||
|
mongodb:
|
||||||
|
condition: service_healthy
|
||||||
|
frontend:
|
||||||
|
container_name: paragonplayground-frontend
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
volumes:
|
||||||
|
- ./frontend/src:/app/src
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf
|
||||||
|
- ./certs:/etc/nginx/certs
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
- frontend
|
||||||
|
volumes:
|
||||||
|
mongo_data:
|
||||||
@@ -15,8 +15,11 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
target: development
|
||||||
ports:
|
ports:
|
||||||
- "5000:8080"
|
- "5000:8080"
|
||||||
|
volumes:
|
||||||
|
- ./backend:/src
|
||||||
environment:
|
environment:
|
||||||
- MongoDb__ConnectionString=mongodb://mongodb:27017
|
- MongoDb__ConnectionString=mongodb://mongodb:27017
|
||||||
- MongoDb__DatabaseName=paragon_playground
|
- MongoDb__DatabaseName=paragon_playground
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
{
|
{
|
||||||
"semi": false,
|
"semi": true,
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"trailingComma": "all",
|
"trailingComma": "all",
|
||||||
"printWidth": 100,
|
"printWidth": 100,
|
||||||
"tabWidth": 2,
|
"tabWidth": 2,
|
||||||
"arrowParens": "always"
|
"arrowParens": "always",
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"vueIndentScriptAndStyle": true,
|
||||||
|
"endOfLine": "auto",
|
||||||
|
"singleAttributePerLine": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import js from '@eslint/js'
|
import js from '@eslint/js';
|
||||||
import tseslint from 'typescript-eslint'
|
import tseslint from 'typescript-eslint';
|
||||||
import pluginVue from 'eslint-plugin-vue'
|
import pluginVue from 'eslint-plugin-vue';
|
||||||
import prettier from 'eslint-config-prettier'
|
import prettier from 'eslint-config-prettier';
|
||||||
|
import globals from 'globals';
|
||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
@@ -13,10 +14,21 @@ export default tseslint.config(
|
|||||||
parserOptions: {
|
parserOptions: {
|
||||||
parser: tseslint.parser,
|
parser: tseslint.parser,
|
||||||
},
|
},
|
||||||
|
globals: {
|
||||||
|
...globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['*.ts', '**/*.ts'],
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.browser,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
prettier,
|
prettier,
|
||||||
{
|
{
|
||||||
ignores: ['dist/', 'node_modules/'],
|
ignores: ['dist/', 'node_modules/'],
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
|
|||||||
@@ -2,11 +2,17 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1.0"
|
||||||
|
/>
|
||||||
<title>paragon-playground</title>
|
<title>paragon-playground</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script
|
||||||
|
type="module"
|
||||||
|
src="/src/main.ts"
|
||||||
|
></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+1345
-6
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@
|
|||||||
"format": "prettier --write ."
|
"format": "prettier --write ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@useparagon/connect": "^2.5.0",
|
||||||
"vue": "^3.5.39",
|
"vue": "^3.5.39",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4"
|
||||||
},
|
},
|
||||||
@@ -23,10 +24,12 @@
|
|||||||
"eslint": "^10.8.0",
|
"eslint": "^10.8.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-vue": "^10.10.0",
|
"eslint-plugin-vue": "^10.10.0",
|
||||||
|
"globals": "^17.8.0",
|
||||||
"prettier": "^3.9.6",
|
"prettier": "^3.9.6",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"typescript-eslint": "^8.65.0",
|
"typescript-eslint": "^8.65.0",
|
||||||
"vite": "^8.1.1",
|
"vite": "^8.1.1",
|
||||||
|
"vite-plugin-vue-devtools": "^8.2.1",
|
||||||
"vue-tsc": "^3.3.5"
|
"vue-tsc": "^3.3.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,111 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import { useCurrentUser } from './composables/useCurrentUser';
|
||||||
|
import { logout } from './services/auth';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const { currentUser, fetchCurrentUser, clearCurrentUser } = useCurrentUser();
|
||||||
|
|
||||||
|
const showNav = computed(() => route.meta.public !== true);
|
||||||
|
|
||||||
|
const isActive = (name: string) => route.name === name;
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!currentUser.value) {
|
||||||
|
await fetchCurrentUser();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
await logout();
|
||||||
|
clearCurrentUser();
|
||||||
|
router.push('/login');
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<div class="app-shell">
|
||||||
|
<nav
|
||||||
|
v-if="showNav"
|
||||||
|
class="app-nav"
|
||||||
|
>
|
||||||
|
<span class="app-brand">Paragon Playground</span>
|
||||||
|
<div class="nav-links">
|
||||||
|
<router-link
|
||||||
|
to="/"
|
||||||
|
:class="{ active: isActive('dashboard') }"
|
||||||
|
>
|
||||||
|
Dashboard
|
||||||
|
</router-link>
|
||||||
|
<router-link
|
||||||
|
to="/settings"
|
||||||
|
:class="{ active: isActive('settings') }"
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</router-link>
|
||||||
|
<router-link
|
||||||
|
v-if="currentUser?.role === 'admin'"
|
||||||
|
to="/integrations"
|
||||||
|
:class="{ active: isActive('integrations') }"
|
||||||
|
>
|
||||||
|
Integrations
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
@click="handleLogout"
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<router-view />
|
<router-view />
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-nav {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
height: 3.25rem;
|
||||||
|
padding: 0 1rem;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
background: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-brand {
|
||||||
|
font-weight: 650;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
color: var(--color-accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav .nav-links {
|
||||||
|
height: 100%;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav .nav-links a {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0 0.125rem;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
transition: color 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav .nav-links a:hover {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav .nav-links a.active {
|
||||||
|
color: var(--color-accent);
|
||||||
|
border-bottom-color: var(--color-accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
type IconName =
|
||||||
|
| 'folder'
|
||||||
|
| 'file'
|
||||||
|
| 'upload'
|
||||||
|
| 'download'
|
||||||
|
| 'external'
|
||||||
|
| 'trash'
|
||||||
|
| 'x'
|
||||||
|
| 'plus';
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{ name: IconName; size?: number }>(), {
|
||||||
|
size: 16,
|
||||||
|
});
|
||||||
|
|
||||||
|
const paths: Record<IconName, string[]> = {
|
||||||
|
folder: [
|
||||||
|
'M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z',
|
||||||
|
],
|
||||||
|
file: ['M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z', 'M14 2v4a2 2 0 0 0 2 2h4'],
|
||||||
|
upload: ['M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4', 'M17 8l-5-5-5 5', 'M12 3v12'],
|
||||||
|
download: ['M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4', 'M7 10l5 5 5-5', 'M12 15V3'],
|
||||||
|
external: ['M15 3h6v6', 'M10 14 21 3', 'M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6'],
|
||||||
|
trash: [
|
||||||
|
'M3 6h18',
|
||||||
|
'M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6',
|
||||||
|
'M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2',
|
||||||
|
],
|
||||||
|
x: ['M18 6 6 18', 'm6 6 12 12'],
|
||||||
|
plus: ['M5 12h14', 'M12 5v14'],
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
:width="size"
|
||||||
|
:height="size"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.75"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
v-for="(d, i) in paths[props.name]"
|
||||||
|
:key="i"
|
||||||
|
:d="d"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { ref } from 'vue';
|
||||||
|
import { me, type UserResponse } from '../services/auth';
|
||||||
|
|
||||||
|
const currentUser = ref<UserResponse | null>(null);
|
||||||
|
|
||||||
|
export function useCurrentUser() {
|
||||||
|
async function fetchCurrentUser(): Promise<UserResponse | null> {
|
||||||
|
try {
|
||||||
|
currentUser.value = await me();
|
||||||
|
} catch {
|
||||||
|
currentUser.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentUser.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCurrentUser() {
|
||||||
|
currentUser.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { currentUser, fetchCurrentUser, clearCurrentUser };
|
||||||
|
}
|
||||||
@@ -2,6 +2,9 @@ import { createApp } from 'vue';
|
|||||||
import App from './App.vue';
|
import App from './App.vue';
|
||||||
import router from './router';
|
import router from './router';
|
||||||
|
|
||||||
|
import './styles/tokens.css';
|
||||||
|
import './styles/main.css';
|
||||||
|
|
||||||
const app = createApp(App);
|
const app = createApp(App);
|
||||||
app.use(router);
|
app.use(router);
|
||||||
app.mount('#app');
|
app.mount('#app');
|
||||||
|
|||||||
@@ -1,28 +1,90 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router';
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
import LoginPage from '../views/LoginPage.vue';
|
import LoginPage from '../views/LoginPage.vue';
|
||||||
import DashboardPage from '../views/DashboardPage.vue';
|
import DashboardPage from '../views/DashboardPage.vue';
|
||||||
import { me } from '../services/auth';
|
import IntegrationsPage from '../views/IntegrationsPage.vue';
|
||||||
|
import FileExplorerPage from '../views/FileExplorerPage.vue';
|
||||||
|
import SettingsPage from '../views/SettingsPage.vue';
|
||||||
|
import ErrorPage from '../views/ErrorPage.vue';
|
||||||
|
import { useCurrentUser } from '../composables/useCurrentUser';
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
routes: [
|
routes: [
|
||||||
{ path: '/login', name: 'login', component: LoginPage },
|
{
|
||||||
|
path: '/login',
|
||||||
|
name: 'login',
|
||||||
|
component: LoginPage,
|
||||||
|
meta: { public: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
name: 'dashboard',
|
name: 'dashboard',
|
||||||
component: DashboardPage,
|
component: DashboardPage,
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/integrations',
|
||||||
|
name: 'integrations',
|
||||||
|
component: IntegrationsPage,
|
||||||
|
meta: { requiresAuth: true, requiresAdmin: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/settings',
|
||||||
|
name: 'settings',
|
||||||
|
component: SettingsPage,
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/files/:pathMatch(.*)*',
|
||||||
|
name: 'files',
|
||||||
|
component: FileExplorerPage,
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/forbidden',
|
||||||
|
name: 'forbidden',
|
||||||
|
component: ErrorPage,
|
||||||
|
props: {
|
||||||
|
status: '403',
|
||||||
|
heading: 'Forbidden',
|
||||||
|
title: "You don't have access to this area",
|
||||||
|
message:
|
||||||
|
"Your account doesn't have permission to view this page. Contact your admin if you believe this is a mistake.",
|
||||||
|
},
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/:pathMatch(.*)*',
|
||||||
|
name: 'not-found',
|
||||||
|
component: ErrorPage,
|
||||||
|
props: {
|
||||||
|
status: '404',
|
||||||
|
heading: 'Page Not Found',
|
||||||
|
title: "We couldn't find that page",
|
||||||
|
message: 'The URL may be incorrect, or the page may have been moved or removed.',
|
||||||
|
},
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
router.beforeEach(async (to) => {
|
router.beforeEach(async (to) => {
|
||||||
if (to.meta.requiresAuth) {
|
const { currentUser, fetchCurrentUser } = useCurrentUser();
|
||||||
try {
|
|
||||||
await me();
|
if (currentUser.value === null) {
|
||||||
} catch {
|
await fetchCurrentUser();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to.meta.public) {
|
||||||
|
return currentUser.value ? { name: 'dashboard' } : true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentUser.value === null) {
|
||||||
return { name: 'login' };
|
return { name: 'login' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (to.meta.requiresAdmin && currentUser.value.role !== 'admin') {
|
||||||
|
return { name: 'forbidden' };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ export async function api<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const body = await res.json().catch(() => ({ error: res.statusText }));
|
const body = await res.json().catch(() => ({}));
|
||||||
throw new Error(body.error ?? `HTTP ${res.status}`);
|
throw new Error(body.detail ?? body.title ?? res.statusText);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (res.status === 204) {
|
if (res.status === 204) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export interface UserResponse {
|
|||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
role: string;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
organizationName: string;
|
organizationName: string;
|
||||||
organizationSlug: string;
|
organizationSlug: string;
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { api } from './api';
|
||||||
|
|
||||||
|
export interface ParagonTokenResponse {
|
||||||
|
paragonJwt: string;
|
||||||
|
projectId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IntegrationConfig {
|
||||||
|
id: string;
|
||||||
|
organizationId: string;
|
||||||
|
connectionMode: string;
|
||||||
|
sharePointSiteUrl: string | null;
|
||||||
|
sharePointSiteId: string | null;
|
||||||
|
sharePointFolderPath: string | null;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IntegrationConfigRequest {
|
||||||
|
connectionMode: string;
|
||||||
|
sharePointSiteUrl: string | null;
|
||||||
|
sharePointFolderPath: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CredentialResponse {
|
||||||
|
id: string;
|
||||||
|
credentialId: string;
|
||||||
|
integrationType: string;
|
||||||
|
connectedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CredentialRequest {
|
||||||
|
credentialId: string;
|
||||||
|
integrationType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getParagonToken(): Promise<ParagonTokenResponse> {
|
||||||
|
return api<ParagonTokenResponse>('/paragon/token');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getConfig(): Promise<IntegrationConfig> {
|
||||||
|
return api<IntegrationConfig>('/integration/config');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateConfig(config: IntegrationConfigRequest): Promise<IntegrationConfig> {
|
||||||
|
return api<IntegrationConfig>('/integration/config', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(config),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCredentials(): Promise<CredentialResponse[]> {
|
||||||
|
return api<CredentialResponse[]>('/integration/credentials');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOrgCredentials(): Promise<CredentialResponse[]> {
|
||||||
|
return api<CredentialResponse[]>('/integration/credentials/org');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveCredential(req: CredentialRequest): Promise<CredentialResponse> {
|
||||||
|
return api<CredentialResponse>('/integration/credentials', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(req),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCredential(credentialId: string): Promise<void> {
|
||||||
|
await api<void>(`/integration/credentials/${encodeURIComponent(credentialId)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function purgeOrgCredentials(): Promise<void> {
|
||||||
|
await api<void>('/integration/credentials/org', { method: 'DELETE' });
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { api } from './api';
|
||||||
|
|
||||||
|
export interface StorageItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
isFolder: boolean;
|
||||||
|
parentId: string | null;
|
||||||
|
contentType: string | null;
|
||||||
|
fileSize: number;
|
||||||
|
sharePointWebUrl: string | null;
|
||||||
|
createdByUserId: string;
|
||||||
|
createdByDisplayName: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateFolderRequest {
|
||||||
|
name: string;
|
||||||
|
parentId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DownloadResponse {
|
||||||
|
sharePointUrl: string | null;
|
||||||
|
proxyUrl: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getItems(parentId?: string | null): Promise<StorageItem[]> {
|
||||||
|
const params = parentId ? `?parentId=${encodeURIComponent(parentId)}` : '';
|
||||||
|
return api<StorageItem[]>(`/storage${params}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createFolder(req: CreateFolderRequest): Promise<StorageItem> {
|
||||||
|
return api<StorageItem>('/storage/folders', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(req),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadFile(file: File, parentId?: string | null): Promise<StorageItem> {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
if (parentId) {
|
||||||
|
form.append('parentId', parentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return api<StorageItem>('/storage/files', {
|
||||||
|
method: 'POST',
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteItem(id: string): Promise<void> {
|
||||||
|
await api<void>(`/storage/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDownloadUrls(id: string): Promise<DownloadResponse> {
|
||||||
|
return api<DownloadResponse>(`/storage/${encodeURIComponent(id)}/download`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
font-size: var(--text-base);
|
||||||
|
line-height: var(--leading-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3 {
|
||||||
|
line-height: var(--leading-tight);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
button {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
padding: 0.5rem 0.625rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus,
|
||||||
|
select:focus {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.5rem 1rem 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
padding: 1.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section h2 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.5rem 0.875rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
transition:
|
||||||
|
background-color 0.12s,
|
||||||
|
border-color 0.12s,
|
||||||
|
color 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover:not(:disabled) {
|
||||||
|
background: var(--color-surface-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active:not(:disabled) {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: var(--color-on-accent);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: var(--color-accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover:not(:disabled) {
|
||||||
|
background: var(--color-accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: var(--color-danger);
|
||||||
|
color: var(--color-on-danger);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-small {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
transition:
|
||||||
|
background-color 0.12s,
|
||||||
|
color 0.12s,
|
||||||
|
border-color 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-small:hover:not(:disabled) {
|
||||||
|
background: var(--color-surface-subtle);
|
||||||
|
color: var(--color-accent);
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-small:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-small-danger {
|
||||||
|
background: var(--color-danger-soft);
|
||||||
|
color: var(--color-danger);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-small-danger:hover:not(:disabled) {
|
||||||
|
background: var(--color-danger);
|
||||||
|
color: var(--color-on-danger);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
border-left: 3px solid var(--color-border);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert.error {
|
||||||
|
background: var(--color-danger-soft);
|
||||||
|
color: var(--color-danger);
|
||||||
|
border-left-color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert.success {
|
||||||
|
background: var(--color-success-soft);
|
||||||
|
color: var(--color-success);
|
||||||
|
border-left-color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert.warning {
|
||||||
|
background: var(--color-warning-soft);
|
||||||
|
color: var(--color-warning);
|
||||||
|
border-left-color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
padding: 3rem 1rem;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
padding: 3rem 1rem;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-text {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-muted {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
:root {
|
||||||
|
--color-bg: #ffffff;
|
||||||
|
--color-surface: #ffffff;
|
||||||
|
--color-surface-subtle: #fafafa;
|
||||||
|
|
||||||
|
--color-text: #1b1c1f;
|
||||||
|
--color-text-secondary: #5b5d63;
|
||||||
|
--color-text-muted: #90939b;
|
||||||
|
|
||||||
|
--color-border: #e2e3e7;
|
||||||
|
|
||||||
|
--color-accent: #b39cd0;
|
||||||
|
--color-accent-strong: #a088c0;
|
||||||
|
--color-accent-soft: rgba(179, 156, 208, 0.15);
|
||||||
|
--color-on-accent: #1b1c1f;
|
||||||
|
|
||||||
|
--color-danger: #d47373;
|
||||||
|
--color-danger-soft: rgba(212, 115, 115, 0.15);
|
||||||
|
--color-on-danger: #1b1c1f;
|
||||||
|
--color-success: #7ec8c0;
|
||||||
|
--color-success-soft: rgba(126, 200, 192, 0.15);
|
||||||
|
--color-warning: #cc6b2c;
|
||||||
|
--color-warning-soft: rgba(204, 107, 44, 0.15);
|
||||||
|
|
||||||
|
--radius: 0.375rem;
|
||||||
|
|
||||||
|
--text-xs: 0.75rem;
|
||||||
|
--text-sm: 0.875rem;
|
||||||
|
--text-base: 1rem;
|
||||||
|
--text-lg: 1.125rem;
|
||||||
|
--text-xl: 1.375rem;
|
||||||
|
|
||||||
|
--leading-tight: 1.3;
|
||||||
|
--leading-normal: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--color-bg: #161718;
|
||||||
|
--color-surface: #1c1d1f;
|
||||||
|
--color-surface-subtle: #222326;
|
||||||
|
|
||||||
|
--color-text: #e7e5ef;
|
||||||
|
--color-text-secondary: #a5a3b3;
|
||||||
|
--color-text-muted: #7a7891;
|
||||||
|
|
||||||
|
--color-border: #2e2b3d;
|
||||||
|
|
||||||
|
--color-accent: #b39cd0;
|
||||||
|
--color-accent-strong: #a088c0;
|
||||||
|
--color-accent-soft: rgba(179, 156, 208, 0.18);
|
||||||
|
--color-on-accent: #171622;
|
||||||
|
|
||||||
|
--color-danger: #d47373;
|
||||||
|
--color-danger-soft: rgba(212, 115, 115, 0.15);
|
||||||
|
--color-on-danger: #171622;
|
||||||
|
--color-success: #7ec8c0;
|
||||||
|
--color-success-soft: rgba(126, 200, 192, 0.15);
|
||||||
|
--color-warning: #cc6b2c;
|
||||||
|
--color-warning-soft: rgba(204, 107, 44, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export function formatLocaleDate(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleDateString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatLocaleDateWithTime(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleDateString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,108 +1,93 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, onMounted } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { me, type UserResponse } from '../services/auth';
|
||||||
import { me, logout, type UserResponse } from '../services/auth';
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
const user = ref<UserResponse | null>(null);
|
const user = ref<UserResponse | null>(null);
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
user.value = await me();
|
user.value = await me();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function handleLogout() {
|
|
||||||
await logout();
|
|
||||||
router.push('/login');
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="dashboard">
|
<div class="page">
|
||||||
<header>
|
<header class="page-header">
|
||||||
<h1>Paragon Playground</h1>
|
<h1>Paragon Playground</h1>
|
||||||
<button class="logout" @click="handleLogout">Sign out</button>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main v-if="user">
|
<main v-if="user">
|
||||||
<section class="card">
|
<section class="section">
|
||||||
<h2>Welcome, {{ user.displayName }}</h2>
|
<h2>Welcome, {{ user.displayName }}</h2>
|
||||||
<dl>
|
<dl>
|
||||||
<dt>Email</dt>
|
<dt>Email</dt>
|
||||||
<dd>{{ user.email }}</dd>
|
<dd>{{ user.email }}</dd>
|
||||||
<dt>Organization</dt>
|
<dt>Organization</dt>
|
||||||
<dd>{{ user.organizationName }} ({{ user.organizationSlug }})</dd>
|
<dd>{{ user.organizationName }} ({{ user.organizationSlug }})</dd>
|
||||||
|
<dt>Role</dt>
|
||||||
|
<dd>{{ user.role }}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="section">
|
||||||
<h2>Next Steps</h2>
|
<h2>Examples</h2>
|
||||||
<p>This harness is for Paragon integration exploration.</p>
|
<div class="actions">
|
||||||
|
<router-link
|
||||||
|
to="/files"
|
||||||
|
class="action-card"
|
||||||
|
>
|
||||||
|
<h3>File Explorer</h3>
|
||||||
|
<p>Browse, upload, and manage files</p>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.dashboard {
|
.actions {
|
||||||
max-width: 800px;
|
display: grid;
|
||||||
margin: 0 auto;
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
padding: 1rem;
|
gap: 0.75rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
header {
|
.action-card {
|
||||||
display: flex;
|
display: block;
|
||||||
justify-content: space-between;
|
text-decoration: none;
|
||||||
align-items: center;
|
color: inherit;
|
||||||
margin-bottom: 2rem;
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1rem 1.125rem;
|
||||||
|
transition:
|
||||||
|
border-color 0.12s,
|
||||||
|
background-color 0.12s;
|
||||||
}
|
}
|
||||||
|
|
||||||
header h1 {
|
.action-card:hover {
|
||||||
font-size: 1.25rem;
|
border-color: var(--color-accent);
|
||||||
|
background: var(--color-surface-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
.logout {
|
.action-card h3 {
|
||||||
padding: 0.5rem 1rem;
|
margin: 0 0 0.25rem;
|
||||||
background: none;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.action-card p {
|
||||||
background: white;
|
margin: 0;
|
||||||
padding: 1.5rem;
|
font-size: var(--text-sm);
|
||||||
border-radius: 8px;
|
color: var(--color-text-secondary);
|
||||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card h2 {
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dl dt {
|
dl dt {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
color: #555;
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
dl dd {
|
dl dd {
|
||||||
margin: 0 0 0.5rem;
|
margin: 0 0 0.5rem;
|
||||||
}
|
font-size: var(--text-sm);
|
||||||
|
|
||||||
code {
|
|
||||||
background: #f0f0f0;
|
|
||||||
padding: 0.1rem 0.3rem;
|
|
||||||
border-radius: 3px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul {
|
|
||||||
padding-left: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
li {
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
status: string;
|
||||||
|
heading: string;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<header class="page-header">
|
||||||
|
<h1>{{ heading }}</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<p class="status-eyebrow">{{ status }}</p>
|
||||||
|
<h2>{{ title }}</h2>
|
||||||
|
<p class="help-text">{{ message }}</p>
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
@click="router.push('/')"
|
||||||
|
>
|
||||||
|
Back to Dashboard
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.status-eyebrow {
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,490 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed, watch } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import AppIcon from '../components/AppIcon.vue';
|
||||||
|
import {
|
||||||
|
getItems,
|
||||||
|
createFolder,
|
||||||
|
uploadFile,
|
||||||
|
deleteItem,
|
||||||
|
getDownloadUrls,
|
||||||
|
type StorageItem,
|
||||||
|
} from '../services/storage';
|
||||||
|
import { formatLocaleDate } from '../utils/utils';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const items = ref<StorageItem[]>([]);
|
||||||
|
const loading = ref(true);
|
||||||
|
const error = ref('');
|
||||||
|
const currentFolderId = ref<string | null>(null);
|
||||||
|
const breadcrumbs = ref<{ id: string | null; name: string }[]>([]);
|
||||||
|
const showNewFolder = ref(false);
|
||||||
|
const newFolderName = ref('');
|
||||||
|
|
||||||
|
const folders = computed(() => items.value.filter((i) => i.isFolder));
|
||||||
|
const files = computed(() => items.value.filter((i) => !i.isFolder));
|
||||||
|
|
||||||
|
const pathSegments = computed(() => {
|
||||||
|
const raw = route.params.pathMatch;
|
||||||
|
|
||||||
|
if (!raw) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return (Array.isArray(raw) ? raw : [raw]).filter(Boolean);
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
navigateToPath(pathSegments.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(pathSegments, (newPath) => {
|
||||||
|
navigateToPath(newPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function navigateToPath(segments: string[]) {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
let parentId: string | null = null;
|
||||||
|
const crumbs: { id: string | null; name: string }[] = [];
|
||||||
|
|
||||||
|
for (const name of segments) {
|
||||||
|
const children = await getItems(parentId);
|
||||||
|
const folder = children.find((i) => i.isFolder && i.name === name);
|
||||||
|
|
||||||
|
if (!folder) {
|
||||||
|
error.value = `Folder "${name}" not found`;
|
||||||
|
items.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
crumbs.push({ id: folder.id, name: folder.name });
|
||||||
|
parentId = folder.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentFolderId.value = parentId;
|
||||||
|
breadcrumbs.value = crumbs;
|
||||||
|
|
||||||
|
items.value = await getItems(currentFolderId.value);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Failed to navigate';
|
||||||
|
items.value = [];
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openFolder(folder: StorageItem) {
|
||||||
|
router.push({ params: { pathMatch: [...pathSegments.value, folder.name] } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateToBreadcrumb(index: number) {
|
||||||
|
router.push({ params: { pathMatch: pathSegments.value.slice(0, index) } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateFolder() {
|
||||||
|
if (!newFolderName.value.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createFolder({
|
||||||
|
name: newFolderName.value.trim(),
|
||||||
|
parentId: currentFolderId.value,
|
||||||
|
});
|
||||||
|
|
||||||
|
newFolderName.value = '';
|
||||||
|
showNewFolder.value = false;
|
||||||
|
|
||||||
|
await navigateToPath(pathSegments.value);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Failed to create folder';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpload(e: Event) {
|
||||||
|
const input = e.target as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await uploadFile(file, currentFolderId.value);
|
||||||
|
|
||||||
|
input.value = '';
|
||||||
|
|
||||||
|
await navigateToPath(pathSegments.value);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Failed to upload file';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: string) {
|
||||||
|
if (!confirm('Delete this item? The file will remain in SharePoint.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteItem(id);
|
||||||
|
await navigateToPath(pathSegments.value);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Failed to delete item';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDownload(id: string) {
|
||||||
|
try {
|
||||||
|
const urls = await getDownloadUrls(id);
|
||||||
|
|
||||||
|
if (urls.sharePointUrl) {
|
||||||
|
open(urls.sharePointUrl, '_blank');
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Failed to get download URL';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleProxyDownload(id: string) {
|
||||||
|
try {
|
||||||
|
const urls = await getDownloadUrls(id);
|
||||||
|
|
||||||
|
if (urls.proxyUrl) {
|
||||||
|
open(urls.proxyUrl, '_blank');
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Failed to get download URL';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / 1048576).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<header class="page-header">
|
||||||
|
<h1>Files</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="error"
|
||||||
|
class="alert error"
|
||||||
|
>
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="breadcrumbs">
|
||||||
|
<button
|
||||||
|
class="crumb"
|
||||||
|
:class="{ active: currentFolderId === null }"
|
||||||
|
@click="navigateToBreadcrumb(0)"
|
||||||
|
>
|
||||||
|
Root
|
||||||
|
</button>
|
||||||
|
<template
|
||||||
|
v-for="(crumb, i) in breadcrumbs"
|
||||||
|
:key="i"
|
||||||
|
>
|
||||||
|
<span class="crumb-sep">/</span>
|
||||||
|
<button
|
||||||
|
class="crumb"
|
||||||
|
:class="{ active: i === breadcrumbs.length - 1 }"
|
||||||
|
@click="navigateToBreadcrumb(i + 1)"
|
||||||
|
>
|
||||||
|
{{ crumb.name }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
@click="showNewFolder = !showNewFolder"
|
||||||
|
>
|
||||||
|
<AppIcon
|
||||||
|
v-if="!showNewFolder"
|
||||||
|
name="plus"
|
||||||
|
:size="14"
|
||||||
|
/>
|
||||||
|
{{ showNewFolder ? 'Cancel' : 'New Folder' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<label class="btn btn-primary upload-label">
|
||||||
|
<AppIcon
|
||||||
|
name="upload"
|
||||||
|
:size="14"
|
||||||
|
/>
|
||||||
|
Upload File
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
hidden
|
||||||
|
@change="handleUpload"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="showNewFolder"
|
||||||
|
class="inline-form"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="newFolderName"
|
||||||
|
type="text"
|
||||||
|
placeholder="Folder name"
|
||||||
|
@keyup.enter="handleCreateFolder"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
@click="handleCreateFolder"
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="loading"
|
||||||
|
class="loading"
|
||||||
|
>
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="items.length === 0"
|
||||||
|
class="empty"
|
||||||
|
>
|
||||||
|
<p>This folder is empty.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="item-list"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="folder in folders"
|
||||||
|
:key="folder.id"
|
||||||
|
class="item-row folder"
|
||||||
|
@dblclick="openFolder(folder)"
|
||||||
|
>
|
||||||
|
<span class="item-icon"
|
||||||
|
><AppIcon
|
||||||
|
name="folder"
|
||||||
|
:size="16"
|
||||||
|
/></span>
|
||||||
|
<span
|
||||||
|
class="item-name"
|
||||||
|
@click="openFolder(folder)"
|
||||||
|
>{{ folder.name }}</span
|
||||||
|
>
|
||||||
|
<span class="item-meta">Folder</span>
|
||||||
|
<span class="item-user">{{ folder.createdByDisplayName }}</span>
|
||||||
|
<span class="item-date">{{ formatLocaleDate(folder.createdAt) }}</span>
|
||||||
|
<span class="item-actions">
|
||||||
|
<button
|
||||||
|
class="btn-small btn-small-danger"
|
||||||
|
title="Delete"
|
||||||
|
@click="handleDelete(folder.id)"
|
||||||
|
>
|
||||||
|
<AppIcon
|
||||||
|
name="trash"
|
||||||
|
:size="14"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="file in files"
|
||||||
|
:key="file.id"
|
||||||
|
class="item-row file"
|
||||||
|
>
|
||||||
|
<span class="item-icon"
|
||||||
|
><AppIcon
|
||||||
|
name="file"
|
||||||
|
:size="16"
|
||||||
|
/></span>
|
||||||
|
<span class="item-name">{{ file.name }}</span>
|
||||||
|
<span class="item-meta">{{ formatSize(file.fileSize) }}</span>
|
||||||
|
<span class="item-user">{{ file.createdByDisplayName }}</span>
|
||||||
|
<span class="item-date">{{ formatLocaleDate(file.createdAt) }}</span>
|
||||||
|
<span class="item-actions">
|
||||||
|
<button
|
||||||
|
class="btn-small"
|
||||||
|
title="Open in SharePoint"
|
||||||
|
@click="handleDownload(file.id)"
|
||||||
|
>
|
||||||
|
<AppIcon
|
||||||
|
name="external"
|
||||||
|
:size="14"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn-small"
|
||||||
|
title="Download via app"
|
||||||
|
@click="handleProxyDownload(file.id)"
|
||||||
|
>
|
||||||
|
<AppIcon
|
||||||
|
name="download"
|
||||||
|
:size="14"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn-small btn-small-danger"
|
||||||
|
title="Delete"
|
||||||
|
@click="handleDelete(file.id)"
|
||||||
|
>
|
||||||
|
<AppIcon
|
||||||
|
name="trash"
|
||||||
|
:size="14"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page {
|
||||||
|
max-width: 960px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumbs {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crumb {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crumb.active {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crumb-sep {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-label {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-form input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-list {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-row.folder {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-row.folder:hover {
|
||||||
|
background: var(--color-surface-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder .item-icon {
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-name {
|
||||||
|
flex: 1;
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder .item-name {
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-meta {
|
||||||
|
width: 5rem;
|
||||||
|
text-align: right;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-user {
|
||||||
|
width: 8rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-date {
|
||||||
|
width: 7rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 6.5rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue';
|
||||||
|
import type { IntegrationInstallEvent } from '@useparagon/connect';
|
||||||
|
import {
|
||||||
|
getParagonToken,
|
||||||
|
getConfig,
|
||||||
|
updateConfig,
|
||||||
|
getCredentials,
|
||||||
|
saveCredential,
|
||||||
|
purgeOrgCredentials,
|
||||||
|
type IntegrationConfig,
|
||||||
|
type IntegrationConfigRequest,
|
||||||
|
type CredentialResponse,
|
||||||
|
} from '../services/integration';
|
||||||
|
import { formatLocaleDateWithTime } from '../utils/utils';
|
||||||
|
|
||||||
|
const config = ref<IntegrationConfig | null>(null);
|
||||||
|
const credentials = ref<CredentialResponse[]>([]);
|
||||||
|
|
||||||
|
const loading = ref(true);
|
||||||
|
const saving = ref(false);
|
||||||
|
const uninstalling = ref(false);
|
||||||
|
|
||||||
|
const error = ref('');
|
||||||
|
const success = ref('');
|
||||||
|
|
||||||
|
const form = ref<IntegrationConfigRequest>({
|
||||||
|
connectionMode: 'default',
|
||||||
|
sharePointSiteUrl: null,
|
||||||
|
sharePointFolderPath: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasOrgIntegration = computed(() =>
|
||||||
|
credentials.value.some((c) => c.integrationType === 'sharepoint'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const submitLabel = computed(() => {
|
||||||
|
if (saving.value) {
|
||||||
|
return 'Saving...';
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasOrgIntegration.value ? 'Save Configuration' : 'Save & Set Up Integration';
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadData();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [cfg, myCreds] = await Promise.all([
|
||||||
|
getConfig().catch(() => null),
|
||||||
|
getCredentials().catch(() => []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
config.value = cfg;
|
||||||
|
credentials.value = myCreds;
|
||||||
|
|
||||||
|
if (cfg) {
|
||||||
|
form.value = {
|
||||||
|
connectionMode: cfg.connectionMode,
|
||||||
|
sharePointSiteUrl: cfg.sharePointSiteUrl,
|
||||||
|
sharePointFolderPath: cfg.sharePointFolderPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
error.value = 'Failed to load integration data';
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveConfig() {
|
||||||
|
if (!form.value.sharePointSiteUrl?.trim()) {
|
||||||
|
error.value = 'SharePoint Site URL is required to set up the integration.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saving.value = true;
|
||||||
|
error.value = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (hasOrgIntegration.value) {
|
||||||
|
const updated = await updateConfig(form.value);
|
||||||
|
|
||||||
|
config.value = updated;
|
||||||
|
success.value = 'Configuration saved';
|
||||||
|
} else {
|
||||||
|
await installAndSave();
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Failed to save configuration';
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installAndSave() {
|
||||||
|
const tokenResponse = await getParagonToken();
|
||||||
|
const { paragon, SDK_EVENT } = await import('@useparagon/connect');
|
||||||
|
await paragon.authenticate(tokenResponse.projectId, tokenResponse.paragonJwt);
|
||||||
|
|
||||||
|
const unsubscribeInstall = paragon.subscribe(
|
||||||
|
SDK_EVENT.ON_INTEGRATION_INSTALL,
|
||||||
|
async (event: IntegrationInstallEvent) => {
|
||||||
|
if (!event.credentialId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
unsubscribeInstall();
|
||||||
|
saving.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await saveCredential({
|
||||||
|
credentialId: event.credentialId,
|
||||||
|
integrationType: 'sharepoint',
|
||||||
|
});
|
||||||
|
|
||||||
|
const updated = await updateConfig(form.value);
|
||||||
|
|
||||||
|
config.value = updated;
|
||||||
|
success.value = 'SharePoint integration installed and configured!';
|
||||||
|
|
||||||
|
await loadData();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Failed to complete setup';
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
paragon.installIntegration('sharepoint', {
|
||||||
|
allowMultipleCredentials: true,
|
||||||
|
...(form.value.connectionMode === 'byo' ? { accountType: ['user-configured-oauth'] } : {}),
|
||||||
|
});
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Failed to start setup';
|
||||||
|
unsubscribeInstall();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uninstallOrgIntegration() {
|
||||||
|
uninstalling.value = true;
|
||||||
|
error.value = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tokenResponse = await getParagonToken();
|
||||||
|
const { paragon } = await import('@useparagon/connect');
|
||||||
|
await paragon.authenticate(tokenResponse.projectId, tokenResponse.paragonJwt);
|
||||||
|
|
||||||
|
await paragon.uninstallIntegration('sharepoint');
|
||||||
|
await purgeOrgCredentials();
|
||||||
|
|
||||||
|
success.value = 'SharePoint integration removed from organization.';
|
||||||
|
|
||||||
|
await loadData();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Failed to uninstall integration';
|
||||||
|
} finally {
|
||||||
|
uninstalling.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<header class="page-header">
|
||||||
|
<h1>Integrations</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="error"
|
||||||
|
class="alert error"
|
||||||
|
>
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="success"
|
||||||
|
class="alert success"
|
||||||
|
>
|
||||||
|
{{ success }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="loading"
|
||||||
|
class="loading"
|
||||||
|
>
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="!loading">
|
||||||
|
<section class="section">
|
||||||
|
<h2>Integration Configuration</h2>
|
||||||
|
<p class="help-text">
|
||||||
|
Choose how users authenticate to Microsoft and where files are stored. First-time setup
|
||||||
|
connects the integration before saving.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form
|
||||||
|
class="config-form"
|
||||||
|
@submit.prevent="saveConfig"
|
||||||
|
>
|
||||||
|
<label>
|
||||||
|
<span class="label-title">Connection Mode</span>
|
||||||
|
<select v-model="form.connectionMode">
|
||||||
|
<option value="default">Default — ISV-provided Azure AD app</option>
|
||||||
|
<option value="byo">
|
||||||
|
User-Configured OAuth — organization provides their own Azure AD app
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<span class="field-note">
|
||||||
|
Default uses our Azure AD app; BYO uses the organization's own.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span class="label-title">SharePoint Site URL</span>
|
||||||
|
<input
|
||||||
|
v-model="form.sharePointSiteUrl"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://contoso.sharepoint.com/sites/MySite"
|
||||||
|
/>
|
||||||
|
<span class="field-note"
|
||||||
|
>Site ID is resolved from this URL using the connected admin account, which must have
|
||||||
|
access to the site.</span
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span class="label-title">SharePoint Folder Path</span>
|
||||||
|
<input
|
||||||
|
v-model="form.sharePointFolderPath"
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g. Uploads/MyApp"
|
||||||
|
/>
|
||||||
|
<span class="field-note">Created automatically if it doesn't exist.</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="connect-actions">
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
type="submit"
|
||||||
|
:disabled="saving || uninstalling"
|
||||||
|
>
|
||||||
|
{{ submitLabel }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="hasOrgIntegration"
|
||||||
|
class="btn btn-danger"
|
||||||
|
type="button"
|
||||||
|
:disabled="saving || uninstalling"
|
||||||
|
@click="uninstallOrgIntegration()"
|
||||||
|
>
|
||||||
|
{{ uninstalling ? 'Uninstalling...' : 'Uninstall Organization Integration' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="config"
|
||||||
|
class="config-meta"
|
||||||
|
>
|
||||||
|
Last updated: {{ formatLocaleDateWithTime(config.updatedAt) }}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p class="help-text text-muted">
|
||||||
|
Users connect and manage their own accounts from the Settings page.
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.connect-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-form label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-title {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-form input,
|
||||||
|
.config-form select {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-note {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-meta {
|
||||||
|
margin-top: 1rem;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,23 +1,23 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from "vue";
|
import { ref } from 'vue';
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from 'vue-router';
|
||||||
import { login } from "../services/auth";
|
import { login } from '../services/auth';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const email = ref("");
|
const email = ref('');
|
||||||
const password = ref("");
|
const password = ref('');
|
||||||
const error = ref("");
|
const error = ref('');
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
error.value = "";
|
error.value = '';
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await login(email.value, password.value);
|
await login(email.value, password.value);
|
||||||
router.push("/");
|
router.push('/');
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
error.value = e instanceof Error ? e.message : "Login failed";
|
error.value = e instanceof Error ? e.message : 'Login failed';
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
@@ -26,15 +26,28 @@ async function handleSubmit() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="login-container">
|
<div class="login-container">
|
||||||
<form class="login-form" @submit.prevent="handleSubmit">
|
<form
|
||||||
|
class="login-form"
|
||||||
|
@submit.prevent="handleSubmit"
|
||||||
|
>
|
||||||
<h1>Paragon Playground</h1>
|
<h1>Paragon Playground</h1>
|
||||||
<p class="subtitle">Sign in to your account</p>
|
<p class="subtitle">Sign in to your account</p>
|
||||||
|
|
||||||
<div v-if="error" class="error">{{ error }}</div>
|
<div
|
||||||
|
v-if="error"
|
||||||
|
class="alert error"
|
||||||
|
>
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
Email
|
Email
|
||||||
<input v-model="email" type="email" required autocomplete="email" />
|
<input
|
||||||
|
v-model="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
autocomplete="email"
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
@@ -47,8 +60,12 @@ async function handleSubmit() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<button type="submit" :disabled="loading">
|
<button
|
||||||
{{ loading ? "Signing in..." : "Sign in" }}
|
class="btn btn-primary login-submit"
|
||||||
|
type="submit"
|
||||||
|
:disabled="loading"
|
||||||
|
>
|
||||||
|
{{ loading ? 'Signing in...' : 'Sign in' }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -60,64 +77,45 @@ async function handleSubmit() {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #f5f5f5;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-form {
|
.login-form {
|
||||||
background: white;
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-form h1 {
|
.login-form h1 {
|
||||||
margin: 0 0 0.25rem;
|
margin: 0 0 0.25rem;
|
||||||
font-size: 1.5rem;
|
font-size: var(--text-xl);
|
||||||
|
font-weight: 650;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subtitle {
|
.subtitle {
|
||||||
color: #666;
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
|
||||||
background: #fee;
|
|
||||||
color: #c00;
|
|
||||||
padding: 0.5rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
label {
|
label {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
font-size: var(--text-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
input {
|
input {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.5rem;
|
|
||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
border: 1px solid #ccc;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
.login-submit {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.75rem;
|
padding: 0.625rem;
|
||||||
background: #1a73e8;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 1rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
button:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,325 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue';
|
||||||
|
import type { IntegrationInstallEvent } from '@useparagon/connect';
|
||||||
|
import AppIcon from '../components/AppIcon.vue';
|
||||||
|
import {
|
||||||
|
getParagonToken,
|
||||||
|
getConfig,
|
||||||
|
getCredentials,
|
||||||
|
saveCredential,
|
||||||
|
deleteCredential,
|
||||||
|
type IntegrationConfig,
|
||||||
|
type CredentialResponse,
|
||||||
|
} from '../services/integration';
|
||||||
|
import { formatLocaleDateWithTime } from '../utils/utils';
|
||||||
|
|
||||||
|
const config = ref<IntegrationConfig | null>(null);
|
||||||
|
const credentials = ref<CredentialResponse[]>([]);
|
||||||
|
|
||||||
|
const loading = ref(true);
|
||||||
|
const managing = ref(false);
|
||||||
|
const connecting = ref(false);
|
||||||
|
const disconnecting = ref(false);
|
||||||
|
|
||||||
|
const error = ref('');
|
||||||
|
const success = ref('');
|
||||||
|
|
||||||
|
const spCredential = computed(() =>
|
||||||
|
credentials.value.find((c) => c.integrationType === 'sharepoint'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const integrationConfigured = computed(() => Boolean(config.value?.sharePointSiteId));
|
||||||
|
|
||||||
|
const connectionMode = computed(() => config.value?.connectionMode ?? 'default');
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadData();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [cfg, creds] = await Promise.all([
|
||||||
|
getConfig().catch(() => null),
|
||||||
|
getCredentials().catch(() => []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
config.value = cfg;
|
||||||
|
credentials.value = creds;
|
||||||
|
} catch {
|
||||||
|
error.value = 'Failed to load settings data';
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function manageConnection(accountType?: string[]) {
|
||||||
|
managing.value = true;
|
||||||
|
error.value = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tokenResponse = await getParagonToken();
|
||||||
|
const { paragon } = await import('@useparagon/connect');
|
||||||
|
await paragon.authenticate(tokenResponse.projectId, tokenResponse.paragonJwt);
|
||||||
|
|
||||||
|
void paragon.connect('sharepoint', {
|
||||||
|
selectedCredentialId: spCredential.value!.credentialId,
|
||||||
|
...(accountType ? { accountType } : {}),
|
||||||
|
onClose: async () => {
|
||||||
|
managing.value = false;
|
||||||
|
await loadData();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Failed to open connection manager';
|
||||||
|
managing.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectAccount(accountType?: string[]) {
|
||||||
|
connecting.value = true;
|
||||||
|
error.value = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tokenResponse = await getParagonToken();
|
||||||
|
const { paragon, SDK_EVENT } = await import('@useparagon/connect');
|
||||||
|
await paragon.authenticate(tokenResponse.projectId, tokenResponse.paragonJwt);
|
||||||
|
|
||||||
|
const unsub = paragon.subscribe(
|
||||||
|
SDK_EVENT.ON_INTEGRATION_INSTALL,
|
||||||
|
async (event: IntegrationInstallEvent) => {
|
||||||
|
if (!event.credentialId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await saveCredential({
|
||||||
|
credentialId: event.credentialId,
|
||||||
|
integrationType: 'sharepoint',
|
||||||
|
});
|
||||||
|
success.value = 'SharePoint account connected!';
|
||||||
|
unsub();
|
||||||
|
await loadData();
|
||||||
|
} catch {
|
||||||
|
error.value = 'Failed to save credential';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
paragon.installIntegration('sharepoint', {
|
||||||
|
allowMultipleCredentials: true,
|
||||||
|
...(accountType ? { accountType } : {}),
|
||||||
|
});
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Failed to connect account';
|
||||||
|
} finally {
|
||||||
|
connecting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disconnectAccount() {
|
||||||
|
disconnecting.value = true;
|
||||||
|
error.value = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tokenResponse = await getParagonToken();
|
||||||
|
const { paragon } = await import('@useparagon/connect');
|
||||||
|
await paragon.authenticate(tokenResponse.projectId, tokenResponse.paragonJwt);
|
||||||
|
|
||||||
|
await paragon.uninstallIntegration('sharepoint', {
|
||||||
|
selectedCredentialId: spCredential.value!.credentialId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await deleteCredential(spCredential.value!.credentialId);
|
||||||
|
success.value = 'SharePoint account disconnected.';
|
||||||
|
await loadData();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Failed to disconnect account';
|
||||||
|
} finally {
|
||||||
|
disconnecting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<header class="page-header">
|
||||||
|
<h1>Settings</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="error"
|
||||||
|
class="alert error"
|
||||||
|
>
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="success"
|
||||||
|
class="alert success"
|
||||||
|
>
|
||||||
|
{{ success }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="loading"
|
||||||
|
class="loading"
|
||||||
|
>
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="!loading">
|
||||||
|
<section class="section">
|
||||||
|
<h2>Integrations</h2>
|
||||||
|
|
||||||
|
<template v-if="integrationConfigured">
|
||||||
|
<div class="integration-row">
|
||||||
|
<div class="integration-info">
|
||||||
|
<span class="integration-icon"><AppIcon
|
||||||
|
name="folder"
|
||||||
|
:size="20"
|
||||||
|
/></span>
|
||||||
|
<div>
|
||||||
|
<h3 class="integration-name">SharePoint</h3>
|
||||||
|
<p class="help-text integration-desc">
|
||||||
|
Connect your SharePoint account to upload and manage files.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="connect-actions">
|
||||||
|
<template v-if="spCredential">
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
:disabled="managing"
|
||||||
|
@click="
|
||||||
|
manageConnection(
|
||||||
|
connectionMode === 'byo' ? ['user-configured-oauth'] : undefined,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ managing ? 'Loading...' : 'Manage Connection' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn btn-danger"
|
||||||
|
:disabled="disconnecting"
|
||||||
|
@click="disconnectAccount()"
|
||||||
|
>
|
||||||
|
{{ disconnecting ? 'Disconnecting...' : 'Disconnect Account' }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
class="btn btn-primary"
|
||||||
|
:disabled="connecting"
|
||||||
|
@click="
|
||||||
|
connectAccount(
|
||||||
|
connectionMode === 'byo' ? ['user-configured-oauth'] : undefined,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ connecting ? 'Connecting...' : 'Connect Account' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="credentials.length > 0"
|
||||||
|
class="credentials-list"
|
||||||
|
>
|
||||||
|
<h3>Your Connected Accounts</h3>
|
||||||
|
<div
|
||||||
|
v-for="cred in credentials"
|
||||||
|
:key="cred.id"
|
||||||
|
class="credential-row"
|
||||||
|
>
|
||||||
|
<span class="cred-type">{{ cred.integrationType }}</span>
|
||||||
|
<span class="cred-date">Connected {{ formatLocaleDateWithTime(cred.connectedAt) }}</span>
|
||||||
|
<span class="cred-id">{{ cred.credentialId.slice(0, 12) }}...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<p class="help-text text-muted">
|
||||||
|
No integrations configured by your organization's admin yet.
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.integration-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integration-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integration-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--color-accent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integration-name {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integration-desc {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connect-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credentials-list {
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.credential-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cred-type {
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: capitalize;
|
||||||
|
min-width: 6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cred-date {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cred-id {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-family: ui-monospace, 'Cascadia Code', Consolas, monospace;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,7 +1,4 @@
|
|||||||
{
|
{
|
||||||
"files": [],
|
"files": [],
|
||||||
"references": [
|
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||||
{ "path": "./tsconfig.app.json" },
|
|
||||||
{ "path": "./tsconfig.node.json" }
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite';
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
import vueDevTools from 'vite-plugin-vue-devtools';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig(({ command }) => ({
|
||||||
plugins: [vue()],
|
plugins: [vue(), ...(command === 'serve' ? [vueDevTools()] : [])],
|
||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
|
watch: {
|
||||||
|
usePolling: true,
|
||||||
|
},
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:5000',
|
target: 'http://localhost:5000',
|
||||||
@@ -12,4 +16,4 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
}));
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ server {
|
|||||||
ssl_certificate_key /etc/nginx/certs/_wildcard.paragonplayground.localhost-key.pem;
|
ssl_certificate_key /etc/nginx/certs/_wildcard.paragonplayground.localhost-key.pem;
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
|
client_max_body_size 0;
|
||||||
proxy_pass http://backend:8080;
|
proxy_pass http://backend:8080;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
|||||||
Reference in New Issue
Block a user