feat: sharepoint for storing files #1

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