Add on-page editor for the go-graph-cms

Injectable React editor that is side-loaded onto the statically rendered
page and edits through the CMS HTTP API (POST /api/edit).

- Shadow-root UI over server HTML; patches morphed in, undo/redo from
  server inverses (reuses @k6n/cms-editor EditClient/morph/markdown editor)
- Sign-in via go-auth device grant as the CMS client (aud matches), token
  refresh + 401 retry; live preview swapped in on load and on tab focus
- Schema-driven property form (arrays, maps, variants, $ref, validation)
- Every string property editable where it is rendered (value-bound inline
  fields and attribute chips), markdown WYSIWYG for marked fields
- Block palette + drag & drop with ghost drop targets, insertion slots,
  sort snapping between siblings (Alt to nest)
- x-style annotations: on-page padding/margin/gap handles, Style panel,
  wrap-in-layout; x-graph label/group for titles and grouping
- Dev workflow: Vite proxy to the live site (token injected server-side)
  or an offline in-memory mock CMS; jsdom test suite

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
matsandClaude Sonnet 5.5 committed 2026-10-31 22:34:48 +01:00
commit 1af9ec3172
67 files changed
+12322

No files matched your search

+188
View File
@@ -0,0 +1,188 @@
import { afterEach, describe, expect, it } from 'vitest'
import { act } from 'react'
import { byId, click, setup } from './harness'
import type { Harness } from './harness'
import { AuthManager } from '../src/auth'
let h: Harness
afterEach(() => h?.cleanup())
const until = async (cond: () => boolean, ms = 1500) => {
const t0 = Date.now()
while (!cond()) {
if (Date.now() - t0 > ms) throw new Error('timed out waiting')
await act(async () => void (await new Promise((r) => setTimeout(r, 10))))
}
}
const status = () => h.editor.ctrl.store.get().auth.status
const signIn = async () => {
await until(() => status() === 'signed-out')
await act(async () => void h.shadow.querySelector<HTMLElement>('[aria-label="Sign in"] .cms-btn--primary')!.click())
await until(() => status() === 'pending')
h.cms.approve()
await until(() => status() === 'signed-in')
await h.settle()
}
describe('sign-in (device grant)', () => {
it('shows the login panel and blocks editing while signed out', async () => {
h = await setup({ auth: true })
await until(() => status() === 'signed-out')
expect(h.shadow.querySelector('[aria-label="Sign in"]')).not.toBeNull()
await act(async () => void click(byId('t1')))
expect(h.editor.ctrl.store.get().selected).toBeNull()
const ev = new MouseEvent('click', { bubbles: true, cancelable: true })
byId('b1').querySelector('a')!.dispatchEvent(ev)
expect(ev.defaultPrevented).toBe(false) // the page behaves like a normal page
})
it('walks through user code -> approval -> signed in, then editing works', async () => {
h = await setup({ auth: true })
await until(() => status() === 'signed-out')
await act(async () => void h.shadow.querySelector<HTMLElement>('[aria-label="Sign in"] .cms-btn--primary')!.click())
await until(() => status() === 'pending')
expect(h.shadow.querySelector('.cms-code')!.textContent).toBe('MOCK-1234')
expect(h.shadow.querySelector<HTMLAnchorElement>('.cms-login__link')!.href).toContain('/device?user_code=MOCK-1234')
h.cms.approve()
await until(() => status() === 'signed-in')
await h.settle()
expect(h.shadow.querySelector('[aria-label="Sign in"]')).toBeNull()
expect(h.editor.ctrl.store.get().toast!.text).toBe('Signed in as mock-user')
await act(async () => void click(byId('t1')))
expect(h.editor.ctrl.store.get().selected).toBe('t1')
})
it('refreshes transparently when the access token expires', async () => {
h = await setup({ auth: true })
await signIn()
const before = h.auth.headers().Authorization
h.cms.expireAccess()
await act(async () => void click(byId('t1')))
await h.editor.ctrl.nudge('t1', 1)
await h.settle()
expect(h.cms.graph.children('s1').map((e) => e.to)).toEqual(['h1', 'b1', 't1'])
expect(h.auth.headers().Authorization).not.toBe(before) // a new access token was minted via the refresh token
expect(status()).toBe('signed-in')
})
it('a revoked session drops back to the login panel', async () => {
h = await setup({ auth: true })
await signIn()
await act(async () => void click(byId('t1')))
h.cms.revoke()
await h.editor.ctrl.nudge('t1', 1)
await h.settle()
expect(status()).toBe('signed-out')
expect(h.editor.ctrl.store.get().selected).toBeNull()
expect(h.shadow.querySelector('[aria-label="Sign in"]')).not.toBeNull()
})
it('sign out clears the session; pasted tokens work', async () => {
h = await setup({ auth: true, token: 'static-token' })
await until(() => status() === 'signed-out')
await h.editor.ctrl.signInWithToken('static-token')
await h.settle()
expect(status()).toBe('signed-in')
h.editor.ctrl.signOut()
expect(status()).toBe('signed-out')
})
})
describe('AuthManager', () => {
const json = (status: number, body: unknown) => new Response(JSON.stringify(body), { status })
const grant = { device_code: 'd', user_code: 'U', verification_uri: 'https://i/device', interval: 1, expires_in: 600 }
function manager(poll: Array<Response>, extra: Partial<ConstructorParameters<typeof AuthManager>[0]> = {}) {
const sleeps: number[] = []
const calls: string[] = []
const fetch = (async (url: string) => {
calls.push(String(url))
if (String(url).endsWith('/api/auth/device')) return json(200, grant)
if (String(url).endsWith('/device/token')) return poll.shift()!
throw new Error('unexpected ' + url)
}) as unknown as typeof globalThis.fetch
const auth = new AuthManager({ fetch, issuer: async () => 'https://i', persistent: null, session: null, sleep: async (ms) => void sleeps.push(ms), ...extra })
return { auth, sleeps, calls }
}
it('honours slow_down by backing off 5s', async () => {
const { auth, sleeps } = manager([json(400, { error: 'authorization_pending' }), json(400, { error: 'slow_down' }), json(400, { error: 'authorization_pending' }), json(200, { access_token: 'a', refresh_token: 'r', expires_in: 900 })])
expect(await auth.login()).toBe(true)
expect(sleeps).toEqual([1000, 1000, 6000, 6000])
expect(auth.headers()).toEqual({ Authorization: 'Bearer a' })
})
it('runs the grant as the CMS client from /api/auth/config so the token audience matches', async () => {
const bodies: string[] = []
const fetch = (async (url: string, init?: RequestInit) => {
const u = String(url)
if (u === '/api/auth/config') return json(200, { issuer: 'https://i', clientId: 'k6n-admin' })
bodies.push(`${u.replace('https://i', '')} ${String(init?.body)}`)
if (u.endsWith('/api/auth/device')) return json(200, grant)
return json(200, { access_token: 'a', expires_in: 900 })
}) as unknown as typeof globalThis.fetch
const auth = new AuthManager({ fetch, persistent: null, session: null, sleep: async () => {} })
expect(await auth.login()).toBe(true)
expect(bodies[0]).toContain('client_id=k6n-admin') // start
expect(bodies[1]).toContain('client_id=k6n-admin') // poll: must be the same client
})
it('surfaces the server reason (e.g. wrong audience) when a token is rejected', async () => {
const fetch = (async () => json(401, { error: 'unauthorized: oidc: wrong audience' })) as unknown as typeof globalThis.fetch
const auth = new AuthManager({ fetch, persistent: null, session: null })
auth.useToken('t')
await auth.check(async () => {
throw Object.assign(new Error('unauthorized: oidc: wrong audience'), { status: 401 })
})
expect(auth.state.get()).toMatchObject({ status: 'signed-out', error: expect.stringContaining('wrong audience') })
})
it('reports denial and expiry', async () => {
const denied = manager([json(400, { error: 'access_denied' })])
expect(await denied.auth.login()).toBe(false)
expect(denied.auth.state.get()).toMatchObject({ status: 'signed-out', error: 'sign-in was denied' })
const expired = manager([json(400, { error: 'expired_token' })])
await expired.auth.login()
expect(expired.auth.state.get().error).toMatch(/expired/)
})
it('cancel stops polling quietly', async () => {
let auth!: AuthManager
const m = manager([json(400, { error: 'authorization_pending' }), json(400, { error: 'authorization_pending' })], { sleep: async () => auth.cancel() })
auth = m.auth
expect(await auth.login()).toBe(false)
expect(auth.state.get()).toMatchObject({ status: 'signed-out', error: undefined })
})
it('production keeps the refresh token out of localStorage', async () => {
const mk = () => {
const m = new Map<string, string>()
return { getItem: (k: string) => m.get(k) ?? null, setItem: (k: string, v: string) => void m.set(k, v), removeItem: (k: string) => void m.delete(k), data: m } as unknown as Storage & { data: Map<string, string> }
}
for (const persistRefresh of [false, true]) {
const local = mk()
const session = mk()
const { auth } = manager([json(200, { access_token: 'a', refresh_token: 'secret-refresh', expires_in: 900 })], { persistent: local, session, persistRefresh })
await auth.login()
expect([...local.data.values()].join('').includes('secret-refresh')).toBe(persistRefresh)
expect([...session.data.values()].join('').includes('secret-refresh')).toBe(!persistRefresh)
}
})
it('shares one refresh between concurrent callers (refresh tokens rotate)', async () => {
let refreshes = 0
const fetch = (async (url: string) => {
if (String(url).endsWith('/device/refresh')) {
refreshes++
return json(200, { access_token: 'new', refresh_token: 'r2', expires_in: 900 })
}
return json(200, grant)
}) as unknown as typeof globalThis.fetch
const auth = new AuthManager({ fetch, issuer: async () => 'https://i', persistent: null, session: null })
auth.useToken('old')
;(auth as any).tokens = { accessToken: 'old', refreshToken: 'r1', expiresAt: 0 }
await Promise.all([auth.refresh(), auth.refresh(), auth.ensureFresh()])
expect(refreshes).toBe(1)
expect(auth.headers()).toEqual({ Authorization: 'Bearer new' })
})
})