Compare commits

...
5 Commits
79 changed files with 6061 additions and 309 deletions
+2 -3
View File
@@ -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"]
}
],
+5 -1
View File
@@ -1,3 +1,7 @@
{
"FSharp.suggestGitignore": false
"FSharp.suggestGitignore": false,
"cSpell.words": [
"Ingestor",
"useparagon"
]
}
+22
View File
@@ -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",
-2
View File
@@ -17,8 +17,6 @@ A application harness to explore using Paragon for integrations.
.\scripts\setup-certs.ps1
```
> **Run this from an elevated (Admin) PowerShell prompt.** `mkcert` needs admin rights to install the CA into the JDK system trust store. Without elevation the OS and browsers still trust the cert, but Java tools will reject it.
This uses [mkcert](https://github.com/FiloSottile/mkcert) (installed automatically if missing). No hosts file entries needed — `*.paragonplayground.localhost` resolves to 127.0.0.1 natively.
### 2. Start infrastructure
+29 -17
View File
@@ -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;
}
}
@@ -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));
}
}
@@ -6,4 +6,4 @@ internal static class ContextKeys
internal const string SessionToken = nameof(SessionToken);
internal const string User = nameof(User);
internal const string Organization = nameof(Organization);
}
}
@@ -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;
}
}
@@ -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,10 +103,11 @@ 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,
});
}
}
}
@@ -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,
ParagonApiClient 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,
ParagonApiClient 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,
});
}
}
@@ -0,0 +1,525 @@
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
{
private static readonly Action<ILogger, Exception?> LogWebhookTestPing =
LoggerMessage.Define(
LogLevel.Information,
new EventId(1, nameof(HandleParagonWebhook)),
"Received Paragon Webhook test/verification ping."
);
private static readonly Action<ILogger, string, Exception?> LogNoRootItemFoundWarning =
LoggerMessage.Define<string>(
LogLevel.Warning,
new EventId(2, nameof(HandleParagonWebhook)),
"No root item found in DB for syncId: {SyncId}"
);
private static readonly Action<ILogger, string, string?, string?, Exception?> LogSyncErrored =
LoggerMessage.Define<string, string?, string?>(
LogLevel.Error,
new EventId(3, nameof(HandleParagonWebhook)),
"Sync {SyncId} failed with code {Code}: {Message}"
);
private static readonly Action<ILogger, string, Exception?> LogUnhandledWebhookEvent =
LoggerMessage.Define<string>(
LogLevel.Information,
new EventId(4, nameof(HandleParagonWebhook)),
"Received unhandled Paragon event type: {Event}"
);
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.MapPost("/synced-folders", CreateSyncedFolder);
_ = group.MapPost("/webhook/paragon", HandleParagonWebhook).AllowAnonymous();
_ = group.RequireAuthorization();
return group;
}
private static async Task<IResult> HandleParagonWebhook(
ParagonWebhookPayload payload,
StorageItemRepository repo,
UserCredentialRepository credRepo,
ParagonApiClient paragon,
SyncHierarchyIngestor syncHierarchyIngestor,
ILoggerFactory loggerFactory,
CancellationToken ct
)
{
var logger = loggerFactory.CreateLogger("ParagonWebhook");
if (payload is null || string.IsNullOrEmpty(payload.SyncInstanceId) || payload.Event is "test" or "ping")
{
LogWebhookTestPing(logger, null);
return Results.Ok(new { status = "ok", message = "Webhook endpoint active" });
}
var rootItem = await repo.GetByManagedSyncIdAsync(payload.SyncInstanceId, ct);
if (rootItem is null)
{
LogNoRootItemFoundWarning(logger, payload.SyncInstanceId, null);
return Results.Ok(new { status = "ignored", reason = "Sync instance not tracked in app" });
}
var jwt = paragon.GenerateToken(rootItem.OrganizationId);
switch (payload.Event)
{
case "sync_complete":
case "record_created":
case "record_updated":
await syncHierarchyIngestor.IngestSyncRecordsAsync(jwt, payload.SyncInstanceId, rootItem, ct);
break;
case "record_deleted":
if (!string.IsNullOrEmpty(payload.Data?.RecordId))
{
await DeleteSyncedRecordAsync(payload.Data.RecordId, rootItem, repo, ct);
}
break;
case "sync_errored":
LogSyncErrored(logger, payload.SyncInstanceId, payload.Error?.Code, payload.Error?.Message, null);
break;
default:
LogUnhandledWebhookEvent(logger, payload.Event, null);
break;
}
return Results.Ok(new { status = "processed" });
}
private static async Task DeleteSyncedRecordAsync(
string recordId,
StorageItem rootItem,
StorageItemRepository repo,
CancellationToken ct
)
{
StorageItem? existing = null;
if (string.IsNullOrWhiteSpace(rootItem.ManagedSyncId) is false)
{
existing = await repo.GetByParagonRecordIdAsync(
rootItem.OrganizationId,
rootItem.ManagedSyncId,
recordId,
ct
);
}
existing ??= await repo.GetBySharePointDriveItemIdAsync(rootItem.OrganizationId, recordId, ct);
if (existing is not null)
{
await repo.DeleteAsync(existing.Id, ct);
}
}
private static async Task<IResult> CreateSyncedFolder(
CreateSyncedFolderRequest request,
HttpContext context,
StorageItemRepository repo,
UserCredentialRepository credRepo,
ParagonApiClient paragon,
CancellationToken ct
)
{
var user = context.GetUser();
var org = context.GetOrganization();
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);
var folderName = await paragon.GetDriveItemNameAsync(
jwt,
spCredential?.CredentialId,
request.SharePointSiteId,
request.SharePointFolderId,
ct
);
var syncId = await paragon.EnableSyncAsync(
jwt,
spCredential?.CredentialId,
request.SharePointFolderId,
request.SharePointSiteId,
ct
);
var rootFolder = new StorageItem
{
Id = ObjectId.GenerateNewId().ToString(),
OrganizationId = org.Id,
Name = folderName,
IsFolder = true,
ParentId = request.ParentId,
IsManagedSync = true,
ManagedSyncId = syncId,
SharePointFolderId = request.SharePointFolderId,
SharePointSiteId = request.SharePointSiteId,
IsReadOnly = true,
CreatedByUserId = user.Id,
CreatedAt = DateTime.UtcNow
};
await repo.CreateAsync(rootFolder, ct);
return Results.Created($"/api/storage/{rootFolder.Id}", rootFolder);
}
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 => StorageItemResponse.From(i, users.GetValueOrDefault(i.CreatedByUserId, "Unknown"))));
}
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}", StorageItemResponse.From(item, user.DisplayName));
}
private static async Task<IResult> UploadFile(
HttpContext context,
StorageItemRepository storageRepo,
UserCredentialRepository credRepo,
OrganizationIntegrationRepository configRepo,
ParagonApiClient 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}", StorageItemResponse.From(storageItem, user.DisplayName));
}
private static async Task<IResult> DeleteItem(
string id,
HttpContext context,
StorageItemRepository repo,
UserCredentialRepository credRepo,
ParagonApiClient paragon,
CancellationToken ct
)
{
var user = context.GetUser();
var org = context.GetOrganization();
var item = await repo.GetByIdAsync(id, ct);
if (item is null)
{
return Results.NotFound();
}
if (item.IsReadOnly && !item.IsManagedSync)
{
return Results.Problem(detail: "Contents of a managed sync folder are read-only.", statusCode: StatusCodes.Status403Forbidden);
}
if (item.IsManagedSync && !string.IsNullOrEmpty(item.ManagedSyncId))
{
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);
await paragon.DeleteSyncAsync(jwt, item.ManagedSyncId, ct);
await DeleteFolderRecursiveAsync(item.Id, repo, ct);
return Results.NoContent();
}
await repo.DeleteAsync(id, ct);
return Results.NoContent();
}
private static async Task DeleteFolderRecursiveAsync(
string folderId,
StorageItemRepository repo,
CancellationToken ct
)
{
var children = await repo.GetChildrenAsync(folderId, ct);
foreach (var child in children)
{
if (child.IsFolder)
{
await DeleteFolderRecursiveAsync(child.Id, repo, ct);
}
else
{
await repo.DeleteAsync(child.Id, ct);
}
}
await repo.DeleteAsync(folderId, ct);
}
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,
ParagonApiClient paragon,
CancellationToken ct
)
{
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 (paragon.IsConfigured is false)
{
return Results.Problem(
detail: "Paragon integration is not configured.",
statusCode: StatusCodes.Status500InternalServerError
);
}
Stream fileStream;
if (
string.IsNullOrWhiteSpace(item.ManagedSyncId) is false
&& string.IsNullOrWhiteSpace(item.ParagonRecordId) is false
)
{
var jwt = paragon.GenerateToken(org.Id);
fileStream = await paragon.DownloadSyncedRecordContentAsync(
jwt,
item.ManagedSyncId,
item.ParagonRecordId,
ct
);
}
else
{
if (string.IsNullOrEmpty(item.SharePointDriveItemId) || string.IsNullOrEmpty(item.SharePointSiteId))
{
return Results.Problem(
detail: "No SharePoint reference available for this file.",
statusCode: StatusCodes.Status400BadRequest
);
}
var user = context.GetUser();
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
);
}
var jwt = paragon.GenerateToken(org.Id, spCredential.CredentialId);
fileStream = await paragon.DownloadFileAsync(
jwt,
spCredential.CredentialId,
item.SharePointSiteId,
item.SharePointDriveItemId,
ct
);
}
return Results.Stream(fileStream, item.ContentType ?? "application/octet-stream", item.Name);
}
}
@@ -14,4 +14,4 @@ internal static class TokenHelper
{
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
}
}
}
@@ -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);
}
}
@@ -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<SyncHierarchyIngestor>();
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,293 @@
using System.Net.Http.Headers;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using ParagonPlayground.Api.Options;
using ParagonPlayground.Domain.DTOs;
namespace ParagonPlayground.Api.Services;
internal sealed class ParagonApiClient
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
private readonly HttpClient _httpClient;
private readonly string _signingKey;
public string ProjectId { get; }
public ParagonApiClient(HttpClient httpClient, IOptions<ParagonOptions> options)
{
_httpClient = httpClient;
var paragonOptions = options.Value;
ProjectId = paragonOptions.ProjectId;
_signingKey = paragonOptions.SigningKey;
_httpClient.BaseAddress = new Uri(paragonOptions.ProxyBaseUrl.TrimEnd('/') + "/");
}
public bool IsConfigured =>
string.IsNullOrEmpty(ProjectId) is false
&& string.IsNullOrEmpty(_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(_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/{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 async Task<string> GetDriveItemNameAsync(
string jwt,
string? credentialId,
string siteId,
string folderId,
CancellationToken ct
)
{
var url = $"projects/{ProjectId}/sdk/proxy/sharepoint/sites/{siteId}/drive/items/{folderId}";
using var request = new HttpRequestMessage(HttpMethod.Get, url);
_ = request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {jwt}");
if (string.IsNullOrEmpty(credentialId) is false)
{
_ = 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);
if (doc.RootElement.TryGetProperty("output", out var output) && output.TryGetProperty("name", out var nameProp))
{
return nameProp.GetString() ?? "Synced SharePoint Folder";
}
return "Synced SharePoint Folder";
}
public async Task<SyncedRecordsResponse> PullSyncedRecordsAsync(
string jwt,
string syncId,
string? cursor,
int pageSize,
CancellationToken ct
)
{
var url = $"https://sync.useparagon.com/api/syncs/{syncId}/records?pageSize={pageSize}";
if (string.IsNullOrEmpty(cursor) is false)
{
url += $"&cursor={Uri.EscapeDataString(cursor)}";
}
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
using var response = await _httpClient.SendAsync(request, ct).ConfigureAwait(false);
_ = response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
return JsonSerializer.Deserialize<SyncedRecordsResponse>(json, JsonOptions) ?? new SyncedRecordsResponse();
}
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);
_ = request.Headers.TryAddWithoutValidation("X-Paragon-Use-Raw-Response", "1");
var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
_ = response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
}
public async Task<Stream> DownloadSyncedRecordContentAsync(
string jwt,
string syncId,
string recordId,
CancellationToken ct
)
{
var url = $"https://sync.useparagon.com/api/syncs/{syncId}/records/{recordId}/content";
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
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() ?? "";
}
public async Task<string> EnableSyncAsync(
string jwt,
string? credentialId,
string folderId,
string siteId,
CancellationToken ct
)
{
var url = "https://sync.useparagon.com/api/syncs";
using var request = new HttpRequestMessage(HttpMethod.Post, url);
request.Headers.Authorization = new("Bearer", jwt);
var payload = new
{
integration = "sharepoint",
pipeline = "files",
credentialId,
configuration = new
{
folderId,
siteId,
},
};
request.Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
using var response = await _httpClient.SendAsync(request, ct).ConfigureAwait(false);
var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
_ = response.EnsureSuccessStatusCode();
using var doc = JsonDocument.Parse(json);
return doc.RootElement.GetProperty("id").GetString() ?? throw new InvalidOperationException("Failed to obtain sync ID");
}
public async Task DeleteSyncAsync(string jwt, string syncId, CancellationToken ct)
{
var url = $"https://sync.useparagon.com/api/syncs/{syncId}";
using var request = new HttpRequestMessage(HttpMethod.Delete, url);
request.Headers.Authorization = new("Bearer", jwt);
using var response = await _httpClient.SendAsync(request, ct).ConfigureAwait(false);
_ = response.EnsureSuccessStatusCode();
}
}
@@ -0,0 +1,298 @@
using MongoDB.Bson;
using ParagonPlayground.Domain.DTOs;
using ParagonPlayground.Domain.Entities;
using ParagonPlayground.Infrastructure.Data;
namespace ParagonPlayground.Api.Services;
internal sealed class SyncHierarchyIngestor(StorageItemRepository repo, ParagonApiClient paragon)
{
private readonly StorageItemRepository _repo = repo;
private readonly ParagonApiClient _paragon = paragon;
public async Task IngestSyncRecordsAsync(
string jwt,
string syncId,
StorageItem rootItem,
CancellationToken ct
)
{
var fetchedRecords = await FetchRecordsAsync(jwt, syncId, ct);
var normalizedRecords = NormalizeRecords(fetchedRecords, rootItem);
if (normalizedRecords.All.Count == 0)
{
return;
}
var existingState = await LoadExistingStateAsync(rootItem.OrganizationId, normalizedRecords.ExternalIds, ct);
var folderResult = await PersistFoldersAsync(rootItem, syncId, normalizedRecords.Folders, existingState, ct);
await PersistUnresolvedFoldersAtRootAsync(rootItem, syncId, folderResult.UnresolvedFolders, existingState, ct);
await PersistFilesAsync(rootItem, syncId, normalizedRecords.Files, folderResult.FolderStorageIdByExternalId, existingState, ct);
}
private async Task<IReadOnlyList<SyncedRecordItem>> FetchRecordsAsync(
string jwt,
string syncId,
CancellationToken ct
)
{
string? cursor = null;
var hasMore = true;
var records = new List<SyncedRecordItem>();
while (hasMore && !ct.IsCancellationRequested)
{
var response = await _paragon.PullSyncedRecordsAsync(jwt, syncId, cursor, 100, ct);
records.AddRange(response.Data);
cursor = response.Paging?.Cursor;
hasMore = (response.Paging?.RemainingRecords ?? 0) > 0 && !string.IsNullOrEmpty(cursor);
}
return records;
}
private static NormalizedRecordSet NormalizeRecords(
IReadOnlyList<SyncedRecordItem> records,
StorageItem rootItem
)
{
var all = records
.Where(r => string.IsNullOrWhiteSpace(r.ExternalId) is false)
.GroupBy(r => r.ExternalId, StringComparer.Ordinal)
.Select(g => g.Last())
.Where(r => IsRootRecord(r, rootItem) is false)
.ToList();
return new NormalizedRecordSet(
All: all,
Folders: [.. all.Where(r => r.IsFolder())],
Files: [.. all.Where(r => r.IsFolder() is false)],
ExternalIds: [.. all.Select(r => r.ExternalId).Distinct(StringComparer.Ordinal)]
);
}
private async Task<ExistingStorageState> LoadExistingStateAsync(
string organizationId,
IReadOnlyList<string> externalIds,
CancellationToken ct
)
{
var existingItems = await _repo.GetBySharePointDriveItemIdsAsync(organizationId, externalIds, ct);
return ExistingStorageState.FromItems(existingItems);
}
private async Task<FolderResolutionResult> PersistFoldersAsync(
StorageItem rootItem,
string syncId,
IReadOnlyList<SyncedRecordItem> folders,
ExistingStorageState state,
CancellationToken ct
)
{
var pending = folders.ToList();
var folderStorageIdByExternalId = new Dictionary<string, string>(state.FolderStorageIdByExternalId, StringComparer.Ordinal);
while (pending.Count > 0 && !ct.IsCancellationRequested)
{
var progressMade = false;
var nextPending = new List<SyncedRecordItem>();
foreach (var folder in pending)
{
if (TryResolveParentId(rootItem, folder, folderStorageIdByExternalId, out var parentId) is false)
{
nextPending.Add(folder);
continue;
}
var persisted = await UpsertStorageItemAsync(rootItem, syncId, folder, isFolder: true, parentId, state.ItemsByExternalId, ct);
folderStorageIdByExternalId[folder.ExternalId] = persisted.Id;
progressMade = true;
}
pending = nextPending;
if (progressMade is false)
{
break;
}
}
return new FolderResolutionResult(folderStorageIdByExternalId, pending);
}
private async Task PersistUnresolvedFoldersAtRootAsync(
StorageItem rootItem,
string syncId,
IReadOnlyList<SyncedRecordItem> unresolvedFolders,
ExistingStorageState state,
CancellationToken ct
)
{
foreach (var folder in unresolvedFolders)
{
var persisted = await UpsertStorageItemAsync(rootItem, syncId, folder, isFolder: true, rootItem.Id, state.ItemsByExternalId, ct);
state.FolderStorageIdByExternalId[folder.ExternalId] = persisted.Id;
}
}
private async Task PersistFilesAsync(
StorageItem rootItem,
string syncId,
IReadOnlyList<SyncedRecordItem> files,
Dictionary<string, string> folderStorageIdByExternalId,
ExistingStorageState state,
CancellationToken ct
)
{
foreach (var file in files)
{
var parentId = rootItem.Id;
if (
IsRootParent(rootItem, file) is false
&& folderStorageIdByExternalId.TryGetValue(file.ParentFolderId, out var resolvedParentId)
)
{
parentId = resolvedParentId;
}
_ = await UpsertStorageItemAsync(rootItem, syncId, file, isFolder: false, parentId, state.ItemsByExternalId, ct);
}
}
private async Task<StorageItem> UpsertStorageItemAsync(
StorageItem rootItem,
string syncId,
SyncedRecordItem record,
bool isFolder,
string parentId,
Dictionary<string, StorageItem> itemsByExternalId,
CancellationToken ct
)
{
if (itemsByExternalId.TryGetValue(record.ExternalId, out var existing))
{
existing.Name = record.Name;
existing.IsFolder = isFolder;
existing.ParentId = parentId;
existing.ContentType = isFolder ? null : record.MimeType;
existing.FileSize = isFolder ? 0 : record.Size;
existing.SharePointSiteId = rootItem.SharePointSiteId;
existing.SharePointDriveItemId = record.ExternalId;
existing.SharePointWebUrl = record.Url;
existing.IsManagedSync = false;
existing.ManagedSyncId = syncId;
existing.ParagonRecordId = record.Id;
existing.IsReadOnly = true;
await _repo.UpdateAsync(existing, ct);
return existing;
}
var newItem = new StorageItem
{
Id = ObjectId.GenerateNewId().ToString(),
OrganizationId = rootItem.OrganizationId,
Name = record.Name,
IsFolder = isFolder,
ParentId = parentId,
ContentType = isFolder ? null : record.MimeType,
FileSize = isFolder ? 0 : record.Size,
SharePointSiteId = rootItem.SharePointSiteId,
SharePointDriveItemId = record.ExternalId,
SharePointWebUrl = record.Url,
IsManagedSync = false,
ManagedSyncId = syncId,
ParagonRecordId = record.Id,
IsReadOnly = true,
CreatedByUserId = rootItem.CreatedByUserId,
CreatedAt = DateTime.UtcNow
};
await _repo.CreateAsync(newItem, ct);
itemsByExternalId[record.ExternalId] = newItem;
return newItem;
}
private static bool TryResolveParentId(
StorageItem rootItem,
SyncedRecordItem record,
Dictionary<string, string> folderStorageIdByExternalId,
out string parentId
)
{
if (IsRootParent(rootItem, record))
{
parentId = rootItem.Id;
return true;
}
if (folderStorageIdByExternalId.TryGetValue(record.ParentFolderId, out var resolvedParentId))
{
parentId = resolvedParentId;
return true;
}
parentId = string.Empty;
return false;
}
private static bool IsRootRecord(SyncedRecordItem record, StorageItem rootItem)
{
return string.Equals(record.ExternalId, rootItem.SharePointFolderId, StringComparison.OrdinalIgnoreCase)
|| string.Equals(record.ExternalId, rootItem.SharePointDriveItemId, StringComparison.OrdinalIgnoreCase);
}
private static bool IsRootParent(StorageItem rootItem, SyncedRecordItem record)
{
return string.IsNullOrWhiteSpace(record.ParentFolderId)
|| string.Equals(record.ParentFolderId, rootItem.SharePointFolderId, StringComparison.OrdinalIgnoreCase)
|| string.Equals(record.ParentFolderId, rootItem.SharePointDriveItemId, StringComparison.OrdinalIgnoreCase);
}
private sealed record NormalizedRecordSet(
IReadOnlyList<SyncedRecordItem> All,
IReadOnlyList<SyncedRecordItem> Folders,
IReadOnlyList<SyncedRecordItem> Files,
IReadOnlyList<string> ExternalIds
);
private sealed class ExistingStorageState
{
public Dictionary<string, StorageItem> ItemsByExternalId { get; }
public Dictionary<string, string> FolderStorageIdByExternalId { get; }
private ExistingStorageState(
Dictionary<string, StorageItem> itemsByExternalId,
Dictionary<string, string> folderStorageIdByExternalId
)
{
ItemsByExternalId = itemsByExternalId;
FolderStorageIdByExternalId = folderStorageIdByExternalId;
}
public static ExistingStorageState FromItems(IEnumerable<StorageItem> items)
{
var itemList = items.ToList();
var itemsByExternalId = itemList
.Where(i => string.IsNullOrWhiteSpace(i.SharePointDriveItemId) is false)
.ToDictionary(i => i.SharePointDriveItemId!, StringComparer.Ordinal);
var folderStorageIdByExternalId = itemList
.Where(i => i.IsFolder && string.IsNullOrWhiteSpace(i.SharePointDriveItemId) is false)
.ToDictionary(i => i.SharePointDriveItemId!, i => i.Id, StringComparer.Ordinal);
return new ExistingStorageState(itemsByExternalId, folderStorageIdByExternalId);
}
}
private sealed record FolderResolutionResult(
Dictionary<string, string> FolderStorageIdByExternalId,
IReadOnlyList<SyncedRecordItem> UnresolvedFolders
);
}
@@ -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,
};
@@ -12,9 +12,12 @@ namespace ParagonPlayground.Cli.Commands;
internal class SeedCommand(
OrganizationRepository orgRepo,
UserRepository userRepo,
PasswordService passwordService) : AsyncCommand<SeedCommand.Settings>
PasswordService passwordService
) : AsyncCommand<SeedCommand.Settings>
{
internal class Settings : CommandSettings { }
internal class Settings : CommandSettings
{
}
protected override async Task<int> ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken)
{
@@ -41,11 +44,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 +61,7 @@ internal class SeedCommand(
DisplayName = name,
PasswordHash = passwordService.Hash(password),
OrganizationId = org.Id,
Role = role,
CreatedAt = DateTime.UtcNow,
};
@@ -67,7 +71,16 @@ internal class SeedCommand(
}
else
{
AnsiConsole.MarkupLine($"[yellow]User '{email}' already exists[/]");
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[/]");
}
}
}
@@ -0,0 +1,2 @@
[*.{cs,vb}]
dotnet_diagnostic.CA1056.severity = none
@@ -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,17 @@
namespace ParagonPlayground.Domain.DTOs;
/// <summary>Represents a request to sync a folder from a sharepoint site.</summary>
public class CreateSyncedFolderRequest
{
/// <summary>The sharepoint id for the folder that will be synced.</summary>
public required string SharePointFolderId { get; set; }
/// <summary>The id of the sharepoint site where the folder to be synced exists.</summary>
public required string SharePointSiteId { get; set; }
/// <summary>The id of the parent id for the folder being synced.</summary>
public string ParentId { get; set; } = string.Empty;
/// <summary>The associated credentials with the folder sync.</summary>
public string CredentialId { get; set; } = string.Empty;
}
@@ -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; }
}
@@ -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; }
}
@@ -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; }
}
@@ -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;
}
@@ -0,0 +1,60 @@
namespace ParagonPlayground.Domain.DTOs;
/// <summary>Webhook payload sent by Paragon for managed sync lifecycle events.</summary>
public class ParagonWebhookPayload
{
/// <summary>Webhook event type (for example: sync_complete, record_updated, record_deleted).</summary>
public string Event { get; set; } = string.Empty;
/// <summary>Paragon managed sync instance identifier associated with this event.</summary>
public string SyncInstanceId { get; set; } = string.Empty;
/// <summary>Sync descriptor from the webhook payload, when provided.</summary>
public string Sync { get; set; } = string.Empty;
/// <summary>Credential identifier used by the integration, when included by Paragon.</summary>
public string CredentialId { get; set; } = string.Empty;
/// <summary>User context attached to the webhook payload.</summary>
public ParagonWebhookUser? User { get; set; }
/// <summary>Event-specific data payload.</summary>
public ParagonWebhookData? Data { get; set; }
/// <summary>Error payload when the webhook event represents a failure state.</summary>
public ParagonWebhookError? Error { get; set; }
}
/// <summary>User metadata included in a Paragon webhook.</summary>
public class ParagonWebhookUser
{
/// <summary>Paragon user identifier for the connected user.</summary>
public string Id { get; set; } = string.Empty;
}
/// <summary>Event data section of a Paragon webhook payload.</summary>
public class ParagonWebhookData
{
/// <summary>Logical model type for the emitted record event.</summary>
public string Model { get; set; } = string.Empty;
/// <summary>Record identifier relevant to create/update/delete record events.</summary>
public string? RecordId { get; set; }
/// <summary>Timestamp indicating when the sync data was last synchronized.</summary>
public string? SyncedAt { get; set; }
/// <summary>Number of records referenced by the event when available.</summary>
public int? NumRecords { get; set; }
}
/// <summary>Error details included with failed sync webhook events.</summary>
public class ParagonWebhookError
{
/// <summary>Provider-specific error code.</summary>
public string? Code { get; set; }
/// <summary>Human-readable error description.</summary>
public string? Message { get; set; }
}
@@ -0,0 +1,66 @@
using ParagonPlayground.Domain.Entities;
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>Indicates if the storage item is associated with a managed sync.</summary>
public bool IsManagedSync { get; set; }
/// <summary>Indicates if the storage item can be edited by our application.</summary>
public bool IsReadOnly { 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; }
/// <summary>Creates a response DTO from a storage item entity.</summary>
public static StorageItemResponse From(StorageItem item, string createdByDisplayName)
{
ArgumentNullException.ThrowIfNull(item);
ArgumentNullException.ThrowIfNull(createdByDisplayName);
return new StorageItemResponse
{
Id = item.Id,
Name = item.Name,
IsFolder = item.IsFolder,
ParentId = item.ParentId,
ContentType = item.ContentType,
FileSize = item.FileSize,
SharePointWebUrl = item.SharePointWebUrl,
IsManagedSync = item.IsManagedSync,
IsReadOnly = item.IsReadOnly,
CreatedByUserId = item.CreatedByUserId,
CreatedByDisplayName = createdByDisplayName,
CreatedAt = item.CreatedAt,
};
}
}
@@ -0,0 +1,84 @@
using System.Collections.ObjectModel;
using System.Text.Json.Serialization;
namespace ParagonPlayground.Domain.DTOs;
/// <summary>Response payload returned by the Paragon Sync API GET /api/syncs/{syncId}/records endpoint.</summary>
public class SyncedRecordsResponse
{
/// <summary>Synced records returned for the requested page.</summary>
[JsonPropertyName("data")]
public Collection<SyncedRecordItem> Data { get; init; } = [];
/// <summary>Paging metadata used to continue retrieving additional records.</summary>
[JsonPropertyName("paging")]
public SyncedRecordsPaging Paging { get; set; } = new();
}
/// <summary>Pagination metadata returned with synced record batches.</summary>
public class SyncedRecordsPaging
{
/// <summary>Total number of records known to the sync.</summary>
[JsonPropertyName("totalRecords")]
public int TotalRecords { get; set; }
/// <summary>Total number of active (non-deleted) records in the sync.</summary>
[JsonPropertyName("totalActiveRecords")]
public int TotalActiveRecords { get; set; }
/// <summary>Count of records still remaining after the current page.</summary>
[JsonPropertyName("remainingRecords")]
public int RemainingRecords { get; set; }
/// <summary>Cursor to request the next page of records.</summary>
[JsonPropertyName("cursor")]
public string? Cursor { get; set; }
/// <summary>Unix timestamp of the latest record observed in this page.</summary>
[JsonPropertyName("lastSeen")]
public long LastSeen { get; set; }
}
/// <summary>Represents a single synced file or record item returned by Paragon Managed Sync.</summary>
public class SyncedRecordItem
{
/// <summary>Paragon-generated sync record identifier.</summary>
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;
/// <summary>Provider-native record identifier (for example, SharePoint drive item ID).</summary>
[JsonPropertyName("externalId")]
public string ExternalId { get; set; } = string.Empty;
/// <summary>Display name for the synced file or folder.</summary>
[JsonPropertyName("name")]
public string Name { get; set; } = "Untitled Item";
/// <summary>MIME type of the synced file; empty for folders.</summary>
[JsonPropertyName("mimeType")]
public string MimeType { get; set; } = "application/octet-stream";
/// <summary>File size in bytes.</summary>
[JsonPropertyName("size")]
public long Size { get; set; }
/// <summary>Provider URL for viewing the record in the source system.</summary>
[JsonPropertyName("url")]
public string Url { get; set; } = string.Empty;
/// <summary>External identifier of the record's parent folder.</summary>
[JsonPropertyName("parentFolderId")]
public string ParentFolderId { get; set; } = string.Empty;
/// <summary>Integration-specific metadata attached to the synced record.</summary>
[JsonPropertyName("customFields")]
public Dictionary<string, object>? CustomFields { get; init; }
/// <summary>Determines whether the synced item represents a folder.</summary>
public bool IsFolder()
{
return string.IsNullOrEmpty(MimeType);
}
}
@@ -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";
}
@@ -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,59 @@
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>Indicates whether the storage item is associated with a managed sync</summary>
public bool IsManagedSync { get; set; }
/// <summary>Identifies the managed sync this storage item is attached to.</summary>
public string ManagedSyncId { get; set; } = string.Empty;
/// <summary>Paragon Sync record ID (sync-generated UUID) for items ingested from managed sync.</summary>
public string ParagonRecordId { get; set; } = string.Empty;
/// <summary>Identifies the sharepoint folder id that the managed sync is attached to.</summary>
public string SharePointFolderId { get; set; } = string.Empty;
/// <summary>Indicates whether the storage item can be edited or modified by our application.</summary>
public bool IsReadOnly { 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;
}
@@ -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)
{
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -0,0 +1,134 @@
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>Lists all direct child items of a given parent folder.</summary>
public async Task<List<StorageItem>> GetChildrenAsync(string parentId, CancellationToken ct)
{
return await _context.StorageItems.Find(i => i.ParentId == parentId)
.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>Finds a root storage item by Paragon Managed Sync ID.</summary>
public async Task<StorageItem?> GetByManagedSyncIdAsync(string managedSyncId, CancellationToken ct)
{
return await _context.StorageItems.Find(i => i.ManagedSyncId == managedSyncId)
.FirstOrDefaultAsync(ct)
.ConfigureAwait(false);
}
/// <summary>Finds a storage item by organization ID and SharePoint Drive Item ID.</summary>
public async Task<StorageItem?> GetBySharePointDriveItemIdAsync(
string organizationId,
string sharePointDriveItemId,
CancellationToken ct
)
{
var filter = Builders<StorageItem>.Filter.Eq(i => i.OrganizationId, organizationId)
& Builders<StorageItem>.Filter.Eq(i => i.SharePointDriveItemId, sharePointDriveItemId);
return await _context.StorageItems.Find(filter)
.FirstOrDefaultAsync(ct)
.ConfigureAwait(false);
}
/// <summary>Finds a synced storage item by organization, sync ID, and Paragon record ID.</summary>
public async Task<StorageItem?> GetByParagonRecordIdAsync(
string organizationId,
string managedSyncId,
string paragonRecordId,
CancellationToken ct
)
{
var filter = Builders<StorageItem>.Filter.Eq(i => i.OrganizationId, organizationId)
& Builders<StorageItem>.Filter.Eq(i => i.ManagedSyncId, managedSyncId)
& Builders<StorageItem>.Filter.Eq(i => i.ParagonRecordId, paragonRecordId);
return await _context.StorageItems.Find(filter)
.FirstOrDefaultAsync(ct)
.ConfigureAwait(false);
}
/// <summary>Finds storage items by organization ID and SharePoint Drive Item IDs.</summary>
public async Task<List<StorageItem>> GetBySharePointDriveItemIdsAsync(
string organizationId,
IReadOnlyCollection<string> sharePointDriveItemIds,
CancellationToken ct
)
{
ArgumentNullException.ThrowIfNull(sharePointDriveItemIds);
if (sharePointDriveItemIds.Count == 0)
{
return [];
}
var filter = Builders<StorageItem>.Filter.Eq(i => i.OrganizationId, organizationId)
& Builders<StorageItem>.Filter.In(i => i.SharePointDriveItemId, sharePointDriveItemIds);
return await _context.StorageItems.Find(filter)
.ToListAsync(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>Updates an existing storage item.</summary>
public async Task UpdateAsync(StorageItem item, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(item);
_ = await _context.StorageItems
.ReplaceOneAsync(i => i.Id == item.Id, 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);
}
}
@@ -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;
}
}
@@ -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);
}
}
@@ -91,4 +91,4 @@ public class CookieService
&& string.IsNullOrEmpty(headerToken) is false
&& cookieToken == headerToken;
}
}
}
@@ -14,4 +14,4 @@ public class PasswordService
{
return BCrypt.Net.BCrypt.Verify(password, hash);
}
}
}
@@ -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
+6 -2
View File
@@ -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/'],
},
)
);
+8 -2
View File
@@ -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>
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"
}
}
+106 -1
View File
@@ -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>
<router-view />
<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,55 @@
<script setup lang="ts">
type IconName =
'folder' | 'file' | 'upload' | 'download' | 'external' | 'trash' | 'x' | 'plus' | 'sharepoint';
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'],
sharepoint: [
'M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242',
'M12 12v6',
'm-2.5-2.5 2.5 2.5 2.5-2.5',
],
};
</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 {
return { name: 'login' };
}
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' };
}
});
@@ -11,7 +11,7 @@ export async function api<T>(path: string, init?: RequestInit): Promise<T> {
};
const xsrf = getXsrfToken();
if (xsrf) {
headers['X-XSRF-Token'] = xsrf;
}
@@ -23,13 +23,13 @@ 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) {
return undefined as T;
}
return res.json();
}
@@ -1,9 +1,10 @@
import { api } from './api';
export interface UserResponse {
export type UserResponse = {
id: string;
email: string;
displayName: string;
role: string;
organizationId: string;
organizationName: string;
organizationSlug: string;
@@ -0,0 +1,76 @@
import { api } from './api';
export type ParagonTokenResponse = {
paragonJwt: string;
projectId: string;
}
export type IntegrationConfig = {
id: string;
organizationId: string;
connectionMode: string;
sharePointSiteUrl: string | null;
sharePointSiteId: string | null;
sharePointFolderPath: string | null;
updatedAt: string;
}
export type IntegrationConfigRequest = {
connectionMode: string;
sharePointSiteUrl: string | null;
sharePointFolderPath: string | null;
}
export type CredentialResponse = {
id: string;
credentialId: string;
integrationType: string;
connectedAt: string;
}
export type 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,77 @@
import { api } from './api';
export type StorageItem = {
id: string;
name: string;
isFolder: boolean;
parentId: string | null;
contentType: string | null;
fileSize: number;
sharePointWebUrl: string | null;
createdByUserId: string;
createdByDisplayName: string;
createdAt: string;
isReadOnly: boolean;
isManagedSync: boolean;
}
export type CreateFolderRequest = {
name: string;
parentId: string | null;
}
export type DownloadResponse = {
sharePointUrl: string | null;
proxyUrl: string | null;
}
export type CreateSyncedFolderRequest = {
sharePointFolderId: string;
sharePointSiteId: string;
parentId?: string | null;
credentialId?: string;
}
export async function createSyncedFolder(req: CreateSyncedFolderRequest): Promise<StorageItem> {
return api<StorageItem>('/storage/synced-folders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
});
}
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,250 @@
* {
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,
.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,17 @@
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 () => {
user.value = await me();
});
async function handleLogout() {
await logout();
router.push('/login');
}
onMounted(async () => {
user.value = await me();
});
</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 {
font-weight: 600;
margin-top: 0.5rem;
color: var(--color-text-secondary);
font-size: var(--text-sm);
}
dl dt {
font-weight: 600;
margin-top: 0.5rem;
color: #555;
}
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;
}
dl dd {
margin: 0 0 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,581 @@
<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,
createSyncedFolder,
} from '../services/storage';
import { formatLocaleDate } from '../utils/utils';
import { getParagonToken } from '../services/integration';
type SharePointIds = {
listId: string;
webId: string;
siteId: string;
listItemId: string;
listItemUniqueId: string;
};
type ParentReference = {
driveId: string;
sharepointIds: SharePointIds;
};
type SelectedFilePickerItem = {
id: string;
parentReference: ParentReference;
sharepointIds: SharePointIds;
};
type ParagonPickerInstance = {
init: () => Promise<void>;
open: () => void;
};
type ParagonSDKWithPicker = {
authenticate: (projectId: string, jwt: string) => Promise<void>;
ExternalFilePicker: new (
integration: string,
options: {
onFileSelect: (items: SelectedFilePickerItem[]) => Promise<void> | void;
},
) => ParagonPickerInstance;
};
const route = useRoute();
const router = useRouter();
const items = ref<StorageItem[]>([]);
const loading = ref(true);
const error = ref('');
const breadcrumbs = ref<{ id: string | null; name: string }[]>([]);
const showNewFolder = ref(false);
const newFolderName = ref('');
const currentFolder = ref<StorageItem | null>(null);
const folders = computed(() => items.value.filter((i) => i.isFolder));
const files = computed(() => items.value.filter((i) => !i.isFolder));
const isCurrentFolderReadOnly = computed(() => currentFolder.value?.isReadOnly ?? false);
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 }[] = [];
let resolvedFolder: StorageItem | null = null;
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;
resolvedFolder = folder;
}
currentFolder.value = resolvedFolder;
breadcrumbs.value = crumbs;
items.value = await getItems(currentFolder.value?.id ?? null);
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Failed to navigate';
items.value = [];
currentFolder.value = null;
} 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: currentFolder.value?.id ?? null,
});
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, currentFolder.value?.id ?? null);
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`;
}
async function openSharePointFolderPicker() {
try {
const tokenResponse = await getParagonToken();
const { paragon } = await import('@useparagon/connect');
const sdk = paragon as unknown as ParagonSDKWithPicker;
await sdk.authenticate(tokenResponse.projectId, tokenResponse.paragonJwt);
const picker = new sdk.ExternalFilePicker('sharepoint', {
onFileSelect: async (selectedItems: SelectedFilePickerItem[]) => {
if (!selectedItems || selectedItems.length === 0) return;
const selectedItem = selectedItems[0];
const folderId = selectedItem.id;
const siteId = selectedItem.sharepointIds.siteId;
await createSyncedFolder({
sharePointFolderId: folderId,
sharePointSiteId: siteId,
parentId: currentFolder.value?.id ?? null,
});
await navigateToPath(pathSegments.value);
},
});
await picker.init();
picker.open();
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Failed to open SharePoint picker';
}
}
</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: currentFolder === 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"
:disabled="isCurrentFolderReadOnly"
@click="showNewFolder = !showNewFolder"
>
<AppIcon
v-if="!showNewFolder"
name="plus"
:size="14"
/>
{{ showNewFolder ? 'Cancel' : 'New Folder' }}
</button>
<label
class="btn btn-primary upload-label"
:class="{ disabled: isCurrentFolderReadOnly }"
>
<AppIcon
name="upload"
:size="14"
/>
Upload File
<input
type="file"
hidden
:disabled="isCurrentFolderReadOnly"
@change="handleUpload"
/>
</label>
<button
class="btn btn-secondary"
@click="openSharePointFolderPicker"
>
<AppIcon
name="sharepoint"
:size="14"
/>
Sync Folder from SharePoint
</button>
</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
v-if="!folder.isReadOnly || folder.isManagedSync"
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
v-if="!file.isReadOnly"
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 = "";
loading.value = true;
async function handleSubmit() {
error.value = '';
loading.value = true;
try {
await login(email.value, password.value);
router.push("/");
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : "Login failed";
} finally {
loading.value = false;
try {
await login(email.value, password.value);
router.push('/');
} catch (e: unknown) {
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 {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: #f5f5f5;
}
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
padding: 1rem;
}
.login-form {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 400px;
}
.login-form {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 2rem;
width: 100%;
max-width: 400px;
}
.login-form h1 {
margin: 0 0 0.25rem;
font-size: 1.5rem;
}
.login-form h1 {
margin: 0 0 0.25rem;
font-size: var(--text-xl);
font-weight: 650;
}
.subtitle {
color: #666;
margin-bottom: 1.5rem;
}
.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 {
display: block;
margin-bottom: 1rem;
font-weight: 600;
font-size: var(--text-sm);
}
label {
display: block;
margin-bottom: 1rem;
font-weight: 600;
}
input {
display: block;
width: 100%;
margin-top: 0.25rem;
}
input {
display: block;
width: 100%;
padding: 0.5rem;
margin-top: 0.25rem;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 1rem;
}
button {
width: 100%;
padding: 0.75rem;
background: #1a73e8;
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
}
button:disabled {
opacity: 0.6;
}
.login-submit {
width: 100%;
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 -4
View File
@@ -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({
},
},
},
})
}));
+1
View File
@@ -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;