Add users, roles, and per-novel permissions

Introduces accounts (ASP.NET Identity + cookie auth), four global
roles (Admin/Writer/Editor/Reviewer), per-novel ownership and grants
via ProjectMember, and a service-API-key principal for the MCP server
and background import jobs. Enforcement lives in the application
services (not endpoint filters) so the embedded agent and MCP tools,
which call the same services directly, can't bypass it. Web client
gets a login page, session-aware routing, and a People section for
managing per-novel access.

Also includes prior in-flight changes from this branch (CLAUDE.md
compliance pass, dev-deploy docker-compose setup) that were
uncommitted when this feature work started.
This commit is contained in:
James Wampler
2026-08-15 22:29:33 -07:00
parent 7d8dd0c4fd
commit e598c18d67
111 changed files with 6562 additions and 797 deletions
+11
View File
@@ -0,0 +1,11 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.27-alpine AS runtime
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Novel Software</title>
<title>Novelly</title>
</head>
<body>
<div id="root"></div>
+14
View File
@@ -0,0 +1,14 @@
server {
listen 80;
location / {
root /usr/share/nginx/html;
try_files $uri /index.html;
}
location /api/ {
proxy_pass http://api:8080/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
+34 -17
View File
@@ -1,4 +1,4 @@
import { Route, Routes } from 'react-router-dom'
import { Navigate, Outlet, Route, Routes } from 'react-router-dom'
import ProjectsPage from './pages/ProjectsPage'
import ProjectLayout from './pages/ProjectLayout'
import DashboardPage from './pages/DashboardPage'
@@ -8,26 +8,43 @@ import ChaptersPage from './pages/ChaptersPage'
import ChapterPage from './pages/ChapterPage'
import AgentPage from './pages/AgentPage'
import SettingsPage from './pages/SettingsPage'
import LoginPage from './pages/LoginPage'
import { AuthProvider, useAuth } from './auth/AuthContext'
import { Spinner } from './components/ui'
import { HotkeysProvider } from './keyboard/HotkeysContext'
import { HelpOverlay } from './keyboard/HelpOverlay'
function RequireAuth() {
const { user, isPending } = useAuth()
if (isPending) return <Spinner label="Checking your session" />
if (!user) return <Navigate to="/login" replace />
return <Outlet />
}
export default function App() {
return (
<HotkeysProvider>
<HelpOverlay />
<Routes>
<Route path="/" element={<ProjectsPage />} />
<Route path="/projects/:projectId" element={<ProjectLayout />}>
<Route index element={<DashboardPage />} />
<Route path="characters" element={<CharactersPage />} />
<Route path="chapters" element={<ChaptersPage />} />
<Route path="chapters/:chapterId" element={<ChapterPage />} />
<Route path="tags" element={<TagsPage />} />
<Route path="agent" element={<AgentPage />} />
<Route path="settings" element={<SettingsPage />} />
</Route>
<Route path="*" element={<ProjectsPage />} />
</Routes>
</HotkeysProvider>
<AuthProvider>
<HotkeysProvider>
<HelpOverlay />
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route element={<RequireAuth />}>
<Route path="/" element={<ProjectsPage />} />
<Route path="/projects/:projectId" element={<ProjectLayout />}>
<Route index element={<DashboardPage />} />
<Route path="characters" element={<CharactersPage />} />
<Route path="chapters" element={<ChaptersPage />} />
<Route path="chapters/:chapterId" element={<ChapterPage />} />
<Route path="tags" element={<TagsPage />} />
<Route path="agent" element={<AgentPage />} />
<Route path="settings" element={<SettingsPage />} />
</Route>
<Route path="*" element={<ProjectsPage />} />
</Route>
</Routes>
</HotkeysProvider>
</AuthProvider>
)
}
+3 -8
View File
@@ -1,6 +1,5 @@
const BASE = import.meta.env.VITE_API_BASE ?? ''
/** An API error carrying the ProblemDetails message so the UI can show something useful. */
export class ApiError extends Error {
readonly status: number
@@ -14,6 +13,7 @@ export class ApiError extends Error {
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${BASE}${path}`, {
...init,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...init?.headers,
@@ -21,13 +21,8 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
})
if (!response.ok) {
let detail = response.statusText
try {
const problem = await response.json()
detail = problem.detail ?? problem.title ?? detail
} catch {
// Non-JSON error body — the status text is the best we have.
}
const problem = await response.json().catch(() => null)
const detail = problem?.detail ?? problem?.title ?? response.statusText
throw new ApiError(detail, response.status)
}
+70 -1
View File
@@ -1,5 +1,5 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { api } from './client'
import { api, ApiError } from './client'
import type {
AgentTurn,
ArcStage,
@@ -16,12 +16,17 @@ import type {
ImportJobStatus,
OpenQuestion,
Project,
ProjectMember,
ProjectRole,
ProjectSummary,
TagReferences,
TagSummary,
User,
} from './types'
export const keys = {
me: ['me'] as const,
members: (projectId: string) => ['projects', projectId, 'members'] as const,
projects: ['projects'] as const,
genres: ['genres'] as const,
project: (id: string) => ['projects', id] as const,
@@ -37,6 +42,70 @@ export const keys = {
importJob: (id: string) => ['imports', id] as const,
}
export const useMe = () =>
useQuery({
queryKey: keys.me,
queryFn: () =>
api.get<User>('/api/auth/me').catch((error) => {
if (error instanceof ApiError && error.status === 401) return null
throw error
}),
retry: false,
staleTime: Infinity,
})
export function useRegister() {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: { email: string; password: string; displayName: string }) =>
api.post<User>('/api/auth/register', body),
onSuccess: (user) => qc.setQueryData(keys.me, user),
})
}
export function useLogin() {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: { email: string; password: string }) => api.post<User>('/api/auth/login', body),
onSuccess: (user) => qc.setQueryData(keys.me, user),
})
}
export function useLogout() {
const qc = useQueryClient()
return useMutation({
mutationFn: () => api.post<void>('/api/auth/logout'),
onSuccess: () => {
qc.setQueryData(keys.me, null)
qc.removeQueries({ predicate: (query) => query.queryKey[0] !== keys.me[0] })
},
})
}
export const useProjectMembers = (projectId: string) =>
useQuery({
queryKey: keys.members(projectId),
queryFn: () => api.get<ProjectMember[]>(`/api/projects/${projectId}/members`),
retry: false,
})
export function useGrantAccess(projectId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: { email: string; projectRole: ProjectRole }) =>
api.post<ProjectMember>(`/api/projects/${projectId}/members`, body),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.members(projectId) }),
})
}
export function useRevokeAccess(projectId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (userId: string) => api.delete(`/api/projects/${projectId}/members/${userId}`),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.members(projectId) }),
})
}
export const useProjects = () =>
useQuery({ queryKey: keys.projects, queryFn: () => api.get<ProjectSummary[]>('/api/projects') })
+23
View File
@@ -32,6 +32,29 @@ export type ProjectPhase = 'Brainstorming' | 'Outlining' | 'Writing' | 'Editing'
export const projectPhases: ProjectPhase[] = ['Brainstorming', 'Outlining', 'Writing', 'Editing', 'Complete']
export type GlobalRole = 'Admin' | 'Writer' | 'Editor' | 'Reviewer'
export const globalRoles: GlobalRole[] = ['Admin', 'Writer', 'Editor', 'Reviewer']
export type ProjectRole = 'Writer' | 'Editor' | 'Reviewer'
export const projectRoles: ProjectRole[] = ['Writer', 'Editor', 'Reviewer']
export interface User {
id: string
email: string
displayName: string
globalRole: GlobalRole
}
export interface ProjectMember {
userId: string
email: string
displayName: string
projectRole: ProjectRole
grantedAt: string
}
export interface Genre {
id: string
name: string
+32
View File
@@ -0,0 +1,32 @@
import { createContext, useContext, useMemo, type ReactNode } from 'react'
import { useMe } from '../api/hooks'
import type { User } from '../api/types'
export type AuthPermission = 'CreateNovel'
interface AuthValue {
user: User | null
isPending: boolean
can: (permission: AuthPermission) => boolean
}
const AuthContext = createContext<AuthValue>({ user: null, isPending: true, can: () => false })
export function AuthProvider({ children }: { children: ReactNode }) {
const { data, isPending } = useMe()
const user = data ?? null
const value = useMemo<AuthValue>(
() => ({
user,
isPending,
can: (permission) =>
permission === 'CreateNovel' && (user?.globalRole === 'Admin' || user?.globalRole === 'Writer'),
}),
[user, isPending],
)
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}
export const useAuth = () => useContext(AuthContext)
@@ -10,10 +10,6 @@ import {
import type { ArcStage, Character } from '../api/types'
import { AutoField, ErrorNote } from './ui'
/**
* A main character's arc: a flat ordered list of the changes they go through, the same
* shape as a chapter's beat table. Each stage can be pinned to the chapter it lands in.
*/
export function CharacterArc({
projectId,
character,
@@ -2,11 +2,6 @@ import { Link } from 'react-router-dom'
import { useCharacterBeats } from '../api/hooks'
import { ErrorNote, Spinner } from './ui'
/**
* Every beat this character appears in, in manuscript order. This is the dossier's
* reality check: what they actually do on the page, as opposed to what the sheet claims
* about them. Each row links into the beat's chapter outline.
*/
export function CharacterBeats({
projectId,
characterId,
@@ -4,14 +4,6 @@ import { useImportJob, useInspectImport, useStartImport } from '../api/hooks'
import type { ImportInspection, ImportJob } from '../api/types'
import { ErrorNote, Modal, Spinner } from './ui'
/**
* Kicks off (or resumes) an outline import against an absolute folder path. The app runs
* locally with the API and browser on the same machine, so a pasted path is meaningful —
* there's no browser folder picker that can hand back one instead.
*
* State machine: type a path → Check (inspects the folder without starting anything) →
* Start/Resume/Delete-and-reimport → poll until the background job finishes.
*/
export function ImportDialog({
onClose,
onImported,
@@ -31,8 +23,6 @@ export function ImportDialog({
useEffect(() => {
if (job.data?.status !== 'Completed') return
// The import writes project data through the same services the UI uses to edit it —
// everything on screen may be stale once it finishes.
qc.invalidateQueries()
if (job.data.projectId) onImported?.(job.data.projectId)
}, [job.data?.status, job.data?.projectId, qc, onImported])
@@ -9,11 +9,6 @@ import {
import type { OpenQuestion } from '../api/types'
import { ErrorNote, Spinner } from './ui'
/**
* The list of decisions still outstanding. The same section serves a chapter outline and
* a character page — `scope` decides both what it shows and what a new question is
* attached to, so raising one from the outline lands on that chapter without asking.
*/
export function OpenQuestions({
projectId,
scope,
@@ -148,8 +143,6 @@ function QuestionRow({
)
}
// Only show an association the page is not already scoped to — on a chapter outline,
// "Landfall" on every row is noise.
const showsChapter = question.chapterId && !scope.chapterId
const showsCharacter = question.characterName && !scope.characterId
@@ -24,11 +24,6 @@ export function TagChip({ tag, onRemove }: { tag: Tag; onRemove?: () => void })
)
}
/**
* Shows a set of tags and lets you add or remove them by name. The API creates unknown
* tags on the fly, so typing a new one is a single action rather than "create the tag,
* then apply it".
*/
export function TagEditor({
tags,
suggestions = [],
@@ -46,7 +41,6 @@ export function TagEditor({
const add = () => {
const name = draft.trim()
if (!name) return
// Case-insensitive, matching how the API resolves tag names.
if (!tags.some((t) => t.name.toLowerCase() === name.toLowerCase())) {
onChange([...tags.map((t) => t.name), name])
}
-6
View File
@@ -55,10 +55,6 @@ export function StatusBadge({ status }: { status: DraftStatus }) {
)
}
/**
* A field that saves when it loses focus. Writing tools live or die on not making the
* user hunt for a save button, so every editable field here commits on blur.
*/
export function AutoField({
label,
value,
@@ -84,8 +80,6 @@ export function AutoField({
const committed = useRef(value ?? '')
const suggestionsId = useId()
// Adopt changes that arrive from elsewhere (the agent, another tab) unless the user
// is mid-edit, which would yank text out from under them.
useEffect(() => {
const incoming = value ?? ''
if (incoming !== committed.current) {
@@ -15,8 +15,6 @@ interface HotkeysActions {
unregister: (id: string) => void
}
// Split so registering a hotkey (stable actions) never invalidates every other
// hotkey's effect just because the entries list (read only by the help sidebar) changed.
const HotkeysActionsContext = createContext<HotkeysActions | null>(null)
const HotkeysEntriesContext = createContext<HotkeyEntry[]>([])
@@ -114,12 +112,6 @@ export function HotkeysProvider({ children }: { children: ReactNode }) {
)
}
/** Registers a keyboard shortcut and (while mounted) lists it in the help sidebar.
*
* `keys` is either a single token ("n", "?", "Escape", "mod+Enter") or a two-key
* chord ("g d"). Chords never fire while a text field is focused; single keys are
* ignored while typing unless `allowInInputs` is set.
*/
export function useHotkey(
keys: string,
description: string,
@@ -238,9 +238,6 @@ function CharacterSheet({ projectId, character }: { projectId: string; character
)}
</div>
{/* The arc is what a main character is for. Supporting characters keep the section —
hidden only when there is nothing in it — so promoting someone does not surprise
them with work they thought they had lost. */}
{(character.importance === 'Main' || character.arcStages.length > 0) && (
<CharacterArc projectId={projectId} character={character} />
)}
@@ -19,7 +19,6 @@ export default function DashboardPage() {
)
}
/** The only thing the writer needs before there's a shape to the book: a place to dump notes. */
function BrainstormingDashboard({ project }: { project: Project }) {
const update = useUpdateProject(project.id)
@@ -42,7 +41,6 @@ function BrainstormingDashboard({ project }: { project: Project }) {
)
}
/** Chapter outlines and character development — where most of the outlining phase happens. */
function OutliningDashboard({ projectId }: { projectId: string }) {
const { data: characters, isPending: charactersPending, error: charactersError } = useCharacters(projectId)
const { data: chapters, isPending: chaptersPending, error: chaptersError } = useChapters(projectId)
+103
View File
@@ -0,0 +1,103 @@
import { useState } from 'react'
import { Navigate, useNavigate } from 'react-router-dom'
import { useLogin, useRegister } from '../api/hooks'
import { useAuth } from '../auth/AuthContext'
import { ErrorNote, Spinner } from '../components/ui'
export default function LoginPage() {
const { user, isPending } = useAuth()
const navigate = useNavigate()
const login = useLogin()
const register = useRegister()
const [registering, setRegistering] = useState(false)
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [displayName, setDisplayName] = useState('')
if (isPending) return <Spinner label="Checking your session" />
if (user) return <Navigate to="/" replace />
const active = registering ? register : login
const canSubmit = email.trim() && password && (!registering || displayName.trim())
const submit = (e: React.FormEvent) => {
e.preventDefault()
if (!canSubmit) return
const onSuccess = () => navigate('/')
if (registering) {
register.mutate({ email: email.trim(), password, displayName: displayName.trim() }, { onSuccess })
return
}
login.mutate({ email: email.trim(), password }, { onSuccess })
}
return (
<div className="mx-auto max-w-md px-6 py-16">
<header className="mb-6">
<h1 className="text-3xl font-semibold tracking-tight">Novelly</h1>
<p className="mt-1 text-sm muted">
{registering
? 'Create an account to get started.'
: 'Sign in to your outlines, dossiers and drafts.'}
</p>
</header>
<form onSubmit={submit} className="card grid gap-3 p-5">
{registering && (
<label className="block">
<span className="label">Display name</span>
<input
className="input"
autoFocus
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="How your name appears on a novel"
/>
</label>
)}
<label className="block">
<span className="label">Email</span>
<input
className="input"
type="email"
autoComplete="username"
autoFocus={!registering}
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</label>
<label className="block">
<span className="label">Password</span>
<input
className="input"
type="password"
autoComplete={registering ? 'new-password' : 'current-password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</label>
{registering && (
<p className="text-sm muted">
New accounts start as reviewers, which is read-only. Ask an admin to promote you to writer
to create novels of your own. The very first account on a fresh instance becomes the admin.
</p>
)}
{active.error && <ErrorNote error={active.error} />}
<button type="submit" className="btn btn-primary mt-1" disabled={!canSubmit || active.isPending}>
{active.isPending ? 'Working…' : registering ? 'Create account' : 'Sign in'}
</button>
<button
type="button"
className="text-sm muted hover:underline"
onClick={() => setRegistering(!registering)}
>
{registering ? 'Already have an account? Sign in' : 'No account yet? Create one'}
</button>
</form>
</div>
)
}
+33 -15
View File
@@ -1,6 +1,7 @@
import { Outlet, useParams, Link, NavLink, useNavigate } from 'react-router-dom'
import { useProject, useUpdateProject } from '../api/hooks'
import { useLogout, useProject, useUpdateProject } from '../api/hooks'
import { projectPhases } from '../api/types'
import { useAuth } from '../auth/AuthContext'
import { ErrorNote, Spinner } from '../components/ui'
import { useHotkey } from '../keyboard/HotkeysContext'
@@ -18,6 +19,8 @@ export default function ProjectLayout() {
const navigate = useNavigate()
const { data: project, isPending, error } = useProject(projectId)
const update = useUpdateProject(projectId)
const { user } = useAuth()
const logout = useLogout()
const goTo = (path: string) => navigate(path ? `/projects/${projectId}/${path}` : `/projects/${projectId}`)
@@ -38,20 +41,35 @@ export default function ProjectLayout() {
<Link to={`/projects/${projectId}`} className="truncate text-base font-semibold hover:underline">
{project?.title ?? '…'}
</Link>
{project && (
<select
className="input ml-auto w-auto"
value={project.phase}
onChange={(e) => update.mutate({ phase: e.target.value as (typeof projectPhases)[number] })}
aria-label="Novel phase"
>
{projectPhases.map((phase) => (
<option key={phase} value={phase}>
{phase}
</option>
))}
</select>
)}
<div className="ml-auto flex items-center gap-3">
{project && (
<select
className="input w-auto"
value={project.phase}
onChange={(e) => update.mutate({ phase: e.target.value as (typeof projectPhases)[number] })}
aria-label="Novel phase"
>
{projectPhases.map((phase) => (
<option key={phase} value={phase}>
{phase}
</option>
))}
</select>
)}
{user && (
<>
<span className="truncate text-sm muted" title={user.email}>
{user.displayName} · {user.globalRole}
</span>
<button
className="btn"
onClick={() => logout.mutate(undefined, { onSuccess: () => navigate('/login') })}
>
Sign out
</button>
</>
)}
</div>
</div>
<nav className="mx-auto flex max-w-[100rem] gap-1 px-6 pb-2 text-sm">
{sections.map(({ to, label, end }) => (
+47 -12
View File
@@ -1,18 +1,22 @@
import { useState } from 'react'
import { useId, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useCreateProject, useProjects } from '../api/hooks'
import { useCreateProject, useGenres, useLogout, useProjects } from '../api/hooks'
import { useAuth } from '../auth/AuthContext'
import { ImportDialog } from '../components/ImportDialog'
import { EmptyState, ErrorNote, Modal, Spinner } from '../components/ui'
import { useHotkey } from '../keyboard/HotkeysContext'
export default function ProjectsPage() {
const { data: projects, isPending, error } = useProjects()
const { user, can } = useAuth()
const logout = useLogout()
const [creating, setCreating] = useState(false)
const [importing, setImporting] = useState(false)
const navigate = useNavigate()
const canCreate = can('CreateNovel')
useHotkey('n', 'New novel', () => setCreating(true), { group: 'Novels' })
useHotkey('i', 'Import from outline', () => setImporting(true), { group: 'Novels' })
useHotkey('n', 'New novel', () => canCreate && setCreating(true), { group: 'Novels' })
useHotkey('i', 'Import from outline', () => canCreate && setImporting(true), { group: 'Novels' })
return (
<div className="mx-auto max-w-4xl px-6 py-12">
@@ -23,13 +27,30 @@ export default function ProjectsPage() {
Outlines, character dossiers, and a writing partner that knows the book.
</p>
</div>
<div className="flex gap-2">
<button className="btn" onClick={() => setImporting(true)}>
Import from outline
</button>
<button className="btn btn-primary" onClick={() => setCreating(true)}>
New novel
</button>
<div className="flex shrink-0 items-center gap-2 whitespace-nowrap">
{canCreate && (
<>
<button className="btn" onClick={() => setImporting(true)}>
Import from outline
</button>
<button className="btn btn-primary" onClick={() => setCreating(true)}>
New novel
</button>
</>
)}
{user && (
<>
<span className="ml-2 text-sm muted" title={user.email}>
{user.displayName} · {user.globalRole}
</span>
<button
className="btn"
onClick={() => logout.mutate(undefined, { onSuccess: () => navigate('/login') })}
>
Sign out
</button>
</>
)}
</div>
</header>
@@ -85,6 +106,8 @@ export default function ProjectsPage() {
function CreateProjectModal({ onClose }: { onClose: () => void }) {
const create = useCreateProject()
const { data: genres } = useGenres()
const genreListId = useId()
const [title, setTitle] = useState('')
const [author, setAuthor] = useState('')
const [genre, setGenre] = useState('')
@@ -124,7 +147,19 @@ function CreateProjectModal({ onClose }: { onClose: () => void }) {
</label>
<label className="block">
<span className="label">Genre</span>
<input className="input" value={genre} onChange={(e) => setGenre(e.target.value)} />
<input
className="input"
value={genre}
list={genres?.length ? genreListId : undefined}
onChange={(e) => setGenre(e.target.value)}
/>
{genres?.length ? (
<datalist id={genreListId}>
{genres.map((g) => (
<option key={g.id} value={g.name} />
))}
</datalist>
) : null}
</label>
</div>
<label className="block">
+105 -1
View File
@@ -5,11 +5,16 @@ import {
useCharacters,
useDeleteProject,
useGenres,
useGrantAccess,
useProject,
useProjectMembers,
useRevokeAccess,
useUpdateProject,
} from '../api/hooks'
import { ApiError } from '../api/client'
import { projectRoles, type ProjectMember, type ProjectRole } from '../api/types'
import { ImportDialog } from '../components/ImportDialog'
import { AutoField, ErrorNote, Spinner } from '../components/ui'
import { AutoField, ErrorNote, Select, Spinner } from '../components/ui'
import { ConfirmModal } from '../components/ConfirmModal'
export default function SettingsPage() {
@@ -156,6 +161,8 @@ export default function SettingsPage() {
</div>
</aside>
<ProjectPeople projectId={projectId} />
{importing && (
<ImportDialog
onClose={() => setImporting(false)}
@@ -174,3 +181,100 @@ export default function SettingsPage() {
</div>
)
}
function ProjectPeople({ projectId }: { projectId: string }) {
const { data: members, isPending, error } = useProjectMembers(projectId)
const grant = useGrantAccess(projectId)
const revoke = useRevokeAccess(projectId)
const [email, setEmail] = useState('')
const [projectRole, setProjectRole] = useState<ProjectRole>('Reviewer')
const [revoking, setRevoking] = useState<ProjectMember | null>(null)
if (isPending) return null
if (error instanceof ApiError && (error.status === 403 || error.status === 401)) return null
const submit = (e: React.FormEvent) => {
e.preventDefault()
if (!email.trim()) return
grant.mutate({ email: email.trim(), projectRole }, { onSuccess: () => setEmail('') })
}
return (
<section className="card p-5">
<h2 className="mb-1 text-sm font-semibold tracking-wide uppercase muted">People</h2>
<p className="mb-4 text-sm muted">
Writers can add and delete anything in this novel, editors can change what is already here, and
reviewers can only read.
</p>
{error && <ErrorNote error={error} />}
{members?.length === 0 && <p className="mb-4 text-sm muted">Nobody else has access yet.</p>}
{members && members.length > 0 && (
<ul className="mb-4 grid gap-2">
{members.map((member) => (
<li
key={member.userId}
className="flex items-center justify-between gap-4 border-b pb-2 last:border-b-0"
style={{ borderColor: 'var(--line)' }}
>
<div className="min-w-0">
<p className="truncate font-medium">{member.displayName}</p>
<p className="truncate text-xs muted">{member.email}</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<Select
value={member.projectRole}
options={projectRoles}
onChange={(next) => grant.mutate({ email: member.email, projectRole: next })}
/>
<button className="btn btn-danger" onClick={() => setRevoking(member)}>
Remove
</button>
</div>
</li>
))}
</ul>
)}
<form onSubmit={submit} className="flex items-end gap-2">
<label className="block flex-1">
<span className="label">Grant access by email</span>
<input
className="input"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="someone@example.com"
/>
</label>
<Select value={projectRole} options={projectRoles} onChange={setProjectRole} />
<button type="submit" className="btn btn-primary" disabled={!email.trim() || grant.isPending}>
{grant.isPending ? 'Granting' : 'Grant'}
</button>
</form>
{grant.error && (
<div className="mt-3">
<ErrorNote error={grant.error} />
</div>
)}
{revoke.error && (
<div className="mt-3">
<ErrorNote error={revoke.error} />
</div>
)}
{revoking && (
<ConfirmModal
title="Remove access"
message={`Remove ${revoking.displayName}'s access to this novel?`}
confirmLabel="Remove"
onConfirm={() => revoke.mutate(revoking.userId)}
onClose={() => setRevoking(null)}
/>
)}
</section>
)
}
-2
View File
@@ -6,8 +6,6 @@ export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 5173,
// Proxy the API in dev so the browser sees a single origin and CORS never enters
// the picture during local development.
proxy: {
'/api': {
target: process.env.VITE_API_URL ?? 'http://localhost:5080',