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. "
+ "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",
@@ -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(
+4 -2
View File
@@ -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);
}
+23 -6
View File
@@ -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) =>
@@ -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()
{
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 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
}
+16 -5
View File
@@ -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<ProjectMyRole, AuthPermission[]> = {
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<Project, 'myRole'> | null) => boolean
}
const AuthContext = createContext<AuthValue>({ 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],
)
@@ -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,11 +71,14 @@ export function CharacterArc({
canMoveUp={index > 0}
canMoveDown={index < stages.length - 1}
onMove={(delta) => move(index, delta)}
canWrite={canWrite}
canDelete={canDelete}
/>
))}
</ol>
)}
{canCreate && (
<form onSubmit={submit} className="mt-3 flex gap-2">
<input
className="input flex-1"
@@ -81,6 +90,7 @@ export function CharacterArc({
Add
</button>
</form>
)}
{create.error && (
<div className="mt-2">
@@ -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({
<AutoField
value={stage.title}
onCommit={(title) => title.trim() && update.mutate({ id: stage.id, title })}
readOnly={!canWrite}
/>
<AutoField
value={stage.description}
@@ -128,12 +143,14 @@ function ArcStageRow({
rows={2}
placeholder="What shifts here, and what it costs them."
onCommit={(description) => update.mutate({ id: stage.id, description })}
readOnly={!canWrite}
/>
<div className="flex flex-wrap items-center gap-2">
<select
className="input max-w-[16rem] py-1 text-xs"
value={stage.chapterId ?? ''}
disabled={!canWrite}
onChange={(e) => update.mutate({ id: stage.id, chapterId: e.target.value })}
>
<option value="">Not pinned to a chapter</option>
@@ -156,6 +173,7 @@ function ArcStageRow({
</div>
</div>
{canWrite && (
<div className="flex shrink-0 flex-col gap-1">
<button
className="btn px-2 py-0.5 text-xs"
@@ -173,6 +191,7 @@ function ArcStageRow({
>
</button>
{canDelete && (
<button
className="btn px-2 py-0.5 text-xs"
style={{ color: 'var(--accent)' }}
@@ -183,7 +202,9 @@ function ArcStageRow({
>
</button>
)}
</div>
)}
</div>
</li>
)
@@ -6,11 +6,13 @@ export function MarkdownEditor({
onCommit,
placeholder,
rows = 24,
readOnly,
}: {
value: string | null
onCommit: (next: string) => void
placeholder?: string
rows?: number
readOnly?: boolean
}) {
const [draft, setDraft] = useState(value ?? '')
const [mode, setMode] = useState<'write' | 'preview'>('write')
@@ -61,6 +63,7 @@ export function MarkdownEditor({
placeholder={placeholder}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
readOnly={readOnly}
/>
) : (
<div className="markdown-preview card min-h-[20rem] p-4">
@@ -12,9 +12,15 @@ import { ErrorNote, Spinner } from './ui'
export function OpenQuestions({
projectId,
scope,
canCreate,
canWrite,
canDelete,
}: {
projectId: string
scope: { chapterId?: string; characterId?: string }
canCreate: boolean
canWrite: boolean
canDelete: boolean
}) {
const [showResolved, setShowResolved] = useState(false)
const [asking, setAsking] = useState(false)
@@ -61,9 +67,11 @@ export function OpenQuestions({
/>
Show resolved
</label>
{canCreate && (
<button className="btn" onClick={() => setAsking((open) => !open)}>
{asking ? 'Cancel' : 'Ask'}
</button>
)}
</div>
</div>
@@ -99,7 +107,14 @@ export function OpenQuestions({
) : questions?.length ? (
<ul className="grid gap-2">
{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>
) : (
@@ -116,10 +131,14 @@ function QuestionRow({
projectId,
question,
scope,
canWrite,
canDelete,
}: {
projectId: string
question: OpenQuestion
scope: { chapterId?: string; characterId?: string }
canWrite: boolean
canDelete: boolean
}) {
const resolve = useResolveQuestion(projectId)
const reopen = useReopenQuestion(projectId)
@@ -177,7 +196,8 @@ function QuestionRow({
</div>
<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)}>
Reopen
</button>
@@ -188,7 +208,9 @@ function QuestionRow({
>
{resolving ? 'Cancel' : 'Resolve'}
</button>
)
)}
{canDelete && (
<button
className="btn px-2 py-1 text-xs"
style={{ color: 'var(--accent)' }}
@@ -200,6 +222,7 @@ function QuestionRow({
>
Delete
</button>
)}
</div>
</div>
+4
View File
@@ -65,6 +65,7 @@ export function AutoField({
serif,
suggestions,
onContextMenu,
readOnly,
}: {
label?: string
value: string | null | undefined
@@ -75,6 +76,7 @@ export function AutoField({
serif?: boolean
suggestions?: readonly string[]
onContextMenu?: (e: MouseEvent<HTMLTextAreaElement>) => void
readOnly?: boolean
}) {
const [draft, setDraft] = useState(value ?? '')
const committed = useRef(value ?? '')
@@ -109,6 +111,7 @@ export function AutoField({
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onContextMenu={onContextMenu}
readOnly={readOnly}
/>
) : (
<>
@@ -120,6 +123,7 @@ export function AutoField({
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => e.key === 'Enter' && e.currentTarget.blur()}
readOnly={readOnly}
/>
{suggestions?.length ? (
<datalist id={suggestionsId}>
+52 -12
View File
@@ -7,12 +7,14 @@ import {
useCreateBeat,
useDeleteBeat,
useDeleteChapter,
useProject,
useReorderBeats,
useTags,
useUpdateBeat,
useUpdateChapter,
} from '../api/hooks'
import { draftStatuses, type Beat, type Chapter } from '../api/types'
import { useAuth } from '../auth/AuthContext'
import { AutoField, ErrorNote, Select, Spinner } from '../components/ui'
import { ConfirmModal } from '../components/ConfirmModal'
import { TagChip, TagEditor } from '../components/TagEditor'
@@ -28,6 +30,7 @@ export default function ChapterPage() {
const { projectId = '', chapterId = '' } = useParams()
const navigate = useNavigate()
const { data: chapter, isPending, error } = useChapter(chapterId)
const { data: project } = useProject(projectId)
const { data: characters } = useCharacters(projectId)
const { data: allTags } = useTags(projectId)
const update = useUpdateChapter(projectId)
@@ -36,8 +39,12 @@ export default function ChapterPage() {
const [tab, setTab] = useState<ChapterTab>('outline')
const [confirmingDelete, setConfirmingDelete] = useState(false)
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 (error) return <ErrorNote error={error} />
@@ -65,6 +72,7 @@ export default function ChapterPage() {
type="number"
min={1}
defaultValue={chapter.number}
readOnly={!canWrite}
onBlur={(e) => {
const number = Number(e.target.value)
if (number > 0 && number !== chapter.number) patch({ number })
@@ -75,12 +83,13 @@ export default function ChapterPage() {
label="Title"
value={chapter.title}
onCommit={(title) => title.trim() && patch({ title })}
readOnly={!canWrite}
/>
<Select
label="Status"
value={chapter.status}
options={draftStatuses}
onChange={(status) => patch({ status })}
onChange={(status) => canWrite && patch({ status })}
/>
</div>
@@ -89,6 +98,7 @@ export default function ChapterPage() {
label="Setting"
value={chapter.setting}
onCommit={(setting) => patch({ setting })}
readOnly={!canWrite}
/>
</div>
@@ -97,7 +107,7 @@ export default function ChapterPage() {
label="Tags"
tags={chapter.tags}
suggestions={suggestions}
onChange={(tags) => patch({ tags })}
onChange={(tags) => canWrite && patch({ tags })}
/>
</div>
@@ -105,9 +115,11 @@ export default function ChapterPage() {
<div className="text-sm muted">
{chapter.beats.length} beats · {chapter.wordCount.toLocaleString()} words
</div>
{canDelete && (
<button className="btn btn-danger" onClick={() => setConfirmingDelete(true)}>
Delete chapter
</button>
)}
</div>
</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."
onCommit={(summary) => patch({ summary })}
onContextMenu={(e) => handleContextMenu(e, () => {})}
readOnly={!canWrite}
/>
</div>
@@ -169,8 +182,11 @@ export default function ChapterPage() {
characters={characters?.map((c) => ({ id: c.id, name: c.name })) ?? []}
suggestions={suggestions}
onCharacterContextMenu={handleContextMenu}
canWrite={canWrite}
canDelete={canDelete}
/>
{canCreate && (
<button
className="btn btn-primary mt-3"
onClick={() => createBeat.mutate({ title: 'New beat' })}
@@ -178,6 +194,7 @@ export default function ChapterPage() {
>
Add beat
</button>
)}
{createBeat.error && (
<div className="mt-2">
<ErrorNote error={createBeat.error} />
@@ -191,6 +208,7 @@ export default function ChapterPage() {
value={chapter.prose}
placeholder="Start writing the chapter."
onCommit={(prose) => patch({ prose })}
readOnly={!canWrite}
/>
</section>
)}
@@ -207,10 +225,17 @@ export default function ChapterPage() {
rows={5}
placeholder="Notes on this chapter."
onCommit={(notes) => patch({ notes })}
readOnly={!canWrite}
/>
</section>
<OpenQuestions projectId={projectId} scope={{ chapterId: chapter.id }} />
<OpenQuestions
projectId={projectId}
scope={{ chapterId: chapter.id }}
canCreate={canCreate}
canWrite={canWrite}
canDelete={canDelete}
/>
{menuElement}
</div>
@@ -223,6 +248,8 @@ function BeatTable({
characters,
suggestions,
onCharacterContextMenu,
canWrite,
canDelete,
}: {
chapter: Chapter
projectId: string
@@ -232,6 +259,8 @@ function BeatTable({
e: MouseEvent<HTMLTextAreaElement>,
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 (
<div>
{selectedIds.length > 0 && (
{selectedIds.length > 0 && canWrite && (
<div className="card mb-3 flex flex-wrap items-center gap-3 p-3">
<span className="text-sm font-medium">
{selectedIds.length} beat{selectedIds.length === 1 ? '' : 's'} selected
@@ -443,6 +473,7 @@ function BeatTable({
>
</button>
{canDelete && (
<button
className="text-xs muted leading-none transition hover:opacity-100"
style={{ color: 'var(--accent)' }}
@@ -451,6 +482,7 @@ function BeatTable({
>
</button>
)}
</div>
</td>
</tr>
@@ -458,18 +490,26 @@ function BeatTable({
<tr
key={beat.id}
id={`beat-${beat.id}`}
tabIndex={0}
role="button"
aria-label={`Edit beat ${beat.title}`}
className="cursor-pointer transition hover:brightness-110 focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[var(--accent)]"
tabIndex={canWrite ? 0 : undefined}
role={canWrite ? 'button' : undefined}
aria-label={canWrite ? `Edit beat ${beat.title}` : undefined}
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)' }}
onClick={() => setEditingId(beat.id)}
onKeyDown={(e) => {
onClick={canWrite ? () => setEditingId(beat.id) : undefined}
onKeyDown={
canWrite
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setEditingId(beat.id)
}
}}
}
: undefined
}
>
<td className="px-2 py-2 align-top">
<input
+64 -9
View File
@@ -4,10 +4,12 @@ import {
useCharacters,
useCreateCharacter,
useDeleteCharacter,
useProject,
useTags,
useUpdateCharacter,
} from '../api/hooks'
import { characterImportances, characterRoles, type Character } from '../api/types'
import { useAuth } from '../auth/AuthContext'
import { AutoField, EmptyState, ErrorNote, Modal, Select, Spinner } from '../components/ui'
import { ConfirmModal } from '../components/ConfirmModal'
import { TagEditor } from '../components/TagEditor'
@@ -19,10 +21,15 @@ import { useHotkey } from '../keyboard/HotkeysContext'
export default function CharactersPage() {
const { projectId = '' } = useParams()
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 [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 (error) return <ErrorNote error={error} />
@@ -32,9 +39,11 @@ export default function CharactersPage() {
return (
<div className="grid gap-6 lg:grid-cols-[16rem_1fr]">
<aside className="grid content-start gap-2">
{canCreate && (
<button className="btn btn-primary w-full justify-center" onClick={() => setAdding(true)}>
Add character
</button>
)}
{(['Main', 'Supporting'] as const).map((importance) => {
const group = characters?.filter((c) => c.importance === importance) ?? []
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."
/>
) : (
<CharacterSheet key={selected.id} projectId={projectId} character={selected} />
<CharacterSheet
key={selected.id}
projectId={projectId}
character={selected}
canWrite={canWrite}
canCreate={canCreate}
canDelete={canDelete}
/>
)}
</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 update = useUpdateCharacter(projectId)
const remove = useDeleteCharacter(projectId)
@@ -101,36 +129,41 @@ function CharacterSheet({ projectId, character }: { projectId: string; character
label="Name"
value={character.name}
onCommit={(name) => name.trim() && patch({ name })}
readOnly={!canWrite}
/>
<Select
label="Role"
value={character.role}
options={characterRoles}
onChange={(role) => patch({ role })}
onChange={(role) => canWrite && patch({ role })}
/>
<Select
label="Importance"
value={character.importance}
options={characterImportances}
onChange={(importance) => patch({ importance })}
onChange={(importance) => canWrite && patch({ importance })}
/>
</div>
{canDelete && (
<button className="btn btn-danger mt-6" onClick={() => setConfirmingDelete(true)}>
Delete
</button>
)}
</div>
<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
label="Pronouns"
value={character.pronouns}
onCommit={(pronouns) => patch({ pronouns })}
readOnly={!canWrite}
/>
<AutoField
label="Occupation"
value={character.occupation}
onCommit={(occupation) => patch({ occupation })}
readOnly={!canWrite}
/>
</div>
@@ -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 })}
/>
</div>
@@ -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}
/>
<AutoField
label="Needs"
@@ -159,24 +193,28 @@ function CharacterSheet({ projectId, character }: { projectId: string; character
rows={3}
placeholder="What the story will make them face instead."
onCommit={(need) => patch({ need })}
readOnly={!canWrite}
/>
<AutoField
label="Internal conflict"
value={character.internalConflict}
multiline
onCommit={(internalConflict) => patch({ internalConflict })}
readOnly={!canWrite}
/>
<AutoField
label="External conflict"
value={character.externalConflict}
multiline
onCommit={(externalConflict) => patch({ externalConflict })}
readOnly={!canWrite}
/>
<AutoField
label="Arc"
value={character.arcSummary}
multiline
onCommit={(arcSummary) => patch({ arcSummary })}
readOnly={!canWrite}
/>
<AutoField
label="Voice"
@@ -184,18 +222,21 @@ function CharacterSheet({ projectId, character }: { projectId: string; character
multiline
placeholder="Register, rhythm, the words they reach for."
onCommit={(voice) => patch({ voice })}
readOnly={!canWrite}
/>
<AutoField
label="Appearance"
value={character.appearance}
multiline
onCommit={(appearance) => patch({ appearance })}
readOnly={!canWrite}
/>
<AutoField
label="Personality"
value={character.personality}
multiline
onCommit={(personality) => patch({ personality })}
readOnly={!canWrite}
/>
</div>
@@ -207,12 +248,14 @@ function CharacterSheet({ projectId, character }: { projectId: string; character
rows={5}
serif
onCommit={(backstory) => patch({ backstory })}
readOnly={!canWrite}
/>
<AutoField
label="Notes"
value={character.notes}
multiline
onCommit={(notes) => patch({ notes })}
readOnly={!canWrite}
/>
</div>
@@ -239,7 +282,13 @@ function CharacterSheet({ projectId, character }: { projectId: string; character
</div>
{(character.importance === 'Main' || character.arcStages.length > 0) && (
<CharacterArc projectId={projectId} character={character} />
<CharacterArc
projectId={projectId}
character={character}
canWrite={canWrite}
canCreate={canCreate}
canDelete={canDelete}
/>
)}
<CharacterBeats
@@ -248,7 +297,13 @@ function CharacterSheet({ projectId, character }: { projectId: string; character
characterName={character.name}
/>
<OpenQuestions projectId={projectId} scope={{ characterId: character.id }} />
<OpenQuestions
projectId={projectId}
scope={{ characterId: character.id }}
canCreate={canCreate}
canWrite={canWrite}
canDelete={canDelete}
/>
{confirmingDelete && (
<ConfirmModal
@@ -1,6 +1,7 @@
import { Link, useParams } from 'react-router-dom'
import { useChapters, useCharacters, useProject, useUpdateProject } from '../api/hooks'
import type { Project } from '../api/types'
import { useAuth } from '../auth/AuthContext'
import { AutoField, EmptyState, ErrorNote, Spinner, StatusBadge } from '../components/ui'
const RECENT_COUNT = 5
@@ -21,6 +22,7 @@ export default function DashboardPage() {
function BrainstormingDashboard({ project }: { project: Project }) {
const update = useUpdateProject(project.id)
const { can } = useAuth()
return (
<div className="card p-5">
@@ -36,6 +38,7 @@ function BrainstormingDashboard({ project }: { project: Project }) {
serif
placeholder="Start anywhere."
onCommit={(notes) => update.mutate({ notes })}
readOnly={!can('Write', project)}
/>
</div>
)
+3 -1
View File
@@ -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() {
<select
className="input w-auto"
value={project.phase}
disabled={!canWrite}
onChange={(e) => update.mutate({ phase: e.target.value as (typeof projectPhases)[number] })}
aria-label="Novel phase"
>
+16 -1
View File
@@ -13,6 +13,7 @@ import {
} from '../api/hooks'
import { ApiError } from '../api/client'
import { projectRoles, type ProjectMember, type ProjectRole } from '../api/types'
import { useAuth } from '../auth/AuthContext'
import { ImportDialog } from '../components/ImportDialog'
import { AutoField, ErrorNote, Select, Spinner } from '../components/ui'
import { ConfirmModal } from '../components/ConfirmModal'
@@ -28,9 +29,14 @@ export default function SettingsPage() {
const remove = useDeleteProject()
const [importing, setImporting] = useState(false)
const [confirmingDelete, setConfirmingDelete] = useState(false)
const { can } = useAuth()
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 target = project.targetWordCount ?? 0
const percent = target > 0 ? Math.min(100, Math.round((drafted / target) * 100)) : null
@@ -44,12 +50,14 @@ export default function SettingsPage() {
label="Title"
value={project.title}
onCommit={(title) => title.trim() && update.mutate({ title })}
readOnly={!canWrite}
/>
<div className="grid gap-4 sm:grid-cols-2">
<AutoField
label="Author"
value={project.author}
onCommit={(author) => update.mutate({ author })}
readOnly={!canWrite}
/>
<AutoField
label="Genre"
@@ -57,6 +65,7 @@ export default function SettingsPage() {
placeholder="Pick one, or name your own."
suggestions={genres?.map((g) => g.name)}
onCommit={(genre) => update.mutate({ genre })}
readOnly={!canWrite}
/>
</div>
<AutoField
@@ -66,6 +75,7 @@ export default function SettingsPage() {
rows={2}
placeholder="Who wants what, and what stands in the way."
onCommit={(logline) => update.mutate({ logline })}
readOnly={!canWrite}
/>
<AutoField
label="Synopsis"
@@ -75,6 +85,7 @@ export default function SettingsPage() {
serif
placeholder="The whole story in a few paragraphs, ending included."
onCommit={(synopsis) => update.mutate({ synopsis })}
readOnly={!canWrite}
/>
<AutoField
label="Notes"
@@ -83,6 +94,7 @@ export default function SettingsPage() {
rows={4}
placeholder="Theme, tone, comparable titles, research threads."
onCommit={(notes) => update.mutate({ notes })}
readOnly={!canWrite}
/>
<label className="block max-w-48">
<span className="label">Target word count</span>
@@ -92,6 +104,7 @@ export default function SettingsPage() {
min={0}
step={1000}
defaultValue={project.targetWordCount ?? ''}
readOnly={!canWrite}
onBlur={(e) => {
const value = Number(e.target.value)
if (Number.isFinite(value) && value !== project.targetWordCount) {
@@ -150,6 +163,7 @@ export default function SettingsPage() {
</button>
</div>
{canDelete && (
<div className="card p-5">
<h2 className="mb-2 text-sm font-semibold tracking-wide uppercase muted">Danger zone</h2>
<p className="mb-3 text-sm muted">
@@ -159,9 +173,10 @@ export default function SettingsPage() {
Delete this novel
</button>
</div>
)}
</aside>
<ProjectPeople projectId={projectId} />
{canManageAccess && <ProjectPeople projectId={projectId} />}
{importing && (
<ImportDialog
+29 -4
View File
@@ -1,6 +1,7 @@
import { useState } from 'react'
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 { ConfirmModal } from '../components/ConfirmModal'
import { TagChip } from '../components/TagEditor'
@@ -8,6 +9,10 @@ import { TagChip } from '../components/TagEditor'
export default function TagsPage() {
const { projectId = '' } = useParams()
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>()
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."
/>
) : (
<TagReferencePanel key={selected.id} projectId={projectId} tagId={selected.id} />
<TagReferencePanel
key={selected.id}
projectId={projectId}
tagId={selected.id}
canWrite={canWrite}
canDelete={canDelete}
/>
)}
</section>
</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 update = useUpdateTag(projectId)
const remove = useDeleteTag()
@@ -83,6 +104,7 @@ function TagReferencePanel({ projectId, tagId }: { projectId: string; tagId: str
<input
className="input w-64"
defaultValue={data.tag.name}
readOnly={!canWrite}
onBlur={(e) => {
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 })}
/>
</label>
{canDelete && (
<button className="btn btn-danger" onClick={() => setConfirmingDelete(true)}>
Delete tag
</button>
)}
</div>
{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))!;
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()
{