Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
9283a52
Backend now knows if user can download any project
rmunn Jul 18, 2025
ff9c540
Slightly more sensible type for ServerProjects API
rmunn Jul 18, 2025
fbb5c0b
Add placeholder UI
rmunn Jul 18, 2025
c9de80e
Add placeholder "download any project" dialog
rmunn Jul 18, 2025
270ffb9
Add ability to download project by code
rmunn Jul 21, 2025
d1ae300
Catch case where lexbox project not yet CRDT
rmunn Jul 22, 2025
ebff86a
Much improved download-by-code dialog
rmunn Jul 22, 2025
cf58f05
Include role when downloading project by code
rmunn Jul 22, 2025
5368620
Improve UI a little
rmunn Jul 22, 2025
eb727a2
Add new file missed in earlier commit
rmunn Jul 22, 2025
0fd53e7
Dialog checks to see if project already downloaded
rmunn Jul 22, 2025
13704f1
Don't log newlines if we receive them
rmunn Jul 22, 2025
1dd0577
Fix up in-memory-api-service to match new interface
rmunn Jul 22, 2025
8bf48b5
Remove redundant null check
rmunn Jul 22, 2025
12882ce
Fix variable name typo
rmunn Jul 22, 2025
8b2a4ec
Fix grammar mistake in one error message
rmunn Jul 22, 2025
be0583c
Fix another typo
rmunn Jul 22, 2025
2d92326
Add backend check for already-downloaded project
rmunn Jul 23, 2025
8105c8d
Use UserProjectRole enum rather than string
rmunn Jul 23, 2025
d5eeaf4
Switch to radio buttons for user roles
rmunn Jul 23, 2025
f89d7e6
Switch back to Select rather than RadioGroup
rmunn Jul 23, 2025
1ce3f3c
Don't allow closing dialog during download
rmunn Jul 23, 2025
a22793b
Handle dialog closing correctly if exception thrown
rmunn Jul 23, 2025
bec1f6b
Remove accidentally-kept debug line
rmunn Jul 23, 2025
337cd4d
Don't cache IsCrdtProject results
rmunn Jul 23, 2025
0497687
Move extension method into LexAuthUser
rmunn Jul 23, 2025
fd72b6a
Rename CanDownload... property to something shorter
rmunn Jul 23, 2025
00e9b88
Move ListProjectsResult record into Project.cs
rmunn Jul 23, 2025
4646946
Restore listProjects endpoint, add listProjectsV2
rmunn Jul 23, 2025
ee097a2
Also display error if user may not download project
rmunn Jul 23, 2025
836465d
Merge remote-tracking branch 'origin/develop' into feat/admins-can-do…
rmunn Jul 23, 2025
09067d4
Fix lint error
rmunn Jul 23, 2025
d4e2643
Consolidate three API endpoints into one
rmunn Jul 24, 2025
e5080cd
Merge remote-tracking branch 'origin/develop' into feat/admins-can-do…
rmunn Jul 24, 2025
f6d517b
Better name for lookupProjectIdForDownload endpoint
rmunn Jul 30, 2025
2affd43
fix modal size
hahn-kev Jul 31, 2025
5fdb142
prevent closing download dialog when download is in progress
hahn-kev Jul 31, 2025
cef859b
Update /download/crdt/(server)/code API
rmunn Jul 31, 2025
3af11cb
Address review comments
rmunn Jul 31, 2025
b988203
simplify empty list state
hahn-kev Aug 4, 2025
1acf66e
Fix mistaken logic inversion from previous commit
rmunn Aug 5, 2025
20ef246
Merge branch 'develop' into feat/admins-can-download-any-crdt-project
rmunn Aug 5, 2025
75b8f70
Merge branch 'develop' into feat/admins-can-download-any-crdt-project
rmunn Aug 5, 2025
d3283ea
Run i18n:extract after develop merge
rmunn Aug 5, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 47 additions & 13 deletions backend/FwLite/FwLiteShared/Projects/CombinedProjectsService.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Text.Json.Serialization;
using FwLiteShared.Auth;
using FwLiteShared.Sync;
using LcmCrdt;
Expand All @@ -9,6 +10,16 @@

namespace FwLiteShared.Projects;

[JsonConverter(typeof(JsonStringEnumConverter))]
public enum DownloadProjectByCodeResult
{
Success,
Forbidden,
NotCrdtProject,
ProjectNotFound,
ProjectAlreadyDownloaded,
}

public record ProjectModel(
string Name,
string Code,
Expand All @@ -28,7 +39,7 @@ public record ProjectModel(
};
}

