@agentcairn/mcp-server 0.1.5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,15 +1,16 @@
1
1
  import { api, qs } from '../api.js'
2
- import { ago, debounce, emptyState, fmt, h, icon, KINDS, kindBar, searchBox, toast } from '../dom.js'
2
+ import { ago, debounce, emptyState, fmt, h, icon, KINDS, kindBar, renderNotes, searchBox, toast } from '../dom.js'
3
3
  import { closeDrawer, openDrawer } from '../drawer.js'
4
4
  import { importance, openMemory } from './memoryDrawer.js'
5
5
 
6
6
  /** Deleting a project removes every memory under it, so it asks for the project's name first. */
7
7
  function confirmDelete(id, detail, navigate) {
8
- const input = h('input', { class: 'input', placeholder: id, 'aria-label': 'Project name to confirm', autocomplete: 'off', spellcheck: 'false', style: { width: '100%' } })
8
+ const name = detail.name
9
+ const input = h('input', { class: 'input', placeholder: name, 'aria-label': 'Project name to confirm', autocomplete: 'off', spellcheck: 'false', style: { width: '100%' } })
9
10
  const error = h('p', { class: 'error', hidden: '' })
10
11
  const go = h('button', { class: 'btn danger', type: 'button', disabled: '' }, icon('trash'), 'Delete project')
11
12
  const memories = detail.active + detail.superseded
12
- input.addEventListener('input', () => { go.disabled = input.value.trim() !== id })
13
+ input.addEventListener('input', () => { go.disabled = input.value.trim() !== name })
13
14
  go.addEventListener('click', async () => {
14
15
  go.disabled = true
15
16
  try {
@@ -28,7 +29,7 @@ function confirmDelete(id, detail, navigate) {
28
29
  h(
29
30
  'div',
30
31
  { class: 'stack' },
31
- h('p', null, 'This permanently deletes ', h('b', { class: 'mono' }, id), ` and everything under it: ${fmt.int(memories)} ${memories === 1 ? 'memory' : 'memories'} (including superseded ones), ${fmt.int(detail.areas.length)} ${detail.areas.length === 1 ? 'area' : 'areas'} and ${fmt.int(detail.remotes)} git ${detail.remotes === 1 ? 'remote' : 'remotes'}. It cannot be undone.`),
32
+ h('p', null, 'This permanently deletes ', h('b', null, name), ` and everything under it: ${fmt.int(memories)} ${memories === 1 ? 'memory' : 'memories'} (including superseded ones), ${fmt.int(detail.areas.length)} ${detail.areas.length === 1 ? 'area' : 'areas'} and ${fmt.int(detail.remotes)} git ${detail.remotes === 1 ? 'remote' : 'remotes'}. It cannot be undone.`),
32
33
  h('p', { class: 'dim' }, 'Agents that still point at this project will simply create it again, empty, the next time they write.'),
33
34
  h('div', { class: 'field' }, h('label', null, 'Type the project name to confirm'), input),
34
35
  error,
@@ -40,8 +41,18 @@ function confirmDelete(id, detail, navigate) {
40
41
 
41
42
  export async function mount(container, { params, navigate }) {
42
43
  const id = params.id
43
- const detail = await api(`projects/${encodeURIComponent(id)}`)
44
- const state = { kind: '', area: '', status: 'active', q: '' }
44
+ const [detail, handoffs, sessions, membersReply] = await Promise.all([
45
+ api(`projects/${encodeURIComponent(id)}`),
46
+ api(`memories?${qs({ project: id, kind: 'handoff', sort: 'recent', limit: 3 })}`).then(r => r.memories, () => []),
47
+ api(`projects/${encodeURIComponent(id)}/sessions?limit=8`).then(r => r.sessions, () => []),
48
+ api(`projects/${encodeURIComponent(id)}/members`).then(r => r.members, () => []),
49
+ ])
50
+ let members = membersReply
51
+ const role = detail.role
52
+ const isOwner = role === 'owner'
53
+ const canManage = role === 'owner' || role === 'maintainer'
54
+ // newest first by default: what was written last is usually what matters most when picking work up
55
+ const state = { kind: '', area: '', status: 'active', q: '', sort: 'recent' }
45
56
 
46
57
  const list = h('div', { class: 'stack', style: { gap: '10px' } })
47
58
  const count = h('span', { class: 'hint' })
@@ -70,6 +81,143 @@ export async function mount(container, { params, navigate }) {
70
81
  }),
71
82
  )
72
83
 
84
+ const sortSelect = h('select', { 'aria-label': 'Sort memories' },
85
+ h('option', { value: 'recent' }, 'Newest first'),
86
+ h('option', { value: 'oldest' }, 'Oldest first'),
87
+ h('option', { value: 'importance' }, 'Most important first'),
88
+ )
89
+ sortSelect.value = state.sort
90
+ sortSelect.addEventListener('change', () => { state.sort = sortSelect.value; load() })
91
+
92
+ /** The latest handoff, open; older ones folded. This is what the next agent is told first. */
93
+ function handoffCard() {
94
+ if (!handoffs.length) {
95
+ return h('div', { class: 'card resume-card' }, h('div', { class: 'row tight' }, h('span', { class: 'kind k-handoff' }, 'handoff')), h('p', { class: 'dim', style: { margin: '8px 0 0' } }, 'No handoff yet. When an agent stops with work unfinished it writes one with cairn_handoff: what is done, in progress and next, what is blocked, and what was tried and dropped. The next agent, on any machine, starts from it.'))
96
+ }
97
+ const [latest, ...older] = handoffs
98
+ const head = m => h('div', { class: 'row tight' }, h('span', { class: 'kind k-handoff' }, 'handoff'), m.topic && h('span', { class: 'tag' }, m.topic), h('span', { class: 'muted' }, ago(m.createdAt), m.machine && ` · ${m.machine}`, m.author && ` · ${m.author}`))
99
+ return h('div', { class: 'card resume-card' },
100
+ h('div', { class: 'row' }, h('h2', { style: { fontSize: '15px' } }, 'Where we left off'), h('span', { class: 'grow' }), h('button', { class: 'btn sm', type: 'button', onclick: () => openMemory(id, latest.id, navigate) }, 'Open')),
101
+ h('div', { style: { margin: '8px 0 10px' } }, head(latest), h('div', { style: { fontWeight: 620, marginTop: '6px' } }, latest.title)),
102
+ renderNotes(latest.body),
103
+ ...older.map(m => h('details', null, h('summary', null, m.title, h('span', { class: 'muted' }, ` · ${m.topic ?? ''} · `, ago(m.createdAt))), h('div', { style: { marginTop: '8px' } }, renderNotes(m.body)))),
104
+ )
105
+ }
106
+
107
+ function sessionRow(x) {
108
+ const asked = x.prompts[0]?.text
109
+ return h('details', { class: 'session' },
110
+ h('summary', null,
111
+ h('div', { class: 'row tight' }, h('b', null, x.machine ?? 'unknown machine'), x.agent && h('span', { class: 'muted' }, `(${x.agent})`), x.branch && h('span', { class: 'tag' }, x.branch), x.head && h('span', { class: 'mono muted' }, x.head.slice(0, 7)), h('span', { class: 'grow' }), x.dirty.length > 0 && h('span', { class: 'badge auth', title: 'Files that were not committed when the session was last seen' }, `${x.dirty.length} uncommitted`), x.touched.length > 0 && h('span', { class: 'badge' }, `${x.touched.length} edited`), h('span', { class: 'muted' }, ago(x.updatedAt))),
112
+ asked && h('div', { class: 'clamp' }, asked),
113
+ ),
114
+ h('div', { class: 'detail' },
115
+ x.prompts.length > 0 && h('div', null, h('b', null, 'What the person asked'), h('ol', { class: 'notes-list' }, ...x.prompts.map(p => h('li', null, p.text)))),
116
+ x.lastReply && h('div', null, h('b', null, "The agent's last message"), h('p', { class: 'notes-p' }, x.lastReply)),
117
+ x.touched.length > 0 && h('div', null, h('b', null, 'Files edited'), h('div', { class: 'files' }, x.touched.join(' '))),
118
+ x.commits.length > 0 && h('div', null, h('b', null, 'Commits'), h('ul', { class: 'notes-list' }, ...x.commits.map(c => h('li', { class: 'mono' }, c)))),
119
+ x.dirty.length > 0 && h('div', null, h('b', null, 'Left uncommitted'), h('div', { class: 'files' }, x.dirty.join(' '))),
120
+ ),
121
+ )
122
+ }
123
+
124
+ function sessionsCard() {
125
+ if (!sessions.length) return null
126
+ return h('div', { class: 'card', style: { marginBottom: '14px' } },
127
+ h('div', { class: 'row' }, h('h2', { style: { fontSize: '15px' } }, 'Recent sessions'), h('span', { class: 'hint' }, 'recorded automatically as the work happens')),
128
+ h('div', { class: 'sessions' }, ...sessions.map(sessionRow)),
129
+ )
130
+ }
131
+
132
+ // ---- who can see this project -------------------------------------------------
133
+ const meId = (await api('state').catch(() => ({}))).userId
134
+ const membersBox = h('div', { class: 'card', style: { marginBottom: '14px' } })
135
+ const base = `projects/${encodeURIComponent(id)}`
136
+ const ROLE_NOTE = { viewer: 'reads memories', editor: 'reads and writes memories', maintainer: 'also manages who has access', owner: 'everything, including deleting the project' }
137
+
138
+ async function changeMembers(work, done) {
139
+ try {
140
+ members = (await work()).members
141
+ if (done) toast(done)
142
+ drawMembers()
143
+ } catch (error) {
144
+ toast(error.message)
145
+ drawMembers()
146
+ }
147
+ }
148
+
149
+ function memberRow(m) {
150
+ const self = m.userId === meId
151
+ const locked = m.role === 'owner' || !canManage
152
+ const sel = h('select', { 'aria-label': `Access of ${m.username}`, disabled: locked }, ['viewer', 'editor', 'maintainer', ...(m.role === 'owner' ? ['owner'] : [])].map(r => h('option', { value: r, selected: r === m.role }, r)))
153
+ sel.addEventListener('change', () => changeMembers(() => api(`${base}/members/${encodeURIComponent(m.userId)}`, { method: 'PUT', body: { role: sel.value } }), `${m.username} is now ${sel.value}`))
154
+ return h(
155
+ 'div',
156
+ { class: 'row member' },
157
+ h('span', { class: 'avatar' }, m.username[0].toUpperCase()),
158
+ h('div', { class: 'grow' }, h('b', null, m.username), self && h('span', { class: 'badge', style: { marginLeft: '8px' } }, 'you'), h('div', { class: 'hint' }, ROLE_NOTE[m.role])),
159
+ sel,
160
+ m.role !== 'owner' && (canManage || self) && h('button', { class: 'btn sm danger', type: 'button', onclick: () => leaveOrRemove(m, self) }, self ? 'Leave' : 'Remove'),
161
+ )
162
+ }
163
+
164
+ async function leaveOrRemove(m, self) {
165
+ if (!confirm(self ? `Leave ${detail.name}? You lose access until someone adds you again.` : `Remove ${m.username} from ${detail.name}?`)) return
166
+ try {
167
+ members = (await api(`${base}/members/${encodeURIComponent(m.userId)}`, { method: 'DELETE' })).members
168
+ if (self) {
169
+ toast(`You left ${detail.name}`)
170
+ return navigate('#/projects')
171
+ }
172
+ toast(`${m.username} removed`)
173
+ drawMembers()
174
+ } catch (error) {
175
+ toast(error.message)
176
+ }
177
+ }
178
+
179
+ function transferDrawer() {
180
+ const others = members.filter(m => m.role !== 'owner')
181
+ const pick = h('select', { 'aria-label': 'New owner' }, others.map(m => h('option', { value: m.username }, m.username)))
182
+ const go = h('button', { class: 'btn danger', type: 'button', disabled: !others.length }, 'Transfer ownership')
183
+ go.addEventListener('click', async () => {
184
+ go.disabled = true
185
+ try {
186
+ await api(`${base}/transfer`, { body: { username: pick.value } })
187
+ closeDrawer()
188
+ toast(`${pick.value} now owns ${detail.name}`)
189
+ navigate('#/projects')
190
+ } catch (error) {
191
+ toast(error.message)
192
+ go.disabled = false
193
+ }
194
+ })
195
+ openDrawer(h('strong', null, 'Transfer ownership'), h('div', { class: 'stack' }, h('p', null, 'The new owner can delete the project and decides who has access. You stay on as a maintainer.'), others.length ? h('div', { class: 'field' }, h('label', null, 'New owner'), pick) : h('p', { class: 'dim' }, 'Add someone to the project first.'), go))
196
+ }
197
+
198
+ function addForm() {
199
+ const name = h('input', { class: 'input', placeholder: 'Username', 'aria-label': 'Username to add', autocomplete: 'off', style: { width: '180px' } })
200
+ const level = h('select', { 'aria-label': 'Access level' }, ['viewer', 'editor', 'maintainer'].map(r => h('option', { value: r, selected: r === 'editor' }, r)))
201
+ const add = h('button', { class: 'btn primary', type: 'submit' }, icon('plus'), 'Add')
202
+ const form = h('form', { class: 'row tight', style: { marginTop: '12px' } }, name, level, add, h('span', { class: 'hint' }, 'They must already have an account: an admin invites people under Users.'))
203
+ form.addEventListener('submit', async e => {
204
+ e.preventDefault()
205
+ if (!name.value.trim()) return
206
+ add.disabled = true
207
+ await changeMembers(() => api(`${base}/members`, { body: { username: name.value.trim(), role: level.value } }), `${name.value.trim()} added as ${level.value}`)
208
+ add.disabled = false
209
+ })
210
+ return form
211
+ }
212
+
213
+ function drawMembers() {
214
+ membersBox.replaceChildren(
215
+ h('div', { class: 'row' }, h('h2', { style: { fontSize: '15px' } }, 'Who has access'), h('span', { class: 'hint' }, members.length > 1 ? 'private to these people' : 'private to you until you add someone'), h('span', { class: 'grow' }), isOwner && members.length > 1 && h('button', { class: 'btn sm', type: 'button', onclick: transferDrawer }, 'Transfer ownership')),
216
+ h('div', { class: 'stack', style: { gap: '10px', marginTop: '10px' } }, ...members.map(memberRow)),
217
+ canManage && addForm(),
218
+ )
219
+ }
220
+
73
221
  function memoryRow(m) {
74
222
  return h(
75
223
  'div',
@@ -89,7 +237,7 @@ export async function mount(container, { params, navigate }) {
89
237
  async function load() {
90
238
  list.replaceChildren(...Array.from({ length: 3 }, () => h('div', { class: 'skeleton', style: { height: '86px', borderRadius: '12px' } })))
91
239
  try {
92
- const { memories } = await api(`memories?${qs({ project: id, kind: state.kind, area: state.area, status: state.status, q: state.q, limit: 100 })}`)
240
+ const { memories } = await api(`memories?${qs({ project: id, kind: state.kind, area: state.area, status: state.status, q: state.q, sort: state.sort, limit: 100 })}`)
93
241
  count.textContent = `${memories.length}${memories.length === 100 ? '+' : ''} shown${state.q ? ' · ranked by relevance' : ''}`
94
242
  if (!memories.length) return list.replaceChildren(h('div', { class: 'card' }, emptyState('search', 'No memories match', 'Try another filter or clear the search.')))
95
243
  list.replaceChildren(...memories.map(memoryRow))
@@ -105,8 +253,8 @@ export async function mount(container, { params, navigate }) {
105
253
  h(
106
254
  'div',
107
255
  { class: 'page-head' },
108
- h('div', null, h('div', { class: 'crumbs' }, h('a', { href: '#/projects' }, 'Projects'), ' /'), h('h1', { class: 'mono', style: { fontSize: '22px' } }, id)),
109
- h('div', { class: 'row' }, h('button', { class: 'btn', type: 'button', onclick: () => navigate(`#/graph?project=${encodeURIComponent(id)}`) }, icon('graph'), 'Open in graph'), h('button', { class: 'btn danger', type: 'button', onclick: () => confirmDelete(id, detail, navigate) }, icon('trash'), 'Delete'), h('button', { class: 'btn', type: 'button', onclick: () => navigate(`#/access?project=${encodeURIComponent(id)}`) }, icon('link'), 'Connect an agent')),
256
+ h('div', null, h('div', { class: 'crumbs' }, h('a', { href: '#/projects' }, 'Projects'), ' /'), h('div', { class: 'row tight' }, h('h1', { style: { fontSize: '22px' } }, detail.name), role && h('span', { class: 'badge' }, role)), detail.name !== id && h('div', { class: 'mono muted', style: { fontSize: '12px' } }, id)),
257
+ h('div', { class: 'row' }, h('button', { class: 'btn', type: 'button', onclick: () => navigate(`#/graph?project=${encodeURIComponent(id)}`) }, icon('graph'), 'Open in graph'), isOwner && h('button', { class: 'btn danger', type: 'button', onclick: () => confirmDelete(id, detail, navigate) }, icon('trash'), 'Delete'), h('button', { class: 'btn', type: 'button', onclick: () => navigate(`#/access?project=${encodeURIComponent(id)}`) }, icon('link'), 'Connect an agent')),
110
258
  ),
111
259
  h(
112
260
  'div',
@@ -122,9 +270,13 @@ export async function mount(container, { params, navigate }) {
122
270
  ),
123
271
  kindBar(detail.counts),
124
272
  ),
125
- h('div', { class: 'toolbar' }, search.el, areaSelect, statusSeg, h('span', { class: 'grow' }), count),
273
+ handoffCard(),
274
+ sessionsCard(),
275
+ membersBox,
276
+ h('div', { class: 'toolbar' }, search.el, areaSelect, sortSelect, statusSeg, h('span', { class: 'grow' }), count),
126
277
  h('div', { class: 'row tight', style: { marginBottom: '14px' } }, ...kindChips),
127
278
  list,
128
279
  )
280
+ drawMembers()
129
281
  await load()
130
282
  }
@@ -18,7 +18,7 @@ export async function mount(container, { navigate }) {
18
18
  return h(
19
19
  'a',
20
20
  { class: 'card lift', href: `#/projects/${encodeURIComponent(p.id)}`, style: { color: 'inherit', textDecoration: 'none', display: 'flex', flexDirection: 'column', gap: '14px' } },
21
- h('div', { class: 'row' }, h('h2', { class: 'mono grow', style: { overflowWrap: 'anywhere', fontSize: '15px' } }, p.id), now && h('span', { class: 'badge ok', title: `${now.requests} requests this session` }, h('span', { class: 'dot live' }), 'active')),
21
+ h('div', { class: 'row' }, h('div', { class: 'grow', style: { minWidth: 0 } }, h('h2', { style: { overflowWrap: 'anywhere', fontSize: '15px' } }, p.name), p.id !== p.name && h('div', { class: 'mono muted', style: { fontSize: '11.5px' } }, p.id)), now && h('span', { class: 'badge ok', title: `${now.requests} requests this session` }, h('span', { class: 'dot live' }), 'active')),
22
22
  h('div', { class: 'row', style: { alignItems: 'baseline', gap: '8px' } }, h('span', { style: { fontSize: '32px', fontWeight: 680, letterSpacing: '-0.025em' } }, fmt.int(p.active)), h('span', { class: 'muted' }, p.active === 1 ? 'memory' : 'memories')),
23
23
  kindBar(p.counts),
24
24
  h('div', { class: 'legend' }, top.slice(0, 4).map(k => h('span', null, h('i', { class: `sw k-${k}`, style: { background: 'var(--k)' } }), `${p.counts[k]} ${k}`)), top.length > 4 && h('span', { class: 'muted' }, `+${top.length - 4} more`)),
@@ -27,6 +27,8 @@ export async function mount(container, { navigate }) {
27
27
  { class: 'row muted', style: { fontSize: '12px', marginTop: 'auto', paddingTop: '10px', borderTop: '1px solid var(--border)' } },
28
28
  h('span', null, p.lastMemoryAt ? ['updated ', ago(p.lastMemoryAt)] : 'no memories yet'),
29
29
  h('span', { class: 'grow' }),
30
+ p.role && p.role !== 'owner' && h('span', { class: 'badge' }, p.role),
31
+ p.owner && h('span', null, `by ${p.owner}`),
30
32
  p.superseded > 0 && h('span', null, `${p.superseded} superseded`),
31
33
  h('span', null, `${p.remotes} remote${p.remotes === 1 ? '' : 's'}`),
32
34
  icon('arrow'),
@@ -36,7 +38,7 @@ export async function mount(container, { navigate }) {
36
38
 
37
39
  function draw() {
38
40
  const q = search.input.value.trim().toLowerCase()
39
- const shown = projects.filter(p => p.id.toLowerCase().includes(q))
41
+ const shown = projects.filter(p => p.name.toLowerCase().includes(q) || p.id.toLowerCase().includes(q))
40
42
  if (!shown.length) {
41
43
  return grid.replaceChildren(h('div', { class: 'card', style: { gridColumn: '1 / -1' } }, projects.length ? emptyState('search', 'No project matches') : emptyState('folder', 'No projects yet', 'Create one here, or run `cairn init` in a repository.')))
42
44
  }
@@ -44,9 +46,9 @@ export async function mount(container, { navigate }) {
44
46
  }
45
47
  search.input.addEventListener('input', draw)
46
48
 
47
- const idInput = h('input', { class: 'input mono', id: 'new-project', placeholder: 'project-id', maxlength: '64', 'aria-label': 'New project id' })
49
+ const idInput = h('input', { class: 'input mono', id: 'new-project', placeholder: 'Project name', maxlength: '64', 'aria-label': 'New project name' })
48
50
  const createBtn = h('button', { class: 'btn primary', type: 'submit' }, 'Create')
49
- const form = h('form', { class: 'toolbar', style: { display: 'none' } }, h('label', { class: 'dim', for: 'new-project' }, 'New project id'), idInput, createBtn, h('span', { class: 'hint' }, 'Letters, digits, ".", "_" or "-"; up to 64 characters.'))
51
+ const form = h('form', { class: 'toolbar', style: { display: 'none' } }, h('label', { class: 'dim', for: 'new-project' }, 'New project'), idInput, createBtn, h('span', { class: 'hint' }, 'Any name, up to 64 characters. It gets its own generated id.'))
50
52
  const newBtn = h('button', { class: 'btn', type: 'button' }, icon('plus'), 'New project')
51
53
  newBtn.addEventListener('click', () => {
52
54
  form.style.display = form.style.display === 'none' ? '' : 'none'
@@ -56,8 +58,8 @@ export async function mount(container, { navigate }) {
56
58
  e.preventDefault()
57
59
  createBtn.disabled = true
58
60
  try {
59
- const r = await api('projects', { body: { id: idInput.value } })
60
- if (!r.created) toast(`"${r.id}" already exists`)
61
+ const r = await api('projects', { body: { name: idInput.value } })
62
+ if (!r.created) toast(`"${r.name}" already exists`)
61
63
  navigate(`#/projects/${encodeURIComponent(r.id)}`)
62
64
  } catch (error) {
63
65
  toast(error.message)
@@ -0,0 +1,235 @@
1
+ import { api } from '../api.js'
2
+ import { ago, copyText, emptyState, fmt, h, icon, initial, toast } from '../dom.js'
3
+ import { closeDrawer, openDrawer } from '../drawer.js'
4
+
5
+ const ROLE_HELP = {
6
+ admin: 'Manages people, invitations and the server. Does not see other people’s projects unless they are shared.',
7
+ member: 'Has a private space of their own and sees only what is shared with them.',
8
+ }
9
+ const GRANT_ROLES = ['viewer', 'editor', 'maintainer']
10
+
11
+ function inviteLink(token) {
12
+ return `${location.origin}${location.pathname.replace(/[^/]*$/, '')}#/invite/${token}`
13
+ }
14
+
15
+ export async function mount(container, { query }) {
16
+ let { users, invites } = await api('users')
17
+ let projects = []
18
+ try {
19
+ projects = (await api('projects')).projects
20
+ } catch {
21
+ /* the list is only for choosing what to share */
22
+ }
23
+ let me = null
24
+ try {
25
+ me = (await api('state')).userId
26
+ } catch {
27
+ /* not needed */
28
+ }
29
+
30
+ const peopleCard = h('div', { class: 'card flush' })
31
+ const inviteCard = h('div', { class: 'card flush' })
32
+ const linkBox = h('div')
33
+
34
+ async function reload() {
35
+ ;({ users, invites } = await api('users'))
36
+ draw()
37
+ }
38
+
39
+ async function patch(user, body, done) {
40
+ try {
41
+ await api(`users/${encodeURIComponent(user.id)}`, { method: 'PATCH', body })
42
+ if (done) toast(done)
43
+ await reload()
44
+ } catch (error) {
45
+ toast(error.message)
46
+ await reload().catch(() => undefined)
47
+ }
48
+ }
49
+
50
+ async function remove(user) {
51
+ if (user.owned > 0) return toast(`${user.username} owns ${user.owned} project${user.owned === 1 ? '' : 's'}. Transfer or delete them first.`)
52
+ if (!confirm(`Remove ${user.username}? Their tokens stop working and they lose access to every shared project.`)) return
53
+ try {
54
+ await api(`users/${encodeURIComponent(user.id)}`, { method: 'DELETE' })
55
+ toast(`${user.username} removed`)
56
+ await reload()
57
+ } catch (error) {
58
+ toast(error.message)
59
+ }
60
+ }
61
+
62
+ function showLink(info, token, title) {
63
+ const link = inviteLink(token)
64
+ linkBox.replaceChildren(
65
+ h(
66
+ 'div',
67
+ { class: 'callout warn stack', style: { gap: '10px', margin: '0 18px 16px' } },
68
+ h('b', null, title),
69
+ h('div', { class: 'secret' }, link),
70
+ h('div', { class: 'row' }, h('button', { class: 'btn', type: 'button', onclick: () => copyText(link, 'Link copied') }, icon('copy'), 'Copy link'), h('span', { class: 'hint' }, `Works once, expires ${fmt.dateTime(info.expiresAt)}. Only a hash is stored, so it cannot be shown again.`)),
71
+ ),
72
+ )
73
+ }
74
+
75
+ async function resetLink(user) {
76
+ try {
77
+ const made = await api('invites', { body: { kind: 'reset', forUserId: user.id } })
78
+ showLink(made.info, made.token, `Password reset link for ${user.username}`)
79
+ await reload()
80
+ linkBox.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
81
+ } catch (error) {
82
+ toast(error.message)
83
+ }
84
+ }
85
+
86
+ function limitDrawer(user) {
87
+ const input = h('input', { class: 'input', type: 'number', min: '0', max: '1000', placeholder: 'No limit', value: user.maxProjects ?? '', 'aria-label': 'Projects this person may own', style: { width: '160px' } })
88
+ const save = h('button', { class: 'btn primary', type: 'button' }, 'Save')
89
+ save.addEventListener('click', async () => {
90
+ closeDrawer()
91
+ await patch(user, { maxProjects: input.value === '' ? null : Number(input.value) }, 'Limit saved')
92
+ })
93
+ openDrawer(h('strong', null, `Limit for ${user.username}`), h('div', { class: 'stack' }, h('p', { class: 'dim' }, 'How many projects this person may own. Projects shared with them do not count. Leave it empty for no limit.'), h('div', { class: 'field' }, h('label', null, 'Projects they may own'), input), save))
94
+ input.focus()
95
+ }
96
+
97
+ function userRow(u) {
98
+ const self = u.id === me
99
+ const roleSel = h('select', { 'aria-label': `Role of ${u.username}`, disabled: self }, ['admin', 'member'].map(r => h('option', { value: r, selected: r === u.role }, r)))
100
+ roleSel.addEventListener('change', () => patch(u, { role: roleSel.value }, `${u.username} is now ${roleSel.value}`))
101
+ const off = u.status === 'disabled'
102
+ return h(
103
+ 'tr',
104
+ { style: { opacity: off ? 0.6 : 1 } },
105
+ h('td', null, h('div', { class: 'row tight' }, h('span', { class: 'avatar' }, initial(u.username)), h('b', null, u.username), self && h('span', { class: 'badge' }, 'you'))),
106
+ h('td', null, roleSel),
107
+ h('td', null, off ? h('span', { class: 'badge err' }, icon('x'), 'disabled') : h('span', { class: 'badge ok' }, icon('check'), 'active')),
108
+ h('td', { class: 'num' }, `${u.owned}`, h('span', { class: 'sub' }, u.maxProjects === null ? 'no limit' : `of ${u.maxProjects}`)),
109
+ h('td', { class: 'num' }, `${u.reach}`),
110
+ h('td', null, ago(u.createdAt)),
111
+ h(
112
+ 'td',
113
+ { class: 'num' },
114
+ h(
115
+ 'div',
116
+ { class: 'row tight', style: { justifyContent: 'flex-end' } },
117
+ h('button', { class: 'btn sm', type: 'button', onclick: () => limitDrawer(u) }, 'Limit'),
118
+ h('button', { class: 'btn sm', type: 'button', title: 'Create a one-time link so they can choose a new password', onclick: () => resetLink(u) }, icon('key'), 'Reset link'),
119
+ !self && h('button', { class: 'btn sm', type: 'button', onclick: () => patch(u, { status: off ? 'active' : 'disabled' }, off ? `${u.username} enabled` : `${u.username} disabled`) }, off ? 'Enable' : 'Disable'),
120
+ !self && h('button', { class: 'btn sm danger', type: 'button', 'aria-label': `Remove ${u.username}`, onclick: () => remove(u) }, icon('trash')),
121
+ ),
122
+ ),
123
+ )
124
+ }
125
+
126
+ function drawPeople() {
127
+ peopleCard.replaceChildren(
128
+ h('div', { class: 'head' }, h('div', null, h('h2', null, 'People'), h('div', { class: 'hint' }, users.length > 1 ? `${users.length} accounts. Each person has a private space; projects are shared one by one.` : 'Only you so far: Agent Cairn is in solo mode. Invite someone and projects become private to their owner.'))),
129
+ h('div', { class: 'scroll' }, h('table', { class: 'tbl' }, h('thead', null, h('tr', null, ['Person', 'Role', 'Status', 'Owns', 'Reaches', 'Joined', ''].map(t => h('th', { class: ['Owns', 'Reaches'].includes(t) ? 'num' : undefined }, t)))), h('tbody', null, users.map(userRow)))),
130
+ )
131
+ }
132
+
133
+ // ---- invite ----------------------------------------------------------------
134
+ const role = h('select', { 'aria-label': 'Role' }, h('option', { value: 'member' }, 'member'), h('option', { value: 'admin' }, 'admin'))
135
+ const roleHint = h('div', { class: 'hint' }, ROLE_HELP.member)
136
+ role.addEventListener('change', () => {
137
+ roleHint.textContent = ROLE_HELP[role.value]
138
+ grants.hidden = role.value === 'admin'
139
+ })
140
+ const username = h('input', { class: 'input', placeholder: 'Let them choose', maxlength: '32', autocomplete: 'off', 'aria-label': 'Username', style: { width: '180px' } })
141
+ const maxProjects = h('input', { class: 'input', type: 'number', min: '0', max: '1000', placeholder: 'No limit', 'aria-label': 'Project limit', style: { width: '120px' } })
142
+ const ttl = h('select', { 'aria-label': 'Expires after' }, [1, 3, 7, 14, 30].map(d => h('option', { value: d, selected: d === 7 }, `${d} day${d === 1 ? '' : 's'}`)))
143
+ const grantRows = []
144
+ const grantList = h('div', { class: 'stack', style: { gap: '8px' } })
145
+ const grants = h('div', { class: 'field' }, h('label', null, 'Share projects with them (optional)'), grantList)
146
+
147
+ function addGrant() {
148
+ const project = h('select', { 'aria-label': 'Project to share' }, projects.map(p => h('option', { value: p.id }, p.owner ? `${p.name} (${p.owner})` : p.name)))
149
+ const level = h('select', { 'aria-label': 'Access level' }, GRANT_ROLES.map(r => h('option', { value: r, selected: r === 'editor' }, r)))
150
+ const row = { project, level, el: null }
151
+ row.el = h('div', { class: 'row tight' }, project, level, h('button', { class: 'btn sm ghost', type: 'button', 'aria-label': 'Remove', onclick: () => { grantRows.splice(grantRows.indexOf(row), 1); row.el.remove() } }, icon('x')))
152
+ grantRows.push(row)
153
+ grantList.append(row.el)
154
+ }
155
+ const addGrantBtn = h('button', { class: 'btn sm', type: 'button', onclick: addGrant }, icon('plus'), 'Add a project')
156
+ const create = h('button', { class: 'btn primary', type: 'button' }, icon('plus'), 'Create invitation')
157
+
158
+ create.addEventListener('click', async () => {
159
+ create.disabled = true
160
+ try {
161
+ const body = {
162
+ kind: 'join',
163
+ role: role.value,
164
+ username: username.value.trim() || undefined,
165
+ maxProjects: maxProjects.value === '' ? null : Number(maxProjects.value),
166
+ ttlDays: Number(ttl.value),
167
+ grants: role.value === 'admin' ? [] : grantRows.map(g => ({ projectId: g.project.value, role: g.level.value })),
168
+ }
169
+ const made = await api('invites', { body })
170
+ showLink(made.info, made.token, `Invitation ready${made.info.username ? ` for ${made.info.username}` : ''}. Send them this link.`)
171
+ username.value = ''
172
+ await reload()
173
+ } catch (error) {
174
+ toast(error.message)
175
+ } finally {
176
+ create.disabled = false
177
+ }
178
+ })
179
+
180
+ async function revoke(inv) {
181
+ if (!confirm('Withdraw this invitation? The link stops working immediately.')) return
182
+ try {
183
+ await api(`invites/${encodeURIComponent(inv.id)}`, { method: 'DELETE' })
184
+ await reload()
185
+ } catch (error) {
186
+ toast(error.message)
187
+ }
188
+ }
189
+
190
+ function inviteRow(inv) {
191
+ const reset = inv.kind === 'reset'
192
+ const what = reset ? `Password reset for ${inv.forUsername ?? 'a user'}` : `${inv.username ? inv.username : 'Anyone with the link'} as ${inv.role}`
193
+ const pending = inv.status === 'pending'
194
+ return h(
195
+ 'tr',
196
+ { style: { opacity: pending ? 1 : 0.55 } },
197
+ h('td', null, h('b', null, what), !reset && inv.grants.length > 0 && h('span', { class: 'sub' }, inv.grants.map(g => `${g.projectName}: ${g.role}`).join(', '))),
198
+ h('td', null, inv.createdBy ?? '–', h('span', { class: 'sub' }, ago(inv.createdAt))),
199
+ h('td', null, pending ? `expires ${fmt.dateTime(inv.expiresAt)}` : inv.status === 'used' ? `used by ${inv.usedBy ?? 'someone'}` : inv.status),
200
+ h('td', null, h('span', { class: `badge ${pending ? 'auth' : inv.status === 'used' ? 'ok' : 'err'}` }, inv.status)),
201
+ h('td', { class: 'num' }, pending && h('button', { class: 'btn sm danger', type: 'button', onclick: () => revoke(inv) }, icon('trash'), 'Withdraw')),
202
+ )
203
+ }
204
+
205
+ function drawInvite() {
206
+ inviteCard.replaceChildren(
207
+ h('div', { class: 'head' }, h('div', null, h('h2', null, 'Invite someone'), h('div', { class: 'hint' }, 'They open the link, choose a password and are in. No email is sent: you pass the link on yourself.'))),
208
+ h(
209
+ 'div',
210
+ { style: { padding: '0 18px 18px' }, class: 'stack' },
211
+ h('div', { class: 'row', style: { alignItems: 'flex-end', gap: '14px', flexWrap: 'wrap' } }, h('div', { class: 'field', style: { margin: 0 } }, h('label', null, 'Role'), role), h('div', { class: 'field', style: { margin: 0 } }, h('label', null, 'Username'), username), h('div', { class: 'field', style: { margin: 0 } }, h('label', null, 'May own up to'), maxProjects), h('div', { class: 'field', style: { margin: 0 } }, h('label', null, 'Link expires'), ttl)),
212
+ roleHint,
213
+ grants,
214
+ h('div', { class: 'row' }, addGrantBtn, h('span', { class: 'grow' }), create),
215
+ ),
216
+ linkBox,
217
+ invites.length
218
+ ? h('div', { class: 'scroll', style: { maxHeight: '320px' } }, h('table', { class: 'tbl' }, h('thead', null, h('tr', null, ['Invitation', 'By', 'State', '', ''].map(t => h('th', null, t)))), h('tbody', null, invites.map(inviteRow))))
219
+ : emptyState('inbox', 'No invitations', 'Pending, used and expired invitations show up here.'),
220
+ )
221
+ }
222
+
223
+ function draw() {
224
+ drawPeople()
225
+ // the invite form keeps its fields across redraws, so only its table is rebuilt
226
+ drawInvite()
227
+ }
228
+
229
+ container.append(
230
+ h('div', { class: 'page-head' }, h('div', null, h('h1', null, 'Users'), h('div', { class: 'sub' }, 'Invite people, set what they may own, and disable or remove them. Sharing a project is done on the project itself.'))),
231
+ h('div', { class: 'stack', style: { gap: '16px' } }, peopleCard, inviteCard),
232
+ )
233
+ draw()
234
+ if (query?.invite !== undefined) username.focus()
235
+ }