@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.
- package/dist/authn.d.ts +7 -1
- package/dist/authn.js +34 -8
- package/dist/authn.js.map +1 -1
- package/dist/context.d.ts +9 -2
- package/dist/context.js +48 -10
- package/dist/context.js.map +1 -1
- package/dist/dashboard.js +305 -21
- package/dist/dashboard.js.map +1 -1
- package/dist/http.js +51 -9
- package/dist/http.js.map +1 -1
- package/dist/security.d.ts +2 -0
- package/dist/security.js +6 -0
- package/dist/security.js.map +1 -1
- package/dist/serve.js +3 -3
- package/dist/serve.js.map +1 -1
- package/dist/server.d.ts +2 -1
- package/dist/server.js +1 -1
- package/dist/server.js.map +1 -1
- package/dist/tools.d.ts +1 -1
- package/dist/tools.js +81 -6
- package/dist/tools.js.map +1 -1
- package/package.json +2 -2
- package/ui/css/app.css +27 -1
- package/ui/js/app.js +18 -4
- package/ui/js/dom.js +39 -1
- package/ui/js/views/access.js +10 -4
- package/ui/js/views/audit.js +77 -0
- package/ui/js/views/auth.js +7 -8
- package/ui/js/views/graph.js +123 -18
- package/ui/js/views/invite.js +50 -0
- package/ui/js/views/project.js +162 -10
- package/ui/js/views/projects.js +8 -6
- package/ui/js/views/users.js +235 -0
package/ui/css/app.css
CHANGED
|
@@ -172,7 +172,7 @@ textarea { font-family: var(--mono); font-size: 13px; width: 100%; resize: verti
|
|
|
172
172
|
.badge svg { width: 11px; height: 11px; }
|
|
173
173
|
.kind { display: inline-flex; align-items: center; gap: 7px; font-size: 12.5px; color: var(--text-2); font-weight: 520; }
|
|
174
174
|
.kind::before { content: ""; width: 9px; height: 9px; border-radius: 3px; background: var(--k, var(--muted)); }
|
|
175
|
-
.k-overview { --k: var(--s1); } .k-decision { --k: var(--s2); } .k-convention { --k: var(--s3); } .k-gotcha { --k: var(--s4); }
|
|
175
|
+
.k-handoff { --k: var(--s8); } .k-overview { --k: var(--s1); } .k-decision { --k: var(--s2); } .k-convention { --k: var(--s3); } .k-gotcha { --k: var(--s4); }
|
|
176
176
|
.k-checkpoint { --k: var(--s5); } .k-thread { --k: var(--s6); } .k-glossary { --k: var(--s7); }
|
|
177
177
|
|
|
178
178
|
/* ===== data display ===== */
|
|
@@ -359,3 +359,29 @@ textarea { font-family: var(--mono); font-size: 13px; width: 100%; resize: verti
|
|
|
359
359
|
.page-head { margin-bottom: 16px; }
|
|
360
360
|
.toolbar { padding: 10px; }
|
|
361
361
|
}
|
|
362
|
+
|
|
363
|
+
/* handoff and sessions on the project page */
|
|
364
|
+
.notes { display: flex; flex-direction: column; gap: 6px; font-size: 13.5px; line-height: 1.5; }
|
|
365
|
+
.notes-h { margin: 10px 0 0; font-size: 11.5px; font-weight: 640; letter-spacing: 0.04em; text-transform: uppercase; color: var(--muted); }
|
|
366
|
+
.notes-h:first-child { margin-top: 0; }
|
|
367
|
+
.notes-p { margin: 0; color: var(--text-2); }
|
|
368
|
+
.notes-list { margin: 0; padding-left: 20px; display: flex; flex-direction: column; gap: 3px; color: var(--text); }
|
|
369
|
+
.resume-card { border-left: 3px solid var(--s8); margin-bottom: 14px; }
|
|
370
|
+
.resume-card details { border-top: 1px solid var(--border); padding-top: 10px; margin-top: 10px; }
|
|
371
|
+
.resume-card summary { cursor: pointer; font-weight: 560; }
|
|
372
|
+
.sessions { display: flex; flex-direction: column; margin-top: 4px; }
|
|
373
|
+
.session { padding: 12px 0; border-top: 1px solid var(--border); }
|
|
374
|
+
.session:first-child { border-top: 0; padding-top: 4px; }
|
|
375
|
+
.session summary { cursor: pointer; list-style: none; display: flex; flex-direction: column; gap: 4px; }
|
|
376
|
+
.session summary::-webkit-details-marker { display: none; }
|
|
377
|
+
.session .clamp { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; color: var(--text-2); font-size: 13px; }
|
|
378
|
+
.session .detail { margin-top: 10px; display: flex; flex-direction: column; gap: 8px; font-size: 13px; }
|
|
379
|
+
.session .detail b { font-weight: 600; }
|
|
380
|
+
.session .files { font-family: var(--mono); font-size: 12px; color: var(--muted); overflow-wrap: anywhere; }
|
|
381
|
+
|
|
382
|
+
/* ---- users, sharing, scopes ---- */
|
|
383
|
+
.member { gap: 12px; padding: 4px 0; align-items: center; }
|
|
384
|
+
.member .hint { margin-top: 1px; }
|
|
385
|
+
.scope summary { cursor: pointer; color: var(--dim); font-size: 12.5px; }
|
|
386
|
+
.scope[open] summary { color: var(--text); }
|
|
387
|
+
.tbl .sub { display: block; font-size: 11.5px; color: var(--muted); margin-top: 2px; }
|
package/ui/js/app.js
CHANGED
|
@@ -3,6 +3,7 @@ import { clear, h, icon, initial, startClock, svg } from './dom.js'
|
|
|
3
3
|
import { closeDrawer } from './drawer.js'
|
|
4
4
|
import { live } from './store.js'
|
|
5
5
|
import { mountLogin, mountSetup } from './views/auth.js'
|
|
6
|
+
import { mountInvite } from './views/invite.js'
|
|
6
7
|
|
|
7
8
|
const root = document.getElementById('root')
|
|
8
9
|
|
|
@@ -35,14 +36,20 @@ const ROUTES = [
|
|
|
35
36
|
{ pattern: /^\/graph$/, nav: 'graph', load: () => import('./views/graph.js') },
|
|
36
37
|
{ pattern: /^\/query$/, nav: 'query', load: () => import('./views/query.js') },
|
|
37
38
|
{ pattern: /^\/access$/, nav: 'access', load: () => import('./views/access.js') },
|
|
39
|
+
{ pattern: /^\/users$/, nav: 'users', load: () => import('./views/users.js') },
|
|
40
|
+
{ pattern: /^\/audit$/, nav: 'audit', load: () => import('./views/audit.js') },
|
|
38
41
|
{ pattern: /^\/system$/, nav: 'system', load: () => import('./views/system.js') },
|
|
39
42
|
]
|
|
40
43
|
|
|
41
44
|
const NAV = [
|
|
42
45
|
['Monitor', [['live', 'Live', 'live']]],
|
|
43
46
|
['Explore', [['projects', 'Projects', 'folder'], ['graph', 'Graph', 'graph'], ['query', 'Query', 'terminal']]],
|
|
44
|
-
['Manage', [['access', 'Access', 'key'], ['system', 'System', 'server']]],
|
|
47
|
+
['Manage', [['access', 'Access', 'key'], ['users', 'Users', 'users'], ['audit', 'Audit', 'list'], ['system', 'System', 'server']]],
|
|
45
48
|
]
|
|
49
|
+
/** Pages that show the whole server rather than your own projects: admins only. */
|
|
50
|
+
const ADMIN_ONLY = new Set(['live', 'query', 'system', 'users', 'audit'])
|
|
51
|
+
let me = { role: 'admin', solo: true }
|
|
52
|
+
|
|
46
53
|
const TITLES = Object.fromEntries(NAV.flatMap(([, items]) => items.map(([key, label]) => [key, label])))
|
|
47
54
|
|
|
48
55
|
export function navigate(hash) {
|
|
@@ -72,8 +79,9 @@ let shell = null
|
|
|
72
79
|
|
|
73
80
|
function mountShell(username, version) {
|
|
74
81
|
clear(root)
|
|
82
|
+
const visibleNav = NAV.map(([group, items]) => [group, items.filter(([key]) => me.role === 'admin' || !ADMIN_ONLY.has(key))]).filter(([, items]) => items.length)
|
|
75
83
|
const brand = h('div', { class: 'brand' }, logo(), 'Agent Cairn', version && h('small', null, `v${version}`))
|
|
76
|
-
const links =
|
|
84
|
+
const links = visibleNav.flatMap(([group, items]) => [
|
|
77
85
|
h('div', { class: 'label' }, group),
|
|
78
86
|
...items.map(([key, label, ic]) => {
|
|
79
87
|
const count = h('span', { class: 'count', dataset: { count: key } })
|
|
@@ -142,6 +150,7 @@ async function route() {
|
|
|
142
150
|
const { path, query } = parseHash()
|
|
143
151
|
const match = ROUTES.map(r => [r, r.pattern.exec(path)]).find(([, m]) => m)
|
|
144
152
|
const [def, m] = match ?? [ROUTES[0], null]
|
|
153
|
+
if (me.role !== 'admin' && ADMIN_ONLY.has(def.nav)) return navigate('#/projects')
|
|
145
154
|
const params = Object.fromEntries((def.params ?? []).map((name, i) => [name, decodeURIComponent(m?.[i + 1] ?? '')]))
|
|
146
155
|
|
|
147
156
|
document.title = `${TITLES[def.nav] ?? 'Dashboard'} · Agent Cairn`
|
|
@@ -161,7 +170,9 @@ async function route() {
|
|
|
161
170
|
}
|
|
162
171
|
}
|
|
163
172
|
|
|
164
|
-
|
|
173
|
+
const inviteToken = () => /^#\/invite\/([^/?]+)/.exec(location.hash)?.[1]
|
|
174
|
+
|
|
175
|
+
window.addEventListener('hashchange', () => (inviteToken() || !shell ? boot() : route()))
|
|
165
176
|
|
|
166
177
|
// ---- boot ------------------------------------------------------------------
|
|
167
178
|
|
|
@@ -171,6 +182,8 @@ async function boot() {
|
|
|
171
182
|
shell?.unsubscribe()
|
|
172
183
|
shell = null
|
|
173
184
|
live.stop()
|
|
185
|
+
const invite = inviteToken()
|
|
186
|
+
if (invite) return mountInvite(root, decodeURIComponent(invite), boot)
|
|
174
187
|
let state
|
|
175
188
|
try {
|
|
176
189
|
state = await api('state')
|
|
@@ -180,8 +193,9 @@ async function boot() {
|
|
|
180
193
|
}
|
|
181
194
|
if (state.setupRequired) return mountSetup(root, boot)
|
|
182
195
|
if (!state.authenticated) return mountLogin(root, boot)
|
|
196
|
+
me = { role: state.role ?? 'admin', solo: state.solo ?? true }
|
|
183
197
|
mountShell(state.username, state.version)
|
|
184
|
-
live.start()
|
|
198
|
+
if (me.role === 'admin') live.start()
|
|
185
199
|
route()
|
|
186
200
|
}
|
|
187
201
|
|
package/ui/js/dom.js
CHANGED
|
@@ -77,6 +77,8 @@ const ICONS = {
|
|
|
77
77
|
fit: '<path d="M4 9V5h4M20 9V5h-4M4 15v4h4M20 15v4h-4"/>',
|
|
78
78
|
info: '<circle cx="12" cy="12" r="9"/><path d="M12 11v5M12 8h.01"/>',
|
|
79
79
|
arrow: '<path d="M5 12h14M13 6l6 6-6 6"/>',
|
|
80
|
+
users: '<circle cx="9" cy="8" r="3.5"/><path d="M2.5 20a6.5 6.5 0 0 1 13 0M16 4.6a3.5 3.5 0 0 1 0 6.8M18 14.2a6.5 6.5 0 0 1 3.5 5.8"/>',
|
|
81
|
+
list: '<path d="M8 6h12M8 12h12M8 18h12M4 6h.01M4 12h.01M4 18h.01"/>',
|
|
80
82
|
database: '<ellipse cx="12" cy="6" rx="8" ry="3"/><path d="M4 6v6c0 1.7 3.6 3 8 3s8-1.3 8-3V6M4 12v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6"/>',
|
|
81
83
|
}
|
|
82
84
|
|
|
@@ -208,7 +210,43 @@ export function debounce(fn, ms) {
|
|
|
208
210
|
}
|
|
209
211
|
}
|
|
210
212
|
|
|
211
|
-
export const KINDS = ['overview', 'decision', 'convention', 'gotcha', 'checkpoint', 'thread', 'glossary']
|
|
213
|
+
export const KINDS = ['handoff', 'overview', 'decision', 'convention', 'gotcha', 'checkpoint', 'thread', 'glossary']
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Renders the small Markdown a handoff is written in (`## headings`, `- bullets`, `1. steps`) as elements.
|
|
217
|
+
* Text only goes in through textContent, never as HTML.
|
|
218
|
+
*/
|
|
219
|
+
export function renderNotes(body) {
|
|
220
|
+
const out = []
|
|
221
|
+
let list = null
|
|
222
|
+
const flush = () => {
|
|
223
|
+
if (list) out.push(list)
|
|
224
|
+
list = null
|
|
225
|
+
}
|
|
226
|
+
for (const raw of String(body).split('\n')) {
|
|
227
|
+
const line = raw.trimEnd()
|
|
228
|
+
if (!line.trim()) continue
|
|
229
|
+
const heading = /^##\s+(.*)$/.exec(line)
|
|
230
|
+
const bullet = /^-\s+(.*)$/.exec(line)
|
|
231
|
+
const step = /^\d+\.\s+(.*)$/.exec(line)
|
|
232
|
+
if (heading) {
|
|
233
|
+
flush()
|
|
234
|
+
out.push(h('h4', { class: 'notes-h' }, heading[1]))
|
|
235
|
+
} else if (bullet || step) {
|
|
236
|
+
const tag = step ? 'ol' : 'ul'
|
|
237
|
+
if (!list || list.tagName.toLowerCase() !== tag) {
|
|
238
|
+
flush()
|
|
239
|
+
list = h(tag, { class: 'notes-list' })
|
|
240
|
+
}
|
|
241
|
+
list.append(h('li', null, (bullet ?? step)[1]))
|
|
242
|
+
} else {
|
|
243
|
+
flush()
|
|
244
|
+
out.push(h('p', { class: 'notes-p' }, line))
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
flush()
|
|
248
|
+
return h('div', { class: 'notes' }, ...out)
|
|
249
|
+
}
|
|
212
250
|
|
|
213
251
|
export function kindLabel(kind) {
|
|
214
252
|
return h('span', { class: `kind k-${kind}` }, kind)
|
package/ui/js/views/access.js
CHANGED
|
@@ -13,14 +13,19 @@ export async function mount(container, { query }) {
|
|
|
13
13
|
|
|
14
14
|
// ---- tokens --------------------------------------------------------------
|
|
15
15
|
const nameInput = h('input', { class: 'input', placeholder: 'Name, e.g. laptop or ci-runner', maxlength: '60', 'aria-label': 'Token name', style: { width: '250px' } })
|
|
16
|
+
const readOnly = h('input', { type: 'checkbox', id: 'tok-ro' })
|
|
17
|
+
const scopeBoxes = projects.map(p => ({ p, box: h('input', { type: 'checkbox', value: p.id, 'aria-label': p.name }) }))
|
|
18
|
+
const scope = h('details', { class: 'scope' }, h('summary', null, 'Limit to projects'), h('div', { class: 'stack', style: { gap: '6px', padding: '8px 0' } }, scopeBoxes.length ? scopeBoxes.map(({ p, box }) => h('label', { class: 'row tight' }, box, p.name)) : h('span', { class: 'hint' }, 'No projects yet.'), h('span', { class: 'hint' }, 'None ticked means every project you can reach. A limited token cannot create projects.')))
|
|
16
19
|
const createBtn = h('button', { class: 'btn primary', type: 'button' }, icon('plus'), 'Create token')
|
|
17
20
|
|
|
18
21
|
async function create() {
|
|
19
22
|
createBtn.disabled = true
|
|
20
23
|
try {
|
|
21
|
-
const made = await api('tokens', { body: { name: nameInput.value } })
|
|
24
|
+
const made = await api('tokens', { body: { name: nameInput.value, readOnly: readOnly.checked, projects: scopeBoxes.filter(x => x.box.checked).map(x => x.p.id) } })
|
|
22
25
|
fresh = made.token
|
|
23
26
|
nameInput.value = ''
|
|
27
|
+
readOnly.checked = false
|
|
28
|
+
scopeBoxes.forEach(x => (x.box.checked = false))
|
|
24
29
|
tokens = (await api('tokens')).tokens
|
|
25
30
|
secretBox.replaceChildren(
|
|
26
31
|
h(
|
|
@@ -56,12 +61,13 @@ export async function mount(container, { query }) {
|
|
|
56
61
|
function drawTokens() {
|
|
57
62
|
const active = tokens.filter(t => !t.revokedAt)
|
|
58
63
|
tokenCard.replaceChildren(
|
|
59
|
-
h('div', { class: 'head' }, h('div', null, h('h2', null, 'Access tokens'), h('div', { class: 'hint' }, `${active.length} active · agents and the cairn CLI send one as a bearer token`)), h('div', { class: 'tools' }, nameInput, createBtn)),
|
|
64
|
+
h('div', { class: 'head' }, h('div', null, h('h2', null, 'Access tokens'), h('div', { class: 'hint' }, `${active.length} active · agents and the cairn CLI send one as a bearer token`)), h('div', { class: 'tools' }, nameInput, h('label', { class: 'row tight', for: 'tok-ro', title: 'The token can read memories and briefings but never write' }, readOnly, 'Read-only'), createBtn)),
|
|
65
|
+
h('div', { style: { padding: '0 18px 10px' } }, scope),
|
|
60
66
|
secretBox,
|
|
61
67
|
tokens.length
|
|
62
68
|
? h('div', { class: 'scroll', style: { maxHeight: '340px' } }, h('table', { class: 'tbl' }, h('thead', null, h('tr', null, ['Name', 'Token', 'Created', 'Last used', 'Status', ''].map(t => h('th', null, t)))),
|
|
63
69
|
h('tbody', null, tokens.map(t => h('tr', { style: { opacity: t.revokedAt ? 0.55 : 1 } },
|
|
64
|
-
h('td', null, h('span', { class: 'tool-name' }, t.name)),
|
|
70
|
+
h('td', null, h('span', { class: 'tool-name' }, t.name), t.readOnly && h('span', { class: 'badge', style: { marginLeft: '8px' } }, 'read-only'), t.projects?.length > 0 && h('span', { class: 'badge', style: { marginLeft: '6px' }, title: t.projects.map(id => projects.find(p => p.id === id)?.name ?? id).join(', ') }, `${t.projects.length} project${t.projects.length === 1 ? '' : 's'}`)),
|
|
65
71
|
h('td', { class: 'mono' }, `${t.prefix}…`),
|
|
66
72
|
h('td', null, ago(t.createdAt), t.createdBy && h('span', { class: 'sub' }, `by ${t.createdBy}`)),
|
|
67
73
|
h('td', null, t.lastUsedAt ? ago(t.lastUsedAt) : h('span', { class: 'muted' }, 'never')),
|
|
@@ -75,7 +81,7 @@ export async function mount(container, { query }) {
|
|
|
75
81
|
|
|
76
82
|
// ---- connect -------------------------------------------------------------
|
|
77
83
|
const origin = h('input', { class: 'input mono', value: location.origin, 'aria-label': 'Server address', style: { width: '280px' } })
|
|
78
|
-
const projectSelect = h('select', { 'aria-label': 'Project' }, projects.map(p => h('option', { value: p.id }, p.
|
|
84
|
+
const projectSelect = h('select', { 'aria-label': 'Project' }, projects.map(p => h('option', { value: p.id }, p.name)), h('option', { value: '' }, 'A project name or id…'))
|
|
79
85
|
const customProject = h('input', { class: 'input', placeholder: 'project-id', 'aria-label': 'Project id', style: { display: 'none', width: '160px' } })
|
|
80
86
|
if (query.project) projectSelect.value = query.project
|
|
81
87
|
else if (!projects.length) projectSelect.value = ''
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { api, qs } from '../api.js'
|
|
2
|
+
import { emptyState, fmt, h, toast } from '../dom.js'
|
|
3
|
+
|
|
4
|
+
const GROUPS = [
|
|
5
|
+
['', 'Everything'],
|
|
6
|
+
['login', 'Sign-ins'],
|
|
7
|
+
['user.', 'Accounts'],
|
|
8
|
+
['invite.', 'Invitations'],
|
|
9
|
+
['member.', 'Sharing'],
|
|
10
|
+
['project.', 'Projects'],
|
|
11
|
+
['token.', 'Tokens'],
|
|
12
|
+
['console.', 'Query console'],
|
|
13
|
+
]
|
|
14
|
+
const PAGE = 100
|
|
15
|
+
|
|
16
|
+
export async function mount(container) {
|
|
17
|
+
let events = []
|
|
18
|
+
let prefix = ''
|
|
19
|
+
let more = false
|
|
20
|
+
|
|
21
|
+
const body = h('tbody')
|
|
22
|
+
const table = h('div', { class: 'scroll' }, h('table', { class: 'tbl' }, h('thead', null, h('tr', null, ['When', 'Who', 'What', 'On', 'Detail'].map(t => h('th', null, t)))), body))
|
|
23
|
+
const holder = h('div', { class: 'card flush' })
|
|
24
|
+
const select = h('select', { 'aria-label': 'Kind of event' }, GROUPS.map(([v, label]) => h('option', { value: v }, label)))
|
|
25
|
+
const moreBtn = h('button', { class: 'btn', type: 'button' }, 'Load older')
|
|
26
|
+
|
|
27
|
+
function rows(list) {
|
|
28
|
+
return list.map(e =>
|
|
29
|
+
h(
|
|
30
|
+
'tr',
|
|
31
|
+
null,
|
|
32
|
+
h('td', { title: e.at }, h('span', { class: 'mono', style: { fontSize: '12px' } }, fmt.dateTime(e.at))),
|
|
33
|
+
h('td', null, e.actor ?? h('span', { class: 'muted' }, 'system')),
|
|
34
|
+
h('td', null, h('span', { class: `badge ${/failed|removed|disabled|revoked|deleted/.test(e.action) ? 'err' : /created|accepted|added|enabled/.test(e.action) ? 'ok' : ''}` }, e.action)),
|
|
35
|
+
h('td', null, e.target ?? ''),
|
|
36
|
+
h('td', { class: 'dim' }, e.detail ?? ''),
|
|
37
|
+
),
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function load(append) {
|
|
42
|
+
moreBtn.disabled = true
|
|
43
|
+
try {
|
|
44
|
+
const { events: got } = await api(`audit?${qs({ limit: PAGE, action: prefix, before: append ? events[events.length - 1]?.at : undefined })}`)
|
|
45
|
+
events = append ? [...events, ...got] : got
|
|
46
|
+
more = got.length === PAGE
|
|
47
|
+
draw(append ? got : events, append)
|
|
48
|
+
} catch (error) {
|
|
49
|
+
toast(error.message)
|
|
50
|
+
} finally {
|
|
51
|
+
moreBtn.disabled = false
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function draw(list, append) {
|
|
56
|
+
if (!events.length) {
|
|
57
|
+
holder.replaceChildren(emptyState('inbox', 'Nothing recorded', prefix ? 'No events of this kind.' : 'Sign-ins and changes to accounts, sharing, tokens and projects show up here.'))
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
if (!holder.contains(table)) holder.replaceChildren(table, h('div', { class: 'row', style: { padding: '12px 18px', justifyContent: 'center' } }, moreBtn))
|
|
61
|
+
if (append) body.append(...rows(list))
|
|
62
|
+
else body.replaceChildren(...rows(list))
|
|
63
|
+
moreBtn.hidden = !more
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
select.addEventListener('change', () => {
|
|
67
|
+
prefix = select.value
|
|
68
|
+
load(false)
|
|
69
|
+
})
|
|
70
|
+
moreBtn.addEventListener('click', () => load(true))
|
|
71
|
+
|
|
72
|
+
container.append(
|
|
73
|
+
h('div', { class: 'page-head' }, h('div', null, h('h1', null, 'Audit log'), h('div', { class: 'sub' }, 'Who changed what, kept for 180 days. Secrets and memory content are never recorded.')), h('div', { class: 'row' }, select)),
|
|
74
|
+
holder,
|
|
75
|
+
)
|
|
76
|
+
await load(false)
|
|
77
|
+
}
|
package/ui/js/views/auth.js
CHANGED
|
@@ -1,33 +1,32 @@
|
|
|
1
1
|
import { api } from '../api.js'
|
|
2
2
|
import { clear, h, svg } from '../dom.js'
|
|
3
3
|
|
|
4
|
-
function brand() {
|
|
4
|
+
export function brand() {
|
|
5
5
|
return h(
|
|
6
6
|
'div',
|
|
7
7
|
{ class: 'brand' },
|
|
8
8
|
svg(
|
|
9
9
|
'svg',
|
|
10
10
|
{ viewBox: '0 0 32 32', 'aria-hidden': 'true' },
|
|
11
|
-
svg('
|
|
12
|
-
svg('
|
|
13
|
-
svg('
|
|
14
|
-
svg('circle', { cx: 24, cy: 21, r: 3, fill: 'var(--text)' }),
|
|
11
|
+
svg('ellipse', { cx: 16, cy: 23.5, rx: 9, ry: 3.8, fill: 'var(--text)' }),
|
|
12
|
+
svg('ellipse', { cx: 16, cy: 16.2, rx: 6.4, ry: 3.2, fill: 'var(--text)' }),
|
|
13
|
+
svg('ellipse', { cx: 16, cy: 9.6, rx: 3.8, ry: 2.8, fill: 'var(--s1)' }),
|
|
15
14
|
),
|
|
16
15
|
'Agent Cairn',
|
|
17
16
|
)
|
|
18
17
|
}
|
|
19
18
|
|
|
20
|
-
function field(label, input, hint) {
|
|
19
|
+
export function field(label, input, hint) {
|
|
21
20
|
const id = `f-${Math.random().toString(36).slice(2, 8)}`
|
|
22
21
|
input.id = id
|
|
23
22
|
return h('div', { class: 'field' }, h('label', { for: id }, label), input, hint && h('span', { class: 'hint' }, hint))
|
|
24
23
|
}
|
|
25
24
|
|
|
26
|
-
function page(...children) {
|
|
25
|
+
export function page(...children) {
|
|
27
26
|
return h('div', { class: 'auth-page' }, h('div', { class: 'card panel' }, brand(), ...children))
|
|
28
27
|
}
|
|
29
28
|
|
|
30
|
-
async function submit(form, button, errorEl, work) {
|
|
29
|
+
export async function submit(form, button, errorEl, work) {
|
|
31
30
|
errorEl.textContent = ''
|
|
32
31
|
button.disabled = true
|
|
33
32
|
try {
|
package/ui/js/views/graph.js
CHANGED
|
@@ -3,11 +3,11 @@ import { debounce, fmt, h, icon, KINDS, searchBox } from '../dom.js'
|
|
|
3
3
|
import { live } from '../store.js'
|
|
4
4
|
import { importance, openMemory } from './memoryDrawer.js'
|
|
5
5
|
|
|
6
|
-
const KIND_VAR = { overview: '--s1', decision: '--s2', convention: '--s3', gotcha: '--s4', checkpoint: '--s5', thread: '--s6', glossary: '--s7' }
|
|
6
|
+
const KIND_VAR = { handoff: '--s8', overview: '--s1', decision: '--s2', convention: '--s3', gotcha: '--s4', checkpoint: '--s5', thread: '--s6', glossary: '--s7' }
|
|
7
7
|
// Seven kinds are more than hue alone can carry safely, so kind is also a shape.
|
|
8
|
-
const KIND_SHAPE = { overview: 'star', decision: 'diamond', convention: 'round-rectangle', gotcha: 'triangle', checkpoint: 'ellipse', thread: 'round-tag', glossary: 'pentagon' }
|
|
9
|
-
const KIND_GLYPH = { overview: '★', decision: '◆', convention: '▢', gotcha: '▲', checkpoint: '●', thread: '▰', glossary: '⬟' }
|
|
10
|
-
const WRITE_TOOLS = new Set(['cairn_remember', 'cairn_checkpoint', 'cairn_forget'])
|
|
8
|
+
const KIND_SHAPE = { handoff: 'octagon', overview: 'star', decision: 'diamond', convention: 'round-rectangle', gotcha: 'triangle', checkpoint: 'ellipse', thread: 'round-tag', glossary: 'pentagon' }
|
|
9
|
+
const KIND_GLYPH = { handoff: '◼', overview: '★', decision: '◆', convention: '▢', gotcha: '▲', checkpoint: '●', thread: '▰', glossary: '⬟' }
|
|
10
|
+
const WRITE_TOOLS = new Set(['cairn_remember', 'cairn_checkpoint', 'cairn_handoff', 'cairn_forget'])
|
|
11
11
|
const LABEL_ZOOM = 1.15
|
|
12
12
|
|
|
13
13
|
const css = name => getComputedStyle(document.documentElement).getPropertyValue(name).trim()
|
|
@@ -46,15 +46,21 @@ function stylesheet() {
|
|
|
46
46
|
{ selector: '.dim', style: { opacity: 0.1 } },
|
|
47
47
|
{ selector: '.match', style: { 'border-width': 4, 'border-color': css('--warn-text'), label: 'data(label)' } },
|
|
48
48
|
{ selector: '.hidden', style: { display: 'none' } },
|
|
49
|
+
{ selector: '.collapsed', style: { display: 'none' } },
|
|
50
|
+
{ selector: 'node[type="project"].sized', style: { width: 'mapData(memories, 0, 60, 40, 100)', height: 'mapData(memories, 0, 60, 40, 100)', 'font-size': 12, 'text-max-width': 120 } },
|
|
51
|
+
{ selector: 'node[type="project"].quiet', style: { 'border-color': muted, opacity: 0.78 } },
|
|
52
|
+
{ selector: 'node[type="project"].away', style: { opacity: 0.3 } },
|
|
53
|
+
{ selector: 'node[type="project"].open', style: { 'border-color': css('--s3'), 'underlay-opacity': 0.14, 'underlay-padding': 12 } },
|
|
49
54
|
{ selector: ':selected', style: { 'border-width': 4, 'border-color': accent, 'underlay-opacity': 0.22, 'underlay-padding': 9, label: 'data(label)' } },
|
|
50
55
|
]
|
|
51
56
|
}
|
|
52
57
|
|
|
53
58
|
/** Layouts that put the project at the root, then its areas, then their memories. */
|
|
54
|
-
function layoutOptions(cy, name) {
|
|
59
|
+
function layoutOptions(cy, name, overview = false) {
|
|
55
60
|
const roots = cy.nodes('[type="project"]')
|
|
56
61
|
switch (name) {
|
|
57
62
|
case 'radial':
|
|
63
|
+
if (overview) return { name: 'concentric', concentric: n => n.data('memories') ?? 0, levelWidth: () => 25, minNodeSpacing: 70, spacingFactor: 1.2, avoidOverlap: true, padding: 40, animate: false }
|
|
58
64
|
return { name: 'breadthfirst', roots, circle: true, directed: false, spacingFactor: 1.05, avoidOverlap: true, padding: 40, animate: false }
|
|
59
65
|
case 'tree':
|
|
60
66
|
return { name: 'breadthfirst', roots, circle: false, directed: false, spacingFactor: 1.15, avoidOverlap: true, padding: 40, animate: false }
|
|
@@ -66,6 +72,8 @@ function layoutOptions(cy, name) {
|
|
|
66
72
|
}
|
|
67
73
|
|
|
68
74
|
const LAYOUT_LABELS = { radial: 'Radial', tree: 'Tree', force: 'Force', grid: 'Grid' }
|
|
75
|
+
const FEW_PROJECTS = 3 // up to this many, their areas are shown straight away
|
|
76
|
+
const QUIET_AFTER_DAYS = 21
|
|
69
77
|
|
|
70
78
|
export async function mount(container, { query, navigate }) {
|
|
71
79
|
const [{ projects }] = await Promise.all([api('projects'), loadCytoscape()])
|
|
@@ -74,12 +82,15 @@ export async function mount(container, { query, navigate }) {
|
|
|
74
82
|
let limit = 300
|
|
75
83
|
let pulseOn = true
|
|
76
84
|
let showSuperseded = true
|
|
85
|
+
let pinnedAreas = false // overview: keep every project's areas open
|
|
86
|
+
let showRemotes = false
|
|
87
|
+
const expanded = new Set()
|
|
77
88
|
const hiddenKinds = new Set()
|
|
78
89
|
let cy = null
|
|
79
90
|
let refreshTimer = null
|
|
80
91
|
|
|
81
92
|
// ---- toolbar -------------------------------------------------------------
|
|
82
|
-
const projectSelect = h('select', { 'aria-label': 'Project' }, h('option', { value: '' }, 'All projects'), projects.map(p => h('option', { value: p.id }, `${p.
|
|
93
|
+
const projectSelect = h('select', { 'aria-label': 'Project' }, h('option', { value: '' }, 'All projects'), projects.map(p => h('option', { value: p.id }, `${p.name} · ${p.active}`)))
|
|
83
94
|
projectSelect.value = project
|
|
84
95
|
projectSelect.addEventListener('change', () => navigate(`#/graph${projectSelect.value ? `?project=${encodeURIComponent(projectSelect.value)}` : ''}`))
|
|
85
96
|
|
|
@@ -99,6 +110,21 @@ export async function mount(container, { query, navigate }) {
|
|
|
99
110
|
const pulseBtn = h('button', { class: 'chip', type: 'button', 'aria-pressed': 'true', title: 'Flash nodes when an agent reads or writes them' }, icon('zap'), 'Live pulse')
|
|
100
111
|
pulseBtn.addEventListener('click', () => { pulseOn = !pulseOn; pulseBtn.setAttribute('aria-pressed', String(pulseOn)) })
|
|
101
112
|
|
|
113
|
+
const areasBtn = h('button', { class: 'chip', type: 'button', 'aria-pressed': 'false', title: 'Open every project to show its areas' }, 'Areas')
|
|
114
|
+
areasBtn.addEventListener('click', () => {
|
|
115
|
+
pinnedAreas = !pinnedAreas
|
|
116
|
+
areasBtn.setAttribute('aria-pressed', String(pinnedAreas))
|
|
117
|
+
pinnedAreas ? expandAll() : collapseAll()
|
|
118
|
+
fit()
|
|
119
|
+
})
|
|
120
|
+
const remotesBtn = h('button', { class: 'chip', type: 'button', 'aria-pressed': 'false', title: 'Show the git remotes that map to each project' }, 'Git remotes')
|
|
121
|
+
remotesBtn.addEventListener('click', () => {
|
|
122
|
+
showRemotes = !showRemotes
|
|
123
|
+
remotesBtn.setAttribute('aria-pressed', String(showRemotes))
|
|
124
|
+
cy?.nodes('[type="remote"]').toggleClass('collapsed', !showRemotes)
|
|
125
|
+
hud()
|
|
126
|
+
})
|
|
127
|
+
|
|
102
128
|
const supBtn = h('button', { class: 'chip', type: 'button', 'aria-pressed': 'true', title: 'Show memories that were replaced' }, 'Superseded')
|
|
103
129
|
supBtn.addEventListener('click', () => { showSuperseded = !showSuperseded; supBtn.setAttribute('aria-pressed', String(showSuperseded)); applyFilters() })
|
|
104
130
|
|
|
@@ -123,9 +149,9 @@ export async function mount(container, { query, navigate }) {
|
|
|
123
149
|
container.append(
|
|
124
150
|
h('div', { class: 'graph-page' },
|
|
125
151
|
h('div', { class: 'page-head', style: { marginBottom: '14px' } },
|
|
126
|
-
h('div', null, h('h1', null, 'Graph'), h('div', { class: 'sub' }, project ? `The memory tree of ${project}: project, its areas, and the memories filed under each.` : 'Every project,
|
|
152
|
+
h('div', null, h('h1', null, 'Graph'), h('div', { class: 'sub' }, project ? `The memory tree of ${project}: project, its areas, and the memories filed under each.` : 'Every project, sized by how much it remembers. Click one to open its areas; double-click to explore its memories.')),
|
|
127
153
|
),
|
|
128
|
-
h('div', { class: 'toolbar' }, projectSelect, layoutSeg, project && limitSelect, h('span', { class: 'grow' }), search.el, pulseBtn, project && supBtn),
|
|
154
|
+
h('div', { class: 'toolbar' }, projectSelect, layoutSeg, project && limitSelect, h('span', { class: 'grow' }), search.el, !project && areasBtn, !project && remotesBtn, pulseBtn, project && supBtn),
|
|
129
155
|
h('div', { class: 'graph-layout' },
|
|
130
156
|
h('div', { class: 'canvas-wrap' }, canvas, kindChips.length > 0 && h('div', { class: 'canvas-overlay tl' }, ...kindChips), controls, hudEl),
|
|
131
157
|
side,
|
|
@@ -139,7 +165,9 @@ export async function mount(container, { query, navigate }) {
|
|
|
139
165
|
|
|
140
166
|
function runLayout(doFit) {
|
|
141
167
|
if (!cy) return
|
|
142
|
-
const
|
|
168
|
+
const overview = !project
|
|
169
|
+
const eles = overview ? cy.nodes('[type="project"]') : cy.elements()
|
|
170
|
+
const layout = eles.layout(layoutOptions(cy, layoutName, overview))
|
|
143
171
|
if (doFit) layout.one('layoutstop', fit)
|
|
144
172
|
layout.run()
|
|
145
173
|
}
|
|
@@ -147,7 +175,12 @@ export async function mount(container, { query, navigate }) {
|
|
|
147
175
|
cy?.animate({ fit: { eles: cy.elements(':visible'), padding: 48 } }, { duration: 260 })
|
|
148
176
|
}
|
|
149
177
|
function hud() {
|
|
150
|
-
if (cy)
|
|
178
|
+
if (!cy) return
|
|
179
|
+
// what is drawn: folded-away and filtered nodes are not counted
|
|
180
|
+
const shown = n => !n.is('.collapsed, .hidden')
|
|
181
|
+
const nodes = cy.nodes().filter(shown).length
|
|
182
|
+
const edges = cy.edges().filter(e => shown(e.source()) && shown(e.target())).length
|
|
183
|
+
hudEl.textContent = `${nodes} nodes · ${edges} edges · ${cy.zoom().toFixed(2)}×`
|
|
151
184
|
}
|
|
152
185
|
function syncLabels() {
|
|
153
186
|
if (!cy) return
|
|
@@ -155,6 +188,58 @@ export async function mount(container, { query, navigate }) {
|
|
|
155
188
|
cy.nodes('[type="memory"]').toggleClass('lbl', show)
|
|
156
189
|
}
|
|
157
190
|
|
|
191
|
+
/** The areas of a project fan out around it; everything else stays put. */
|
|
192
|
+
function expand(node, animate = true) {
|
|
193
|
+
if (node.data('type') !== 'project') return
|
|
194
|
+
const areas = node.outgoers('node[type="area"]')
|
|
195
|
+
const center = node.position()
|
|
196
|
+
const radius = 64 + areas.length * 7
|
|
197
|
+
areas.forEach((a, i) => {
|
|
198
|
+
const angle = (2 * Math.PI * i) / Math.max(areas.length, 1) - Math.PI / 2
|
|
199
|
+
const spot = { x: center.x + radius * Math.cos(angle), y: center.y + radius * Math.sin(angle) }
|
|
200
|
+
a.removeClass('collapsed')
|
|
201
|
+
if (animate) {
|
|
202
|
+
a.position(center)
|
|
203
|
+
a.animate({ position: spot }, { duration: 240, easing: 'ease-out' })
|
|
204
|
+
} else a.position(spot)
|
|
205
|
+
})
|
|
206
|
+
node.addClass('open')
|
|
207
|
+
expanded.add(node.id())
|
|
208
|
+
hud()
|
|
209
|
+
}
|
|
210
|
+
function collapse(node) {
|
|
211
|
+
node.outgoers('node[type="area"]').addClass('collapsed')
|
|
212
|
+
node.removeClass('open')
|
|
213
|
+
expanded.delete(node.id())
|
|
214
|
+
hud()
|
|
215
|
+
}
|
|
216
|
+
const expandAll = () => cy?.nodes('[type="project"]').forEach(n => expand(n, false))
|
|
217
|
+
const collapseAll = () => {
|
|
218
|
+
cy?.nodes('[type="project"]').forEach(collapse)
|
|
219
|
+
cy?.nodes('[type="project"]').removeClass('away')
|
|
220
|
+
}
|
|
221
|
+
function toggleExpand(node) {
|
|
222
|
+
if (pinnedAreas) return
|
|
223
|
+
const wasOpen = expanded.has(node.id())
|
|
224
|
+
collapseAll()
|
|
225
|
+
if (!wasOpen) {
|
|
226
|
+
expand(node)
|
|
227
|
+
// the rest step back while one is open
|
|
228
|
+
cy.nodes('[type="project"]').not(node).addClass('away')
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
/** Many projects at once is a lot to draw: start with just the projects, and let people open them. */
|
|
232
|
+
function prepareOverview() {
|
|
233
|
+
const cutoff = Date.now() - QUIET_AFTER_DAYS * 86_400_000
|
|
234
|
+
cy.nodes('[type="project"]').forEach(n => {
|
|
235
|
+
n.addClass('sized')
|
|
236
|
+
n.toggleClass('quiet', Date.parse(n.data('lastMemoryAt') ?? n.data('createdAt')) < cutoff)
|
|
237
|
+
})
|
|
238
|
+
cy.nodes('[type="area"]').addClass('collapsed')
|
|
239
|
+
cy.nodes('[type="remote"]').addClass('collapsed')
|
|
240
|
+
expanded.clear()
|
|
241
|
+
}
|
|
242
|
+
|
|
158
243
|
function applyFilters() {
|
|
159
244
|
if (!cy) return
|
|
160
245
|
cy.batch(() => {
|
|
@@ -187,12 +272,24 @@ export async function mount(container, { query, navigate }) {
|
|
|
187
272
|
const graph = await api(`graph?${qs({ project, limit })}`)
|
|
188
273
|
if (cy) cy.destroy()
|
|
189
274
|
cy = window.cytoscape({ container: canvas, elements: [...graph.nodes.map(element), ...graph.edges.map(edgeEl)], style: stylesheet(), wheelSensitivity: 0.22, minZoom: 0.08, maxZoom: 4 })
|
|
275
|
+
if (!project) prepareOverview()
|
|
190
276
|
runLayout(true)
|
|
277
|
+
if (!project && cy.nodes('[type="project"]').length <= FEW_PROJECTS) expandAll()
|
|
191
278
|
applyFilters()
|
|
192
279
|
hud()
|
|
193
280
|
|
|
194
|
-
cy.on('tap', 'node', e =>
|
|
195
|
-
|
|
281
|
+
cy.on('tap', 'node', e => {
|
|
282
|
+
select(e.target)
|
|
283
|
+
if (!project && e.target.data('type') === 'project') toggleExpand(e.target)
|
|
284
|
+
})
|
|
285
|
+
cy.on('dbltap', 'node[type="project"]', e => { if (!project) navigate(`#/graph?project=${encodeURIComponent(e.target.data('mid'))}`) })
|
|
286
|
+
cy.on('tap', e => {
|
|
287
|
+
if (e.target !== cy) return
|
|
288
|
+
cy.elements().removeClass('dim')
|
|
289
|
+
cy.elements().unselect()
|
|
290
|
+
if (!project && !pinnedAreas) collapseAll()
|
|
291
|
+
drawSide(null, graph)
|
|
292
|
+
})
|
|
196
293
|
cy.on('mouseover', 'node', e => {
|
|
197
294
|
if (search.input.value.trim()) return
|
|
198
295
|
const around = e.target.closedNeighborhood()
|
|
@@ -204,7 +301,16 @@ export async function mount(container, { query, navigate }) {
|
|
|
204
301
|
cy.on('zoom', debounce(() => { syncLabels(); hud() }, 60))
|
|
205
302
|
|
|
206
303
|
drawSide(null, graph)
|
|
207
|
-
if (query.focus) {
|
|
304
|
+
if (!project && query.focus) {
|
|
305
|
+
// a link like #/graph?focus=<project id> opens that project's areas
|
|
306
|
+
const target = cy.getElementById(`p:${query.focus}`)
|
|
307
|
+
if (target.nonempty()) {
|
|
308
|
+
select(target)
|
|
309
|
+
toggleExpand(target)
|
|
310
|
+
target.select()
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (project && query.focus) {
|
|
208
314
|
const target = cy.nodes('[type="memory"]').filter(n => n.data('mid').startsWith(query.focus))
|
|
209
315
|
if (target.nonempty()) {
|
|
210
316
|
select(target[0])
|
|
@@ -277,16 +383,15 @@ export async function mount(container, { query, navigate }) {
|
|
|
277
383
|
h('div', null, h('div', { class: 'eyebrow' }, project ? 'Project' : 'Overview'), h('h2', { style: { fontSize: '17px', marginTop: '4px' } }, project || 'All projects')),
|
|
278
384
|
graph?.truncated && h('div', { class: 'callout warn' }, `Showing the ${limit} most important and recent memories. Raise the limit to see more.`),
|
|
279
385
|
h('div', { class: 'row' }, ...Object.entries(counts).filter(([, n]) => n > 0).map(([k, n]) => h('div', { class: 'stat', style: { marginRight: '14px' } }, h('b', null, fmt.int(n)), h('span', null, k === 'memory' ? 'memories' : `${k}${n === 1 ? '' : 's'}`)))),
|
|
280
|
-
h('p', { class: 'dim', style: { margin: 0 } }, 'Click a node to inspect it and double-click a memory to open it. Hover to follow its branch; zoom in to read memory titles.'),
|
|
386
|
+
h('p', { class: 'dim', style: { margin: 0 } }, project ? 'Click a node to inspect it and double-click a memory to open it. Hover to follow its branch; zoom in to read memory titles.' : 'Click a project to open its areas and double-click it to explore its memories. Bigger bubbles remember more; faded ones have been quiet for three weeks. Use Areas to open them all.'),
|
|
281
387
|
h('h3', null, 'How to read it'),
|
|
282
388
|
h('div', { class: 'legend-list' },
|
|
283
389
|
h('div', null, h('span', { class: 'g' }, '◯'), 'project: the root'),
|
|
284
390
|
h('div', null, h('span', { class: 'g' }, '⬢'), 'area: a branch of the project'),
|
|
285
|
-
...KINDS.map(k => h('div', { class: `k-${k}` }, h('span', { class: 'g', style: { color: 'var(--k)' } }, KIND_GLYPH[k]), k)),
|
|
286
|
-
h('div', null, h('span', { class: 'g', style: { opacity: 0.45 } }, '◆'), 'faded, dashed: superseded'),
|
|
287
|
-
h('div', null, h('span', { class: 'g', style: { color: 'var(--s5)' } }, '⇢'), 'newer replaces older'),
|
|
391
|
+
...(project ? KINDS.map(k => h('div', { class: `k-${k}` }, h('span', { class: 'g', style: { color: 'var(--k)' } }, KIND_GLYPH[k]), k)) : [h('div', null, h('span', { class: 'g', style: { opacity: 0.5 } }, '◯'), 'faded project: nothing new in three weeks')]),
|
|
392
|
+
...(project ? [h('div', null, h('span', { class: 'g', style: { opacity: 0.45 } }, '◆'), 'faded, dashed: superseded'), h('div', null, h('span', { class: 'g', style: { color: 'var(--s5)' } }, '⇢'), 'newer replaces older')] : []),
|
|
288
393
|
),
|
|
289
|
-
h('p', { class: 'hint', style: { margin: 0 } }, 'Size is importance. With live pulse on, a node glows when an agent reads or writes it.'),
|
|
394
|
+
h('p', { class: 'hint', style: { margin: 0 } }, project ? 'Size is importance. With live pulse on, a node glows when an agent reads or writes it.' : 'Bubble size is how many memories a project holds. With live pulse on, a project glows when an agent reads or writes it.'),
|
|
290
395
|
),
|
|
291
396
|
)
|
|
292
397
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { api } from '../api.js'
|
|
2
|
+
import { clear, h } from '../dom.js'
|
|
3
|
+
import { brand, field, page, submit } from './auth.js'
|
|
4
|
+
|
|
5
|
+
/** The page behind an invitation link: public, so it asks the server what the link is before showing a form. */
|
|
6
|
+
export async function mountInvite(root, token, done) {
|
|
7
|
+
clear(root).append(page(h('p', { class: 'lead' }, 'Checking your invitation…')))
|
|
8
|
+
let info
|
|
9
|
+
try {
|
|
10
|
+
info = await api(`invite/${encodeURIComponent(token)}`)
|
|
11
|
+
} catch (error) {
|
|
12
|
+
return clear(root).append(
|
|
13
|
+
page(
|
|
14
|
+
h('h1', null, 'This link no longer works'),
|
|
15
|
+
h('p', { class: 'lead' }, error.status === 404 || error.status === 410 ? 'It was already used, withdrawn or has expired. Ask the person who invited you for a new one.' : error.message),
|
|
16
|
+
h('a', { class: 'btn', href: '#/' }, 'Go to sign in'),
|
|
17
|
+
),
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const reset = info.kind === 'reset'
|
|
22
|
+
const fixed = reset ? info.forUsername : info.username
|
|
23
|
+
const error = h('p', { class: 'error', role: 'alert' })
|
|
24
|
+
const username = h('input', { class: 'input', autocomplete: 'username', required: true, minlength: '3', maxlength: '32', value: fixed ?? '', disabled: Boolean(fixed) })
|
|
25
|
+
const password = h('input', { class: 'input', type: 'password', autocomplete: 'new-password', required: true, minlength: '10' })
|
|
26
|
+
const confirmField = h('input', { class: 'input', type: 'password', autocomplete: 'new-password', required: true })
|
|
27
|
+
const button = h('button', { class: 'btn primary', type: 'submit', style: { width: '100%', justifyContent: 'center' } }, reset ? 'Set password' : 'Create account')
|
|
28
|
+
const form = h('form', null, field('Username', username, fixed ? undefined : 'Letters, numbers, dot, dash and underscore.'), field('Password', password, 'At least 10 characters.'), field('Repeat password', confirmField), error, button)
|
|
29
|
+
form.addEventListener('submit', e => {
|
|
30
|
+
e.preventDefault()
|
|
31
|
+
submit(form, button, error, async () => {
|
|
32
|
+
if (password.value !== confirmField.value) throw new Error('The passwords do not match.')
|
|
33
|
+
await api(`invite/${encodeURIComponent(token)}/accept`, { body: { username: fixed ? undefined : username.value, password: password.value } })
|
|
34
|
+
history.replaceState(null, '', '#/projects') // leaves the single-use link out of the address bar and history
|
|
35
|
+
done()
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
clear(root).append(
|
|
40
|
+
page(
|
|
41
|
+
h('h1', null, reset ? 'Choose a new password' : 'Join Agent Cairn'),
|
|
42
|
+
h('p', { class: 'lead' }, reset ? `For ${info.forUsername}.` : info.invitedBy ? `${info.invitedBy} invited you as ${info.role === 'admin' ? 'an admin' : 'a member'}.` : `You were invited as ${info.role === 'admin' ? 'an admin' : 'a member'}.`),
|
|
43
|
+
!reset && info.grants.length > 0 && h('div', { class: 'callout', style: { marginBottom: '16px' } }, 'You will start with access to: ', info.grants.map((g, i) => [i > 0 && ', ', h('b', null, g.projectName), ` (${g.role})`])),
|
|
44
|
+
form,
|
|
45
|
+
),
|
|
46
|
+
)
|
|
47
|
+
;(fixed ? password : username).focus()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export { brand }
|