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
+2 -2
View File
@@ -99,7 +99,7 @@ public class NovelAgentToolset(
"Read the project's title, logline, synopsis, genre, notes and word-count target. " "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.", + "Call this first in a conversation to ground yourself in what the book is.",
new JsonSchemaBuilder().Build(), 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( yield return new AgentTool(
"update_project_brief", "update_project_brief",
@@ -121,7 +121,7 @@ public class NovelAgentToolset(
JsonInput.String(input, "logline"), JsonInput.String(input, "logline"),
JsonInput.String(input, "synopsis"), JsonInput.String(input, "synopsis"),
JsonInput.String(input, "notes"), 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( yield return new AgentTool(
"list_characters", "list_characters",
@@ -202,7 +202,7 @@ public class ImportAgentToolset(
Notes: JsonInput.String(input, "notes")), ct); Notes: JsonInput.String(input, "notes")), ct);
ProjectId = created.Id; ProjectId = created.Id;
return created.ToResponse(); return created.ToResponse(null);
}); });
yield return new ImportAgentTool( yield return new ImportAgentTool(
@@ -225,7 +225,7 @@ public class ImportAgentToolset(
return updated is null return updated is null
? new ImportToolNotFound("Project", projectId) ? new ImportToolNotFound("Project", projectId)
: updated.ToResponse(); : updated.ToResponse(null);
}); });
yield return new ImportAgentTool( yield return new ImportAgentTool(
+4 -2
View File
@@ -25,6 +25,8 @@ public record ProjectResponse(
string? Notes, string? Notes,
int? TargetWordCount, int? TargetWordCount,
ProjectPhase Phase, ProjectPhase Phase,
Guid? OwnerId,
string? MyRole,
DateTimeOffset CreatedAt, DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt); DateTimeOffset UpdatedAt);
@@ -103,7 +105,7 @@ file static class ProjectValidation
public static class ProjectMapping 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.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);
} }
+23 -6
View File
@@ -1,5 +1,6 @@
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Common.Validation; using Novelly.Api.Common.Validation;
using Novelly.Api.Users;
namespace Novelly.Api.Projects; namespace Novelly.Api.Projects;
@@ -15,20 +16,36 @@ public static class ProjectEndpoints
Results.Ok(await service.ListAsync(ct))) Results.Ok(await service.ListAsync(ct)))
.WithSummary("List all novel projects."); .WithSummary("List all novel projects.");
group.MapGet("/{id:guid}", async (Guid id, ProjectService service, CancellationToken ct) => group.MapGet("/{id:guid}", async (Guid id, ProjectService service, ProjectAccessService access, CancellationToken ct) =>
(await service.GetAsync(id, ct))?.ToResponse().ToApiResult()) {
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."); .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); return Results.Created($"/api/projects/{created.Id}", created);
}) })
.WithSummary("Create a novel project."); .WithSummary("Create a novel project.");
group.MapPatch("/{id:guid}", async ( group.MapPatch("/{id:guid}", async (
Guid id, UpdateProjectRequest request, ProjectService service, CancellationToken ct) => Guid id, UpdateProjectRequest request, ProjectService service, ProjectAccessService access, CancellationToken ct) =>
(await service.UpdateAsync(id, request, ct))?.ToResponse().ToApiResult()) {
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."); .WithSummary("Update a project's brief.");
group.MapDelete("/{id:guid}", async (Guid id, ProjectService service, CancellationToken ct) => group.MapDelete("/{id:guid}", async (Guid id, ProjectService service, CancellationToken ct) =>
@@ -51,6 +51,23 @@ public class ProjectAccessService(INovelDbContext db, INovelUserContext userCont
} }
} }
public async Task<string?> 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<Project> VisibleProjects() public IQueryable<Project> VisibleProjects()
{ {
if (userContext.GlobalRole == GlobalRole.Admin) if (userContext.GlobalRole == GlobalRole.Admin)
+4
View File
@@ -40,6 +40,8 @@ export type ProjectRole = 'Writer' | 'Editor' | 'Reviewer'
export const projectRoles: ProjectRole[] = ['Writer', 'Editor', 'Reviewer'] export const projectRoles: ProjectRole[] = ['Writer', 'Editor', 'Reviewer']
export type ProjectMyRole = 'Admin' | 'Owner' | 'Writer' | 'Editor' | 'Reviewer'
export interface User { export interface User {
id: string id: string
email: string email: string
@@ -84,6 +86,8 @@ export interface Project {
notes: string | null notes: string | null
targetWordCount: number | null targetWordCount: number | null
phase: ProjectPhase phase: ProjectPhase
ownerId: string | null
myRole: ProjectMyRole | null
createdAt: string createdAt: string
updatedAt: string updatedAt: string
} }
+16 -5
View File
@@ -1,13 +1,21 @@
import { createContext, useContext, useMemo, type ReactNode } from 'react' import { createContext, useContext, useMemo, type ReactNode } from 'react'
import { useMe } from '../api/hooks' 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<ProjectMyRole, AuthPermission[]> = {
Admin: ['Write', 'CreateContent', 'DeleteContent', 'ManageAccess'],
Owner: ['Write', 'CreateContent', 'DeleteContent', 'ManageAccess'],
Writer: ['Write', 'CreateContent', 'DeleteContent'],
Editor: ['Write'],
Reviewer: [],
}
interface AuthValue { interface AuthValue {
user: User | null user: User | null
isPending: boolean isPending: boolean
can: (permission: AuthPermission) => boolean can: (permission: AuthPermission, project?: Pick<Project, 'myRole'> | null) => boolean
} }
const AuthContext = createContext<AuthValue>({ user: null, isPending: true, can: () => false }) const AuthContext = createContext<AuthValue>({ user: null, isPending: true, can: () => false })
@@ -20,8 +28,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
() => ({ () => ({
user, user,
isPending, isPending,
can: (permission) => can: (permission, project) => {
permission === 'CreateNovel' && (user?.globalRole === 'Admin' || user?.globalRole === 'Writer'), if (permission === 'CreateNovel') return user?.globalRole === 'Admin' || user?.globalRole === 'Writer'
const myRole = project?.myRole
return myRole ? projectPermissionsByRole[myRole].includes(permission) : false
},
}), }),
[user, isPending], [user, isPending],
) )
@@ -13,9 +13,15 @@ import { AutoField, ErrorNote } from './ui'
export function CharacterArc({ export function CharacterArc({
projectId, projectId,
character, character,
canWrite,
canCreate,
canDelete,
}: { }: {
projectId: string projectId: string
character: Character character: Character
canWrite: boolean
canCreate: boolean
canDelete: boolean
}) { }) {
const { data: chapters } = useChapters(projectId) const { data: chapters } = useChapters(projectId)
const create = useCreateArcStage(projectId) const create = useCreateArcStage(projectId)
@@ -65,11 +71,14 @@ export function CharacterArc({
canMoveUp={index > 0} canMoveUp={index > 0}
canMoveDown={index < stages.length - 1} canMoveDown={index < stages.length - 1}
onMove={(delta) => move(index, delta)} onMove={(delta) => move(index, delta)}
canWrite={canWrite}
canDelete={canDelete}
/> />
))} ))}
</ol> </ol>
)} )}
{canCreate && (
<form onSubmit={submit} className="mt-3 flex gap-2"> <form onSubmit={submit} className="mt-3 flex gap-2">
<input <input
className="input flex-1" className="input flex-1"
@@ -81,6 +90,7 @@ export function CharacterArc({
Add Add
</button> </button>
</form> </form>
)}
{create.error && ( {create.error && (
<div className="mt-2"> <div className="mt-2">
@@ -98,6 +108,8 @@ function ArcStageRow({
canMoveUp, canMoveUp,
canMoveDown, canMoveDown,
onMove, onMove,
canWrite,
canDelete,
}: { }: {
projectId: string projectId: string
stage: ArcStage stage: ArcStage
@@ -105,6 +117,8 @@ function ArcStageRow({
canMoveUp: boolean canMoveUp: boolean
canMoveDown: boolean canMoveDown: boolean
onMove: (delta: number) => void onMove: (delta: number) => void
canWrite: boolean
canDelete: boolean
}) { }) {
const update = useUpdateArcStage(projectId) const update = useUpdateArcStage(projectId)
const remove = useDeleteArcStage(projectId) const remove = useDeleteArcStage(projectId)
@@ -121,6 +135,7 @@ function ArcStageRow({
<AutoField <AutoField
value={stage.title} value={stage.title}
onCommit={(title) => title.trim() && update.mutate({ id: stage.id, title })} onCommit={(title) => title.trim() && update.mutate({ id: stage.id, title })}
readOnly={!canWrite}
/> />
<AutoField <AutoField
value={stage.description} value={stage.description}
@@ -128,12 +143,14 @@ function ArcStageRow({
rows={2} rows={2}
placeholder="What shifts here, and what it costs them." placeholder="What shifts here, and what it costs them."
onCommit={(description) => update.mutate({ id: stage.id, description })} onCommit={(description) => update.mutate({ id: stage.id, description })}
readOnly={!canWrite}
/> />
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<select <select
className="input max-w-[16rem] py-1 text-xs" className="input max-w-[16rem] py-1 text-xs"
value={stage.chapterId ?? ''} value={stage.chapterId ?? ''}
disabled={!canWrite}
onChange={(e) => update.mutate({ id: stage.id, chapterId: e.target.value })} onChange={(e) => update.mutate({ id: stage.id, chapterId: e.target.value })}
> >
<option value="">Not pinned to a chapter</option> <option value="">Not pinned to a chapter</option>
@@ -156,6 +173,7 @@ function ArcStageRow({
</div> </div>
</div> </div>
{canWrite && (
<div className="flex shrink-0 flex-col gap-1"> <div className="flex shrink-0 flex-col gap-1">
<button <button
className="btn px-2 py-0.5 text-xs" className="btn px-2 py-0.5 text-xs"
@@ -173,6 +191,7 @@ function ArcStageRow({
> >
</button> </button>
{canDelete && (
<button <button
className="btn px-2 py-0.5 text-xs" className="btn px-2 py-0.5 text-xs"
style={{ color: 'var(--accent)' }} style={{ color: 'var(--accent)' }}
@@ -183,7 +202,9 @@ function ArcStageRow({
> >
</button> </button>
)}
</div> </div>
)}
</div> </div>
</li> </li>
) )
@@ -6,11 +6,13 @@ export function MarkdownEditor({
onCommit, onCommit,
placeholder, placeholder,
rows = 24, rows = 24,
readOnly,
}: { }: {
value: string | null value: string | null
onCommit: (next: string) => void onCommit: (next: string) => void
placeholder?: string placeholder?: string
rows?: number rows?: number
readOnly?: boolean
}) { }) {
const [draft, setDraft] = useState(value ?? '') const [draft, setDraft] = useState(value ?? '')
const [mode, setMode] = useState<'write' | 'preview'>('write') const [mode, setMode] = useState<'write' | 'preview'>('write')
@@ -61,6 +63,7 @@ export function MarkdownEditor({
placeholder={placeholder} placeholder={placeholder}
onChange={(e) => setDraft(e.target.value)} onChange={(e) => setDraft(e.target.value)}
onBlur={commit} onBlur={commit}
readOnly={readOnly}
/> />
) : ( ) : (
<div className="markdown-preview card min-h-[20rem] p-4"> <div className="markdown-preview card min-h-[20rem] p-4">
@@ -12,9 +12,15 @@ import { ErrorNote, Spinner } from './ui'
export function OpenQuestions({ export function OpenQuestions({
projectId, projectId,
scope, scope,
canCreate,
canWrite,
canDelete,
}: { }: {
projectId: string projectId: string
scope: { chapterId?: string; characterId?: string } scope: { chapterId?: string; characterId?: string }
canCreate: boolean
canWrite: boolean
canDelete: boolean
}) { }) {
const [showResolved, setShowResolved] = useState(false) const [showResolved, setShowResolved] = useState(false)
const [asking, setAsking] = useState(false) const [asking, setAsking] = useState(false)
@@ -61,9 +67,11 @@ export function OpenQuestions({
/> />
Show resolved Show resolved
</label> </label>
{canCreate && (
<button className="btn" onClick={() => setAsking((open) => !open)}> <button className="btn" onClick={() => setAsking((open) => !open)}>
{asking ? 'Cancel' : 'Ask'} {asking ? 'Cancel' : 'Ask'}
</button> </button>
)}
</div> </div>
</div> </div>
@@ -99,7 +107,14 @@ export function OpenQuestions({
) : questions?.length ? ( ) : questions?.length ? (
<ul className="grid gap-2"> <ul className="grid gap-2">
{questions.map((q) => ( {questions.map((q) => (
<QuestionRow key={q.id} projectId={projectId} question={q} scope={scope} /> <QuestionRow
key={q.id}
projectId={projectId}
question={q}
scope={scope}
canWrite={canWrite}
canDelete={canDelete}
/>
))} ))}
</ul> </ul>
) : ( ) : (
@@ -116,10 +131,14 @@ function QuestionRow({
projectId, projectId,
question, question,
scope, scope,
canWrite,
canDelete,
}: { }: {
projectId: string projectId: string
question: OpenQuestion question: OpenQuestion
scope: { chapterId?: string; characterId?: string } scope: { chapterId?: string; characterId?: string }
canWrite: boolean
canDelete: boolean
}) { }) {
const resolve = useResolveQuestion(projectId) const resolve = useResolveQuestion(projectId)
const reopen = useReopenQuestion(projectId) const reopen = useReopenQuestion(projectId)
@@ -177,7 +196,8 @@ function QuestionRow({
</div> </div>
<div className="flex shrink-0 gap-1"> <div className="flex shrink-0 gap-1">
{question.isResolved ? ( {canWrite && (
question.isResolved ? (
<button className="btn px-2 py-1 text-xs" onClick={() => reopen.mutate(question.id)}> <button className="btn px-2 py-1 text-xs" onClick={() => reopen.mutate(question.id)}>
Reopen Reopen
</button> </button>
@@ -188,7 +208,9 @@ function QuestionRow({
> >
{resolving ? 'Cancel' : 'Resolve'} {resolving ? 'Cancel' : 'Resolve'}
</button> </button>
)
)} )}
{canDelete && (
<button <button
className="btn px-2 py-1 text-xs" className="btn px-2 py-1 text-xs"
style={{ color: 'var(--accent)' }} style={{ color: 'var(--accent)' }}
@@ -200,6 +222,7 @@ function QuestionRow({
> >
Delete Delete
</button> </button>
)}
</div> </div>
</div> </div>
+4
View File
@@ -65,6 +65,7 @@ export function AutoField({
serif, serif,
suggestions, suggestions,
onContextMenu, onContextMenu,
readOnly,
}: { }: {
label?: string label?: string
value: string | null | undefined value: string | null | undefined
@@ -75,6 +76,7 @@ export function AutoField({
serif?: boolean serif?: boolean
suggestions?: readonly string[] suggestions?: readonly string[]
onContextMenu?: (e: MouseEvent<HTMLTextAreaElement>) => void onContextMenu?: (e: MouseEvent<HTMLTextAreaElement>) => void
readOnly?: boolean
}) { }) {
const [draft, setDraft] = useState(value ?? '') const [draft, setDraft] = useState(value ?? '')
const committed = useRef(value ?? '') const committed = useRef(value ?? '')
@@ -109,6 +111,7 @@ export function AutoField({
onChange={(e) => setDraft(e.target.value)} onChange={(e) => setDraft(e.target.value)}
onBlur={commit} onBlur={commit}
onContextMenu={onContextMenu} onContextMenu={onContextMenu}
readOnly={readOnly}
/> />
) : ( ) : (
<> <>
@@ -120,6 +123,7 @@ export function AutoField({
onChange={(e) => setDraft(e.target.value)} onChange={(e) => setDraft(e.target.value)}
onBlur={commit} onBlur={commit}
onKeyDown={(e) => e.key === 'Enter' && e.currentTarget.blur()} onKeyDown={(e) => e.key === 'Enter' && e.currentTarget.blur()}
readOnly={readOnly}
/> />
{suggestions?.length ? ( {suggestions?.length ? (
<datalist id={suggestionsId}> <datalist id={suggestionsId}>
+52 -12
View File
@@ -7,12 +7,14 @@ import {
useCreateBeat, useCreateBeat,
useDeleteBeat, useDeleteBeat,
useDeleteChapter, useDeleteChapter,
useProject,
useReorderBeats, useReorderBeats,
useTags, useTags,
useUpdateBeat, useUpdateBeat,
useUpdateChapter, useUpdateChapter,
} from '../api/hooks' } from '../api/hooks'
import { draftStatuses, type Beat, type Chapter } from '../api/types' import { draftStatuses, type Beat, type Chapter } from '../api/types'
import { useAuth } from '../auth/AuthContext'
import { AutoField, ErrorNote, Select, Spinner } from '../components/ui' import { AutoField, ErrorNote, Select, Spinner } from '../components/ui'
import { ConfirmModal } from '../components/ConfirmModal' import { ConfirmModal } from '../components/ConfirmModal'
import { TagChip, TagEditor } from '../components/TagEditor' import { TagChip, TagEditor } from '../components/TagEditor'
@@ -28,6 +30,7 @@ export default function ChapterPage() {
const { projectId = '', chapterId = '' } = useParams() const { projectId = '', chapterId = '' } = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const { data: chapter, isPending, error } = useChapter(chapterId) const { data: chapter, isPending, error } = useChapter(chapterId)
const { data: project } = useProject(projectId)
const { data: characters } = useCharacters(projectId) const { data: characters } = useCharacters(projectId)
const { data: allTags } = useTags(projectId) const { data: allTags } = useTags(projectId)
const update = useUpdateChapter(projectId) const update = useUpdateChapter(projectId)
@@ -36,8 +39,12 @@ export default function ChapterPage() {
const [tab, setTab] = useState<ChapterTab>('outline') const [tab, setTab] = useState<ChapterTab>('outline')
const [confirmingDelete, setConfirmingDelete] = useState(false) const [confirmingDelete, setConfirmingDelete] = useState(false)
const { handleContextMenu, menuElement } = useCharacterContextMenu(projectId) const { handleContextMenu, menuElement } = useCharacterContextMenu(projectId)
const { can } = useAuth()
const canWrite = can('Write', project)
const canCreate = can('CreateContent', project)
const canDelete = can('DeleteContent', project)
useHotkey('b', 'Add beat', () => createBeat.mutate({ title: 'New beat' }), { group: 'Chapter' }) useHotkey('b', 'Add beat', () => canCreate && createBeat.mutate({ title: 'New beat' }), { group: 'Chapter' })
if (isPending) return <Spinner label="Loading chapter" /> if (isPending) return <Spinner label="Loading chapter" />
if (error) return <ErrorNote error={error} /> if (error) return <ErrorNote error={error} />
@@ -65,6 +72,7 @@ export default function ChapterPage() {
type="number" type="number"
min={1} min={1}
defaultValue={chapter.number} defaultValue={chapter.number}
readOnly={!canWrite}
onBlur={(e) => { onBlur={(e) => {
const number = Number(e.target.value) const number = Number(e.target.value)
if (number > 0 && number !== chapter.number) patch({ number }) if (number > 0 && number !== chapter.number) patch({ number })
@@ -75,12 +83,13 @@ export default function ChapterPage() {
label="Title" label="Title"
value={chapter.title} value={chapter.title}
onCommit={(title) => title.trim() && patch({ title })} onCommit={(title) => title.trim() && patch({ title })}
readOnly={!canWrite}
/> />
<Select <Select
label="Status" label="Status"
value={chapter.status} value={chapter.status}
options={draftStatuses} options={draftStatuses}
onChange={(status) => patch({ status })} onChange={(status) => canWrite && patch({ status })}
/> />
</div> </div>
@@ -89,6 +98,7 @@ export default function ChapterPage() {
label="Setting" label="Setting"
value={chapter.setting} value={chapter.setting}
onCommit={(setting) => patch({ setting })} onCommit={(setting) => patch({ setting })}
readOnly={!canWrite}
/> />
</div> </div>
@@ -97,7 +107,7 @@ export default function ChapterPage() {
label="Tags" label="Tags"
tags={chapter.tags} tags={chapter.tags}
suggestions={suggestions} suggestions={suggestions}
onChange={(tags) => patch({ tags })} onChange={(tags) => canWrite && patch({ tags })}
/> />
</div> </div>
@@ -105,9 +115,11 @@ export default function ChapterPage() {
<div className="text-sm muted"> <div className="text-sm muted">
{chapter.beats.length} beats · {chapter.wordCount.toLocaleString()} words {chapter.beats.length} beats · {chapter.wordCount.toLocaleString()} words
</div> </div>
{canDelete && (
<button className="btn btn-danger" onClick={() => setConfirmingDelete(true)}> <button className="btn btn-danger" onClick={() => setConfirmingDelete(true)}>
Delete chapter Delete chapter
</button> </button>
)}
</div> </div>
</section> </section>
@@ -160,6 +172,7 @@ export default function ChapterPage() {
placeholder="What this chapter is for: where it starts, what shifts, where it leaves the reader." placeholder="What this chapter is for: where it starts, what shifts, where it leaves the reader."
onCommit={(summary) => patch({ summary })} onCommit={(summary) => patch({ summary })}
onContextMenu={(e) => handleContextMenu(e, () => {})} onContextMenu={(e) => handleContextMenu(e, () => {})}
readOnly={!canWrite}
/> />
</div> </div>
@@ -169,8 +182,11 @@ export default function ChapterPage() {
characters={characters?.map((c) => ({ id: c.id, name: c.name })) ?? []} characters={characters?.map((c) => ({ id: c.id, name: c.name })) ?? []}
suggestions={suggestions} suggestions={suggestions}
onCharacterContextMenu={handleContextMenu} onCharacterContextMenu={handleContextMenu}
canWrite={canWrite}
canDelete={canDelete}
/> />
{canCreate && (
<button <button
className="btn btn-primary mt-3" className="btn btn-primary mt-3"
onClick={() => createBeat.mutate({ title: 'New beat' })} onClick={() => createBeat.mutate({ title: 'New beat' })}
@@ -178,6 +194,7 @@ export default function ChapterPage() {
> >
Add beat Add beat
</button> </button>
)}
{createBeat.error && ( {createBeat.error && (
<div className="mt-2"> <div className="mt-2">
<ErrorNote error={createBeat.error} /> <ErrorNote error={createBeat.error} />
@@ -191,6 +208,7 @@ export default function ChapterPage() {
value={chapter.prose} value={chapter.prose}
placeholder="Start writing the chapter." placeholder="Start writing the chapter."
onCommit={(prose) => patch({ prose })} onCommit={(prose) => patch({ prose })}
readOnly={!canWrite}
/> />
</section> </section>
)} )}
@@ -207,10 +225,17 @@ export default function ChapterPage() {
rows={5} rows={5}
placeholder="Notes on this chapter." placeholder="Notes on this chapter."
onCommit={(notes) => patch({ notes })} onCommit={(notes) => patch({ notes })}
readOnly={!canWrite}
/> />
</section> </section>
<OpenQuestions projectId={projectId} scope={{ chapterId: chapter.id }} /> <OpenQuestions
projectId={projectId}
scope={{ chapterId: chapter.id }}
canCreate={canCreate}
canWrite={canWrite}
canDelete={canDelete}
/>
{menuElement} {menuElement}
</div> </div>
@@ -223,6 +248,8 @@ function BeatTable({
characters, characters,
suggestions, suggestions,
onCharacterContextMenu, onCharacterContextMenu,
canWrite,
canDelete,
}: { }: {
chapter: Chapter chapter: Chapter
projectId: string projectId: string
@@ -232,6 +259,8 @@ function BeatTable({
e: MouseEvent<HTMLTextAreaElement>, e: MouseEvent<HTMLTextAreaElement>,
onCreated: (characterId: string) => void, onCreated: (characterId: string) => void,
) => void ) => void
canWrite: boolean
canDelete: boolean
}) { }) {
const update = useUpdateBeat(chapter.id, projectId) const update = useUpdateBeat(chapter.id, projectId)
const remove = useDeleteBeat(chapter.id) const remove = useDeleteBeat(chapter.id)
@@ -266,6 +295,7 @@ function BeatTable({
} }
const move = (index: number, delta: number) => { const move = (index: number, delta: number) => {
if (!canWrite) return
const ids = chapter.beats.map((b) => b.id) const ids = chapter.beats.map((b) => b.id)
const target = index + delta const target = index + delta
if (target < 0 || target >= ids.length) return if (target < 0 || target >= ids.length) return
@@ -278,7 +308,7 @@ function BeatTable({
return ( return (
<div> <div>
{selectedIds.length > 0 && ( {selectedIds.length > 0 && canWrite && (
<div className="card mb-3 flex flex-wrap items-center gap-3 p-3"> <div className="card mb-3 flex flex-wrap items-center gap-3 p-3">
<span className="text-sm font-medium"> <span className="text-sm font-medium">
{selectedIds.length} beat{selectedIds.length === 1 ? '' : 's'} selected {selectedIds.length} beat{selectedIds.length === 1 ? '' : 's'} selected
@@ -443,6 +473,7 @@ function BeatTable({
> >
</button> </button>
{canDelete && (
<button <button
className="text-xs muted leading-none transition hover:opacity-100" className="text-xs muted leading-none transition hover:opacity-100"
style={{ color: 'var(--accent)' }} style={{ color: 'var(--accent)' }}
@@ -451,6 +482,7 @@ function BeatTable({
> >
</button> </button>
)}
</div> </div>
</td> </td>
</tr> </tr>
@@ -458,18 +490,26 @@ function BeatTable({
<tr <tr
key={beat.id} key={beat.id}
id={`beat-${beat.id}`} id={`beat-${beat.id}`}
tabIndex={0} tabIndex={canWrite ? 0 : undefined}
role="button" role={canWrite ? 'button' : undefined}
aria-label={`Edit beat ${beat.title}`} aria-label={canWrite ? `Edit beat ${beat.title}` : undefined}
className="cursor-pointer transition hover:brightness-110 focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[var(--accent)]" className={
canWrite
? 'cursor-pointer transition hover:brightness-110 focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[var(--accent)]'
: ''
}
style={{ borderBottom: '1px solid var(--line)' }} style={{ borderBottom: '1px solid var(--line)' }}
onClick={() => setEditingId(beat.id)} onClick={canWrite ? () => setEditingId(beat.id) : undefined}
onKeyDown={(e) => { onKeyDown={
canWrite
? (e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault() e.preventDefault()
setEditingId(beat.id) setEditingId(beat.id)
} }
}} }
: undefined
}
> >
<td className="px-2 py-2 align-top"> <td className="px-2 py-2 align-top">
<input <input
+64 -9
View File
@@ -4,10 +4,12 @@ import {
useCharacters, useCharacters,
useCreateCharacter, useCreateCharacter,
useDeleteCharacter, useDeleteCharacter,
useProject,
useTags, useTags,
useUpdateCharacter, useUpdateCharacter,
} from '../api/hooks' } from '../api/hooks'
import { characterImportances, characterRoles, type Character } from '../api/types' import { characterImportances, characterRoles, type Character } from '../api/types'
import { useAuth } from '../auth/AuthContext'
import { AutoField, EmptyState, ErrorNote, Modal, Select, Spinner } from '../components/ui' import { AutoField, EmptyState, ErrorNote, Modal, Select, Spinner } from '../components/ui'
import { ConfirmModal } from '../components/ConfirmModal' import { ConfirmModal } from '../components/ConfirmModal'
import { TagEditor } from '../components/TagEditor' import { TagEditor } from '../components/TagEditor'
@@ -19,10 +21,15 @@ import { useHotkey } from '../keyboard/HotkeysContext'
export default function CharactersPage() { export default function CharactersPage() {
const { projectId = '' } = useParams() const { projectId = '' } = useParams()
const { data: characters, isPending, error } = useCharacters(projectId) const { data: characters, isPending, error } = useCharacters(projectId)
const { data: project } = useProject(projectId)
const { can } = useAuth()
const canCreate = can('CreateContent', project)
const canWrite = can('Write', project)
const canDelete = can('DeleteContent', project)
const [selectedId, setSelectedId] = useState<string | null>(null) const [selectedId, setSelectedId] = useState<string | null>(null)
const [adding, setAdding] = useState(false) 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 <Spinner label="Loading characters" /> if (isPending) return <Spinner label="Loading characters" />
if (error) return <ErrorNote error={error} /> if (error) return <ErrorNote error={error} />
@@ -32,9 +39,11 @@ export default function CharactersPage() {
return ( return (
<div className="grid gap-6 lg:grid-cols-[16rem_1fr]"> <div className="grid gap-6 lg:grid-cols-[16rem_1fr]">
<aside className="grid content-start gap-2"> <aside className="grid content-start gap-2">
{canCreate && (
<button className="btn btn-primary w-full justify-center" onClick={() => setAdding(true)}> <button className="btn btn-primary w-full justify-center" onClick={() => setAdding(true)}>
Add character Add character
</button> </button>
)}
{(['Main', 'Supporting'] as const).map((importance) => { {(['Main', 'Supporting'] as const).map((importance) => {
const group = characters?.filter((c) => c.importance === importance) ?? [] const group = characters?.filter((c) => c.importance === importance) ?? []
if (group.length === 0) return null if (group.length === 0) return null
@@ -69,7 +78,14 @@ export default function CharactersPage() {
hint="Add the protagonist first — most outline questions resolve once you know what they want." hint="Add the protagonist first — most outline questions resolve once you know what they want."
/> />
) : ( ) : (
<CharacterSheet key={selected.id} projectId={projectId} character={selected} /> <CharacterSheet
key={selected.id}
projectId={projectId}
character={selected}
canWrite={canWrite}
canCreate={canCreate}
canDelete={canDelete}
/>
)} )}
</section> </section>
@@ -84,7 +100,19 @@ export default function CharactersPage() {
) )
} }
function CharacterSheet({ projectId, character }: { projectId: string; character: Character }) { function CharacterSheet({
projectId,
character,
canWrite,
canCreate,
canDelete,
}: {
projectId: string
character: Character
canWrite: boolean
canCreate: boolean
canDelete: boolean
}) {
const { data: allTags } = useTags(projectId) const { data: allTags } = useTags(projectId)
const update = useUpdateCharacter(projectId) const update = useUpdateCharacter(projectId)
const remove = useDeleteCharacter(projectId) const remove = useDeleteCharacter(projectId)
@@ -101,36 +129,41 @@ function CharacterSheet({ projectId, character }: { projectId: string; character
label="Name" label="Name"
value={character.name} value={character.name}
onCommit={(name) => name.trim() && patch({ name })} onCommit={(name) => name.trim() && patch({ name })}
readOnly={!canWrite}
/> />
<Select <Select
label="Role" label="Role"
value={character.role} value={character.role}
options={characterRoles} options={characterRoles}
onChange={(role) => patch({ role })} onChange={(role) => canWrite && patch({ role })}
/> />
<Select <Select
label="Importance" label="Importance"
value={character.importance} value={character.importance}
options={characterImportances} options={characterImportances}
onChange={(importance) => patch({ importance })} onChange={(importance) => canWrite && patch({ importance })}
/> />
</div> </div>
{canDelete && (
<button className="btn btn-danger mt-6" onClick={() => setConfirmingDelete(true)}> <button className="btn btn-danger mt-6" onClick={() => setConfirmingDelete(true)}>
Delete Delete
</button> </button>
)}
</div> </div>
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-3">
<AutoField label="Age" value={character.age} onCommit={(age) => patch({ age })} /> <AutoField label="Age" value={character.age} onCommit={(age) => patch({ age })} readOnly={!canWrite} />
<AutoField <AutoField
label="Pronouns" label="Pronouns"
value={character.pronouns} value={character.pronouns}
onCommit={(pronouns) => patch({ pronouns })} onCommit={(pronouns) => patch({ pronouns })}
readOnly={!canWrite}
/> />
<AutoField <AutoField
label="Occupation" label="Occupation"
value={character.occupation} value={character.occupation}
onCommit={(occupation) => patch({ occupation })} onCommit={(occupation) => patch({ occupation })}
readOnly={!canWrite}
/> />
</div> </div>
@@ -139,7 +172,7 @@ function CharacterSheet({ projectId, character }: { projectId: string; character
label="Tags" label="Tags"
tags={character.tags} tags={character.tags}
suggestions={allTags?.map((t) => t.name) ?? []} suggestions={allTags?.map((t) => t.name) ?? []}
onChange={(tags) => patch({ tags })} onChange={(tags) => canWrite && patch({ tags })}
/> />
</div> </div>
@@ -151,6 +184,7 @@ function CharacterSheet({ projectId, character }: { projectId: string; character
rows={3} rows={3}
placeholder="What they are consciously chasing." placeholder="What they are consciously chasing."
onCommit={(want) => patch({ want })} onCommit={(want) => patch({ want })}
readOnly={!canWrite}
/> />
<AutoField <AutoField
label="Needs" label="Needs"
@@ -159,24 +193,28 @@ function CharacterSheet({ projectId, character }: { projectId: string; character
rows={3} rows={3}
placeholder="What the story will make them face instead." placeholder="What the story will make them face instead."
onCommit={(need) => patch({ need })} onCommit={(need) => patch({ need })}
readOnly={!canWrite}
/> />
<AutoField <AutoField
label="Internal conflict" label="Internal conflict"
value={character.internalConflict} value={character.internalConflict}
multiline multiline
onCommit={(internalConflict) => patch({ internalConflict })} onCommit={(internalConflict) => patch({ internalConflict })}
readOnly={!canWrite}
/> />
<AutoField <AutoField
label="External conflict" label="External conflict"
value={character.externalConflict} value={character.externalConflict}
multiline multiline
onCommit={(externalConflict) => patch({ externalConflict })} onCommit={(externalConflict) => patch({ externalConflict })}
readOnly={!canWrite}
/> />
<AutoField <AutoField
label="Arc" label="Arc"
value={character.arcSummary} value={character.arcSummary}
multiline multiline
onCommit={(arcSummary) => patch({ arcSummary })} onCommit={(arcSummary) => patch({ arcSummary })}
readOnly={!canWrite}
/> />
<AutoField <AutoField
label="Voice" label="Voice"
@@ -184,18 +222,21 @@ function CharacterSheet({ projectId, character }: { projectId: string; character
multiline multiline
placeholder="Register, rhythm, the words they reach for." placeholder="Register, rhythm, the words they reach for."
onCommit={(voice) => patch({ voice })} onCommit={(voice) => patch({ voice })}
readOnly={!canWrite}
/> />
<AutoField <AutoField
label="Appearance" label="Appearance"
value={character.appearance} value={character.appearance}
multiline multiline
onCommit={(appearance) => patch({ appearance })} onCommit={(appearance) => patch({ appearance })}
readOnly={!canWrite}
/> />
<AutoField <AutoField
label="Personality" label="Personality"
value={character.personality} value={character.personality}
multiline multiline
onCommit={(personality) => patch({ personality })} onCommit={(personality) => patch({ personality })}
readOnly={!canWrite}
/> />
</div> </div>
@@ -207,12 +248,14 @@ function CharacterSheet({ projectId, character }: { projectId: string; character
rows={5} rows={5}
serif serif
onCommit={(backstory) => patch({ backstory })} onCommit={(backstory) => patch({ backstory })}
readOnly={!canWrite}
/> />
<AutoField <AutoField
label="Notes" label="Notes"
value={character.notes} value={character.notes}
multiline multiline
onCommit={(notes) => patch({ notes })} onCommit={(notes) => patch({ notes })}
readOnly={!canWrite}
/> />
</div> </div>
@@ -239,7 +282,13 @@ function CharacterSheet({ projectId, character }: { projectId: string; character
</div> </div>
{(character.importance === 'Main' || character.arcStages.length > 0) && ( {(character.importance === 'Main' || character.arcStages.length > 0) && (
<CharacterArc projectId={projectId} character={character} /> <CharacterArc
projectId={projectId}
character={character}
canWrite={canWrite}
canCreate={canCreate}
canDelete={canDelete}
/>
)} )}
<CharacterBeats <CharacterBeats
@@ -248,7 +297,13 @@ function CharacterSheet({ projectId, character }: { projectId: string; character
characterName={character.name} characterName={character.name}
/> />
<OpenQuestions projectId={projectId} scope={{ characterId: character.id }} /> <OpenQuestions
projectId={projectId}
scope={{ characterId: character.id }}
canCreate={canCreate}
canWrite={canWrite}
canDelete={canDelete}
/>
{confirmingDelete && ( {confirmingDelete && (
<ConfirmModal <ConfirmModal
@@ -1,6 +1,7 @@
import { Link, useParams } from 'react-router-dom' import { Link, useParams } from 'react-router-dom'
import { useChapters, useCharacters, useProject, useUpdateProject } from '../api/hooks' import { useChapters, useCharacters, useProject, useUpdateProject } from '../api/hooks'
import type { Project } from '../api/types' import type { Project } from '../api/types'
import { useAuth } from '../auth/AuthContext'
import { AutoField, EmptyState, ErrorNote, Spinner, StatusBadge } from '../components/ui' import { AutoField, EmptyState, ErrorNote, Spinner, StatusBadge } from '../components/ui'
const RECENT_COUNT = 5 const RECENT_COUNT = 5
@@ -21,6 +22,7 @@ export default function DashboardPage() {
function BrainstormingDashboard({ project }: { project: Project }) { function BrainstormingDashboard({ project }: { project: Project }) {
const update = useUpdateProject(project.id) const update = useUpdateProject(project.id)
const { can } = useAuth()
return ( return (
<div className="card p-5"> <div className="card p-5">
@@ -36,6 +38,7 @@ function BrainstormingDashboard({ project }: { project: Project }) {
serif serif
placeholder="Start anywhere." placeholder="Start anywhere."
onCommit={(notes) => update.mutate({ notes })} onCommit={(notes) => update.mutate({ notes })}
readOnly={!can('Write', project)}
/> />
</div> </div>
) )
+3 -1
View File
@@ -19,7 +19,8 @@ export default function ProjectLayout() {
const navigate = useNavigate() const navigate = useNavigate()
const { data: project, isPending, error } = useProject(projectId) const { data: project, isPending, error } = useProject(projectId)
const update = useUpdateProject(projectId) const update = useUpdateProject(projectId)
const { user } = useAuth() const { user, can } = useAuth()
const canWrite = can('Write', project)
const logout = useLogout() const logout = useLogout()
const goTo = (path: string) => navigate(path ? `/projects/${projectId}/${path}` : `/projects/${projectId}`) const goTo = (path: string) => navigate(path ? `/projects/${projectId}/${path}` : `/projects/${projectId}`)
@@ -46,6 +47,7 @@ export default function ProjectLayout() {
<select <select
className="input w-auto" className="input w-auto"
value={project.phase} value={project.phase}
disabled={!canWrite}
onChange={(e) => update.mutate({ phase: e.target.value as (typeof projectPhases)[number] })} onChange={(e) => update.mutate({ phase: e.target.value as (typeof projectPhases)[number] })}
aria-label="Novel phase" aria-label="Novel phase"
> >
+16 -1
View File
@@ -13,6 +13,7 @@ import {
} from '../api/hooks' } from '../api/hooks'
import { ApiError } from '../api/client' import { ApiError } from '../api/client'
import { projectRoles, type ProjectMember, type ProjectRole } from '../api/types' import { projectRoles, type ProjectMember, type ProjectRole } from '../api/types'
import { useAuth } from '../auth/AuthContext'
import { ImportDialog } from '../components/ImportDialog' import { ImportDialog } from '../components/ImportDialog'
import { AutoField, ErrorNote, Select, Spinner } from '../components/ui' import { AutoField, ErrorNote, Select, Spinner } from '../components/ui'
import { ConfirmModal } from '../components/ConfirmModal' import { ConfirmModal } from '../components/ConfirmModal'
@@ -28,9 +29,14 @@ export default function SettingsPage() {
const remove = useDeleteProject() const remove = useDeleteProject()
const [importing, setImporting] = useState(false) const [importing, setImporting] = useState(false)
const [confirmingDelete, setConfirmingDelete] = useState(false) const [confirmingDelete, setConfirmingDelete] = useState(false)
const { can } = useAuth()
if (isPending || !project) return <Spinner label="Loading brief" /> if (isPending || !project) return <Spinner label="Loading brief" />
const canWrite = can('Write', project)
const canDelete = can('DeleteContent', project)
const canManageAccess = can('ManageAccess', project)
const drafted = chapters?.reduce((sum, c) => sum + c.wordCount, 0) ?? 0 const drafted = chapters?.reduce((sum, c) => sum + c.wordCount, 0) ?? 0
const target = project.targetWordCount ?? 0 const target = project.targetWordCount ?? 0
const percent = target > 0 ? Math.min(100, Math.round((drafted / target) * 100)) : null const percent = target > 0 ? Math.min(100, Math.round((drafted / target) * 100)) : null
@@ -44,12 +50,14 @@ export default function SettingsPage() {
label="Title" label="Title"
value={project.title} value={project.title}
onCommit={(title) => title.trim() && update.mutate({ title })} onCommit={(title) => title.trim() && update.mutate({ title })}
readOnly={!canWrite}
/> />
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<AutoField <AutoField
label="Author" label="Author"
value={project.author} value={project.author}
onCommit={(author) => update.mutate({ author })} onCommit={(author) => update.mutate({ author })}
readOnly={!canWrite}
/> />
<AutoField <AutoField
label="Genre" label="Genre"
@@ -57,6 +65,7 @@ export default function SettingsPage() {
placeholder="Pick one, or name your own." placeholder="Pick one, or name your own."
suggestions={genres?.map((g) => g.name)} suggestions={genres?.map((g) => g.name)}
onCommit={(genre) => update.mutate({ genre })} onCommit={(genre) => update.mutate({ genre })}
readOnly={!canWrite}
/> />
</div> </div>
<AutoField <AutoField
@@ -66,6 +75,7 @@ export default function SettingsPage() {
rows={2} rows={2}
placeholder="Who wants what, and what stands in the way." placeholder="Who wants what, and what stands in the way."
onCommit={(logline) => update.mutate({ logline })} onCommit={(logline) => update.mutate({ logline })}
readOnly={!canWrite}
/> />
<AutoField <AutoField
label="Synopsis" label="Synopsis"
@@ -75,6 +85,7 @@ export default function SettingsPage() {
serif serif
placeholder="The whole story in a few paragraphs, ending included." placeholder="The whole story in a few paragraphs, ending included."
onCommit={(synopsis) => update.mutate({ synopsis })} onCommit={(synopsis) => update.mutate({ synopsis })}
readOnly={!canWrite}
/> />
<AutoField <AutoField
label="Notes" label="Notes"
@@ -83,6 +94,7 @@ export default function SettingsPage() {
rows={4} rows={4}
placeholder="Theme, tone, comparable titles, research threads." placeholder="Theme, tone, comparable titles, research threads."
onCommit={(notes) => update.mutate({ notes })} onCommit={(notes) => update.mutate({ notes })}
readOnly={!canWrite}
/> />
<label className="block max-w-48"> <label className="block max-w-48">
<span className="label">Target word count</span> <span className="label">Target word count</span>
@@ -92,6 +104,7 @@ export default function SettingsPage() {
min={0} min={0}
step={1000} step={1000}
defaultValue={project.targetWordCount ?? ''} defaultValue={project.targetWordCount ?? ''}
readOnly={!canWrite}
onBlur={(e) => { onBlur={(e) => {
const value = Number(e.target.value) const value = Number(e.target.value)
if (Number.isFinite(value) && value !== project.targetWordCount) { if (Number.isFinite(value) && value !== project.targetWordCount) {
@@ -150,6 +163,7 @@ export default function SettingsPage() {
</button> </button>
</div> </div>
{canDelete && (
<div className="card p-5"> <div className="card p-5">
<h2 className="mb-2 text-sm font-semibold tracking-wide uppercase muted">Danger zone</h2> <h2 className="mb-2 text-sm font-semibold tracking-wide uppercase muted">Danger zone</h2>
<p className="mb-3 text-sm muted"> <p className="mb-3 text-sm muted">
@@ -159,9 +173,10 @@ export default function SettingsPage() {
Delete this novel Delete this novel
</button> </button>
</div> </div>
)}
</aside> </aside>
<ProjectPeople projectId={projectId} /> {canManageAccess && <ProjectPeople projectId={projectId} />}
{importing && ( {importing && (
<ImportDialog <ImportDialog
+29 -4
View File
@@ -1,6 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import { Link, useParams } from 'react-router-dom' import { Link, useParams } from 'react-router-dom'
import { useDeleteTag, useTagReferences, useTags, useUpdateTag } from '../api/hooks' import { useDeleteTag, useProject, useTagReferences, useTags, useUpdateTag } from '../api/hooks'
import { useAuth } from '../auth/AuthContext'
import { EmptyState, ErrorNote, Spinner } from '../components/ui' import { EmptyState, ErrorNote, Spinner } from '../components/ui'
import { ConfirmModal } from '../components/ConfirmModal' import { ConfirmModal } from '../components/ConfirmModal'
import { TagChip } from '../components/TagEditor' import { TagChip } from '../components/TagEditor'
@@ -8,6 +9,10 @@ import { TagChip } from '../components/TagEditor'
export default function TagsPage() { export default function TagsPage() {
const { projectId = '' } = useParams() const { projectId = '' } = useParams()
const { data: tags, isPending, error } = useTags(projectId) const { data: tags, isPending, error } = useTags(projectId)
const { data: project } = useProject(projectId)
const { can } = useAuth()
const canWrite = can('Write', project)
const canDelete = can('DeleteContent', project)
const [selectedId, setSelectedId] = useState<string | undefined>() const [selectedId, setSelectedId] = useState<string | undefined>()
if (isPending) return <Spinner label="Loading tags" /> if (isPending) return <Spinner label="Loading tags" />
@@ -55,14 +60,30 @@ export default function TagsPage() {
hint="Tags cross-reference the book: attach one to a character, a chapter and a beat, then trace it from here." hint="Tags cross-reference the book: attach one to a character, a chapter and a beat, then trace it from here."
/> />
) : ( ) : (
<TagReferencePanel key={selected.id} projectId={projectId} tagId={selected.id} /> <TagReferencePanel
key={selected.id}
projectId={projectId}
tagId={selected.id}
canWrite={canWrite}
canDelete={canDelete}
/>
)} )}
</section> </section>
</div> </div>
) )
} }
function TagReferencePanel({ projectId, tagId }: { projectId: string; tagId: string }) { function TagReferencePanel({
projectId,
tagId,
canWrite,
canDelete,
}: {
projectId: string
tagId: string
canWrite: boolean
canDelete: boolean
}) {
const { data, isPending, error } = useTagReferences(tagId) const { data, isPending, error } = useTagReferences(tagId)
const update = useUpdateTag(projectId) const update = useUpdateTag(projectId)
const remove = useDeleteTag() const remove = useDeleteTag()
@@ -83,6 +104,7 @@ function TagReferencePanel({ projectId, tagId }: { projectId: string; tagId: str
<input <input
className="input w-64" className="input w-64"
defaultValue={data.tag.name} defaultValue={data.tag.name}
readOnly={!canWrite}
onBlur={(e) => { onBlur={(e) => {
const name = e.target.value.trim() const name = e.target.value.trim()
if (name && name !== data.tag.name) update.mutate({ id: tagId, name }) 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" className="input h-9 w-20 p-1"
type="color" type="color"
defaultValue={data.tag.color ?? '#9a4a2f'} 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 })}
/> />
</label> </label>
{canDelete && (
<button className="btn btn-danger" onClick={() => setConfirmingDelete(true)}> <button className="btn btn-danger" onClick={() => setConfirmingDelete(true)}>
Delete tag Delete tag
</button> </button>
)}
</div> </div>
{update.error && <ErrorNote error={update.error} />} {update.error && <ErrorNote error={update.error} />}
@@ -132,4 +132,51 @@ public class ProjectAccessTests : ServiceTestFixture
var ownerNavigation = Db.Context.Model.FindEntityType(typeof(Project))!.FindNavigation(nameof(Project.Owner))!; var ownerNavigation = Db.Context.Model.FindEntityType(typeof(Project))!.FindNavigation(nameof(Project.Owner))!;
Assert.That(ownerNavigation.ForeignKey.DeleteBehavior, Is.EqualTo(DeleteBehavior.Restrict)); 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)); 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] [Test]
public async Task Chapters_are_numbered_in_sequence_when_no_number_is_given() public async Task Chapters_are_numbered_in_sequence_when_no_number_is_given()
{ {