public record ServerProjects(LexboxServer Server, ProjectModel[] Projects);
public record ServerProjects(LexboxServer Server, ProjectModel[] Projects, bool CanDownloadByCode);
public class CombinedProjectsService(LexboxProjectService lexboxProjectService,
CrdtProjectsService crdtProjectsService,
IEnumerable<IProjectProvider> projectProviders,
Expand All @@ -44,21 +55,20 @@ public async Task<ServerProjects[]> RemoteProjects()
ServerProjects[] serverProjects = new ServerProjects[lexboxServers.Length];
for (var i = 0; i < lexboxServers.Length; i++)
{
var server = lexboxServers[i];
var projectModels = await ServerProjects(server);
serverProjects[i] = new ServerProjects(server, projectModels);
serverProjects[i] = await ServerProjects(lexboxServers[i]);
}

return serverProjects;
}

private async Task<ProjectModel[]> ServerProjects(LexboxServer server, bool forceRefresh = false)
private async Task<ServerProjects> ServerProjects(LexboxServer server, bool forceRefresh = false)
{
if (forceRefresh)
lexboxProjectService.InvalidateProjectsCache(server);
var lexboxProjects = await lexboxProjectService.GetLexboxProjects(server);
await UpdateProjectServerInfo(lexboxProjects, await lexboxProjectService.GetLexboxUser(server));
var projectModels = lexboxProjects.Select(p => new ProjectModel(
var user = await lexboxProjectService.GetLexboxUser(server);
await UpdateProjectServerInfo(lexboxProjects.Projects, user);
var projectModels = lexboxProjects.Projects.Select(p => new ProjectModel(
p.Name,
p.Code,
Crdt: p.IsCrdtProject,
Expand All @@ -68,7 +78,7 @@ private async Task<ProjectModel[]> ServerProjects(LexboxServer server, bool forc
server,
p.Id))
.ToArray();
return projectModels;
return new(server, projectModels, lexboxProjects.CanDownloadByCode);
}

private async Task UpdateProjectServerInfo(FieldWorksLiteProject[] lexboxProjects, LexboxUser? lexboxUser)
Expand All @@ -83,10 +93,10 @@ private async Task UpdateProjectServerInfo(FieldWorksLiteProject[] lexboxProject


[JSInvokable]
public async Task<ProjectModel[]> ServerProjects(string serverId, bool forceRefresh)
public async Task<ServerProjects?> ServerProjects(string serverId, bool forceRefresh)
Comment thread
hahn-kev marked this conversation as resolved.
{
var server = lexboxProjectService.Servers().FirstOrDefault(s => s.Id == serverId);
if (server is null) return [];
if (server is null) return null;
return await ServerProjects(server, forceRefresh);
}

Expand Down Expand Up @@ -143,12 +153,36 @@ private ProjectRole FromRole(UserProjectRole role) =>
_ => ProjectRole.Unknown
};

public async Task DownloadProject(string code, LexboxServer server)
[JSInvokable]
public async Task<DownloadProjectByCodeResult> DownloadProjectByCode(string code, LexboxServer server, UserProjectRole? userRole = null)
{
var serverProjects = await ServerProjects(server, false);
var project = serverProjects.FirstOrDefault(p => p.Code == code)
?? throw new InvalidOperationException($"Project {code} not found on server {server.Authority}");
var project = serverProjects.Projects.FirstOrDefault(p => p.Code == code);
if (project is null)
{
if (serverProjects.CanDownloadByCode)
{
var (status, projectId) = await lexboxProjectService.GetLexboxProjectId(server, code);
if (status != DownloadProjectByCodeResult.Success) return status;
if (crdtProjectsService.ProjectExists(code)) return DownloadProjectByCodeResult.ProjectAlreadyDownloaded;
var role = userRole.HasValue ? FromRole(userRole.Value) : ProjectRole.Editor;
project = new ProjectModel(
Name: code,
Code: code,
Crdt: true,
Fwdata: false,
Lexbox: true,
Role: role,
Server: server,
Id: projectId
);
await DownloadProject(project);
return DownloadProjectByCodeResult.Success;
}
return DownloadProjectByCodeResult.ProjectNotFound;
}
await DownloadProject(project);
return DownloadProjectByCodeResult.Success;
Comment thread
hahn-kev marked this conversation as resolved.
}

[JSInvokable]
Expand Down
32 changes: 23 additions & 9 deletions backend/FwLite/FwLiteShared/Projects/LexboxProjectService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,24 +64,24 @@ public LexboxServer[] Servers()
return Servers().FirstOrDefault(s => s.Id == projectData.ServerId);
}

public async Task<FieldWorksLiteProject[]> GetLexboxProjects(LexboxServer server)
public async Task<ListProjectsResult> GetLexboxProjects(LexboxServer server)
{
return await cache.GetOrCreateAsync(CacheKey(server),
async entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
var httpClient = await clientFactory.GetClient(server).CreateHttpClient();
if (httpClient is null) return [];
if (httpClient is null) return new([], false);
try
{
return await httpClient.GetFromJsonAsync<FieldWorksLiteProject[]>("api/crdt/listProjects") ?? [];
return await httpClient.GetFromJsonAsync<ListProjectsResult>("api/crdt/listProjectsV2") ?? new([], false);
}
catch (Exception e)
{
logger.LogError(e, "Error getting lexbox projects");
return [];
return new([], false);
}
}) ?? [];
}) ?? new([], false);
}

