Compare commits
2
Commits
ebdea9588e
...
b8b665a552
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8b665a552 | ||
|
|
93e56df4b1 |
Vendored
+1
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"FSharp.suggestGitignore": false,
|
"FSharp.suggestGitignore": false,
|
||||||
"cSpell.words": [
|
"cSpell.words": [
|
||||||
|
"Ingestor",
|
||||||
"useparagon"
|
"useparagon"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -17,8 +17,6 @@ A application harness to explore using Paragon for integrations.
|
|||||||
.\scripts\setup-certs.ps1
|
.\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.
|
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
|
### 2. Start infrastructure
|
||||||
|
|||||||
+1
-1
@@ -56,7 +56,7 @@ internal static class IntegrationEndpoints
|
|||||||
HttpContext context,
|
HttpContext context,
|
||||||
OrganizationIntegrationRepository repo,
|
OrganizationIntegrationRepository repo,
|
||||||
UserCredentialRepository credRepo,
|
UserCredentialRepository credRepo,
|
||||||
ParagonService paragon,
|
ParagonApiClient paragon,
|
||||||
CancellationToken ct
|
CancellationToken ct
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@ internal static class ParagonEndpoints
|
|||||||
|
|
||||||
private static async Task<IResult> GenerateToken(
|
private static async Task<IResult> GenerateToken(
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
ParagonService paragon,
|
ParagonApiClient paragon,
|
||||||
UserCredentialRepository credRepo,
|
UserCredentialRepository credRepo,
|
||||||
CancellationToken ct
|
CancellationToken ct
|
||||||
)
|
)
|
||||||
|
|||||||
+248
-52
@@ -12,6 +12,34 @@ namespace ParagonPlayground.Api.Endpoints;
|
|||||||
|
|
||||||
internal static class StorageEndpoints
|
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)
|
internal static RouteGroupBuilder MapStorageEndpoints(this RouteGroupBuilder group)
|
||||||
{
|
{
|
||||||
_ = group.MapGet("/", ListItems);
|
_ = group.MapGet("/", ListItems);
|
||||||
@@ -20,10 +48,150 @@ internal static class StorageEndpoints
|
|||||||
_ = group.MapDelete("/{id}", DeleteItem);
|
_ = group.MapDelete("/{id}", DeleteItem);
|
||||||
_ = group.MapGet("/{id}/download", GetDownloadUrls);
|
_ = group.MapGet("/{id}/download", GetDownloadUrls);
|
||||||
_ = group.MapGet("/{id}/content", ProxyContent);
|
_ = group.MapGet("/{id}/content", ProxyContent);
|
||||||
|
_ = group.MapPost("/synced-folders", CreateSyncedFolder);
|
||||||
|
_ = group.MapPost("/webhook/paragon", HandleParagonWebhook).AllowAnonymous();
|
||||||
_ = group.RequireAuthorization();
|
_ = group.RequireAuthorization();
|
||||||
return group;
|
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(
|
private static async Task<IResult> ListItems(
|
||||||
string? parentId,
|
string? parentId,
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
@@ -44,19 +212,7 @@ internal static class StorageEndpoints
|
|||||||
users[uid] = u?.DisplayName ?? "Unknown";
|
users[uid] = u?.DisplayName ?? "Unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
return Results.Ok(items.Select(i => new StorageItemResponse
|
return Results.Ok(items.Select(i => StorageItemResponse.From(i, users.GetValueOrDefault(i.CreatedByUserId, "Unknown"))));
|
||||||
{
|
|
||||||
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(
|
private static async Task<IResult> CreateFolder(
|
||||||
@@ -87,16 +243,7 @@ internal static class StorageEndpoints
|
|||||||
|
|
||||||
await repo.CreateAsync(item, ct);
|
await repo.CreateAsync(item, ct);
|
||||||
|
|
||||||
return Results.Created($"/api/storage/{item.Id}", new StorageItemResponse
|
return Results.Created($"/api/storage/{item.Id}", StorageItemResponse.From(item, user.DisplayName));
|
||||||
{
|
|
||||||
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(
|
private static async Task<IResult> UploadFile(
|
||||||
@@ -104,7 +251,7 @@ internal static class StorageEndpoints
|
|||||||
StorageItemRepository storageRepo,
|
StorageItemRepository storageRepo,
|
||||||
UserCredentialRepository credRepo,
|
UserCredentialRepository credRepo,
|
||||||
OrganizationIntegrationRepository configRepo,
|
OrganizationIntegrationRepository configRepo,
|
||||||
ParagonService paragon,
|
ParagonApiClient paragon,
|
||||||
UserRepository userRepo,
|
UserRepository userRepo,
|
||||||
CancellationToken ct
|
CancellationToken ct
|
||||||
)
|
)
|
||||||
@@ -206,42 +353,71 @@ internal static class StorageEndpoints
|
|||||||
|
|
||||||
await storageRepo.CreateAsync(storageItem, ct);
|
await storageRepo.CreateAsync(storageItem, ct);
|
||||||
|
|
||||||
return Results.Created($"/api/storage/{storageItem.Id}", new StorageItemResponse
|
return Results.Created($"/api/storage/{storageItem.Id}", StorageItemResponse.From(storageItem, user.DisplayName));
|
||||||
{
|
|
||||||
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(
|
private static async Task<IResult> DeleteItem(
|
||||||
string id,
|
string id,
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
StorageItemRepository repo,
|
StorageItemRepository repo,
|
||||||
|
UserCredentialRepository credRepo,
|
||||||
|
ParagonApiClient paragon,
|
||||||
CancellationToken ct
|
CancellationToken ct
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
|
var user = context.GetUser();
|
||||||
var org = context.GetOrganization();
|
var org = context.GetOrganization();
|
||||||
|
|
||||||
var item = await repo.GetByIdAsync(id, ct);
|
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);
|
await repo.DeleteAsync(id, ct);
|
||||||
|
|
||||||
return Results.NoContent();
|
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(
|
private static async Task<IResult> GetDownloadUrls(
|
||||||
string id,
|
string id,
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
@@ -272,11 +448,10 @@ internal static class StorageEndpoints
|
|||||||
HttpContext context,
|
HttpContext context,
|
||||||
StorageItemRepository storageRepo,
|
StorageItemRepository storageRepo,
|
||||||
UserCredentialRepository credRepo,
|
UserCredentialRepository credRepo,
|
||||||
ParagonService paragon,
|
ParagonApiClient paragon,
|
||||||
CancellationToken ct
|
CancellationToken ct
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var user = context.GetUser();
|
|
||||||
var org = context.GetOrganization();
|
var org = context.GetOrganization();
|
||||||
|
|
||||||
var item = await storageRepo.GetByIdAsync(id, ct);
|
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);
|
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))
|
if (string.IsNullOrEmpty(item.SharePointDriveItemId) || string.IsNullOrEmpty(item.SharePointSiteId))
|
||||||
{
|
{
|
||||||
return Results.Problem(
|
return Results.Problem(
|
||||||
@@ -294,6 +495,7 @@ internal static class StorageEndpoints
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var user = context.GetUser();
|
||||||
var credentials = await credRepo.GetByUserIdAsync(user.Id, ct);
|
var credentials = await credRepo.GetByUserIdAsync(user.Id, ct);
|
||||||
var spCredential = credentials.FirstOrDefault(c =>
|
var spCredential = credentials.FirstOrDefault(c =>
|
||||||
c.IntegrationType.Equals("sharepoint", StringComparison.OrdinalIgnoreCase)
|
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 jwt = paragon.GenerateToken(org.Id, spCredential.CredentialId);
|
||||||
var fileStream = await paragon.DownloadFileAsync(
|
|
||||||
|
fileStream = await paragon.DownloadFileAsync(
|
||||||
jwt,
|
jwt,
|
||||||
spCredential.CredentialId,
|
spCredential.CredentialId,
|
||||||
item.SharePointSiteId,
|
item.SharePointSiteId,
|
||||||
item.SharePointDriveItemId,
|
item.SharePointDriveItemId,
|
||||||
ct
|
ct
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return Results.Stream(fileStream, item.ContentType ?? "application/octet-stream", item.Name);
|
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<StorageItemRepository>();
|
||||||
builder.Services.AddSingleton<UserCredentialRepository>();
|
builder.Services.AddSingleton<UserCredentialRepository>();
|
||||||
builder.Services.AddSingleton<OrganizationIntegrationRepository>();
|
builder.Services.AddSingleton<OrganizationIntegrationRepository>();
|
||||||
builder.Services.AddSingleton<ParagonService>();
|
builder.Services.AddSingleton<SyncHierarchyIngestor>();
|
||||||
|
|
||||||
builder.Services.Configure<ParagonOptions>(builder.Configuration.GetSection(ParagonOptions.SectionName));
|
builder.Services.Configure<ParagonOptions>(builder.Configuration.GetSection(ParagonOptions.SectionName));
|
||||||
|
|
||||||
|
|||||||
+192
-6
@@ -1,22 +1,145 @@
|
|||||||
using System.Net.Http.Headers;
|
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 System.Text.Json;
|
||||||
|
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
|
||||||
using ParagonPlayground.Api.Options;
|
using ParagonPlayground.Api.Options;
|
||||||
|
using ParagonPlayground.Domain.DTOs;
|
||||||
|
|
||||||
namespace ParagonPlayground.Api.Services;
|
namespace ParagonPlayground.Api.Services;
|
||||||
|
|
||||||
internal sealed class ParagonApiClient
|
internal sealed class ParagonApiClient
|
||||||
{
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true
|
||||||
|
};
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
private readonly string _projectId;
|
private readonly string _signingKey;
|
||||||
|
public string ProjectId { get; }
|
||||||
|
|
||||||
public ParagonApiClient(HttpClient httpClient, IOptions<ParagonOptions> options)
|
public ParagonApiClient(HttpClient httpClient, IOptions<ParagonOptions> options)
|
||||||
{
|
{
|
||||||
_httpClient = httpClient;
|
_httpClient = httpClient;
|
||||||
_projectId = options.Value.ProjectId;
|
var paragonOptions = options.Value;
|
||||||
_httpClient.BaseAddress = new Uri(options.Value.ProxyBaseUrl.TrimEnd('/') + "/");
|
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(
|
public async Task<string> UploadFileAsync(
|
||||||
@@ -30,7 +153,7 @@ internal sealed class ParagonApiClient
|
|||||||
CancellationToken ct
|
CancellationToken ct
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var url = $"projects/{_projectId}/sdk/proxy/sharepoint"
|
var url = $"projects/{ProjectId}/sdk/proxy/sharepoint"
|
||||||
+ $"/sites/{siteId}/drive/root:/{folderPath.Trim('/')}/{fileName}:/content";
|
+ $"/sites/{siteId}/drive/root:/{folderPath.Trim('/')}/{fileName}:/content";
|
||||||
|
|
||||||
using var ms = new MemoryStream();
|
using var ms = new MemoryStream();
|
||||||
@@ -60,13 +183,31 @@ internal sealed class ParagonApiClient
|
|||||||
CancellationToken ct
|
CancellationToken ct
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var url = $"projects/{_projectId}/sdk/proxy/sharepoint"
|
var url = $"projects/{ProjectId}/sdk/proxy/sharepoint"
|
||||||
+ $"/sites/{siteId}/drive/items/{driveItemId}/content";
|
+ $"/sites/{siteId}/drive/items/{driveItemId}/content";
|
||||||
|
|
||||||
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||||
|
|
||||||
_ = request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {jwt}");
|
_ = request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {jwt}");
|
||||||
_ = request.Headers.TryAddWithoutValidation("X-Paragon-Credential", credentialId);
|
_ = 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);
|
var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
|
||||||
_ = response.EnsureSuccessStatusCode();
|
_ = response.EnsureSuccessStatusCode();
|
||||||
@@ -88,7 +229,7 @@ internal sealed class ParagonApiClient
|
|||||||
? ":/" + string.Join("/", segments.Select(Uri.EscapeDataString))
|
? ":/" + 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);
|
using var request = new HttpRequestMessage(HttpMethod.Get, proxyUrl);
|
||||||
_ = request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {jwt}");
|
_ = request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {jwt}");
|
||||||
@@ -104,4 +245,49 @@ internal sealed class ParagonApiClient
|
|||||||
var idProp = output.GetProperty("id");
|
var idProp = output.GetProperty("id");
|
||||||
return idProp.GetString() ?? "";
|
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+298
@@ -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(
|
internal class SeedCommand(
|
||||||
OrganizationRepository orgRepo,
|
OrganizationRepository orgRepo,
|
||||||
UserRepository userRepo,
|
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)
|
protected override async Task<int> ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
+17
@@ -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;
|
||||||
|
}
|
||||||
+60
@@ -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; }
|
||||||
|
}
|
||||||
+31
@@ -1,3 +1,5 @@
|
|||||||
|
using ParagonPlayground.Domain.Entities;
|
||||||
|
|
||||||
namespace ParagonPlayground.Domain.DTOs;
|
namespace ParagonPlayground.Domain.DTOs;
|
||||||
|
|
||||||
/// <summary>File or folder returned by the storage API.</summary>
|
/// <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>
|
/// <summary>Direct SharePoint web URL (null for folders).</summary>
|
||||||
public string? SharePointWebUrl { get; set; }
|
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>
|
/// <summary>User who created this item.</summary>
|
||||||
public string CreatedByUserId { get; set; } = string.Empty;
|
public string CreatedByUserId { get; set; } = string.Empty;
|
||||||
|
|
||||||
@@ -32,4 +40,27 @@ public class StorageItemResponse
|
|||||||
|
|
||||||
/// <summary>Timestamp when the item was created.</summary>
|
/// <summary>Timestamp when the item was created.</summary>
|
||||||
public DateTime CreatedAt { get; set; }
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+84
@@ -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>
|
/// <summary>File size in bytes (0 for folders).</summary>
|
||||||
public long FileSize { get; set; }
|
public long FileSize { get; set; }
|
||||||
|
|
||||||
|
|
||||||
/// <summary>SharePoint site ID where the file was uploaded (null for folders).</summary>
|
/// <summary>SharePoint site ID where the file was uploaded (null for folders).</summary>
|
||||||
public string? SharePointSiteId { get; set; }
|
public string? SharePointSiteId { get; set; }
|
||||||
|
|
||||||
@@ -33,6 +34,23 @@ public class StorageItem
|
|||||||
/// <summary>SharePoint web URL for direct access (null for folders).</summary>
|
/// <summary>SharePoint web URL for direct access (null for folders).</summary>
|
||||||
public string? SharePointWebUrl { get; set; }
|
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>
|
/// <summary>User who created this item.</summary>
|
||||||
public string CreatedByUserId { get; set; } = string.Empty;
|
public string CreatedByUserId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
|||||||
+84
-1
@@ -11,7 +11,10 @@ public class StorageItemRepository(MongoDbContext context)
|
|||||||
|
|
||||||
/// <summary>Lists items in a folder (or root items when parentId is null).</summary>
|
/// <summary>Lists items in a folder (or root items when parentId is null).</summary>
|
||||||
public async Task<List<StorageItem>> GetByParentIdAsync(
|
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)
|
var filter = Builders<StorageItem>.Filter.Eq(i => i.OrganizationId, organizationId)
|
||||||
& Builders<StorageItem>.Filter.Eq(i => i.ParentId, parentId);
|
& Builders<StorageItem>.Filter.Eq(i => i.ParentId, parentId);
|
||||||
@@ -23,6 +26,14 @@ public class StorageItemRepository(MongoDbContext context)
|
|||||||
.ConfigureAwait(false);
|
.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>
|
/// <summary>Finds a storage item by ID.</summary>
|
||||||
public async Task<StorageItem?> GetByIdAsync(string id, CancellationToken ct)
|
public async Task<StorageItem?> GetByIdAsync(string id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
@@ -31,6 +42,68 @@ public class StorageItemRepository(MongoDbContext context)
|
|||||||
.ConfigureAwait(false);
|
.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>
|
/// <summary>Creates a new storage item.</summary>
|
||||||
public async Task CreateAsync(StorageItem item, CancellationToken ct)
|
public async Task CreateAsync(StorageItem item, CancellationToken ct)
|
||||||
{
|
{
|
||||||
@@ -41,6 +114,16 @@ public class StorageItemRepository(MongoDbContext context)
|
|||||||
.ConfigureAwait(false);
|
.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>
|
/// <summary>Deletes a storage item by ID.</summary>
|
||||||
public async Task DeleteAsync(string id, CancellationToken ct)
|
public async Task DeleteAsync(string id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<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 }>(), {
|
const props = withDefaults(defineProps<{ name: IconName; size?: number }>(), {
|
||||||
size: 16,
|
size: 16,
|
||||||
@@ -24,6 +25,11 @@
|
|||||||
],
|
],
|
||||||
x: ['M18 6 6 18', 'm6 6 12 12'],
|
x: ['M18 6 6 18', 'm6 6 12 12'],
|
||||||
plus: ['M5 12h14', 'M12 5v14'],
|
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>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { api } from './api';
|
import { api } from './api';
|
||||||
|
|
||||||
export interface UserResponse {
|
export type UserResponse = {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { api } from './api';
|
import { api } from './api';
|
||||||
|
|
||||||
export interface ParagonTokenResponse {
|
export type ParagonTokenResponse = {
|
||||||
paragonJwt: string;
|
paragonJwt: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IntegrationConfig {
|
export type IntegrationConfig = {
|
||||||
id: string;
|
id: string;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
connectionMode: string;
|
connectionMode: string;
|
||||||
@@ -15,20 +15,20 @@ export interface IntegrationConfig {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IntegrationConfigRequest {
|
export type IntegrationConfigRequest = {
|
||||||
connectionMode: string;
|
connectionMode: string;
|
||||||
sharePointSiteUrl: string | null;
|
sharePointSiteUrl: string | null;
|
||||||
sharePointFolderPath: string | null;
|
sharePointFolderPath: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CredentialResponse {
|
export type CredentialResponse = {
|
||||||
id: string;
|
id: string;
|
||||||
credentialId: string;
|
credentialId: string;
|
||||||
integrationType: string;
|
integrationType: string;
|
||||||
connectedAt: string;
|
connectedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CredentialRequest {
|
export type CredentialRequest = {
|
||||||
credentialId: string;
|
credentialId: string;
|
||||||
integrationType: string;
|
integrationType: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { api } from './api';
|
import { api } from './api';
|
||||||
|
|
||||||
export interface StorageItem {
|
export type StorageItem = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
isFolder: boolean;
|
isFolder: boolean;
|
||||||
@@ -11,18 +11,35 @@ export interface StorageItem {
|
|||||||
createdByUserId: string;
|
createdByUserId: string;
|
||||||
createdByDisplayName: string;
|
createdByDisplayName: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
isReadOnly: boolean;
|
||||||
|
isManagedSync: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateFolderRequest {
|
export type CreateFolderRequest = {
|
||||||
name: string;
|
name: string;
|
||||||
parentId: string | null;
|
parentId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DownloadResponse {
|
export type DownloadResponse = {
|
||||||
sharePointUrl: string | null;
|
sharePointUrl: string | null;
|
||||||
proxyUrl: 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[]> {
|
export async function getItems(parentId?: string | null): Promise<StorageItem[]> {
|
||||||
const params = parentId ? `?parentId=${encodeURIComponent(parentId)}` : '';
|
const params = parentId ? `?parentId=${encodeURIComponent(parentId)}` : '';
|
||||||
return api<StorageItem[]>(`/storage${params}`);
|
return api<StorageItem[]>(`/storage${params}`);
|
||||||
|
|||||||
@@ -129,7 +129,8 @@ select:focus {
|
|||||||
transform: translateY(1px);
|
transform: translateY(1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:disabled {
|
.btn:disabled,
|
||||||
|
.btn.disabled {
|
||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,44 @@
|
|||||||
deleteItem,
|
deleteItem,
|
||||||
getDownloadUrls,
|
getDownloadUrls,
|
||||||
type StorageItem,
|
type StorageItem,
|
||||||
|
createSyncedFolder,
|
||||||
} from '../services/storage';
|
} from '../services/storage';
|
||||||
import { formatLocaleDate } from '../utils/utils';
|
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 route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -18,13 +54,14 @@
|
|||||||
const items = ref<StorageItem[]>([]);
|
const items = ref<StorageItem[]>([]);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const error = ref('');
|
const error = ref('');
|
||||||
const currentFolderId = ref<string | null>(null);
|
|
||||||
const breadcrumbs = ref<{ id: string | null; name: string }[]>([]);
|
const breadcrumbs = ref<{ id: string | null; name: string }[]>([]);
|
||||||
const showNewFolder = ref(false);
|
const showNewFolder = ref(false);
|
||||||
const newFolderName = ref('');
|
const newFolderName = ref('');
|
||||||
|
const currentFolder = ref<StorageItem | null>(null);
|
||||||
|
|
||||||
const folders = computed(() => items.value.filter((i) => i.isFolder));
|
const folders = computed(() => items.value.filter((i) => i.isFolder));
|
||||||
const files = 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 pathSegments = computed(() => {
|
||||||
const raw = route.params.pathMatch;
|
const raw = route.params.pathMatch;
|
||||||
@@ -51,6 +88,7 @@
|
|||||||
try {
|
try {
|
||||||
let parentId: string | null = null;
|
let parentId: string | null = null;
|
||||||
const crumbs: { id: string | null; name: string }[] = [];
|
const crumbs: { id: string | null; name: string }[] = [];
|
||||||
|
let resolvedFolder: StorageItem | null = null;
|
||||||
|
|
||||||
for (const name of segments) {
|
for (const name of segments) {
|
||||||
const children = await getItems(parentId);
|
const children = await getItems(parentId);
|
||||||
@@ -64,15 +102,17 @@
|
|||||||
|
|
||||||
crumbs.push({ id: folder.id, name: folder.name });
|
crumbs.push({ id: folder.id, name: folder.name });
|
||||||
parentId = folder.id;
|
parentId = folder.id;
|
||||||
|
resolvedFolder = folder;
|
||||||
}
|
}
|
||||||
|
|
||||||
currentFolderId.value = parentId;
|
currentFolder.value = resolvedFolder;
|
||||||
breadcrumbs.value = crumbs;
|
breadcrumbs.value = crumbs;
|
||||||
|
|
||||||
items.value = await getItems(currentFolderId.value);
|
items.value = await getItems(currentFolder.value?.id ?? null);
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
error.value = e instanceof Error ? e.message : 'Failed to navigate';
|
error.value = e instanceof Error ? e.message : 'Failed to navigate';
|
||||||
items.value = [];
|
items.value = [];
|
||||||
|
currentFolder.value = null;
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
@@ -94,7 +134,7 @@
|
|||||||
try {
|
try {
|
||||||
await createFolder({
|
await createFolder({
|
||||||
name: newFolderName.value.trim(),
|
name: newFolderName.value.trim(),
|
||||||
parentId: currentFolderId.value,
|
parentId: currentFolder.value?.id ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
newFolderName.value = '';
|
newFolderName.value = '';
|
||||||
@@ -115,7 +155,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await uploadFile(file, currentFolderId.value);
|
await uploadFile(file, currentFolder.value?.id ?? null);
|
||||||
|
|
||||||
input.value = '';
|
input.value = '';
|
||||||
|
|
||||||
@@ -167,6 +207,39 @@
|
|||||||
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
|
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
return `${(bytes / 1048576).toFixed(1)} MB`;
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -185,7 +258,7 @@
|
|||||||
<nav class="breadcrumbs">
|
<nav class="breadcrumbs">
|
||||||
<button
|
<button
|
||||||
class="crumb"
|
class="crumb"
|
||||||
:class="{ active: currentFolderId === null }"
|
:class="{ active: currentFolder === null }"
|
||||||
@click="navigateToBreadcrumb(0)"
|
@click="navigateToBreadcrumb(0)"
|
||||||
>
|
>
|
||||||
Root
|
Root
|
||||||
@@ -208,6 +281,7 @@
|
|||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<button
|
<button
|
||||||
class="btn"
|
class="btn"
|
||||||
|
:disabled="isCurrentFolderReadOnly"
|
||||||
@click="showNewFolder = !showNewFolder"
|
@click="showNewFolder = !showNewFolder"
|
||||||
>
|
>
|
||||||
<AppIcon
|
<AppIcon
|
||||||
@@ -218,7 +292,10 @@
|
|||||||
{{ showNewFolder ? 'Cancel' : 'New Folder' }}
|
{{ showNewFolder ? 'Cancel' : 'New Folder' }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<label class="btn btn-primary upload-label">
|
<label
|
||||||
|
class="btn btn-primary upload-label"
|
||||||
|
:class="{ disabled: isCurrentFolderReadOnly }"
|
||||||
|
>
|
||||||
<AppIcon
|
<AppIcon
|
||||||
name="upload"
|
name="upload"
|
||||||
:size="14"
|
:size="14"
|
||||||
@@ -227,9 +304,21 @@
|
|||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
hidden
|
hidden
|
||||||
|
:disabled="isCurrentFolderReadOnly"
|
||||||
@change="handleUpload"
|
@change="handleUpload"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn btn-secondary"
|
||||||
|
@click="openSharePointFolderPicker"
|
||||||
|
>
|
||||||
|
<AppIcon
|
||||||
|
name="sharepoint"
|
||||||
|
:size="14"
|
||||||
|
/>
|
||||||
|
Sync Folder from SharePoint
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -289,6 +378,7 @@
|
|||||||
<span class="item-date">{{ formatLocaleDate(folder.createdAt) }}</span>
|
<span class="item-date">{{ formatLocaleDate(folder.createdAt) }}</span>
|
||||||
<span class="item-actions">
|
<span class="item-actions">
|
||||||
<button
|
<button
|
||||||
|
v-if="!folder.isReadOnly || folder.isManagedSync"
|
||||||
class="btn-small btn-small-danger"
|
class="btn-small btn-small-danger"
|
||||||
title="Delete"
|
title="Delete"
|
||||||
@click="handleDelete(folder.id)"
|
@click="handleDelete(folder.id)"
|
||||||
@@ -337,6 +427,7 @@
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="!file.isReadOnly"
|
||||||
class="btn-small btn-small-danger"
|
class="btn-small btn-small-danger"
|
||||||
title="Delete"
|
title="Delete"
|
||||||
@click="handleDelete(file.id)"
|
@click="handleDelete(file.id)"
|
||||||
|
|||||||
Reference in New Issue
Block a user