diff --git a/src/Novelly.Api/Agent/NovelAgentToolset.cs b/src/Novelly.Api/Agent/NovelAgentToolset.cs index 0170ed0..8aba0bd 100644 --- a/src/Novelly.Api/Agent/NovelAgentToolset.cs +++ b/src/Novelly.Api/Agent/NovelAgentToolset.cs @@ -99,7 +99,7 @@ public class NovelAgentToolset( "Read the project's title, logline, synopsis, genre, notes and word-count target. " + "Call this first in a conversation to ground yourself in what the book is.", new JsonSchemaBuilder().Build(), - async (projectId, _, ct) => await OrNotFound(projects.GetAsync(projectId, ct), p => p.ToResponse(), "Project", projectId)); + async (projectId, _, ct) => await OrNotFound(projects.GetAsync(projectId, ct), p => p.ToResponse(null), "Project", projectId)); yield return new AgentTool( "update_project_brief", @@ -121,7 +121,7 @@ public class NovelAgentToolset( JsonInput.String(input, "logline"), JsonInput.String(input, "synopsis"), JsonInput.String(input, "notes"), - JsonInput.Int(input, "target_word_count")), ct), p => p.ToResponse(), "Project", projectId)); + JsonInput.Int(input, "target_word_count")), ct), p => p.ToResponse(null), "Project", projectId)); yield return new AgentTool( "list_characters", diff --git a/src/Novelly.Api/Imports/ImportAgentToolset.cs b/src/Novelly.Api/Imports/ImportAgentToolset.cs index ebf8907..675f689 100644 --- a/src/Novelly.Api/Imports/ImportAgentToolset.cs +++ b/src/Novelly.Api/Imports/ImportAgentToolset.cs @@ -202,7 +202,7 @@ public class ImportAgentToolset( Notes: JsonInput.String(input, "notes")), ct); ProjectId = created.Id; - return created.ToResponse(); + return created.ToResponse(null); }); yield return new ImportAgentTool( @@ -225,7 +225,7 @@ public class ImportAgentToolset( return updated is null ? new ImportToolNotFound("Project", projectId) - : updated.ToResponse(); + : updated.ToResponse(null); }); yield return new ImportAgentTool( diff --git a/src/Novelly.Api/Projects/ProjectContracts.cs b/src/Novelly.Api/Projects/ProjectContracts.cs index f84a232..695f9e4 100644 --- a/src/Novelly.Api/Projects/ProjectContracts.cs +++ b/src/Novelly.Api/Projects/ProjectContracts.cs @@ -25,6 +25,8 @@ public record ProjectResponse( string? Notes, int? TargetWordCount, ProjectPhase Phase, + Guid? OwnerId, + string? MyRole, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt); @@ -103,7 +105,7 @@ file static class ProjectValidation public static class ProjectMapping { - public static ProjectResponse ToResponse(this Project p) => new( + public static ProjectResponse ToResponse(this Project p, string? myRole) => new( p.Id, p.Title, p.Author, p.Genre, p.Logline, p.Synopsis, p.Notes, - p.TargetWordCount, p.Phase, p.CreatedAt, p.UpdatedAt); + p.TargetWordCount, p.Phase, p.OwnerId, myRole, p.CreatedAt, p.UpdatedAt); } diff --git a/src/Novelly.Api/Projects/ProjectEndpoints.cs b/src/Novelly.Api/Projects/ProjectEndpoints.cs index 167220e..f7e4cbf 100644 --- a/src/Novelly.Api/Projects/ProjectEndpoints.cs +++ b/src/Novelly.Api/Projects/ProjectEndpoints.cs @@ -1,5 +1,6 @@ using Novelly.Api.Common; using Novelly.Api.Common.Validation; +using Novelly.Api.Users; namespace Novelly.Api.Projects; @@ -15,20 +16,36 @@ public static class ProjectEndpoints Results.Ok(await service.ListAsync(ct))) .WithSummary("List all novel projects."); - group.MapGet("/{id:guid}", async (Guid id, ProjectService service, CancellationToken ct) => - (await service.GetAsync(id, ct))?.ToResponse().ToApiResult()) + group.MapGet("/{id:guid}", async (Guid id, ProjectService service, ProjectAccessService access, CancellationToken ct) => + { + var project = await service.GetAsync(id, ct); + if (project is null) + return Results.NotFound(); + + var myRole = await access.GetMyRoleAsync(project, ct); + return Results.Ok(project.ToResponse(myRole)); + }) .WithSummary("Read a project's brief."); - group.MapPost("/", async (CreateProjectRequest request, ProjectService service, CancellationToken ct) => + group.MapPost("/", async (CreateProjectRequest request, ProjectService service, ProjectAccessService access, CancellationToken ct) => { - var created = (await service.CreateAsync(request, ct)).ToResponse(); + var project = await service.CreateAsync(request, ct); + var myRole = await access.GetMyRoleAsync(project, ct); + var created = project.ToResponse(myRole); return Results.Created($"/api/projects/{created.Id}", created); }) .WithSummary("Create a novel project."); group.MapPatch("/{id:guid}", async ( - Guid id, UpdateProjectRequest request, ProjectService service, CancellationToken ct) => - (await service.UpdateAsync(id, request, ct))?.ToResponse().ToApiResult()) + Guid id, UpdateProjectRequest request, ProjectService service, ProjectAccessService access, CancellationToken ct) => + { + var project = await service.UpdateAsync(id, request, ct); + if (project is null) + return Results.NotFound(); + + var myRole = await access.GetMyRoleAsync(project, ct); + return Results.Ok(project.ToResponse(myRole)); + }) .WithSummary("Update a project's brief."); group.MapDelete("/{id:guid}", async (Guid id, ProjectService service, CancellationToken ct) => diff --git a/src/Novelly.Api/Users/ProjectAccessService.cs b/src/Novelly.Api/Users/ProjectAccessService.cs index d753eea..affa89a 100644 --- a/src/Novelly.Api/Users/ProjectAccessService.cs +++ b/src/Novelly.Api/Users/ProjectAccessService.cs @@ -51,6 +51,23 @@ public class ProjectAccessService(INovelDbContext db, INovelUserContext userCont } } + public async Task GetMyRoleAsync(Project project, CancellationToken ct = default) + { + if (userContext.GlobalRole == GlobalRole.Admin) + return "Admin"; + + if (project.OwnerId is not null && project.OwnerId == userContext.UserId) + return "Owner"; + + if (userContext.UserId is null) + return null; + + var member = await db.ProjectMembers.AsNoTracking() + .FirstOrDefaultAsync(m => m.ProjectId == project.Id && m.UserId == userContext.UserId, ct); + + return member?.ProjectRole.ToString(); + } + public IQueryable VisibleProjects() { if (userContext.GlobalRole == GlobalRole.Admin) diff --git a/src/Novelly.Web/src/api/types.ts b/src/Novelly.Web/src/api/types.ts index ac16c3f..db5cc7f 100644 --- a/src/Novelly.Web/src/api/types.ts +++ b/src/Novelly.Web/src/api/types.ts @@ -40,6 +40,8 @@ export type ProjectRole = 'Writer' | 'Editor' | 'Reviewer' export const projectRoles: ProjectRole[] = ['Writer', 'Editor', 'Reviewer'] +export type ProjectMyRole = 'Admin' | 'Owner' | 'Writer' | 'Editor' | 'Reviewer' + export interface User { id: string email: string @@ -84,6 +86,8 @@ export interface Project { notes: string | null targetWordCount: number | null phase: ProjectPhase + ownerId: string | null + myRole: ProjectMyRole | null createdAt: string updatedAt: string } diff --git a/src/Novelly.Web/src/auth/AuthContext.tsx b/src/Novelly.Web/src/auth/AuthContext.tsx index 9071469..264bdb7 100644 --- a/src/Novelly.Web/src/auth/AuthContext.tsx +++ b/src/Novelly.Web/src/auth/AuthContext.tsx @@ -1,13 +1,21 @@ import { createContext, useContext, useMemo, type ReactNode } from 'react' import { useMe } from '../api/hooks' -import type { User } from '../api/types' +import type { Project, ProjectMyRole, User } from '../api/types' -export type AuthPermission = 'CreateNovel' +export type AuthPermission = 'CreateNovel' | 'Write' | 'CreateContent' | 'DeleteContent' | 'ManageAccess' + +const projectPermissionsByRole: Record = { + Admin: ['Write', 'CreateContent', 'DeleteContent', 'ManageAccess'], + Owner: ['Write', 'CreateContent', 'DeleteContent', 'ManageAccess'], + Writer: ['Write', 'CreateContent', 'DeleteContent'], + Editor: ['Write'], + Reviewer: [], +} interface AuthValue { user: User | null isPending: boolean - can: (permission: AuthPermission) => boolean + can: (permission: AuthPermission, project?: Pick | null) => boolean } const AuthContext = createContext({ user: null, isPending: true, can: () => false }) @@ -20,8 +28,11 @@ export function AuthProvider({ children }: { children: ReactNode }) { () => ({ user, isPending, - can: (permission) => - permission === 'CreateNovel' && (user?.globalRole === 'Admin' || user?.globalRole === 'Writer'), + can: (permission, project) => { + if (permission === 'CreateNovel') return user?.globalRole === 'Admin' || user?.globalRole === 'Writer' + const myRole = project?.myRole + return myRole ? projectPermissionsByRole[myRole].includes(permission) : false + }, }), [user, isPending], ) diff --git a/src/Novelly.Web/src/components/CharacterArc.tsx b/src/Novelly.Web/src/components/CharacterArc.tsx index b481343..af78153 100644 --- a/src/Novelly.Web/src/components/CharacterArc.tsx +++ b/src/Novelly.Web/src/components/CharacterArc.tsx @@ -13,9 +13,15 @@ import { AutoField, ErrorNote } from './ui' export function CharacterArc({ projectId, character, + canWrite, + canCreate, + canDelete, }: { projectId: string character: Character + canWrite: boolean + canCreate: boolean + canDelete: boolean }) { const { data: chapters } = useChapters(projectId) const create = useCreateArcStage(projectId) @@ -65,22 +71,26 @@ export function CharacterArc({ canMoveUp={index > 0} canMoveDown={index < stages.length - 1} onMove={(delta) => move(index, delta)} + canWrite={canWrite} + canDelete={canDelete} /> ))} )} -
- setTitle(e.target.value)} - /> - -
+ {canCreate && ( +
+ setTitle(e.target.value)} + /> + +
+ )} {create.error && (
@@ -98,6 +108,8 @@ function ArcStageRow({ canMoveUp, canMoveDown, onMove, + canWrite, + canDelete, }: { projectId: string stage: ArcStage @@ -105,6 +117,8 @@ function ArcStageRow({ canMoveUp: boolean canMoveDown: boolean onMove: (delta: number) => void + canWrite: boolean + canDelete: boolean }) { const update = useUpdateArcStage(projectId) const remove = useDeleteArcStage(projectId) @@ -121,6 +135,7 @@ function ArcStageRow({ title.trim() && update.mutate({ id: stage.id, title })} + readOnly={!canWrite} /> update.mutate({ id: stage.id, description })} + readOnly={!canWrite} />
patch({ status })} + onChange={(status) => canWrite && patch({ status })} />
@@ -89,6 +98,7 @@ export default function ChapterPage() { label="Setting" value={chapter.setting} onCommit={(setting) => patch({ setting })} + readOnly={!canWrite} />
@@ -97,7 +107,7 @@ export default function ChapterPage() { label="Tags" tags={chapter.tags} suggestions={suggestions} - onChange={(tags) => patch({ tags })} + onChange={(tags) => canWrite && patch({ tags })} /> @@ -105,9 +115,11 @@ export default function ChapterPage() {
{chapter.beats.length} beats · {chapter.wordCount.toLocaleString()} words
- + {canDelete && ( + + )} @@ -160,6 +172,7 @@ export default function ChapterPage() { placeholder="What this chapter is for: where it starts, what shifts, where it leaves the reader." onCommit={(summary) => patch({ summary })} onContextMenu={(e) => handleContextMenu(e, () => {})} + readOnly={!canWrite} /> @@ -169,15 +182,19 @@ export default function ChapterPage() { characters={characters?.map((c) => ({ id: c.id, name: c.name })) ?? []} suggestions={suggestions} onCharacterContextMenu={handleContextMenu} + canWrite={canWrite} + canDelete={canDelete} /> - + {canCreate && ( + + )} {createBeat.error && (
@@ -191,6 +208,7 @@ export default function ChapterPage() { value={chapter.prose} placeholder="Start writing the chapter." onCommit={(prose) => patch({ prose })} + readOnly={!canWrite} /> )} @@ -207,10 +225,17 @@ export default function ChapterPage() { rows={5} placeholder="Notes on this chapter." onCommit={(notes) => patch({ notes })} + readOnly={!canWrite} /> - + {menuElement}
@@ -223,6 +248,8 @@ function BeatTable({ characters, suggestions, onCharacterContextMenu, + canWrite, + canDelete, }: { chapter: Chapter projectId: string @@ -232,6 +259,8 @@ function BeatTable({ e: MouseEvent, onCreated: (characterId: string) => void, ) => void + canWrite: boolean + canDelete: boolean }) { const update = useUpdateBeat(chapter.id, projectId) const remove = useDeleteBeat(chapter.id) @@ -266,6 +295,7 @@ function BeatTable({ } const move = (index: number, delta: number) => { + if (!canWrite) return const ids = chapter.beats.map((b) => b.id) const target = index + delta if (target < 0 || target >= ids.length) return @@ -278,7 +308,7 @@ function BeatTable({ return (
- {selectedIds.length > 0 && ( + {selectedIds.length > 0 && canWrite && (
{selectedIds.length} beat{selectedIds.length === 1 ? '' : 's'} selected @@ -443,14 +473,16 @@ function BeatTable({ > ✓ - + {canDelete && ( + + )}
@@ -458,18 +490,26 @@ function BeatTable({ setEditingId(beat.id)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - setEditingId(beat.id) - } - }} + onClick={canWrite ? () => setEditingId(beat.id) : undefined} + onKeyDown={ + canWrite + ? (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + setEditingId(beat.id) + } + } + : undefined + } > (null) const [adding, setAdding] = useState(false) - useHotkey('n', 'Add character', () => setAdding(true), { group: 'Characters' }) + useHotkey('n', 'Add character', () => canCreate && setAdding(true), { group: 'Characters' }) if (isPending) return if (error) return @@ -32,9 +39,11 @@ export default function CharactersPage() { return (
- + {canDelete && ( + + )}
- patch({ age })} /> + patch({ age })} readOnly={!canWrite} /> patch({ pronouns })} + readOnly={!canWrite} /> patch({ occupation })} + readOnly={!canWrite} />
@@ -139,7 +172,7 @@ function CharacterSheet({ projectId, character }: { projectId: string; character label="Tags" tags={character.tags} suggestions={allTags?.map((t) => t.name) ?? []} - onChange={(tags) => patch({ tags })} + onChange={(tags) => canWrite && patch({ tags })} /> @@ -151,6 +184,7 @@ function CharacterSheet({ projectId, character }: { projectId: string; character rows={3} placeholder="What they are consciously chasing." onCommit={(want) => patch({ want })} + readOnly={!canWrite} /> patch({ need })} + readOnly={!canWrite} /> patch({ internalConflict })} + readOnly={!canWrite} /> patch({ externalConflict })} + readOnly={!canWrite} /> patch({ arcSummary })} + readOnly={!canWrite} /> patch({ voice })} + readOnly={!canWrite} /> patch({ appearance })} + readOnly={!canWrite} /> patch({ personality })} + readOnly={!canWrite} /> @@ -207,12 +248,14 @@ function CharacterSheet({ projectId, character }: { projectId: string; character rows={5} serif onCommit={(backstory) => patch({ backstory })} + readOnly={!canWrite} /> patch({ notes })} + readOnly={!canWrite} /> @@ -239,7 +282,13 @@ function CharacterSheet({ projectId, character }: { projectId: string; character {(character.importance === 'Main' || character.arcStages.length > 0) && ( - + )} - + {confirmingDelete && ( @@ -36,6 +38,7 @@ function BrainstormingDashboard({ project }: { project: Project }) { serif placeholder="Start anywhere." onCommit={(notes) => update.mutate({ notes })} + readOnly={!can('Write', project)} /> ) diff --git a/src/Novelly.Web/src/pages/ProjectLayout.tsx b/src/Novelly.Web/src/pages/ProjectLayout.tsx index f3d430d..e567b91 100644 --- a/src/Novelly.Web/src/pages/ProjectLayout.tsx +++ b/src/Novelly.Web/src/pages/ProjectLayout.tsx @@ -19,7 +19,8 @@ export default function ProjectLayout() { const navigate = useNavigate() const { data: project, isPending, error } = useProject(projectId) const update = useUpdateProject(projectId) - const { user } = useAuth() + const { user, can } = useAuth() + const canWrite = can('Write', project) const logout = useLogout() const goTo = (path: string) => navigate(path ? `/projects/${projectId}/${path}` : `/projects/${projectId}`) @@ -46,6 +47,7 @@ export default function ProjectLayout() { { const name = e.target.value.trim() if (name && name !== data.tag.name) update.mutate({ id: tagId, name }) @@ -95,12 +117,15 @@ function TagReferencePanel({ projectId, tagId }: { projectId: string; tagId: str className="input h-9 w-20 p-1" type="color" defaultValue={data.tag.color ?? '#9a4a2f'} - onBlur={(e) => update.mutate({ id: tagId, color: e.target.value })} + readOnly={!canWrite} + onBlur={(e) => canWrite && update.mutate({ id: tagId, color: e.target.value })} /> - + {canDelete && ( + + )} {update.error && } diff --git a/tests/Novelly.Api.Tests/ProjectAccessTests.cs b/tests/Novelly.Api.Tests/ProjectAccessTests.cs index 5992904..a75276c 100644 --- a/tests/Novelly.Api.Tests/ProjectAccessTests.cs +++ b/tests/Novelly.Api.Tests/ProjectAccessTests.cs @@ -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); + } } diff --git a/tests/Novelly.Api.Tests/ProjectDataTests.cs b/tests/Novelly.Api.Tests/ProjectDataTests.cs index 15c32a6..ef08880 100644 --- a/tests/Novelly.Api.Tests/ProjectDataTests.cs +++ b/tests/Novelly.Api.Tests/ProjectDataTests.cs @@ -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() {