public async Task<LexboxUser?> GetLexboxUser(LexboxServer server)
Expand All @@ -94,18 +94,32 @@ private static string CacheKey(LexboxServer server)
return $"Projects|{server.Authority.Authority}";
}

public async Task<Guid?> GetLexboxProjectId(LexboxServer server, string code)
public async Task<(DownloadProjectByCodeResult, Guid?)> GetLexboxProjectId(LexboxServer server, string code)
{
var httpClient = await clientFactory.GetClient(server).CreateHttpClient();
if (httpClient is null) return null;
if (httpClient is null) return (DownloadProjectByCodeResult.Forbidden, null);
try
{
return await httpClient.GetFromJsonAsync<Guid?>($"api/crdt/lookupProjectId?code={code}");
var result = await httpClient.GetAsync($"api/crdt/lookupProjectId?code={code}");
if (result.StatusCode == System.Net.HttpStatusCode.Forbidden) // 403
{
return (DownloadProjectByCodeResult.Forbidden, null);
}
if (result.StatusCode == System.Net.HttpStatusCode.NotFound) // 404
{
return (DownloadProjectByCodeResult.ProjectNotFound, null);
}
if (result.StatusCode == System.Net.HttpStatusCode.NotAcceptable) // 406
{
return (DownloadProjectByCodeResult.NotCrdtProject, null);
}
var guid = await result.Content.ReadFromJsonAsync<Guid?>();
return (DownloadProjectByCodeResult.Success, guid);
}
catch (Exception e)
{
logger.LogError(e, "Error getting lexbox project id");
return null;
return (DownloadProjectByCodeResult.Forbidden, null);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ private static void ConfigureFwLiteSharedTypes(ConfigurationBuilder builder)
builder.ExportAsEnum<UserProjectRole>().UseString();
builder.ExportAsEnum<ProjectRole>().UseString();
builder.ExportAsEnum<SyncStatus>().UseString();
builder.ExportAsEnum<DownloadProjectByCodeResult>().UseString();
builder.ExportAsEnum<SyncJobStatusEnum>().UseString();
var serviceTypes = Enum.GetValues<DotnetService>()
//lcm has it's own dedicated export, config is not a service just a object, and testing needs a custom export below
Expand Down
16 changes: 13 additions & 3 deletions backend/FwLite/FwLiteWeb/Routes/ProjectRoutes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,22 @@ public static IEndpointConventionBuilder MapProjectRoutes(this WebApplication ap
async (IOptions<AuthConfig> options,
CombinedProjectsService combinedProjectsService,
string code,
string serverAuthority
string serverAuthority,
[FromQuery] UserProjectRole? role
) =>
{
var server = options.Value.GetServerByAuthority(serverAuthority);
await combinedProjectsService.DownloadProject(code, server);
return TypedResults.Ok();
var result = await combinedProjectsService.DownloadProjectByCode(code, server, role);
return result switch
{
DownloadProjectByCodeResult.Success => Results.Ok(),
DownloadProjectByCodeResult.Forbidden => Results.Forbid(),
DownloadProjectByCodeResult.NotCrdtProject => Results.InternalServerError("Not a CRDT project"),
DownloadProjectByCodeResult.ProjectNotFound => Results.NotFound("Project not found"),
DownloadProjectByCodeResult.ProjectAlreadyDownloaded => Results.NoContent(),
// If we reach this point then we updated DownloadProjectByCodeResult and forgot to update this switch
_ => Results.InternalServerError("DownloadProjectByCodeResult enum value not handled, please inform FW Lite devs")
};
});
return group;
}
Expand Down
4 changes: 3 additions & 1 deletion backend/FwLite/LcmCrdt/CrdtProject.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using LcmCrdt.Project;
using System.Text.Json.Serialization;
using LcmCrdt.Project;
using Microsoft.Extensions.Caching.Memory;

namespace LcmCrdt;
Expand Down Expand Up @@ -40,6 +41,7 @@ public record ProjectData(string Name, string Code, Guid Id, string? OriginDomai
public bool IsReadonly => Role is not UserProjectRole.Editor and not UserProjectRole.Manager;
}

[JsonConverter(typeof(JsonStringEnumConverter))]
public enum UserProjectRole
{
Unknown,
Expand Down
36 changes: 30 additions & 6 deletions backend/LexBoxApi/Controllers/CrdtController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,19 +93,43 @@ public async Task<ActionResult<FieldWorksLiteProject[]>> ListProjects()
return myProjects;
}

[HttpGet("listProjectsV2")]
// Will eventually become `listProjects`, once current clients have been updated, at which point we'll
// retire the V2 endpoint
public async Task<ActionResult<ListProjectsResult>> ListProjectsWithDownloadRights()
{
var myProjects = await projectService.UserProjects(loggedInContext.User.Id)
.Where(p => p.Type == ProjectType.FLEx)
.Select(p => new FieldWorksLiteProject(p.Id,
p.Code,
p.Name,
p.LastCommit != null,
dbContext.Set<ServerCommit>().Any(c => c.ProjectId == p.Id),
p.Users.Where(u => u.UserId == loggedInContext.User.Id).Select(m => m.Role).FirstOrDefault()))
.ToArrayAsync();
if (loggedInContext.User.IsOutOfSyncWithMyProjects(myProjects))
{
await lexAuthService.RefreshUser(LexAuthConstants.ProjectsClaimType);
}
return new ListProjectsResult(myProjects, loggedInContext.User.CanDownloadProjectsWithoutMembership());
}

[HttpGet("lookupProjectId")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status406NotAcceptable)] // Closest HTTP code that fits the semantics for "not a CRDT project"
[ProducesDefaultResponseType]
public async Task<ActionResult<Guid>> GetProjectId(string code)
{
await permissionService.AssertCanViewProject(code);
var allowed = await permissionService.CanViewProject(code);
if (!allowed) return Forbid();
var projectId = await projectService.LookupProjectId(code);
if (projectId is null)
{
return NotFound();
}

if (projectId is null) return NotFound();
allowed = await permissionService.CanDownloadProject(projectId.Value);
if (!allowed) return Forbid();
var isCrdt = projectService.IsCrdtProject(projectId.Value);
if (!isCrdt) return StatusCode(StatusCodes.Status406NotAcceptable);
return Ok(projectId.Value);
}

