feat: add ability to create folders and files and persist files to sharepoint
This commit is contained in:
Vendored
+2
-3
@@ -13,7 +13,7 @@
|
||||
"pipeCwd": "${workspaceFolder}",
|
||||
"quoteArgs": false
|
||||
},
|
||||
"preLaunchTask": "docker-compose up (backend only)",
|
||||
"preLaunchTask": "docker-compose up (debug backend only)",
|
||||
"sourceFileMap": {
|
||||
"/src/src/ParagonPlayground.Api": "${workspaceFolder}/src/ParagonPlayground/backend/src/ParagonPlayground.Api",
|
||||
"/src/src/ParagonPlayground.Domain": "${workspaceFolder}/src/ParagonPlayground/backend/src/ParagonPlayground.Domain",
|
||||
@@ -39,8 +39,7 @@
|
||||
},
|
||||
{
|
||||
"name": "Attach to All",
|
||||
"type": "compound",
|
||||
"preLaunchTask": "docker-compose up",
|
||||
"preLaunchTask": "docker-compose up (debug)",
|
||||
"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",
|
||||
"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",
|
||||
"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
|
||||
|
||||
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 . .
|
||||
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 && \
|
||||
apt-get install -y unzip curl && \
|
||||
@@ -24,4 +10,30 @@ RUN apt-get update && \
|
||||
apt-get clean && \
|
||||
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"]
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
<PackageVersion Include="BCrypt.Net-Next" Version="4.2.0" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.11" />
|
||||
<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>
|
||||
|
||||
</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;
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -39,8 +46,15 @@ internal static class HttpContextExtensions
|
||||
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)
|
||||
{
|
||||
_ = group.MapPost("/login", LoginAsync);
|
||||
_ = group.MapPost("/logout", LogoutAsync);
|
||||
_ = group.MapGet("/me", Me);
|
||||
_ = group.MapPost("/logout", LogoutAsync).RequireAuthorization();
|
||||
_ = group.MapGet("/me", Me).RequireAuthorization();
|
||||
return group;
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ internal static class AuthEndpoints
|
||||
|
||||
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);
|
||||
@@ -65,6 +65,7 @@ internal static class AuthEndpoints
|
||||
Id = user.Id,
|
||||
Email = user.Email,
|
||||
DisplayName = user.DisplayName,
|
||||
Role = user.Role,
|
||||
OrganizationId = org?.Id ?? string.Empty,
|
||||
OrganizationName = org?.Name ?? string.Empty,
|
||||
OrganizationSlug = org?.Slug ?? string.Empty,
|
||||
@@ -88,18 +89,13 @@ internal static class AuthEndpoints
|
||||
cookieService.ClearSessionCookie(context);
|
||||
cookieService.ClearXsrfCookie(context);
|
||||
|
||||
return Results.Ok(new { message = "Logged out" });
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static IResult Me(HttpContext context)
|
||||
{
|
||||
var user = context.GetUser();
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
return Results.Json(new { error = "Not authenticated" }, statusCode: StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
|
||||
var org = context.GetOrganization();
|
||||
|
||||
return Results.Ok(new UserResponse
|
||||
@@ -107,6 +103,7 @@ internal static class AuthEndpoints
|
||||
Id = user.Id,
|
||||
Email = user.Email,
|
||||
DisplayName = user.DisplayName,
|
||||
Role = user.Role,
|
||||
OrganizationId = org?.Id ?? string.Empty,
|
||||
OrganizationName = org?.Name ?? 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>
|
||||
<PackageReference Include="MongoDB.Driver" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Resilience" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
using ParagonPlayground.Api.Auth;
|
||||
using ParagonPlayground.Api.Endpoints;
|
||||
using ParagonPlayground.Api.Middleware;
|
||||
using ParagonPlayground.Api.Options;
|
||||
using ParagonPlayground.Api.Services;
|
||||
using ParagonPlayground.Infrastructure.Data;
|
||||
using ParagonPlayground.Infrastructure.Services;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.Configure<MongoDbOptions>(builder.Configuration.GetSection(MongoDbOptions.SectionName));
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -20,6 +25,29 @@ builder.Services.AddSingleton<CookieService>();
|
||||
builder.Services.AddSingleton<OrganizationRepository>();
|
||||
builder.Services.AddSingleton<UserRepository>();
|
||||
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 =>
|
||||
{
|
||||
@@ -29,8 +57,14 @@ builder.Services.Configure<ForwardedHeadersOptions>(static options =>
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseForwardedHeaders();
|
||||
app.UseExceptionHandler();
|
||||
app.UseStatusCodePages();
|
||||
app.UseMiddleware<TenantResolutionMiddleware>();
|
||||
app.UseMiddleware<SessionAuthMiddleware>();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.MapGroup("/api/auth").MapAuthEndpoints();
|
||||
app.MapGroup("/api/paragon").MapParagonEndpoints();
|
||||
app.MapGroup("/api/integration").MapIntegrationEndpoints();
|
||||
app.MapGroup("/api/storage").MapStorageEndpoints();
|
||||
|
||||
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(
|
||||
OrganizationRepository orgRepo,
|
||||
UserRepository userRepo,
|
||||
PasswordService passwordService) : AsyncCommand<ProvisionUserCommand.Settings>
|
||||
PasswordService passwordService
|
||||
) : AsyncCommand<ProvisionUserCommand.Settings>
|
||||
{
|
||||
internal class Settings : CommandSettings
|
||||
{
|
||||
@@ -33,6 +34,10 @@ internal class ProvisionUserCommand(
|
||||
[Description("Organization slug")]
|
||||
[CommandOption("-o|--org-slug")]
|
||||
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)
|
||||
@@ -60,6 +65,7 @@ internal class ProvisionUserCommand(
|
||||
DisplayName = settings.Name,
|
||||
PasswordHash = passwordService.Hash(settings.Password),
|
||||
OrganizationId = org.Id,
|
||||
Role = settings.Role,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
|
||||
@@ -41,11 +41,11 @@ internal class SeedCommand(
|
||||
|
||||
var users = new[]
|
||||
{
|
||||
(Email: "alice@acme.com", Name: "Alice", Password: "password123"),
|
||||
(Email: "bob@acme.com", Name: "Bob", Password: "password123"),
|
||||
(Email: "alice@acme.com", Name: "Alice", Password: "password123", Role: "admin"),
|
||||
(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);
|
||||
|
||||
@@ -58,6 +58,7 @@ internal class SeedCommand(
|
||||
DisplayName = name,
|
||||
PasswordHash = passwordService.Hash(password),
|
||||
OrganizationId = org.Id,
|
||||
Role = role,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
@@ -66,10 +67,19 @@ internal class SeedCommand(
|
||||
AnsiConsole.MarkupLine($"[green]Created user: {user.Email} ({user.DisplayName})[/]");
|
||||
}
|
||||
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("[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>
|
||||
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>
|
||||
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>
|
||||
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 =>
|
||||
_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>
|
||||
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>
|
||||
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>
|
||||
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>
|
||||
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>
|
||||
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>
|
||||
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>
|
||||
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>
|
||||
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>
|
||||
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>
|
||||
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:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
target: development
|
||||
ports:
|
||||
- "5000:8080"
|
||||
volumes:
|
||||
- ./backend:/src
|
||||
environment:
|
||||
- MongoDb__ConnectionString=mongodb://mongodb:27017
|
||||
- MongoDb__DatabaseName=paragon_playground
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
{
|
||||
"semi": false,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"arrowParens": "always"
|
||||
"arrowParens": "always",
|
||||
"bracketSpacing": true,
|
||||
"vueIndentScriptAndStyle": true,
|
||||
"endOfLine": "auto",
|
||||
"singleAttributePerLine": true
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import js from '@eslint/js'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import pluginVue from 'eslint-plugin-vue'
|
||||
import prettier from 'eslint-config-prettier'
|
||||
import js from '@eslint/js';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import pluginVue from 'eslint-plugin-vue';
|
||||
import prettier from 'eslint-config-prettier';
|
||||
import globals from 'globals';
|
||||
|
||||
export default tseslint.config(
|
||||
js.configs.recommended,
|
||||
@@ -13,10 +14,21 @@ export default tseslint.config(
|
||||
parserOptions: {
|
||||
parser: tseslint.parser,
|
||||
},
|
||||
globals: {
|
||||
...globals.browser,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['*.ts', '**/*.ts'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
},
|
||||
},
|
||||
},
|
||||
prettier,
|
||||
{
|
||||
ignores: ['dist/', 'node_modules/'],
|
||||
},
|
||||
)
|
||||
);
|
||||
|
||||
@@ -2,11 +2,17 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<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>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<script
|
||||
type="module"
|
||||
src="/src/main.ts"
|
||||
></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+1345
-6
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@useparagon/connect": "^2.5.0",
|
||||
"vue": "^3.5.39",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
@@ -23,10 +24,12 @@
|
||||
"eslint": "^10.8.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-vue": "^10.10.0",
|
||||
"globals": "^17.8.0",
|
||||
"prettier": "^3.9.6",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.65.0",
|
||||
"vite": "^8.1.1",
|
||||
"vite-plugin-vue-devtools": "^8.2.1",
|
||||
"vue-tsc": "^3.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,111 @@
|
||||
<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>
|
||||
|
||||
<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 />
|
||||
</div>
|
||||
</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 router from './router';
|
||||
|
||||
import './styles/tokens.css';
|
||||
import './styles/main.css';
|
||||
|
||||
const app = createApp(App);
|
||||
app.use(router);
|
||||
app.mount('#app');
|
||||
|
||||
@@ -1,28 +1,90 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import LoginPage from '../views/LoginPage.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({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/login', name: 'login', component: LoginPage },
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: LoginPage,
|
||||
meta: { public: true },
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
name: 'dashboard',
|
||||
component: DashboardPage,
|
||||
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) => {
|
||||
if (to.meta.requiresAuth) {
|
||||
try {
|
||||
await me();
|
||||
} catch {
|
||||
const { currentUser, fetchCurrentUser } = useCurrentUser();
|
||||
|
||||
if (currentUser.value === null) {
|
||||
await fetchCurrentUser();
|
||||
}
|
||||
|
||||
if (to.meta.public) {
|
||||
return currentUser.value ? { name: 'dashboard' } : true;
|
||||
}
|
||||
|
||||
if (currentUser.value === null) {
|
||||
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) {
|
||||
const body = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(body.error ?? `HTTP ${res.status}`);
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.detail ?? body.title ?? res.statusText);
|
||||
}
|
||||
|
||||
if (res.status === 204) {
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface UserResponse {
|
||||
id: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
role: string;
|
||||
organizationId: string;
|
||||
organizationName: 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">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { me, logout, type UserResponse } from '../services/auth';
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { me, 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();
|
||||
});
|
||||
|
||||
async function handleLogout() {
|
||||
await logout();
|
||||
router.push('/login');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<header>
|
||||
<div class="page">
|
||||
<header class="page-header">
|
||||
<h1>Paragon Playground</h1>
|
||||
<button class="logout" @click="handleLogout">Sign out</button>
|
||||
</header>
|
||||
|
||||
<main v-if="user">
|
||||
<section class="card">
|
||||
<section class="section">
|
||||
<h2>Welcome, {{ user.displayName }}</h2>
|
||||
<dl>
|
||||
<dt>Email</dt>
|
||||
<dd>{{ user.email }}</dd>
|
||||
<dt>Organization</dt>
|
||||
<dd>{{ user.organizationName }} ({{ user.organizationSlug }})</dd>
|
||||
<dt>Role</dt>
|
||||
<dd>{{ user.role }}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Next Steps</h2>
|
||||
<p>This harness is for Paragon integration exploration.</p>
|
||||
<section class="section">
|
||||
<h2>Examples</h2>
|
||||
<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>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dashboard {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
.actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.action-card {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
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 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
.action-card:hover {
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-surface-subtle);
|
||||
}
|
||||
|
||||
.logout {
|
||||
padding: 0.5rem 1rem;
|
||||
background: none;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.action-card h3 {
|
||||
margin: 0 0 0.25rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.action-card p {
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
dl dt {
|
||||
dl dt {
|
||||
font-weight: 600;
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
</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,40 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { login } from "../services/auth";
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { login } from '../services/auth';
|
||||
|
||||
const router = useRouter();
|
||||
const email = ref("");
|
||||
const password = ref("");
|
||||
const error = ref("");
|
||||
const loading = ref(false);
|
||||
const router = useRouter();
|
||||
const email = ref('');
|
||||
const password = ref('');
|
||||
const error = ref('');
|
||||
const loading = ref(false);
|
||||
|
||||
async function handleSubmit() {
|
||||
error.value = "";
|
||||
async function handleSubmit() {
|
||||
error.value = '';
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
await login(email.value, password.value);
|
||||
router.push("/");
|
||||
router.push('/');
|
||||
} catch (e: unknown) {
|
||||
error.value = e instanceof Error ? e.message : "Login failed";
|
||||
error.value = e instanceof Error ? e.message : 'Login failed';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<form class="login-form" @submit.prevent="handleSubmit">
|
||||
<form
|
||||
class="login-form"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<h1>Paragon Playground</h1>
|
||||
<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>
|
||||
Email
|
||||
<input v-model="email" type="email" required autocomplete="email" />
|
||||
<input
|
||||
v-model="email"
|
||||
type="email"
|
||||
required
|
||||
autocomplete="email"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
@@ -47,77 +60,62 @@ async function handleSubmit() {
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button type="submit" :disabled="loading">
|
||||
{{ loading ? "Signing in..." : "Sign in" }}
|
||||
<button
|
||||
class="btn btn-primary login-submit"
|
||||
type="submit"
|
||||
:disabled="loading"
|
||||
>
|
||||
{{ loading ? 'Signing in...' : 'Sign in' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
.login-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
background: white;
|
||||
.login-form {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
padding: 2rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
.login-form h1 {
|
||||
.login-form h1 {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #666;
|
||||
.subtitle {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
background: #fee;
|
||||
color: #c00;
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
label {
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
input {
|
||||
input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
.login-submit {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: #1a73e8;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
padding: 0.625rem;
|
||||
}
|
||||
</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": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import vueDevTools from 'vite-plugin-vue-devtools';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
export default defineConfig(({ command }) => ({
|
||||
plugins: [vue(), ...(command === 'serve' ? [vueDevTools()] : [])],
|
||||
server: {
|
||||
port: 3000,
|
||||
watch: {
|
||||
usePolling: true,
|
||||
},
|
||||
proxy: {
|
||||
'/api': {
|
||||
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;
|
||||
|
||||
location /api/ {
|
||||
client_max_body_size 0;
|
||||
proxy_pass http://backend:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
Reference in New Issue
Block a user