Surface project ownership/role on the wire and gate the web UI by it

ProjectResponse now carries OwnerId and a server-resolved MyRole so
Editors/Reviewers see read-only fields and no delete/grant-management
affordances instead of only finding out via a 403 after the fact.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PuBH9QSv66DPXSSBERmPs6
This commit is contained in:
Claude
2026-08-16 15:37:50 +00:00
parent e598c18d67
commit e2a2e69631
19 changed files with 447 additions and 145 deletions
@@ -132,4 +132,51 @@ public class ProjectAccessTests : ServiceTestFixture
var ownerNavigation = Db.Context.Model.FindEntityType(typeof(Project))!.FindNavigation(nameof(Project.Owner))!;
Assert.That(ownerNavigation.ForeignKey.DeleteBehavior, Is.EqualTo(DeleteBehavior.Restrict));
}
[Test]
public async Task The_creator_of_a_novel_sees_their_role_as_owner()
{
var writerId = AsNewUser(GlobalRole.Writer);
var project = await Projects.CreateAsync(new CreateProjectRequest("Owned by writer"));
UserContext.UserId = writerId;
var role = await Access.GetMyRoleAsync(project);
Assert.That(role, Is.EqualTo("Owner"));
}
[Test]
public async Task An_admin_sees_their_role_as_admin_even_on_a_novel_they_do_not_own()
{
AsNewUser(GlobalRole.Writer);
var project = await Projects.CreateAsync(new CreateProjectRequest("Owned by someone else"));
AsAdmin();
var role = await Access.GetMyRoleAsync(project);
Assert.That(role, Is.EqualTo("Admin"));
}
[Test]
public async Task A_user_granted_editor_sees_their_role_as_editor()
{
var project = await Projects.CreateAsync(new CreateProjectRequest("Granted Novel"));
var editorId = AsNewUser(GlobalRole.Reviewer);
GrantProjectRole(project.Id, editorId, ProjectRole.Editor);
var role = await Access.GetMyRoleAsync(project);
Assert.That(role, Is.EqualTo("Editor"));
}
[Test]
public async Task A_user_with_no_access_sees_a_null_role()
{
var project = await Projects.CreateAsync(new CreateProjectRequest("Someone Else's Novel"));
AsNewUser(GlobalRole.Writer);
var role = await Access.GetMyRoleAsync(project);
Assert.That(role, Is.Null);
}
}
@@ -49,6 +49,19 @@ public class ProjectDataTests : ServiceTestFixture
Assert.That(afterUnrelatedUpdate.Phase, Is.EqualTo(ProjectPhase.Outlining));
}
[Test]
public async Task A_project_response_carries_the_owner_id_and_the_caller_s_role()
{
var project = await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"));
var response = project.ToResponse(await Access.GetMyRoleAsync(project));
Assert.Multiple(() =>
{
Assert.That(response.OwnerId, Is.EqualTo(UserContext.UserId));
Assert.That(response.MyRole, Is.EqualTo("Admin"));
});
}
[Test]
public async Task Chapters_are_numbered_in_sequence_when_no_number_is_given()
{