Skip to content

Commit 1341a4e

Browse files
committed
Add access-aware UI, assignee on kanban, read-only mode
UI: - API client: Bearer token auth support (setApiKey/getApiKey) - AccessContext: useGraphAccess/useCanWrite hooks for per-graph access - Sidebar: hides nav items for disabled graphs - Read-only mode on all entity pages (knowledge, tasks, skills): list pages hide create buttons, detail pages hide edit/delete, new/edit pages disable submit with warning - Kanban: assignee display on cards, client-side assignee filter, team member dropdown in filter bar and TaskForm - Task entity: assignee field in API types and CRUD functions Server: - Author auto-added to .team/ on first mutation (markDirty trigger) - Workspace: .team/ in mirrorDir; standalone: .team/ in projectDir
1 parent b849eb6 commit 1341a4e

File tree

20 files changed

+285
-96
lines changed

20 files changed

+285
-96
lines changed

src/lib/project-manager.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import type { WatcherHandle } from '@/lib/watcher';
1919
import type { GraphManagerContext, ExternalGraphs } from '@/graphs/manager-types';
2020
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
2121
import { MirrorWriteTracker, scanMirrorDirs, startMirrorWatcher } from '@/lib/mirror-watcher';
22+
import { ensureAuthorInTeam } from '@/lib/team';
23+
import path from 'path';
2224

2325
// ---------------------------------------------------------------------------
2426
// ProjectInstance
@@ -118,8 +120,15 @@ export class ProjectManager extends EventEmitter {
118120
dirty: false,
119121
} as WorkspaceInstance;
120122

