feat: add managed sync with sharepoint #2
Vendored
+1
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"FSharp.suggestGitignore": false,
|
||||
"cSpell.words": [
|
||||
"Ingestor",
|
||||
"useparagon"
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -56,7 +56,7 @@ internal static class IntegrationEndpoints
|
||||
HttpContext context,
|
||||
OrganizationIntegrationRepository repo,
|
||||
UserCredentialRepository credRepo,
|
||||
ParagonService paragon,
|
||||
ParagonApiClient paragon,
|
||||
CancellationToken ct
|
||||
)
|
||||
{
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ internal static class ParagonEndpoints
|
||||
|
||||
private static async Task<IResult> GenerateToken(
|
||||
HttpContext context,
|
||||
ParagonService paragon,
|
||||
ParagonApiClient paragon,
|
||||
UserCredentialRepository credRepo,
|
||||
CancellationToken ct
|
||||
)
|
||||
|
||||
+268
-72
@@ -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,27 +461,6 @@ internal static class StorageEndpoints
|
||||
return Results.Problem(detail: "Item not found", statusCode: StatusCodes.Status404NotFound);
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(item.SharePointDriveItemId) || string.IsNullOrEmpty(item.SharePointSiteId))
|
||||
{
|
||||
return Results.Problem(
|
||||
detail: "No SharePoint reference available for this file.",
|
||||
statusCode: StatusCodes.Status400BadRequest
|
||||
);
|
||||
}
|
||||
|
||||
var credentials = await credRepo.GetByUserIdAsync(user.Id, ct);
|
||||
var spCredential = credentials.FirstOrDefault(c =>
|
||||
c.IntegrationType.Equals("sharepoint", StringComparison.OrdinalIgnoreCase)
|
||||
);
|
||||
|
||||
if (spCredential is null)
|
||||
{
|
||||
return Results.Problem(
|
||||
detail: "No SharePoint credential found.",
|
||||
statusCode: StatusCodes.Status400BadRequest
|
||||
);
|
||||
}
|
||||
|
||||
if (paragon.IsConfigured is false)
|
||||
{
|
||||
return Results.Problem(
|
||||
@@ -315,14 +469,56 @@ internal static class StorageEndpoints
|
||||
);
|
||||
}
|
||||
|
||||
var jwt = paragon.GenerateToken(org.Id, spCredential.CredentialId);
|
||||
var fileStream = await paragon.DownloadFileAsync(
|
||||
jwt,
|
||||
spCredential.CredentialId,
|
||||
item.SharePointSiteId,
|
||||
item.SharePointDriveItemId,
|
||||
ct
|
||||
);
|
||||
Stream fileStream;
|
||||
|
||||
if (
|
||||
string.IsNullOrWhiteSpace(item.ManagedSyncId) is false
|
||||
&& string.IsNullOrWhiteSpace(item.ParagonRecordId) is false
|
||||
)
|
||||
{
|
||||
var jwt = paragon.GenerateToken(org.Id);
|
||||
|
||||
fileStream = await paragon.DownloadSyncedRecordContentAsync(
|
||||
jwt,
|
||||
item.ManagedSyncId,
|
||||
item.ParagonRecordId,
|
||||
ct
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (string.IsNullOrEmpty(item.SharePointDriveItemId) || string.IsNullOrEmpty(item.SharePointSiteId))
|
||||
{
|
||||
return Results.Problem(
|
||||
detail: "No SharePoint reference available for this file.",
|
||||
statusCode: StatusCodes.Status400BadRequest
|
||||
);
|
||||
}
|
||||
|
||||
var user = context.GetUser();
|
||||
var credentials = await credRepo.GetByUserIdAsync(user.Id, ct);
|
||||
var spCredential = credentials.FirstOrDefault(c =>
|
||||
c.IntegrationType.Equals("sharepoint", StringComparison.OrdinalIgnoreCase)
|
||||
);
|
||||
|
||||
if (spCredential is null)
|
||||
{
|
||||
return Results.Problem(
|
||||
detail: "No SharePoint credential found.",
|
||||
statusCode: StatusCodes.Status400BadRequest
|
||||
);
|
||||
}
|
||||
|
||||
var jwt = paragon.GenerateToken(org.Id, spCredential.CredentialId);
|
||||
|
||||
fileStream = await paragon.DownloadFileAsync(
|
||||
jwt,
|
||||
spCredential.CredentialId,
|
||||
item.SharePointSiteId,
|
||||
item.SharePointDriveItemId,
|
||||
ct
|
||||
);
|
||||
}
|
||||
|
||||
return Results.Stream(fileStream, item.ContentType ?? "application/octet-stream", item.Name);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
+192
-6
@@ -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);
|
||||
}
|
||||
}
|
||||
+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(
|
||||
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)
|
||||
{
|
||||
|
||||
+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;
|
||||
|
||||
/// <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,
|
||||
};
|
||||
}
|
||||
}
|
||||
+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>
|
||||
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;
|
||||
|
||||
|
||||
+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>
|
||||
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)"
|
||||
|
||||
Reference in New Issue
Block a user