Compare commits

...
2 Commits
22 changed files with 1194 additions and 210 deletions
+1
View File
@@ -1,6 +1,7 @@
{
"FSharp.suggestGitignore": false,
"cSpell.words": [
"Ingestor",
"useparagon"
]
}
-2
View File
@@ -17,8 +17,6 @@ A application harness to explore using Paragon for integrations.
.\scripts\setup-certs.ps1
```
> **Run this from an elevated (Admin) PowerShell prompt.** `mkcert` needs admin rights to install the CA into the JDK system trust store. Without elevation the OS and browsers still trust the cert, but Java tools will reject it.
This uses [mkcert](https://github.com/FiloSottile/mkcert) (installed automatically if missing). No hosts file entries needed — `*.paragonplayground.localhost` resolves to 127.0.0.1 natively.
### 2. Start infrastructure
@@ -56,7 +56,7 @@ internal static class IntegrationEndpoints
HttpContext context,
OrganizationIntegrationRepository repo,
UserCredentialRepository credRepo,
ParagonService paragon,
ParagonApiClient paragon,
CancellationToken ct
)
{
@@ -16,7 +16,7 @@ internal static class ParagonEndpoints
private static async Task<IResult> GenerateToken(
HttpContext context,
ParagonService paragon,
ParagonApiClient paragon,
UserCredentialRepository credRepo,
CancellationToken ct
)
@@ -12,6 +12,34 @@ namespace ParagonPlayground.Api.Endpoints;
internal static class StorageEndpoints
{
private static readonly Action<ILogger, Exception?> LogWebhookTestPing =
LoggerMessage.Define(
LogLevel.Information,
new EventId(1, nameof(HandleParagonWebhook)),
"Received Paragon Webhook test/verification ping."
);
private static readonly Action<ILogger, string, Exception?> LogNoRootItemFoundWarning =
LoggerMessage.Define<string>(
LogLevel.Warning,
new EventId(2, nameof(HandleParagonWebhook)),
"No root item found in DB for syncId: {SyncId}"
);
private static readonly Action<ILogger, string, string?, string?, Exception?> LogSyncErrored =
LoggerMessage.Define<string, string?, string?>(
LogLevel.Error,
new EventId(3, nameof(HandleParagonWebhook)),
"Sync {SyncId} failed with code {Code}: {Message}"
);
private static readonly Action<ILogger, string, Exception?> LogUnhandledWebhookEvent =
LoggerMessage.Define<string>(
LogLevel.Information,
new EventId(4, nameof(HandleParagonWebhook)),
"Received unhandled Paragon event type: {Event}"
);
internal static RouteGroupBuilder MapStorageEndpoints(this RouteGroupBuilder group)
{
_ = group.MapGet("/", ListItems);
@@ -20,10 +48,150 @@ internal static class StorageEndpoints
_ = group.MapDelete("/{id}", DeleteItem);
_ = group.MapGet("/{id}/download", GetDownloadUrls);
_ = group.MapGet("/{id}/content", ProxyContent);
_ = group.MapPost("/synced-folders", CreateSyncedFolder);
_ = group.MapPost("/webhook/paragon", HandleParagonWebhook).AllowAnonymous();
_ = group.RequireAuthorization();
return group;
}
private static async Task<IResult> HandleParagonWebhook(
ParagonWebhookPayload payload,
StorageItemRepository repo,
UserCredentialRepository credRepo,
ParagonApiClient paragon,
SyncHierarchyIngestor syncHierarchyIngestor,
ILoggerFactory loggerFactory,
CancellationToken ct
)
{
var logger = loggerFactory.CreateLogger("ParagonWebhook");
if (payload is null || string.IsNullOrEmpty(payload.SyncInstanceId) || payload.Event is "test" or "ping")
{
LogWebhookTestPing(logger, null);
return Results.Ok(new { status = "ok", message = "Webhook endpoint active" });
}
var rootItem = await repo.GetByManagedSyncIdAsync(payload.SyncInstanceId, ct);
if (rootItem is null)
{
LogNoRootItemFoundWarning(logger, payload.SyncInstanceId, null);
return Results.Ok(new { status = "ignored", reason = "Sync instance not tracked in app" });
}
var jwt = paragon.GenerateToken(rootItem.OrganizationId);
switch (payload.Event)
{
case "sync_complete":
case "record_created":
case "record_updated":
await syncHierarchyIngestor.IngestSyncRecordsAsync(jwt, payload.SyncInstanceId, rootItem, ct);
break;
case "record_deleted":
if (!string.IsNullOrEmpty(payload.Data?.RecordId))
{
await DeleteSyncedRecordAsync(payload.Data.RecordId, rootItem, repo, ct);
}
break;
case "sync_errored":
LogSyncErrored(logger, payload.SyncInstanceId, payload.Error?.Code, payload.Error?.Message, null);
break;
default:
LogUnhandledWebhookEvent(logger, payload.Event, null);
break;
}
return Results.Ok(new { status = "processed" });
}
private static async Task DeleteSyncedRecordAsync(
string recordId,
StorageItem rootItem,
StorageItemRepository repo,
CancellationToken ct
)
{
StorageItem? existing = null;
if (string.IsNullOrWhiteSpace(rootItem.ManagedSyncId) is false)
{
existing = await repo.GetByParagonRecordIdAsync(
rootItem.OrganizationId,
rootItem.ManagedSyncId,
recordId,
ct
);
}
existing ??= await repo.GetBySharePointDriveItemIdAsync(rootItem.OrganizationId, recordId, ct);
if (existing is not null)
{
await repo.DeleteAsync(existing.Id, ct);
}
}
private static async Task<IResult> CreateSyncedFolder(
CreateSyncedFolderRequest request,
HttpContext context,
StorageItemRepository repo,
UserCredentialRepository credRepo,
ParagonApiClient paragon,
CancellationToken ct
)
{
var user = context.GetUser();
var org = context.GetOrganization();
var credentials = await credRepo.GetByUserIdAsync(user.Id, ct);
var spCredential = credentials.FirstOrDefault(
c => c.IntegrationType.Equals("sharepoint", StringComparison.OrdinalIgnoreCase)
);
var jwt = paragon.GenerateToken(org.Id, spCredential?.CredentialId);
var folderName = await paragon.GetDriveItemNameAsync(
jwt,
spCredential?.CredentialId,
request.SharePointSiteId,
request.SharePointFolderId,
ct
);
var syncId = await paragon.EnableSyncAsync(
jwt,
spCredential?.CredentialId,
request.SharePointFolderId,
request.SharePointSiteId,
ct
);
var rootFolder = new StorageItem
{
Id = ObjectId.GenerateNewId().ToString(),
OrganizationId = org.Id,
Name = folderName,
IsFolder = true,
ParentId = request.ParentId,
IsManagedSync = true,
ManagedSyncId = syncId,
SharePointFolderId = request.SharePointFolderId,
SharePointSiteId = request.SharePointSiteId,
IsReadOnly = true,
CreatedByUserId = user.Id,
CreatedAt = DateTime.UtcNow
};
await repo.CreateAsync(rootFolder, ct);
return Results.Created($"/api/storage/{rootFolder.Id}", rootFolder);
}
private static async Task<IResult> ListItems(
string? parentId,
HttpContext context,
@@ -44,19 +212,7 @@ internal static class StorageEndpoints
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,
}));
return Results.Ok(items.Select(i => StorageItemResponse.From(i, users.GetValueOrDefault(i.CreatedByUserId, "Unknown"))));
}
private static async Task<IResult> CreateFolder(
@@ -87,16 +243,7 @@ internal static class StorageEndpoints
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,
});
return Results.Created($"/api/storage/{item.Id}", StorageItemResponse.From(item, user.DisplayName));
}
private static async Task<IResult> UploadFile(
@@ -104,7 +251,7 @@ internal static class StorageEndpoints
StorageItemRepository storageRepo,
UserCredentialRepository credRepo,
OrganizationIntegrationRepository configRepo,
ParagonService paragon,
ParagonApiClient paragon,
UserRepository userRepo,
CancellationToken ct
)
@@ -206,42 +353,71 @@ internal static class StorageEndpoints
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,
});
return Results.Created($"/api/storage/{storageItem.Id}", StorageItemResponse.From(storageItem, user.DisplayName));
}
private static async Task<IResult> DeleteItem(
string id,
HttpContext context,
StorageItemRepository repo,
UserCredentialRepository credRepo,
ParagonApiClient paragon,
CancellationToken ct
)
{
var user = context.GetUser();
var org = context.GetOrganization();
var item = await repo.GetByIdAsync(id, ct);
if (item is null || item.OrganizationId != org.Id)
if (item is null)
{
return Results.Problem(detail: "Item not found", statusCode: StatusCodes.Status404NotFound);
return Results.NotFound();
}
if (item.IsReadOnly && !item.IsManagedSync)
{
return Results.Problem(detail: "Contents of a managed sync folder are read-only.", statusCode: StatusCodes.Status403Forbidden);
}
if (item.IsManagedSync && !string.IsNullOrEmpty(item.ManagedSyncId))
{
var credentials = await credRepo.GetByUserIdAsync(user.Id, ct);
var spCredential = credentials.FirstOrDefault(c => c.IntegrationType.Equals("sharepoint", StringComparison.OrdinalIgnoreCase));
var jwt = paragon.GenerateToken(org.Id, spCredential?.CredentialId);
await paragon.DeleteSyncAsync(jwt, item.ManagedSyncId, ct);
await DeleteFolderRecursiveAsync(item.Id, repo, ct);
return Results.NoContent();
}
await repo.DeleteAsync(id, ct);
return Results.NoContent();
}
private static async Task DeleteFolderRecursiveAsync(
string folderId,
StorageItemRepository repo,
CancellationToken ct
)
{
var children = await repo.GetChildrenAsync(folderId, ct);
foreach (var child in children)
{
if (child.IsFolder)
{
await DeleteFolderRecursiveAsync(child.Id, repo, ct);
}
else
{
await repo.DeleteAsync(child.Id, ct);
}
}
await repo.DeleteAsync(folderId, ct);
}
private static async Task<IResult> GetDownloadUrls(
string id,
HttpContext context,
@@ -272,11 +448,10 @@ internal static class StorageEndpoints
HttpContext context,
StorageItemRepository storageRepo,
UserCredentialRepository credRepo,
ParagonService paragon,
ParagonApiClient paragon,
CancellationToken ct
)
{
var user = context.GetUser();
var org = context.GetOrganization();
var item = await storageRepo.GetByIdAsync(id, ct);
@@ -286,6 +461,32 @@ internal static class StorageEndpoints
return Results.Problem(detail: "Item not found", statusCode: StatusCodes.Status404NotFound);
}
if (paragon.IsConfigured is false)
{
return Results.Problem(
detail: "Paragon integration is not configured.",
statusCode: StatusCodes.Status500InternalServerError
);
}
Stream fileStream;
if (
string.IsNullOrWhiteSpace(item.ManagedSyncId) is false
&& string.IsNullOrWhiteSpace(item.ParagonRecordId) is false
)
{
var jwt = paragon.GenerateToken(org.Id);
fileStream = await paragon.DownloadSyncedRecordContentAsync(
jwt,
item.ManagedSyncId,
item.ParagonRecordId,
ct
);
}
else
{
if (string.IsNullOrEmpty(item.SharePointDriveItemId) || string.IsNullOrEmpty(item.SharePointSiteId))
{
return Results.Problem(
@@ -294,6 +495,7 @@ internal static class StorageEndpoints
);
}
var user = context.GetUser();
var credentials = await credRepo.GetByUserIdAsync(user.Id, ct);
var spCredential = credentials.FirstOrDefault(c =>
c.IntegrationType.Equals("sharepoint", StringComparison.OrdinalIgnoreCase)
@@ -307,22 +509,16 @@ internal static class StorageEndpoints
);
}
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(
fileStream = await paragon.DownloadFileAsync(
jwt,
spCredential.CredentialId,
item.SharePointSiteId,
item.SharePointDriveItemId,
ct
);
}
return Results.Stream(fileStream, item.ContentType ?? "application/octet-stream", item.Name);
}
@@ -28,7 +28,7 @@ builder.Services.AddSingleton<SessionRepository>();
builder.Services.AddSingleton<StorageItemRepository>();
builder.Services.AddSingleton<UserCredentialRepository>();
builder.Services.AddSingleton<OrganizationIntegrationRepository>();
builder.Services.AddSingleton<ParagonService>();
builder.Services.AddSingleton<SyncHierarchyIngestor>();
builder.Services.Configure<ParagonOptions>(builder.Configuration.GetSection(ParagonOptions.SectionName));
@@ -1,22 +1,145 @@
using System.Net.Http.Headers;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using ParagonPlayground.Api.Options;
using ParagonPlayground.Domain.DTOs;
namespace ParagonPlayground.Api.Services;
internal sealed class ParagonApiClient
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
private readonly HttpClient _httpClient;
private readonly string _projectId;
private readonly string _signingKey;
public string ProjectId { get; }
public ParagonApiClient(HttpClient httpClient, IOptions<ParagonOptions> options)
{
_httpClient = httpClient;
_projectId = options.Value.ProjectId;
_httpClient.BaseAddress = new Uri(options.Value.ProxyBaseUrl.TrimEnd('/') + "/");
var paragonOptions = options.Value;
ProjectId = paragonOptions.ProjectId;
_signingKey = paragonOptions.SigningKey;
_httpClient.BaseAddress = new Uri(paragonOptions.ProxyBaseUrl.TrimEnd('/') + "/");
}
public bool IsConfigured =>
string.IsNullOrEmpty(ProjectId) is false
&& string.IsNullOrEmpty(_signingKey) is false;
public string GenerateToken(string organizationId, string? credentialId = null)
{
if (IsConfigured is false)
{
throw new InvalidOperationException("Paragon is not configured. Set Paragon:ProjectId and Paragon:SigningKey.");
}
using var rsa = RSA.Create();
rsa.ImportFromPem(_signingKey);
var key = new RsaSecurityKey(rsa.ExportParameters(true)) { KeyId = "paragon" };
var signingCredentials = new SigningCredentials(key, SecurityAlgorithms.RsaSha256);
var now = DateTime.UtcNow;
var permissions = credentialId is not null
? new Dictionary<string, object>
{
["integration:sharepoint"] = new Dictionary<string, object>
{
[$"credential:{credentialId}"] = true,
},
}
: (object)new Dictionary<string, object>
{
["integration:sharepoint"] = new Dictionary<string, object>
{
["credential:*"] = new[] { "credential:write" },
},
};
var claims = new[]
{
new Claim("sub", $"org:{organizationId}"),
new Claim("aud", $"useparagon.com/{ProjectId}"),
new Claim("urn:useparagon:connect:permissions", JsonSerializer.Serialize(permissions)),
};
var token = new JwtSecurityToken(
claims: claims,
notBefore: now,
expires: now.AddHours(1),
signingCredentials: signingCredentials
);
return new JwtSecurityTokenHandler().WriteToken(token);
}
public async Task<string> GetDriveItemNameAsync(
string jwt,
string? credentialId,
string siteId,
string folderId,
CancellationToken ct
)
{
var url = $"projects/{ProjectId}/sdk/proxy/sharepoint/sites/{siteId}/drive/items/{folderId}";
using var request = new HttpRequestMessage(HttpMethod.Get, url);
_ = request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {jwt}");
if (string.IsNullOrEmpty(credentialId) is false)
{
_ = request.Headers.TryAddWithoutValidation("X-Paragon-Credential", credentialId);
}
using var response = await _httpClient.SendAsync(request, ct).ConfigureAwait(false);
_ = response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.TryGetProperty("output", out var output) && output.TryGetProperty("name", out var nameProp))
{
return nameProp.GetString() ?? "Synced SharePoint Folder";
}
return "Synced SharePoint Folder";
}
public async Task<SyncedRecordsResponse> PullSyncedRecordsAsync(
string jwt,
string syncId,
string? cursor,
int pageSize,
CancellationToken ct
)
{
var url = $"https://sync.useparagon.com/api/syncs/{syncId}/records?pageSize={pageSize}";
if (string.IsNullOrEmpty(cursor) is false)
{
url += $"&cursor={Uri.EscapeDataString(cursor)}";
}
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
using var response = await _httpClient.SendAsync(request, ct).ConfigureAwait(false);
_ = response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
return JsonSerializer.Deserialize<SyncedRecordsResponse>(json, JsonOptions) ?? new SyncedRecordsResponse();
}
public async Task<string> UploadFileAsync(
@@ -30,7 +153,7 @@ internal sealed class ParagonApiClient
CancellationToken ct
)
{
var url = $"projects/{_projectId}/sdk/proxy/sharepoint"
var url = $"projects/{ProjectId}/sdk/proxy/sharepoint"
+ $"/sites/{siteId}/drive/root:/{folderPath.Trim('/')}/{fileName}:/content";
using var ms = new MemoryStream();
@@ -60,13 +183,31 @@ internal sealed class ParagonApiClient
CancellationToken ct
)
{
var url = $"projects/{_projectId}/sdk/proxy/sharepoint"
var url = $"projects/{ProjectId}/sdk/proxy/sharepoint"
+ $"/sites/{siteId}/drive/items/{driveItemId}/content";
using var request = new HttpRequestMessage(HttpMethod.Get, url);
_ = request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {jwt}");
_ = request.Headers.TryAddWithoutValidation("X-Paragon-Credential", credentialId);
_ = request.Headers.TryAddWithoutValidation("X-Paragon-Use-Raw-Response", "1");
var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
_ = response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
}
public async Task<Stream> DownloadSyncedRecordContentAsync(
string jwt,
string syncId,
string recordId,
CancellationToken ct
)
{
var url = $"https://sync.useparagon.com/api/syncs/{syncId}/records/{recordId}/content";
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
_ = response.EnsureSuccessStatusCode();
@@ -88,7 +229,7 @@ internal sealed class ParagonApiClient
? ":/" + string.Join("/", segments.Select(Uri.EscapeDataString))
: "";
var proxyUrl = $"projects/{_projectId}/sdk/proxy/sharepoint/sites/{uri.Host}{encodedPath}";
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}");
@@ -104,4 +245,49 @@ internal sealed class ParagonApiClient
var idProp = output.GetProperty("id");
return idProp.GetString() ?? "";
}
public async Task<string> EnableSyncAsync(
string jwt,
string? credentialId,
string folderId,
string siteId,
CancellationToken ct
)
{
var url = "https://sync.useparagon.com/api/syncs";
using var request = new HttpRequestMessage(HttpMethod.Post, url);
request.Headers.Authorization = new("Bearer", jwt);
var payload = new
{
integration = "sharepoint",
pipeline = "files",
credentialId,
configuration = new
{
folderId,
siteId,
},
};
request.Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
using var response = await _httpClient.SendAsync(request, ct).ConfigureAwait(false);
var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
_ = response.EnsureSuccessStatusCode();
using var doc = JsonDocument.Parse(json);
return doc.RootElement.GetProperty("id").GetString() ?? throw new InvalidOperationException("Failed to obtain sync ID");
}
public async Task DeleteSyncAsync(string jwt, string syncId, CancellationToken ct)
{
var url = $"https://sync.useparagon.com/api/syncs/{syncId}";
using var request = new HttpRequestMessage(HttpMethod.Delete, url);
request.Headers.Authorization = new("Bearer", jwt);
using var response = await _httpClient.SendAsync(request, ct).ConfigureAwait(false);
_ = response.EnsureSuccessStatusCode();
}
}
@@ -1,106 +0,0 @@
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);
}
}
@@ -0,0 +1,298 @@
using MongoDB.Bson;
using ParagonPlayground.Domain.DTOs;
using ParagonPlayground.Domain.Entities;
using ParagonPlayground.Infrastructure.Data;
namespace ParagonPlayground.Api.Services;
internal sealed class SyncHierarchyIngestor(StorageItemRepository repo, ParagonApiClient paragon)
{
private readonly StorageItemRepository _repo = repo;
private readonly ParagonApiClient _paragon = paragon;
public async Task IngestSyncRecordsAsync(
string jwt,
string syncId,
StorageItem rootItem,
CancellationToken ct
)
{
var fetchedRecords = await FetchRecordsAsync(jwt, syncId, ct);
var normalizedRecords = NormalizeRecords(fetchedRecords, rootItem);
if (normalizedRecords.All.Count == 0)
{
return;
}
var existingState = await LoadExistingStateAsync(rootItem.OrganizationId, normalizedRecords.ExternalIds, ct);
var folderResult = await PersistFoldersAsync(rootItem, syncId, normalizedRecords.Folders, existingState, ct);
await PersistUnresolvedFoldersAtRootAsync(rootItem, syncId, folderResult.UnresolvedFolders, existingState, ct);
await PersistFilesAsync(rootItem, syncId, normalizedRecords.Files, folderResult.FolderStorageIdByExternalId, existingState, ct);
}
private async Task<IReadOnlyList<SyncedRecordItem>> FetchRecordsAsync(
string jwt,
string syncId,
CancellationToken ct
)
{
string? cursor = null;
var hasMore = true;
var records = new List<SyncedRecordItem>();
while (hasMore && !ct.IsCancellationRequested)
{
var response = await _paragon.PullSyncedRecordsAsync(jwt, syncId, cursor, 100, ct);
records.AddRange(response.Data);
cursor = response.Paging?.Cursor;
hasMore = (response.Paging?.RemainingRecords ?? 0) > 0 && !string.IsNullOrEmpty(cursor);
}
return records;
}
private static NormalizedRecordSet NormalizeRecords(
IReadOnlyList<SyncedRecordItem> records,
StorageItem rootItem
)
{
var all = records
.Where(r => string.IsNullOrWhiteSpace(r.ExternalId) is false)
.GroupBy(r => r.ExternalId, StringComparer.Ordinal)
.Select(g => g.Last())
.Where(r => IsRootRecord(r, rootItem) is false)
.ToList();
return new NormalizedRecordSet(
All: all,
Folders: [.. all.Where(r => r.IsFolder())],
Files: [.. all.Where(r => r.IsFolder() is false)],
ExternalIds: [.. all.Select(r => r.ExternalId).Distinct(StringComparer.Ordinal)]
);
}
private async Task<ExistingStorageState> LoadExistingStateAsync(
string organizationId,
IReadOnlyList<string> externalIds,
CancellationToken ct
)
{
var existingItems = await _repo.GetBySharePointDriveItemIdsAsync(organizationId, externalIds, ct);
return ExistingStorageState.FromItems(existingItems);
}
private async Task<FolderResolutionResult> PersistFoldersAsync(
StorageItem rootItem,
string syncId,
IReadOnlyList<SyncedRecordItem> folders,
ExistingStorageState state,
CancellationToken ct
)
{
var pending = folders.ToList();
var folderStorageIdByExternalId = new Dictionary<string, string>(state.FolderStorageIdByExternalId, StringComparer.Ordinal);
while (pending.Count > 0 && !ct.IsCancellationRequested)
{
var progressMade = false;
var nextPending = new List<SyncedRecordItem>();
foreach (var folder in pending)
{
if (TryResolveParentId(rootItem, folder, folderStorageIdByExternalId, out var parentId) is false)
{
nextPending.Add(folder);
continue;
}
var persisted = await UpsertStorageItemAsync(rootItem, syncId, folder, isFolder: true, parentId, state.ItemsByExternalId, ct);
folderStorageIdByExternalId[folder.ExternalId] = persisted.Id;
progressMade = true;
}
pending = nextPending;
if (progressMade is false)
{
break;
}
}
return new FolderResolutionResult(folderStorageIdByExternalId, pending);
}
private async Task PersistUnresolvedFoldersAtRootAsync(
StorageItem rootItem,
string syncId,
IReadOnlyList<SyncedRecordItem> unresolvedFolders,
ExistingStorageState state,
CancellationToken ct
)
{
foreach (var folder in unresolvedFolders)
{
var persisted = await UpsertStorageItemAsync(rootItem, syncId, folder, isFolder: true, rootItem.Id, state.ItemsByExternalId, ct);
state.FolderStorageIdByExternalId[folder.ExternalId] = persisted.Id;
}
}
private async Task PersistFilesAsync(
StorageItem rootItem,
string syncId,
IReadOnlyList<SyncedRecordItem> files,
Dictionary<string, string> folderStorageIdByExternalId,
ExistingStorageState state,
CancellationToken ct
)
{
foreach (var file in files)
{
var parentId = rootItem.Id;
if (
IsRootParent(rootItem, file) is false
&& folderStorageIdByExternalId.TryGetValue(file.ParentFolderId, out var resolvedParentId)
)
{
parentId = resolvedParentId;
}
_ = await UpsertStorageItemAsync(rootItem, syncId, file, isFolder: false, parentId, state.ItemsByExternalId, ct);
}
}
private async Task<StorageItem> UpsertStorageItemAsync(
StorageItem rootItem,
string syncId,
SyncedRecordItem record,
bool isFolder,
string parentId,
Dictionary<string, StorageItem> itemsByExternalId,
CancellationToken ct
)
{
if (itemsByExternalId.TryGetValue(record.ExternalId, out var existing))
{
existing.Name = record.Name;
existing.IsFolder = isFolder;
existing.ParentId = parentId;
existing.ContentType = isFolder ? null : record.MimeType;
existing.FileSize = isFolder ? 0 : record.Size;
existing.SharePointSiteId = rootItem.SharePointSiteId;
existing.SharePointDriveItemId = record.ExternalId;
existing.SharePointWebUrl = record.Url;
existing.IsManagedSync = false;
existing.ManagedSyncId = syncId;
existing.ParagonRecordId = record.Id;
existing.IsReadOnly = true;
await _repo.UpdateAsync(existing, ct);
return existing;
}
var newItem = new StorageItem
{
Id = ObjectId.GenerateNewId().ToString(),
OrganizationId = rootItem.OrganizationId,
Name = record.Name,
IsFolder = isFolder,
ParentId = parentId,
ContentType = isFolder ? null : record.MimeType,
FileSize = isFolder ? 0 : record.Size,
SharePointSiteId = rootItem.SharePointSiteId,
SharePointDriveItemId = record.ExternalId,
SharePointWebUrl = record.Url,
IsManagedSync = false,
ManagedSyncId = syncId,
ParagonRecordId = record.Id,
IsReadOnly = true,
CreatedByUserId = rootItem.CreatedByUserId,
CreatedAt = DateTime.UtcNow
};
await _repo.CreateAsync(newItem, ct);
itemsByExternalId[record.ExternalId] = newItem;
return newItem;
}
private static bool TryResolveParentId(
StorageItem rootItem,
SyncedRecordItem record,
Dictionary<string, string> folderStorageIdByExternalId,
out string parentId
)
{
if (IsRootParent(rootItem, record))
{
parentId = rootItem.Id;
return true;
}
if (folderStorageIdByExternalId.TryGetValue(record.ParentFolderId, out var resolvedParentId))
{
parentId = resolvedParentId;
return true;
}
parentId = string.Empty;
return false;
}
private static bool IsRootRecord(SyncedRecordItem record, StorageItem rootItem)
{
return string.Equals(record.ExternalId, rootItem.SharePointFolderId, StringComparison.OrdinalIgnoreCase)
|| string.Equals(record.ExternalId, rootItem.SharePointDriveItemId, StringComparison.OrdinalIgnoreCase);
}
private static bool IsRootParent(StorageItem rootItem, SyncedRecordItem record)
{
return string.IsNullOrWhiteSpace(record.ParentFolderId)
|| string.Equals(record.ParentFolderId, rootItem.SharePointFolderId, StringComparison.OrdinalIgnoreCase)
|| string.Equals(record.ParentFolderId, rootItem.SharePointDriveItemId, StringComparison.OrdinalIgnoreCase);
}
private sealed record NormalizedRecordSet(
IReadOnlyList<SyncedRecordItem> All,
IReadOnlyList<SyncedRecordItem> Folders,
IReadOnlyList<SyncedRecordItem> Files,
IReadOnlyList<string> ExternalIds
);
private sealed class ExistingStorageState
{
public Dictionary<string, StorageItem> ItemsByExternalId { get; }
public Dictionary<string, string> FolderStorageIdByExternalId { get; }
private ExistingStorageState(
Dictionary<string, StorageItem> itemsByExternalId,
Dictionary<string, string> folderStorageIdByExternalId
)
{
ItemsByExternalId = itemsByExternalId;
FolderStorageIdByExternalId = folderStorageIdByExternalId;
}
public static ExistingStorageState FromItems(IEnumerable<StorageItem> items)
{
var itemList = items.ToList();
var itemsByExternalId = itemList
.Where(i => string.IsNullOrWhiteSpace(i.SharePointDriveItemId) is false)
.ToDictionary(i => i.SharePointDriveItemId!, StringComparer.Ordinal);
var folderStorageIdByExternalId = itemList
.Where(i => i.IsFolder && string.IsNullOrWhiteSpace(i.SharePointDriveItemId) is false)
.ToDictionary(i => i.SharePointDriveItemId!, i => i.Id, StringComparer.Ordinal);
return new ExistingStorageState(itemsByExternalId, folderStorageIdByExternalId);
}
}
private sealed record FolderResolutionResult(
Dictionary<string, string> FolderStorageIdByExternalId,
IReadOnlyList<SyncedRecordItem> UnresolvedFolders
);
}
@@ -12,9 +12,12 @@ namespace ParagonPlayground.Cli.Commands;
internal class SeedCommand(
OrganizationRepository orgRepo,
UserRepository userRepo,
PasswordService passwordService) : AsyncCommand<SeedCommand.Settings>
PasswordService passwordService
) : AsyncCommand<SeedCommand.Settings>
{
internal class Settings : CommandSettings { }
internal class Settings : CommandSettings
{
}
protected override async Task<int> ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken)
{
@@ -0,0 +1,17 @@
namespace ParagonPlayground.Domain.DTOs;
/// <summary>Represents a request to sync a folder from a sharepoint site.</summary>
public class CreateSyncedFolderRequest
{
/// <summary>The sharepoint id for the folder that will be synced.</summary>
public required string SharePointFolderId { get; set; }
/// <summary>The id of the sharepoint site where the folder to be synced exists.</summary>
public required string SharePointSiteId { get; set; }
/// <summary>The id of the parent id for the folder being synced.</summary>
public string ParentId { get; set; } = string.Empty;
/// <summary>The associated credentials with the folder sync.</summary>
public string CredentialId { get; set; } = string.Empty;
}
@@ -0,0 +1,60 @@
namespace ParagonPlayground.Domain.DTOs;
/// <summary>Webhook payload sent by Paragon for managed sync lifecycle events.</summary>
public class ParagonWebhookPayload
{
/// <summary>Webhook event type (for example: sync_complete, record_updated, record_deleted).</summary>
public string Event { get; set; } = string.Empty;
/// <summary>Paragon managed sync instance identifier associated with this event.</summary>
public string SyncInstanceId { get; set; } = string.Empty;
/// <summary>Sync descriptor from the webhook payload, when provided.</summary>
public string Sync { get; set; } = string.Empty;
/// <summary>Credential identifier used by the integration, when included by Paragon.</summary>
public string CredentialId { get; set; } = string.Empty;
/// <summary>User context attached to the webhook payload.</summary>
public ParagonWebhookUser? User { get; set; }
/// <summary>Event-specific data payload.</summary>
public ParagonWebhookData? Data { get; set; }
/// <summary>Error payload when the webhook event represents a failure state.</summary>
public ParagonWebhookError? Error { get; set; }
}
/// <summary>User metadata included in a Paragon webhook.</summary>
public class ParagonWebhookUser
{
/// <summary>Paragon user identifier for the connected user.</summary>
public string Id { get; set; } = string.Empty;
}
/// <summary>Event data section of a Paragon webhook payload.</summary>
public class ParagonWebhookData
{
/// <summary>Logical model type for the emitted record event.</summary>
public string Model { get; set; } = string.Empty;
/// <summary>Record identifier relevant to create/update/delete record events.</summary>
public string? RecordId { get; set; }
/// <summary>Timestamp indicating when the sync data was last synchronized.</summary>
public string? SyncedAt { get; set; }
/// <summary>Number of records referenced by the event when available.</summary>
public int? NumRecords { get; set; }
}
/// <summary>Error details included with failed sync webhook events.</summary>
public class ParagonWebhookError
{
/// <summary>Provider-specific error code.</summary>
public string? Code { get; set; }
/// <summary>Human-readable error description.</summary>
public string? Message { get; set; }
}
@@ -1,3 +1,5 @@
using ParagonPlayground.Domain.Entities;
namespace ParagonPlayground.Domain.DTOs;
/// <summary>File or folder returned by the storage API.</summary>
@@ -24,6 +26,12 @@ public class StorageItemResponse
/// <summary>Direct SharePoint web URL (null for folders).</summary>
public string? SharePointWebUrl { get; set; }
/// <summary>Indicates if the storage item is associated with a managed sync.</summary>
public bool IsManagedSync { get; set; }
/// <summary>Indicates if the storage item can be edited by our application.</summary>
public bool IsReadOnly { get; set; }
/// <summary>User who created this item.</summary>
public string CreatedByUserId { get; set; } = string.Empty;
@@ -32,4 +40,27 @@ public class StorageItemResponse
/// <summary>Timestamp when the item was created.</summary>
public DateTime CreatedAt { get; set; }
/// <summary>Creates a response DTO from a storage item entity.</summary>
public static StorageItemResponse From(StorageItem item, string createdByDisplayName)
{
ArgumentNullException.ThrowIfNull(item);
ArgumentNullException.ThrowIfNull(createdByDisplayName);
return new StorageItemResponse
{
Id = item.Id,
Name = item.Name,
IsFolder = item.IsFolder,
ParentId = item.ParentId,
ContentType = item.ContentType,
FileSize = item.FileSize,
SharePointWebUrl = item.SharePointWebUrl,
IsManagedSync = item.IsManagedSync,
IsReadOnly = item.IsReadOnly,
CreatedByUserId = item.CreatedByUserId,
CreatedByDisplayName = createdByDisplayName,
CreatedAt = item.CreatedAt,
};
}
}
@@ -0,0 +1,84 @@
using System.Collections.ObjectModel;
using System.Text.Json.Serialization;
namespace ParagonPlayground.Domain.DTOs;
/// <summary>Response payload returned by the Paragon Sync API GET /api/syncs/{syncId}/records endpoint.</summary>
public class SyncedRecordsResponse
{
/// <summary>Synced records returned for the requested page.</summary>
[JsonPropertyName("data")]
public Collection<SyncedRecordItem> Data { get; init; } = [];
/// <summary>Paging metadata used to continue retrieving additional records.</summary>
[JsonPropertyName("paging")]
public SyncedRecordsPaging Paging { get; set; } = new();
}
/// <summary>Pagination metadata returned with synced record batches.</summary>
public class SyncedRecordsPaging
{
/// <summary>Total number of records known to the sync.</summary>
[JsonPropertyName("totalRecords")]
public int TotalRecords { get; set; }
/// <summary>Total number of active (non-deleted) records in the sync.</summary>
[JsonPropertyName("totalActiveRecords")]
public int TotalActiveRecords { get; set; }
/// <summary>Count of records still remaining after the current page.</summary>
[JsonPropertyName("remainingRecords")]
public int RemainingRecords { get; set; }
/// <summary>Cursor to request the next page of records.</summary>
[JsonPropertyName("cursor")]
public string? Cursor { get; set; }
/// <summary>Unix timestamp of the latest record observed in this page.</summary>
[JsonPropertyName("lastSeen")]
public long LastSeen { get; set; }
}
/// <summary>Represents a single synced file or record item returned by Paragon Managed Sync.</summary>
public class SyncedRecordItem
{
/// <summary>Paragon-generated sync record identifier.</summary>
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;
/// <summary>Provider-native record identifier (for example, SharePoint drive item ID).</summary>
[JsonPropertyName("externalId")]
public string ExternalId { get; set; } = string.Empty;
/// <summary>Display name for the synced file or folder.</summary>
[JsonPropertyName("name")]
public string Name { get; set; } = "Untitled Item";
/// <summary>MIME type of the synced file; empty for folders.</summary>
[JsonPropertyName("mimeType")]
public string MimeType { get; set; } = "application/octet-stream";
/// <summary>File size in bytes.</summary>
[JsonPropertyName("size")]
public long Size { get; set; }
/// <summary>Provider URL for viewing the record in the source system.</summary>
[JsonPropertyName("url")]
public string Url { get; set; } = string.Empty;
/// <summary>External identifier of the record's parent folder.</summary>
[JsonPropertyName("parentFolderId")]
public string ParentFolderId { get; set; } = string.Empty;
/// <summary>Integration-specific metadata attached to the synced record.</summary>
[JsonPropertyName("customFields")]
public Dictionary<string, object>? CustomFields { get; init; }
/// <summary>Determines whether the synced item represents a folder.</summary>
public bool IsFolder()
{
return string.IsNullOrEmpty(MimeType);
}
}
@@ -24,6 +24,7 @@ public class StorageItem
/// <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; }
@@ -33,6 +34,23 @@ public class StorageItem
/// <summary>SharePoint web URL for direct access (null for folders).</summary>
public string? SharePointWebUrl { get; set; }
/// <summary>Indicates whether the storage item is associated with a managed sync</summary>
public bool IsManagedSync { get; set; }
/// <summary>Identifies the managed sync this storage item is attached to.</summary>
public string ManagedSyncId { get; set; } = string.Empty;
/// <summary>Paragon Sync record ID (sync-generated UUID) for items ingested from managed sync.</summary>
public string ParagonRecordId { get; set; } = string.Empty;
/// <summary>Identifies the sharepoint folder id that the managed sync is attached to.</summary>
public string SharePointFolderId { get; set; } = string.Empty;
/// <summary>Indicates whether the storage item can be edited or modified by our application.</summary>
public bool IsReadOnly { get; set; }
/// <summary>User who created this item.</summary>
public string CreatedByUserId { get; set; } = string.Empty;
@@ -11,7 +11,10 @@ public class StorageItemRepository(MongoDbContext 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)
string organizationId,
string? parentId,
CancellationToken ct
)
{
var filter = Builders<StorageItem>.Filter.Eq(i => i.OrganizationId, organizationId)
& Builders<StorageItem>.Filter.Eq(i => i.ParentId, parentId);
@@ -23,6 +26,14 @@ public class StorageItemRepository(MongoDbContext context)
.ConfigureAwait(false);
}
/// <summary>Lists all direct child items of a given parent folder.</summary>
public async Task<List<StorageItem>> GetChildrenAsync(string parentId, CancellationToken ct)
{
return await _context.StorageItems.Find(i => i.ParentId == parentId)
.ToListAsync(ct)
.ConfigureAwait(false);
}
/// <summary>Finds a storage item by ID.</summary>
public async Task<StorageItem?> GetByIdAsync(string id, CancellationToken ct)
{
@@ -31,6 +42,68 @@ public class StorageItemRepository(MongoDbContext context)
.ConfigureAwait(false);
}
/// <summary>Finds a root storage item by Paragon Managed Sync ID.</summary>
public async Task<StorageItem?> GetByManagedSyncIdAsync(string managedSyncId, CancellationToken ct)
{
return await _context.StorageItems.Find(i => i.ManagedSyncId == managedSyncId)
.FirstOrDefaultAsync(ct)
.ConfigureAwait(false);
}
/// <summary>Finds a storage item by organization ID and SharePoint Drive Item ID.</summary>
public async Task<StorageItem?> GetBySharePointDriveItemIdAsync(
string organizationId,
string sharePointDriveItemId,
CancellationToken ct
)
{
var filter = Builders<StorageItem>.Filter.Eq(i => i.OrganizationId, organizationId)
& Builders<StorageItem>.Filter.Eq(i => i.SharePointDriveItemId, sharePointDriveItemId);
return await _context.StorageItems.Find(filter)
.FirstOrDefaultAsync(ct)
.ConfigureAwait(false);
}
/// <summary>Finds a synced storage item by organization, sync ID, and Paragon record ID.</summary>
public async Task<StorageItem?> GetByParagonRecordIdAsync(
string organizationId,
string managedSyncId,
string paragonRecordId,
CancellationToken ct
)
{
var filter = Builders<StorageItem>.Filter.Eq(i => i.OrganizationId, organizationId)
& Builders<StorageItem>.Filter.Eq(i => i.ManagedSyncId, managedSyncId)
& Builders<StorageItem>.Filter.Eq(i => i.ParagonRecordId, paragonRecordId);
return await _context.StorageItems.Find(filter)
.FirstOrDefaultAsync(ct)
.ConfigureAwait(false);
}
/// <summary>Finds storage items by organization ID and SharePoint Drive Item IDs.</summary>
public async Task<List<StorageItem>> GetBySharePointDriveItemIdsAsync(
string organizationId,
IReadOnlyCollection<string> sharePointDriveItemIds,
CancellationToken ct
)
{
ArgumentNullException.ThrowIfNull(sharePointDriveItemIds);
if (sharePointDriveItemIds.Count == 0)
{
return [];
}
var filter = Builders<StorageItem>.Filter.Eq(i => i.OrganizationId, organizationId)
& Builders<StorageItem>.Filter.In(i => i.SharePointDriveItemId, sharePointDriveItemIds);
return await _context.StorageItems.Find(filter)
.ToListAsync(ct)
.ConfigureAwait(false);
}
/// <summary>Creates a new storage item.</summary>
public async Task CreateAsync(StorageItem item, CancellationToken ct)
{
@@ -41,6 +114,16 @@ public class StorageItemRepository(MongoDbContext context)
.ConfigureAwait(false);
}
/// <summary>Updates an existing storage item.</summary>
public async Task UpdateAsync(StorageItem item, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(item);
_ = await _context.StorageItems
.ReplaceOneAsync(i => i.Id == item.Id, item, cancellationToken: ct)
.ConfigureAwait(false);
}
/// <summary>Deletes a storage item by ID.</summary>
public async Task DeleteAsync(string id, CancellationToken ct)
{
@@ -1,5 +1,6 @@
<script setup lang="ts">
type IconName = 'folder' | 'file' | 'upload' | 'download' | 'external' | 'trash' | 'x' | 'plus';
type IconName =
'folder' | 'file' | 'upload' | 'download' | 'external' | 'trash' | 'x' | 'plus' | 'sharepoint';
const props = withDefaults(defineProps<{ name: IconName; size?: number }>(), {
size: 16,
@@ -24,6 +25,11 @@
],
x: ['M18 6 6 18', 'm6 6 12 12'],
plus: ['M5 12h14', 'M12 5v14'],
sharepoint: [
'M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242',
'M12 12v6',
'm-2.5-2.5 2.5 2.5 2.5-2.5',
],
};
</script>
@@ -1,6 +1,6 @@
import { api } from './api';
export interface UserResponse {
export type UserResponse = {
id: string;
email: string;
displayName: string;
@@ -1,11 +1,11 @@
import { api } from './api';
export interface ParagonTokenResponse {
export type ParagonTokenResponse = {
paragonJwt: string;
projectId: string;
}
export interface IntegrationConfig {
export type IntegrationConfig = {
id: string;
organizationId: string;
connectionMode: string;
@@ -15,20 +15,20 @@ export interface IntegrationConfig {
updatedAt: string;
}
export interface IntegrationConfigRequest {
export type IntegrationConfigRequest = {
connectionMode: string;
sharePointSiteUrl: string | null;
sharePointFolderPath: string | null;
}
export interface CredentialResponse {
export type CredentialResponse = {
id: string;
credentialId: string;
integrationType: string;
connectedAt: string;
}
export interface CredentialRequest {
export type CredentialRequest = {
credentialId: string;
integrationType: string;
}
@@ -1,6 +1,6 @@
import { api } from './api';
export interface StorageItem {
export type StorageItem = {
id: string;
name: string;
isFolder: boolean;
@@ -11,18 +11,35 @@ export interface StorageItem {
createdByUserId: string;
createdByDisplayName: string;
createdAt: string;
isReadOnly: boolean;
isManagedSync: boolean;
}
export interface CreateFolderRequest {
export type CreateFolderRequest = {
name: string;
parentId: string | null;
}
export interface DownloadResponse {
export type DownloadResponse = {
sharePointUrl: string | null;
proxyUrl: string | null;
}
export type CreateSyncedFolderRequest = {
sharePointFolderId: string;
sharePointSiteId: string;
parentId?: string | null;
credentialId?: string;
}
export async function createSyncedFolder(req: CreateSyncedFolderRequest): Promise<StorageItem> {
return api<StorageItem>('/storage/synced-folders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
});
}
export async function getItems(parentId?: string | null): Promise<StorageItem[]> {
const params = parentId ? `?parentId=${encodeURIComponent(parentId)}` : '';
return api<StorageItem[]>(`/storage${params}`);
@@ -129,7 +129,8 @@ select:focus {
transform: translateY(1px);
}
.btn:disabled {
.btn:disabled,
.btn.disabled {
opacity: 0.55;
cursor: not-allowed;
}
@@ -9,8 +9,44 @@
deleteItem,
getDownloadUrls,
type StorageItem,
createSyncedFolder,
} from '../services/storage';
import { formatLocaleDate } from '../utils/utils';
import { getParagonToken } from '../services/integration';
type SharePointIds = {
listId: string;
webId: string;
siteId: string;
listItemId: string;
listItemUniqueId: string;
};
type ParentReference = {
driveId: string;
sharepointIds: SharePointIds;
};
type SelectedFilePickerItem = {
id: string;
parentReference: ParentReference;
sharepointIds: SharePointIds;
};
type ParagonPickerInstance = {
init: () => Promise<void>;
open: () => void;
};
type ParagonSDKWithPicker = {
authenticate: (projectId: string, jwt: string) => Promise<void>;
ExternalFilePicker: new (
integration: string,
options: {
onFileSelect: (items: SelectedFilePickerItem[]) => Promise<void> | void;
},
) => ParagonPickerInstance;
};
const route = useRoute();
const router = useRouter();
@@ -18,13 +54,14 @@
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 currentFolder = ref<StorageItem | null>(null);
const folders = computed(() => items.value.filter((i) => i.isFolder));
const files = computed(() => items.value.filter((i) => !i.isFolder));
const isCurrentFolderReadOnly = computed(() => currentFolder.value?.isReadOnly ?? false);
const pathSegments = computed(() => {
const raw = route.params.pathMatch;
@@ -51,6 +88,7 @@
try {
let parentId: string | null = null;
const crumbs: { id: string | null; name: string }[] = [];
let resolvedFolder: StorageItem | null = null;
for (const name of segments) {
const children = await getItems(parentId);
@@ -64,15 +102,17 @@
crumbs.push({ id: folder.id, name: folder.name });
parentId = folder.id;
resolvedFolder = folder;
}
currentFolderId.value = parentId;
currentFolder.value = resolvedFolder;
breadcrumbs.value = crumbs;
items.value = await getItems(currentFolderId.value);
items.value = await getItems(currentFolder.value?.id ?? null);
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Failed to navigate';
items.value = [];
currentFolder.value = null;
} finally {
loading.value = false;
}
@@ -94,7 +134,7 @@
try {
await createFolder({
name: newFolderName.value.trim(),
parentId: currentFolderId.value,
parentId: currentFolder.value?.id ?? null,
});
newFolderName.value = '';
@@ -115,7 +155,7 @@
}
try {
await uploadFile(file, currentFolderId.value);
await uploadFile(file, currentFolder.value?.id ?? null);
input.value = '';
@@ -167,6 +207,39 @@
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1048576).toFixed(1)} MB`;
}
async function openSharePointFolderPicker() {
try {
const tokenResponse = await getParagonToken();
const { paragon } = await import('@useparagon/connect');
const sdk = paragon as unknown as ParagonSDKWithPicker;
await sdk.authenticate(tokenResponse.projectId, tokenResponse.paragonJwt);
const picker = new sdk.ExternalFilePicker('sharepoint', {
onFileSelect: async (selectedItems: SelectedFilePickerItem[]) => {
if (!selectedItems || selectedItems.length === 0) return;
const selectedItem = selectedItems[0];
const folderId = selectedItem.id;
const siteId = selectedItem.sharepointIds.siteId;
await createSyncedFolder({
sharePointFolderId: folderId,
sharePointSiteId: siteId,
parentId: currentFolder.value?.id ?? null,
});
await navigateToPath(pathSegments.value);
},
});
await picker.init();
picker.open();
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Failed to open SharePoint picker';
}
}
</script>
<template>
@@ -185,7 +258,7 @@
<nav class="breadcrumbs">
<button
class="crumb"
:class="{ active: currentFolderId === null }"
:class="{ active: currentFolder === null }"
@click="navigateToBreadcrumb(0)"
>
Root
@@ -208,6 +281,7 @@
<div class="toolbar">
<button
class="btn"
:disabled="isCurrentFolderReadOnly"
@click="showNewFolder = !showNewFolder"
>
<AppIcon
@@ -218,7 +292,10 @@
{{ showNewFolder ? 'Cancel' : 'New Folder' }}
</button>
<label class="btn btn-primary upload-label">
<label
class="btn btn-primary upload-label"
:class="{ disabled: isCurrentFolderReadOnly }"
>
<AppIcon
name="upload"
:size="14"
@@ -227,9 +304,21 @@
<input
type="file"
hidden
:disabled="isCurrentFolderReadOnly"
@change="handleUpload"
/>
</label>
<button
class="btn btn-secondary"
@click="openSharePointFolderPicker"
>
<AppIcon
name="sharepoint"
:size="14"
/>
Sync Folder from SharePoint
</button>
</div>
<div
@@ -289,6 +378,7 @@
<span class="item-date">{{ formatLocaleDate(folder.createdAt) }}</span>
<span class="item-actions">
<button
v-if="!folder.isReadOnly || folder.isManagedSync"
class="btn-small btn-small-danger"
title="Delete"
@click="handleDelete(folder.id)"
@@ -337,6 +427,7 @@
/>
</button>
<button
v-if="!file.isReadOnly"
class="btn-small btn-small-danger"
title="Delete"
@click="handleDelete(file.id)"