123+
let _authorEnsured = false;
121124
const ctx: GraphManagerContext = {
122-
markDirty: () => { wsInstance.dirty = true; },
125+
markDirty: () => {
126+
wsInstance.dirty = true;
127+
if (!_authorEnsured) {
128+
_authorEnsured = true;
129+
ensureAuthorInTeam(path.join(config.mirrorDir, '.team'), config.author);
130+
}
131+
},
123132
emit: (event: string, data: unknown) => { this.emit(event, data); },
124133
projectId: id,
125134
mirrorDir: config.mirrorDir,
@@ -251,8 +260,15 @@ export class ProjectManager extends EventEmitter {
251260
} as ProjectInstance;
252261

253262
// Build graph manager context
263+
let _authorEnsured = false;
254264
const ctx: GraphManagerContext = {
255-
markDirty: () => { instance.dirty = true; },
265+
markDirty: () => {
266+
instance.dirty = true;
267+
if (!_authorEnsured) {
268+
_authorEnsured = true;
269+
ensureAuthorInTeam(path.join(config.projectDir, '.team'), config.author);
270+
}
271+
},
256272
emit: (event: string, data: unknown) => { this.emit(event, data); },
257273
projectId: id,
258274
projectDir: config.projectDir,

ui/src/entities/project/api.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
import { request, unwrapList, type ListResponse } from '@/shared/api/client.ts';
22

3+
export interface GraphInfo {
4+
enabled: boolean;
5+
access: 'deny' | 'r' | 'rw' | null;
6+
}
7+
38
export interface ProjectInfo {
49
id: string;
510
projectDir: string;
611
workspaceId: string | null;
7-
stats: { docs: number; code: number; knowledge: number; files: number; tasks: number };
12+
graphs: Record<string, GraphInfo>;
13+
stats: { docs: number; code: number; knowledge: number; files: number; tasks: number; skills: number };
814
}
915

1016
export interface WorkspaceInfo {
@@ -32,3 +38,13 @@ export function listWorkspaces(): Promise<WorkspaceInfo[]> {
3238
export function getProjectStats(projectId: string) {
3339
return request<ProjectDetailedStats>(`/projects/${projectId}/stats`);
3440
}
41+
42+
export interface TeamMember {
43+
id: string;
44+
name: string;
45+
email: string;
46+
}
47+
48+
export function listTeam(projectId: string): Promise<TeamMember[]> {
49+
return request<ListResponse<TeamMember>>(`/projects/${projectId}/team`).then(unwrapList);
50+
}

ui/src/entities/project/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
export { listProjects, listWorkspaces, getProjectStats, type ProjectInfo, type WorkspaceInfo, type ProjectDetailedStats } from './api.ts';
1+
export { listProjects, listWorkspaces, getProjectStats, listTeam, type ProjectInfo, type WorkspaceInfo, type ProjectDetailedStats, type GraphInfo, type TeamMember } from './api.ts';
22
export { useProjects } from './useProjects.ts';

ui/src/entities/task/api.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,29 +13,30 @@ export interface Task {
1313
dueDate: number | null;
1414
estimate: number | null;
1515
completedAt: number | null;
16+
assignee: string | null;
1617
createdAt: number;
1718
updatedAt: number;
1819
version: number;
1920
createdBy?: string;
2021
updatedBy?: string;
2122
}
2223

23-
export function listTasks(projectId: string, params?: { status?: TaskStatus; priority?: TaskPriority; tag?: string; limit?: number }) {
24-
return request<ListResponse<Task>>(`/projects/${projectId}/tasks${qs({ status: params?.status, priority: params?.priority, tag: params?.tag, limit: params?.limit })}`).then(unwrapList);
24+
export function listTasks(projectId: string, params?: { status?: TaskStatus; priority?: TaskPriority; tag?: string; assignee?: string; limit?: number }) {
25+
return request<ListResponse<Task>>(`/projects/${projectId}/tasks${qs({ status: params?.status, priority: params?.priority, tag: params?.tag, assignee: params?.assignee, limit: params?.limit })}`).then(unwrapList);
2526
}
2627

2728
export function getTask(projectId: string, taskId: string) {
2829
return request<Task>(`/projects/${projectId}/tasks/${taskId}`);
2930
}
3031

31-
export function createTask(projectId: string, data: { title: string; description?: string; status?: TaskStatus; priority?: TaskPriority; tags?: string[]; dueDate?: number | null; estimate?: number | null }) {
32+
export function createTask(projectId: string, data: { title: string; description?: string; status?: TaskStatus; priority?: TaskPriority; tags?: string[]; dueDate?: number | null; estimate?: number | null; assignee?: string | null }) {
3233
return request<Task>(`/projects/${projectId}/tasks`, {
3334
method: 'POST',
3435
body: JSON.stringify(data),
3536
});
3637
}
3738

38-
export function updateTask(projectId: string, taskId: string, data: Partial<Pick<Task, 'title' | 'description' | 'status' | 'priority' | 'tags' | 'dueDate' | 'estimate'>>) {
39+
export function updateTask(projectId: string, taskId: string, data: Partial<Pick<Task, 'title' | 'description' | 'status' | 'priority' | 'tags' | 'dueDate' | 'estimate' | 'assignee'>>) {
3940
return request<Task>(`/projects/${projectId}/tasks/${taskId}`, {
4041
method: 'PUT',
4142
body: JSON.stringify(data),

ui/src/features/task-crud/TaskForm.tsx

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,31 @@
11
import { useState, useEffect } from 'react';
2+
import { useParams } from 'react-router-dom';
23
import {
34
Box, Button, TextField, Select, MenuItem,
45
CircularProgress,
56
} from '@mui/material';
67
import { Section, FormGrid, FormField, FieldLabel, Tags, MarkdownEditor } from '@/shared/ui/index.ts';
78
import { COLUMNS, type Task, type TaskStatus, type TaskPriority } from '@/entities/task/index.ts';
9+
import { listTeam, type TeamMember } from '@/entities/project/api.ts';
810

911
interface TaskFormProps {
1012
task?: Task;
11-
onSubmit: (data: { title: string; description: string; status: TaskStatus; priority: TaskPriority; tags: string[]; dueDate?: number | null; estimate?: number | null }) => Promise<void>;
13+
onSubmit: (data: { title: string; description: string; status: TaskStatus; priority: TaskPriority; tags: string[]; dueDate?: number | null; estimate?: number | null; assignee?: string | null }) => Promise<void>;
1214
onCancel: () => void;
1315
submitLabel?: string;
1416
}
1517

1618
export function TaskForm({ task, onSubmit, onCancel, submitLabel = 'Save' }: TaskFormProps) {
19+
const { projectId } = useParams<{ projectId: string }>();
1720
const [title, setTitle] = useState('');
1821
const [description, setDescription] = useState('');
1922
const [status, setStatus] = useState<TaskStatus>('todo');
2023
const [priority, setPriority] = useState<TaskPriority>('medium');
2124
const [tags, setTags] = useState<string[]>([]);
2225
const [dueDate, setDueDate] = useState('');
2326
const [estimate, setEstimate] = useState('');
27+
const [assignee, setAssignee] = useState<string>('');
28+
const [team, setTeam] = useState<TeamMember[]>([]);
2429
const [saving, setSaving] = useState(false);
2530
const [titleError, setTitleError] = useState(false);
2631

@@ -33,9 +38,15 @@ export function TaskForm({ task, onSubmit, onCancel, submitLabel = 'Save' }: Tas
3338
setTags(task.tags ?? []);
3439
setDueDate(task.dueDate ? new Date(task.dueDate).toISOString().split('T')[0] : '');
3540
setEstimate(task.estimate != null ? String(task.estimate) : '');
41+
setAssignee(task.assignee ?? '');
3642
}
3743
}, [task]);
3844

45+
useEffect(() => {
46+
if (!projectId) return;
47+
listTeam(projectId).then(setTeam).catch(() => {});
48+
}, [projectId]);
49+
3950
const handleSubmit = async () => {
4051
if (!title.trim()) {
4152
setTitleError(true);
@@ -51,6 +62,7 @@ export function TaskForm({ task, onSubmit, onCancel, submitLabel = 'Save' }: Tas
5162
tags,
5263
dueDate: dueDate ? new Date(dueDate).getTime() : null,
5364
estimate: estimate ? Number(estimate) : null,
65+
assignee: assignee || null,
5466
});
5567
} finally {
5668
setSaving(false);
@@ -115,6 +127,23 @@ export function TaskForm({ task, onSubmit, onCancel, submitLabel = 'Save' }: Tas
115127
slotProps={{ input: { inputProps: { min: 0, step: 0.5 } } }}
116128
/>
117129
</FormField>
130+
<FormField>
131+
<FieldLabel>Assignee</FieldLabel>
132+
<Select
133+
fullWidth
134+
value={assignee}
135+
onChange={e => setAssignee(e.target.value)}
136+
displayEmpty
137+
renderValue={v => {
138+
if (!v) return 'Unassigned';
139+
const m = team.find(t => t.id === v);
140+
return m?.name || v;
141+
}}
142+
>
143+
<MenuItem value="">Unassigned</MenuItem>
144+
{team.map(m => <MenuItem key={m.id} value={m.id}>{m.name || m.id}</MenuItem>)}
145+
</Select>
146+
</FormField>
118147
<FormField fullWidth>
119148
<Tags
120149
tags={tags}

ui/src/pages/knowledge/[noteId].tsx

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,13 @@ import {
1111
import { RelationManager } from '@/features/relation-manager/index.ts';
1212
import { AttachmentSection } from '@/features/attachments/index.ts';
1313
import { useWebSocket } from '@/shared/lib/useWebSocket.ts';
14+
import { useCanWrite } from '@/shared/lib/AccessContext.tsx';
1415
import { PageTopBar, Section, FieldRow, Tags, CopyButton, ConfirmDialog, MarkdownRenderer, DateDisplay } from '@/shared/ui/index.ts';
1516

1617
export default function NoteDetailPage() {
1718
const { projectId, noteId } = useParams<{ projectId: string; noteId: string }>();
1819
const navigate = useNavigate();
20+
const canWrite = useCanWrite('knowledge');
1921
const [note, setNote] = useState<Note | null>(null);
2022
const [relations, setRelations] = useState<Relation[]>([]);
2123
const [attachments, setAttachments] = useState<AttachmentMeta[]>([]);
@@ -70,14 +72,16 @@ export default function NoteDetailPage() {
7072
{ label: note.title },
7173
]}
7274
actions={
73-
<>
74-
<Button variant="contained" color="success" startIcon={<EditIcon />} onClick={() => navigate(`/${projectId}/knowledge/${noteId}/edit`)}>
75-
Edit
76-
</Button>
77-
<Button color="error" startIcon={<DeleteIcon />} onClick={() => setDeleteConfirm(true)}>
78-
Delete
79-
</Button>
80-
</>
75+
canWrite ? (
76+
<>
77+
<Button variant="contained" color="success" startIcon={<EditIcon />} onClick={() => navigate(`/${projectId}/knowledge/${noteId}/edit`)}>
78+
Edit
79+
</Button>
80+
<Button color="error" startIcon={<DeleteIcon />} onClick={() => setDeleteConfirm(true)}>
81+
Delete
82+
</Button>
83+
</>
84+
) : undefined
8185
}
8286
/>
8387

ui/src/pages/knowledge/edit.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ import { useParams, useNavigate } from 'react-router-dom';
33
import { Box, Button, CircularProgress, Alert } from '@mui/material';
44
import { getNote, updateNote, type Note } from '@/entities/note/index.ts';
55
import { NoteForm } from '@/features/note-crud/NoteForm.tsx';
6+
import { useCanWrite } from '@/shared/lib/AccessContext.tsx';
67
import { PageTopBar } from '@/shared/ui/index.ts';
78

89
export default function NoteEditPage() {
910
const { projectId, noteId } = useParams<{ projectId: string; noteId: string }>();
1011
const navigate = useNavigate();
12+
const canWrite = useCanWrite('knowledge');
1113
const [note, setNote] = useState<Note | null>(null);
1214
const [loading, setLoading] = useState(true);
1315
const [error, setError] = useState<string | null>(null);
@@ -43,11 +45,12 @@ export default function NoteEditPage() {
4345
{ label: 'Edit' },
4446
]}
4547
actions={
46-
<Button variant="contained" form="note-form" type="submit">
48+
<Button variant="contained" form="note-form" type="submit" disabled={!canWrite}>
4749
Save
4850
</Button>
4951
}
5052
/>
53+
{!canWrite && <Alert severity="warning" sx={{ mb: 2 }}>Read-only access — you cannot edit notes.</Alert>}
5154
<NoteForm
5255
note={note}
5356
onSubmit={handleSubmit}

ui/src/pages/knowledge/index.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import SearchIcon from '@mui/icons-material/Search';
99
import CloseIcon from '@mui/icons-material/Close';
1010
import LightbulbOutlinedIcon from '@mui/icons-material/LightbulbOutlined';
1111
import { useWebSocket } from '@/shared/lib/useWebSocket.ts';
12+
import { useCanWrite } from '@/shared/lib/AccessContext.tsx';
1213
import { PageTopBar, FilterBar, EmptyState } from '@/shared/ui/index.ts';
1314
import { searchNotes, type Note, NoteCard } from '@/entities/note/index.ts';
1415
import { useNotes } from '@/features/note-crud/index.ts';
@@ -17,6 +18,7 @@ export default function KnowledgePage() {
1718
const { projectId } = useParams<{ projectId: string }>();
1819
const navigate = useNavigate();
1920
const [searchParams, setSearchParams] = useSearchParams();
21+
const canWrite = useCanWrite('knowledge');
2022
const { notes, loading, error, refresh } = useNotes(projectId ?? null);
2123

2224
const [search, setSearch] = useState(searchParams.get('q') || '');
@@ -68,9 +70,11 @@ export default function KnowledgePage() {
6870
<PageTopBar
6971
breadcrumbs={[{ label: 'Knowledge' }]}
7072
actions={
71-
<Button variant="contained" startIcon={<AddIcon />} onClick={() => navigate('new')}>
72-
New Note
73-
</Button>
73+
canWrite ? (
74+
<Button variant="contained" startIcon={<AddIcon />} onClick={() => navigate('new')}>
75+
New Note
76+
</Button>
77+
) : undefined
7478
}
7579
/>
7680

@@ -114,7 +118,7 @@ export default function KnowledgePage() {
114118
title={searchResults ? 'No matching notes found' : 'No notes yet'}
115119
description={searchResults ? 'Try a different search query' : 'Create your first note to get started'}
116120
action={
117-
!searchResults ? (
121+
!searchResults && canWrite ? (
118122
<Button variant="contained" startIcon={<AddIcon />} onClick={() => navigate('new')}>
119123
New Note
120124
</Button>
@@ -129,7 +133,7 @@ export default function KnowledgePage() {
129133
note={note}
130134
score={'score' in note ? (note as Note & { score: number }).score : undefined}
131135
onClick={() => navigate(note.id)}
132-
onEdit={() => navigate(`${note.id}/edit`)}
136+
onEdit={canWrite ? () => navigate(`${note.id}/edit`) : undefined}
133137
/>
134138
))}
135139
</Stack>

ui/src/pages/knowledge/new.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import { useParams, useNavigate } from 'react-router-dom';
2-
import { Box, Button } from '@mui/material';
2+
import { Box, Button, Alert } from '@mui/material';
33
import { createNote } from '@/entities/note/index.ts';
44
import { NoteForm } from '@/features/note-crud/NoteForm.tsx';
5+
import { useCanWrite } from '@/shared/lib/AccessContext.tsx';
56
import { PageTopBar } from '@/shared/ui/index.ts';
67

78
export default function NoteNewPage() {
89
const { projectId } = useParams<{ projectId: string }>();
910
const navigate = useNavigate();
11+
const canWrite = useCanWrite('knowledge');
1012

1113
const handleSubmit = async (data: { title: string; content: string; tags: string[] }) => {
1214
if (!projectId) return;
@@ -22,11 +24,12 @@ export default function NoteNewPage() {
2224
{ label: 'Create' },
2325
]}
2426
actions={
25-
<Button variant="contained" form="note-form" type="submit">
27+
<Button variant="contained" form="note-form" type="submit" disabled={!canWrite}>
2628
Create
2729
</Button>
2830
}
2931
/>
32+
{!canWrite && <Alert severity="warning" sx={{ mb: 2 }}>Read-only access — you cannot create notes.</Alert>}
3033
<NoteForm
3134
onSubmit={handleSubmit}
3235
onCancel={() => navigate(`/${projectId}/knowledge`)}

ui/src/pages/skills/[skillId].tsx

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@ import { sourceLabel, confidenceLabel, SOURCE_BADGE_COLOR } from '@/entities/ski
1212
import { RelationManager } from '@/features/relation-manager/index.ts';
1313
import { AttachmentSection } from '@/features/attachments/index.ts';
1414
import { useWebSocket } from '@/shared/lib/useWebSocket.ts';
15+
import { useCanWrite } from '@/shared/lib/AccessContext.tsx';
1516
import { PageTopBar, Section, FieldRow, Tags, CopyButton, ConfirmDialog, MarkdownRenderer, StatusBadge } from '@/shared/ui/index.ts';
1617

1718
export default function SkillDetailPage() {
1819
const { projectId, skillId } = useParams<{ projectId: string; skillId: string }>();
1920
const navigate = useNavigate();
21+
const canWrite = useCanWrite('skills');
2022
const [skill, setSkill] = useState<Skill | null>(null);
2123
const [relations, setRelations] = useState<SkillRelation[]>([]);
2224
const [attachments, setAttachments] = useState<AttachmentMeta[]>([]);
@@ -71,14 +73,16 @@ export default function SkillDetailPage() {
7173
{ label: skill.title },
7274
]}
7375
actions={
74-
<>
75-
<Button variant="contained" color="success" startIcon={<EditIcon />} onClick={() => navigate(`/${projectId}/skills/${skillId}/edit`)}>
76-
Edit
77-
</Button>
78-
<Button color="error" startIcon={<DeleteIcon />} onClick={() => setDeleteConfirm(true)}>
79-
Delete
80-
</Button>
81-
</>
76+
canWrite ? (
77+
<>
78+
<Button variant="contained" color="success" startIcon={<EditIcon />} onClick={() => navigate(`/${projectId}/skills/${skillId}/edit`)}>
79+
Edit
80+
</Button>
81+
<Button color="error" startIcon={<DeleteIcon />} onClick={() => setDeleteConfirm(true)}>
82+
Delete
83+
</Button>
84+
</>
85+
) : undefined
8286
}
8387
/>
8488

0 commit comments

Comments
 (0)