Expand Down
5 changes: 5 additions & 0 deletions backend/LexBoxApi/Services/ProjectService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -364,4 +364,9 @@ public IQueryable<Project> UserProjects(Guid userId)
{
return dbContext.Projects.Where(p => p.Users.Select(u => u.UserId).Contains(userId));
}

public bool IsCrdtProject(Guid projectId)
{
return dbContext.CrdtCommits(projectId).Any();
}
}
7 changes: 7 additions & 0 deletions backend/LexCore/Auth/LexAuthUser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,13 @@ public bool IsProjectMember(Guid projectId, params Span<ProjectRole> roles)
return false;
}

public bool CanDownloadProjectsWithoutMembership()
{
if (IsAdmin) return true;
if (Orgs.Any(o => o.Role == OrgRole.Admin)) return true;
return false;
}

public bool HasFeature(FeatureFlag feature)
{
if (FeatureFlags is null) return false;
Expand Down
4 changes: 4 additions & 0 deletions backend/LexCore/Entities/Project.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@ public record FieldWorksLiteProject(
bool IsCrdtProject,
[property:JsonConverter(typeof(JsonStringEnumConverter))]ProjectRole Role);

public record ListProjectsResult(
FieldWorksLiteProject[] Projects,
bool CanDownloadByCode);

public enum ProjectMigrationStatus
{
//default value
Expand Down
1 change: 1 addition & 0 deletions backend/LexCore/ServiceInterfaces/IPermissionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ public interface IPermissionService
ValueTask<bool> CanSyncProject(Guid projectId);
ValueTask AssertCanSyncProject(string projectCode);
ValueTask AssertCanSyncProject(Guid projectId);
ValueTask<bool> CanDownloadProject(Guid projectId);
ValueTask AssertCanDownloadProject(Guid projectId);
ValueTask<bool> CanViewProject(Guid projectId, LexAuthUser? overrideUser = null);
ValueTask AssertCanViewProject(Guid projectId, LexAuthUser? overrideUser = null);
Expand Down
Loading
Loading