diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 33fe7d7..4a69f67 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -20,8 +20,10 @@ jobs: - run: npm run lint - run: npm run build - run: npm run test:e2e + - run: npm run test:rollback - run: npx playwright install --with-deps chromium - run: npm run test:browser + - run: npm run test:browser:access - uses: actions/upload-artifact@v4 if: failure() with: diff --git a/e2e/access-rollback.e2e.test.ts b/e2e/access-rollback.e2e.test.ts new file mode 100644 index 0000000..77455b5 --- /dev/null +++ b/e2e/access-rollback.e2e.test.ts @@ -0,0 +1,43 @@ +import { afterEach, beforeEach, expect, it, onTestFailed } from 'vitest' +import { saveTrace, startHarness, type Harness } from './harness' + +// Rollback hazard check (evidence for the release plan, not a product fix): after someone has signed in through Access, +// their account row is access:. If the release is rolled back to the legacy sign-in (AUTH_MODE unset), can that +// person still reach their contacts by the legacy email link, and does the legacy path avoid creating a second account +// with the same email (which would make Access sign-in fail closed with "Account access requires review" after a re-enable)? +// Skipped unless ROLLBACK_CHECK=1 because it documents a known gap that is being reported, not a passing expectation. + +let h: Harness +beforeEach(async () => { + h = await startHarness() +}) +afterEach(async () => { + await h.dispose() +}) + +async function seedAccessAccount(email: string, contactName: string) { + const db = await h.mf.getD1Database('DB') + const id = `access:${'a'.repeat(64)}` + await db.prepare("INSERT INTO users (id, email, name, auth_provider, created_at, updated_at) VALUES (?, ?, ?, 'access', 't', 't')").bind(id, email, email).run() + await db + .prepare("INSERT INTO contacts (id, user_id, name, created_at, updated_at) VALUES ('c1', ?, ?, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')") + .bind(id, contactName) + .run() + return { db, id } +} + +it.skipIf(process.env.ROLLBACK_CHECK !== '1')('after a rollback, the legacy email link reaches the contacts of a person who signed in through Access', async () => { + onTestFailed((r) => saveTrace('access-rollback-contacts', h.trace, r.errors?.[0]?.message)) + await seedAccessAccount('alice@example.test', 'Alice Contact') + const alice = await h.signIn('alice@example.test') + const list = (await (await alice.request('/api/contacts')).json()) as { contacts: Array<{ name: string }> } + expect(list.contacts.map((c) => c.name)).toEqual(['Alice Contact']) +}) + +it.skipIf(process.env.ROLLBACK_CHECK !== '1')('after a rollback, the legacy email link does not create a second account with the same email', async () => { + onTestFailed((r) => saveTrace('access-rollback-duplicate', h.trace, r.errors?.[0]?.message)) + const { db } = await seedAccessAccount('alice@example.test', 'Alice Contact') + await h.signIn('alice@example.test') + const count = (await db.prepare("SELECT COUNT(*) AS n FROM users WHERE lower(trim(email)) = 'alice@example.test'").first<{ n: number }>())!.n + expect(count).toBe(1) +}) diff --git a/e2e/access.e2e.test.ts b/e2e/access.e2e.test.ts new file mode 100644 index 0000000..7204f1a --- /dev/null +++ b/e2e/access.e2e.test.ts @@ -0,0 +1,332 @@ +import { createHmac } from 'node:crypto' +import { afterEach, beforeEach, describe, expect, it, onTestFailed } from 'vitest' +import { ACCESS_AUDIENCE, ACCESS_ISSUER, defaultClaims, makeAccessKey, mintToken } from './access' +import { SESSION_SECRET, saveTrace, startHarness, uploadForm, type Harness } from './harness' + +// Cloudflare Access sign-in, tested against the contract Codex posted (seq 59): +// AUTH_MODE=access, ACCESS_ISSUER/ACCESS_AUDIENCE/ACCESS_ALLOWED_HOSTS, RS256 JWT checked against the issuer's JWKS, +// every /api route gated, 401 {loginUrl:'/cdn-cgi/access/login'}, logoutUrl '/cdn-cgi/access/logout', legacy sign-in routes 410. +// The simulator's keys and JWKS exist only in the test process. These are SIMULATED checks, not live Access behavior. + +const LOGIN_URL = '/cdn-cgi/access/login' +const LOGOUT_URL = '/cdn-cgi/access/logout' +const now = () => Math.floor(Date.now() / 1000) + +let h: Harness + +beforeEach(async () => { + h = await startHarness({ access: true }) + h.openai.mode = { + kind: 'card', + card: { name: 'Maria Okafor', company: 'Sunrise Realty Group', role: 'Broker', emails: ['maria@sunrise.example'], phones: ['(602) 555-0142'], website: '', address: '', tags: [], notes: '', confidence: 0.95, needs_review: false }, + } +}) + +afterEach(async () => { + await h.dispose() +}) + +function scenario(name: string, fn: () => Promise) { + it(name, async () => { + onTestFailed((result) => saveTrace(`access-${name}`, h.trace, result.errors?.[0]?.message)) + await fn() + }) +} + +async function db() { + return h.mf.getD1Database('DB') +} + +async function seedUser(id: string, email: string, contactName?: string) { + const d = await db() + await d.prepare("INSERT INTO users (id, email, name, created_at, updated_at) VALUES (?, ?, ?, 't', 't')").bind(id, email, email).run() + if (contactName) { + await d + .prepare("INSERT INTO contacts (id, user_id, name, created_at, updated_at) VALUES (?, ?, ?, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')") + .bind(`c-${id}`, id, contactName) + .run() + } +} + +async function contactNames(res: Response): Promise { + return ((await res.json()) as { contacts: Array<{ name: string }> }).contacts.map((c) => c.name) +} + +describe('valid Access sessions and existing-account mapping', () => { + scenario('a valid token signs in as the existing canonical account and sees its contacts', async () => { + await seedUser('email:alice@example.test', 'alice@example.test', 'Alice Contact') + const alice = h.access.session('alice@example.test') + const me = await alice.request('/api/me') + expect(me.status).toBe(200) + expect(await me.json()).toEqual({ email: 'alice@example.test', id: 'email:alice@example.test' }) + expect(await contactNames(await alice.request('/api/contacts'))).toEqual(['Alice Contact']) + }) + + scenario('email case and spaces in the token do not create a second account', async () => { + await seedUser('email:alice@example.test', 'alice@example.test', 'Alice Contact') + const session = h.access.session(' Alice@Example.TEST ') + const me = await session.request('/api/me') + expect(me.status).toBe(200) + expect((await me.json()) as { id: string }).toMatchObject({ id: 'email:alice@example.test' }) + expect(await contactNames(await session.request('/api/contacts'))).toEqual(['Alice Contact']) + }) + + scenario('an existing account with a non-canonical id keeps its id and its data', async () => { + await seedUser('legacy-42', 'bob@example.test', 'Bob Legacy Contact') + const bob = h.access.session('bob@example.test') + const me = await bob.request('/api/me') + expect(me.status).toBe(200) + expect(await me.json()).toEqual({ email: 'bob@example.test', id: 'legacy-42' }) + expect(await contactNames(await bob.request('/api/contacts'))).toEqual(['Bob Legacy Contact']) + }) + + scenario('the same person keeps the same account when their Access subject changes', async () => { + await seedUser('email:alice@example.test', 'alice@example.test', 'Alice Contact') + const first = await h.access.session('alice@example.test', { sub: 'subject-one' }).request('/api/me') + const second = await h.access.session('alice@example.test', { sub: 'subject-two' }).request('/api/me') + expect(((await first.json()) as { id: string }).id).toBe('email:alice@example.test') + expect(((await second.json()) as { id: string }).id).toBe('email:alice@example.test') + }) + + scenario('a first-time person gets a new account with no data and no password row, and keeps it on later sign-ins', async () => { + const carol = h.access.session('carol@example.test') + const me = await carol.request('/api/me') + expect(me.status).toBe(200) + const created = (await me.json()) as { email: string; id: string } + expect(created.email).toBe('carol@example.test') + expect(created.id).toMatch(/^access:[0-9a-f]{64}$/) + const again = (await (await h.access.session('carol@example.test').request('/api/me')).json()) as { id: string } + expect(again.id).toBe(created.id) + expect(await contactNames(await carol.request('/api/contacts'))).toEqual([]) + const rows = (await (await db()).prepare('SELECT COUNT(*) AS n FROM user_passwords').first<{ n: number }>())!.n + expect(rows).toBe(0) + }) + + scenario('two accounts with the same normalized email fail closed and expose no data', async () => { + await seedUser('dup-1', 'dana@example.test', 'Dana One') + await seedUser('dup-2', 'Dana@Example.test', 'Dana Two') + const dana = h.access.session('dana@example.test') + const res = await dana.request('/api/contacts') + expect([401, 403]).toContain(res.status) + expect(JSON.stringify(await res.json())).not.toMatch(/Dana (One|Two)/) + }) +}) + +describe('forged, expired and malformed tokens are rejected', () => { + async function expectRejected(token: string | null, label: string) { + const headers: Record = token === null ? {} : { 'Cf-Access-Jwt-Assertion': token } + const res = await h.access.raw('/api/me', { headers }) + expect(res.status, label).toBe(401) + const body = (await res.json()) as { loginUrl?: string } + expect(body.loginUrl, label).toBe(LOGIN_URL) + } + + const claims = (over: Record = {}) => ({ ...defaultClaims('alice@example.test'), ...over }) + + scenario('no token at all', async () => { + await expectRejected(null, 'no token') + }) + + scenario('expired token', async () => { + await expectRejected(mintToken({ key: h.access.keys[0], claims: claims({ exp: now() - 60, iat: now() - 3600 }) }), 'expired') + }) + + scenario('token issued in the future (iat) or not yet valid (nbf)', async () => { + await expectRejected(mintToken({ key: h.access.keys[0], claims: claims({ iat: now() + 3600, nbf: now() + 3600, exp: now() + 7200 }) }), 'future iat/nbf') + }) + + scenario('wrong issuer', async () => { + await expectRejected(mintToken({ key: h.access.keys[0], claims: claims({ iss: 'https://other-team.cloudflareaccess.com' }) }), 'wrong iss') + }) + + scenario('wrong audience (string and array forms)', async () => { + await expectRejected(mintToken({ key: h.access.keys[0], claims: claims({ aud: ['some-other-app-aud'] }) }), 'wrong aud array') + await expectRejected(mintToken({ key: h.access.keys[0], claims: claims({ aud: 'some-other-app-aud' }) }), 'wrong aud string') + }) + + scenario('missing audience, missing subject, missing email, or not an app token', async () => { + const base = claims() + const { aud: _aud, ...noAud } = base + const { sub: _sub, ...noSub } = base + const { email: _email, ...noEmail } = base + void _aud + void _sub + void _email + await expectRejected(mintToken({ key: h.access.keys[0], claims: noAud }), 'no aud') + await expectRejected(mintToken({ key: h.access.keys[0], claims: noSub }), 'no sub') + await expectRejected(mintToken({ key: h.access.keys[0], claims: noEmail }), 'no email') + await expectRejected(mintToken({ key: h.access.keys[0], claims: claims({ type: 'service-token' }) }), 'type not app') + }) + + scenario('unsigned (alg none) and algorithm-confusion (HS256 with the public key) tokens', async () => { + await expectRejected(mintToken({ key: h.access.keys[0], alg: 'none', claims: claims() }), 'alg none') + await expectRejected(mintToken({ key: h.access.keys[0], alg: 'HS256', claims: claims() }), 'HS256 confusion') + }) + + scenario('tampered signature, tampered payload, unknown key id', async () => { + const good = mintToken({ key: h.access.keys[0], claims: claims() }) + await expectRejected(mintToken({ key: h.access.keys[0], claims: claims(), tamperSignature: true }), 'tampered signature') + const [head, , sig] = good.split('.') + const evil = Buffer.from(JSON.stringify(claims({ email: 'victim@example.test' }))).toString('base64url') + await expectRejected(`${head}.${evil}.${sig}`, 'tampered payload') + await expectRejected(mintToken({ key: h.access.keys[0], kid: 'not-published', claims: claims() }), 'unknown kid') + }) + + scenario('token signed by a key that is not in the issuer JWKS', async () => { + const rogue = makeAccessKey('test-key-1') // same kid, different key material + await expectRejected(mintToken({ key: rogue, claims: claims() }), 'rogue key same kid') + }) + + scenario('garbage, empty and oversized tokens', async () => { + await expectRejected('not-a-jwt', 'garbage') + await expectRejected('a.b.c', 'three junk parts') + await expectRejected('x'.repeat(20000), 'oversized') + await expectRejected('', 'empty') + }) + + scenario('a rejected token never exposes data or creates an account', async () => { + const rogue = makeAccessKey('test-key-1') + const res = await h.access.raw('/api/contacts', { headers: { 'Cf-Access-Jwt-Assertion': mintToken({ key: rogue, claims: claims({ email: 'mallory@example.test' }) }) } }) + expect(res.status).toBe(401) + const users = (await (await db()).prepare("SELECT COUNT(*) AS n FROM users WHERE email LIKE 'mallory%'").first<{ n: number }>())!.n + expect(users).toBe(0) + }) +}) + +describe('old sign-in credentials no longer work in Access mode', () => { + scenario('plain identity headers and a forged CF_Authorization cookie are ignored', async () => { + const forgedPayload = Buffer.from(JSON.stringify(defaultClaims('alice@example.test'))).toString('base64url') + for (const headers of [ + { 'Cf-Access-Authenticated-User-Email': 'alice@example.test' }, + { 'Cf-Access-Jwt-Assertion': `x.${forgedPayload}.y` }, + { Cookie: `CF_Authorization=x.${forgedPayload}.y` }, + ]) { + expect((await h.access.raw('/api/me', { headers })).status).toBe(401) + } + }) + + scenario('a validly signed legacy cardcap_session cookie does not authenticate', async () => { + const userId = 'email:alice@example.test' + const expires = now() + 3600 + const sig = createHmac('sha256', SESSION_SECRET).update(`${userId}.${expires}`).digest('base64url') + const cookie = `cardcap_session=${encodeURIComponent(`v1.${Buffer.from(userId).toString('base64url')}.${expires}.${sig}`)}` + const res = await h.access.raw('/api/me', { headers: { Cookie: cookie } }) + expect(res.status).toBe(401) + }) + + scenario('legacy sign-in routes answer 410 to an Access-authenticated caller and never issue a session', async () => { + const alice = h.access.session('alice@example.test') + const json = { 'content-type': 'application/json' } + const attempts: Array<[string, string, string | undefined]> = [ + ['POST', '/api/auth/request-link', JSON.stringify({ email: 'alice@example.test' })], + ['POST', '/api/auth/password-login', JSON.stringify({ email: 'alice@example.test', password: 'correct horse battery' })], + ['POST', '/api/auth/set-password', JSON.stringify({ password: 'correct horse battery' })], + ['GET', '/api/auth/verify?token=abc', undefined], + ] + for (const [method, path, body] of attempts) { + const res = await alice.request(path, { method, headers: json, body }) + expect(res.status, `${method} ${path}`).toBe(410) + expect(res.headers.get('set-cookie'), `${method} ${path}`).toBeNull() + } + }) +}) + +describe('every API route is gated, and other hosts cannot bypass Access', () => { + scenario('health and auth config are not public in Access mode', async () => { + for (const path of ['/api/health', '/api/auth/config']) { + const res = await h.access.raw(path) + expect(res.status, path).toBe(401) + expect(((await res.json()) as { loginUrl?: string }).loginUrl, path).toBe(LOGIN_URL) + } + }) + + scenario('a valid token sent to an allowed host works, including case differences', async () => { + for (const host of ['cardcap.test', 'CARDCAP.TEST', 'cardcap.example.test']) { + const res = await h.access.session('alice@example.test', { host }).request('/api/me') + expect(res.status, host).toBe(200) + } + }) + + scenario('a valid token sent to a host that is not allowed (workers.dev, lookalikes, suffix tricks) is refused', async () => { + for (const host of ['cardcap.codylecates.workers.dev', 'evilcardcap.test', 'cardcap.test.evil.example', 'cardcap.test.', 'localhost']) { + const res = await h.access.session('alice@example.test', { host }).request('/api/me') + expect([401, 403], host).toContain(res.status) + expect(JSON.stringify(await res.json()), host).not.toContain('alice@example.test') + } + }) + + scenario('a forwarded-host header cannot talk the Worker into accepting a disallowed host', async () => { + const res = await h.access + .session('alice@example.test', { host: 'cardcap.codylecates.workers.dev' }) + .request('/api/me', { headers: { 'X-Forwarded-Host': 'cardcap.test', Host: 'cardcap.test', Origin: 'https://cardcap.test' } }) + expect([401, 403]).toContain(res.status) + }) + + scenario('the published JWKS is actually fetched from the issuer certs URL', async () => { + await h.access.session('alice@example.test').request('/api/me') + expect(h.access.jwksFetches).toBeGreaterThan(0) + const fetched = h.trace.filter((t) => t.kind === 'outbound' && t.note?.includes('access jwks')) + expect(fetched.every((t) => t.url === `${ACCESS_ISSUER}/cdn-cgi/access/certs`)).toBe(true) + }) + + scenario('the token audience is bound to this app', async () => { + const other = mintToken({ key: h.access.keys[0], claims: { ...defaultClaims('alice@example.test'), aud: [`${ACCESS_AUDIENCE}-other`] } }) + expect((await h.access.raw('/api/me', { headers: { 'Cf-Access-Jwt-Assertion': other } })).status).toBe(401) + }) +}) + +describe('logout', () => { + scenario('logout clears the legacy cookie and returns the Access logout URL', async () => { + const alice = h.access.session('alice@example.test') + const res = await alice.request('/api/auth/logout', { method: 'POST' }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ logoutUrl: LOGOUT_URL }) + expect(res.headers.get('set-cookie') || '').toMatch(/cardcap_session=;.*Max-Age=0/) + }) +}) + +describe('a refused account can still sign out', () => { + scenario('with a valid token but an ambiguous account mapping, data is refused (403) yet logout answers with the Access logout URL', async () => { + await seedUser('dup-1', 'dana@example.test', 'Dana One') + await seedUser('dup-2', 'Dana@Example.test', 'Dana Two') + const dana = h.access.session('dana@example.test') + const data = await dana.request('/api/contacts') + expect(data.status).toBe(403) + expect(await data.json()).toEqual({ error: 'Account access requires review.' }) + const out = await dana.request('/api/auth/logout', { method: 'POST' }) + expect(out.status).toBe(200) + expect(await out.json()).toEqual({ logoutUrl: LOGOUT_URL }) + expect(out.headers.get('set-cookie') || '').toMatch(/cardcap_session=;.*Max-Age=0/) + }) + + scenario('logout still requires a cryptographically valid token (a forged one gets nothing)', async () => { + const rogue = makeAccessKey('test-key-1') + const res = await h.access.raw('/api/auth/logout', { + method: 'POST', + headers: { 'Cf-Access-Jwt-Assertion': mintToken({ key: rogue, claims: defaultClaims('dana@example.test') }) }, + }) + expect(res.status).toBe(401) + }) +}) + +describe('cross-user isolation with Access sign-in', () => { + scenario('another Access user cannot list, read, edit, delete or fetch the image of a contact', async () => { + const alice = h.access.session('alice@example.test') + const bob = h.access.session('bob@example.test') + const upload = await alice.request('/api/cards/upload', { method: 'POST', body: uploadForm('maria.png') }) + expect(upload.status).toBe(201) + const { contact } = (await upload.json()) as { contact: { id: string; sourceImageUrl: string; name: string } } + expect(contact.name).toBe('Maria Okafor') + expect((await alice.request(contact.sourceImageUrl)).status).toBe(200) + + expect(await contactNames(await bob.request('/api/contacts'))).toEqual([]) + expect((await bob.request(`/api/contacts/${contact.id}`)).status).toBe(404) + expect((await bob.request(contact.sourceImageUrl)).status).toBe(404) + const put = await bob.request(`/api/contacts/${contact.id}`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'Hijacked' }) }) + expect(put.status).toBe(404) + const del = await bob.request(`/api/contacts/${contact.id}`, { method: 'DELETE' }) + expect(del.status).toBe(404) + expect(await contactNames(await alice.request('/api/contacts'))).toEqual(['Maria Okafor']) + expect((await alice.request(contact.sourceImageUrl)).status).toBe(200) + }) +}) diff --git a/e2e/access.ts b/e2e/access.ts new file mode 100644 index 0000000..3bc8762 --- /dev/null +++ b/e2e/access.ts @@ -0,0 +1,68 @@ +import { createHmac, createSign, generateKeyPairSync, type KeyObject } from 'node:crypto' + +// Cloudflare Access simulator for tests: a throwaway RSA key pair, the JWKS the Worker will fetch from +// /cdn-cgi/access/certs (served by the harness's outbound interceptor), and a signed-JWT minter. +// Nothing here can reach production: the keys exist only in the test process. + +export const ACCESS_ISSUER = 'https://csi-test.cloudflareaccess.com' +export const ACCESS_AUDIENCE = 'aud-tag-for-cardcap-tests-0123456789' +export const ACCESS_HOSTS = ['cardcap.test', 'cardcap.example.test'] +export const JWKS_URL = `${ACCESS_ISSUER}/cdn-cgi/access/certs` + +export type AccessKey = { kid: string; privateKey: KeyObject; jwk: Record } + +export function makeAccessKey(kid: string): AccessKey { + const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }) + const jwk = publicKey.export({ format: 'jwk' }) as Record + return { kid, privateKey, jwk: { ...jwk, kid, alg: 'RS256', use: 'sig' } } +} + +export type TokenClaims = { + iss?: string + aud?: string | string[] + sub?: string + email?: string | null + type?: string + iat?: number + nbf?: number + exp?: number + [key: string]: unknown +} + +export type MintOptions = { + key: AccessKey + alg?: 'RS256' | 'HS256' | 'none' + kid?: string | null + claims?: TokenClaims + /** flip a byte in the signature after signing */ + tamperSignature?: boolean +} + +const b64url = (input: Buffer | string) => Buffer.from(input).toString('base64url') + +export function defaultClaims(email: string, sub = `sub-${email}`, now = Math.floor(Date.now() / 1000)): TokenClaims { + return { iss: ACCESS_ISSUER, aud: [ACCESS_AUDIENCE], sub, email, type: 'app', iat: now - 5, nbf: now - 5, exp: now + 300 } +} + +export function mintToken(options: MintOptions): string { + const { key, alg = 'RS256', claims = {} } = options + const header: Record = { alg, typ: 'JWT' } + const kid = options.kid === undefined ? key.kid : options.kid + if (kid !== null) header.kid = kid + const payload = claims + const signingInput = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(payload))}` + let signature: Buffer + if (alg === 'none') { + signature = Buffer.alloc(0) + } else if (alg === 'HS256') { + // Classic algorithm-confusion attempt: HMAC using the public key material as the secret. + signature = createHmac('sha256', String(key.jwk.n)).update(signingInput).digest() + } else { + signature = createSign('RSA-SHA256').update(signingInput).sign(key.privateKey) + } + if (options.tamperSignature && signature.length > 0) { + signature = Buffer.from(signature) + signature[0] ^= 0xff + } + return `${signingInput}.${b64url(signature)}` +} diff --git a/e2e/browser/access.spec.ts b/e2e/browser/access.spec.ts new file mode 100644 index 0000000..15f16d4 --- /dev/null +++ b/e2e/browser/access.spec.ts @@ -0,0 +1,210 @@ +import { expect, test, type Browser, type Page } from '@playwright/test' +import { PNG_BUFFER } from '../fixtures' +import { chooseFromMenu, expectSignedInAs, openContact, WELCOME_HEADING } from './helpers' + +// Sign-in through a SIMULATED Cloudflare Access edge (see e2e/browser/server.ts, ACCESS_SIM=1; the port comes from the Playwright project): +// the Worker runs in AUTH_MODE=access, the edge redirects signed-out people to a fake sign-in page, and signed-in +// requests carry a signed Access JWT. This shows how CardCap behaves behind Access; it is not live Access behavior. +test.skip(process.env.ACCESS_SIM !== '1', 'needs the simulated Access server (ACCESS_SIM=1)') + +let counter = 0 +const uniqueEmail = (name: string) => `${name}-${Date.now()}-${++counter}@example.test` + +async function edgeSignIn(page: Page, email: string) { + await expect(page.getByRole('heading', { name: 'Simulated Access sign-in' })).toBeVisible() + await page.getByLabel('Email').fill(email) + await page.getByRole('button', { name: 'Continue' }).click() +} + +async function openApp(page: Page, email: string, path = '/') { + await page.goto(path) + await edgeSignIn(page, email) + await expect(page.getByRole('button', { name: 'Add cards' })).toBeVisible() +} + +async function upload(page: Page) { + await page.locator('input[type=file][accept="image/*"]').setInputFiles({ name: 'maria.png', mimeType: 'image/png', buffer: PNG_BUFFER }) + await expect(page.getByLabel('Name', { exact: true })).toHaveValue('Maria Okafor') + await expect(page.getByRole('status')).toContainText('Cards added') +} + +test.beforeEach(async ({ request }) => { + await request.get('/__test/openai?mode=card') +}) + +test('first visit goes through the edge, returns to the same address, and shows no CardCap sign-in or password screens', async ({ page, baseURL }) => { + const email = uniqueEmail('alice') + await page.goto('/?from=bookmark') + await expect(page).toHaveURL(/\/sim-idp\?return=/) + await edgeSignIn(page, email) + await expect(page).toHaveURL(new URL('/?from=bookmark', baseURL).toString()) + await expect(page.getByRole('button', { name: 'Add cards' })).toBeVisible() + await expectSignedInAs(page, email) + await expect(page.getByRole('heading', { name: 'Sign in to CardCap' })).toHaveCount(0) + await page.getByRole('button', { name: /^Account menu/ }).click() + await expect(page.getByRole('menuitem', { name: 'Set password' })).toHaveCount(0) + await expect(page.getByRole('menuitem', { name: 'Sign out' })).toBeVisible() +}) + +test('upload → review/edit → save → reload keeps the exact values behind Access', async ({ page }) => { + await openApp(page, uniqueEmail('alice')) + await upload(page) + await page.getByLabel('Name', { exact: true }).fill('Maria Okafor-Lee') + await page.getByRole('button', { name: 'Save Contact' }).click() + await expect(page.getByText('Saved.', { exact: true })).toBeVisible() + await page.reload() + await openContact(page, 'Maria Okafor-Lee') + await expect(page.getByLabel('Name', { exact: true })).toHaveValue('Maria Okafor-Lee') + await expect(page.locator('.source-body img')).toBeVisible() +}) + +test('when the session ends the person is told, keeps their edits, signs in again at the edge and is back where they were', async ({ page, request }) => { + const email = uniqueEmail('alice') + await openApp(page, email) + await upload(page) + await page.getByLabel('Name', { exact: true }).fill('Maria Okafor-Lee') + + await request.get('/__test/access-expire') // the edge session ends; the next API call gets an edge redirect + await page.getByRole('button', { name: 'Save Contact' }).click() + const dialog = page.getByRole('alertdialog', { name: 'Your session ended' }) + await expect(dialog).toBeVisible() + await expect(dialog).toContainText('We kept the edits you had not saved') + await expect(page.locator('.banner.error')).toHaveCount(0) + + await dialog.getByRole('button', { name: 'Sign in again' }).click() + await edgeSignIn(page, email) + await expect(page.getByLabel('Name', { exact: true })).toHaveValue('Maria Okafor-Lee') + await expect(page.getByRole('status')).toContainText('We restored the edits you had not saved') + await page.getByRole('button', { name: 'Save Contact' }).click() + await expect(page.getByText('Saved.', { exact: true })).toBeVisible() + await page.reload() + await openContact(page, 'Maria Okafor-Lee') + await expect(page.getByLabel('Name', { exact: true })).toHaveValue('Maria Okafor-Lee') +}) + +const SIGN_OUT_TITLE = 'Sign out of CardCap and Blackboard?' + +test('sign out asks first, naming Blackboard, then leaves through the Access logout address and the app is closed again', async ({ page }) => { + await openApp(page, uniqueEmail('alice')) + await chooseFromMenu(page, 'Account', 'Sign out') + const dialog = page.getByRole('alertdialog', { name: SIGN_OUT_TITLE }) + await expect(dialog).toBeVisible() + await expect(dialog).toContainText('This also signs you out of Blackboard') + await expect(dialog).toContainText('up to 30 seconds') + // The safe choice has focus; nothing has been signed out yet. + await expect(dialog.getByRole('button', { name: 'Cancel' })).toBeFocused() + await expect(page).not.toHaveURL(/\/cdn-cgi\/access\/logout/) + await dialog.getByRole('button', { name: 'Sign out', exact: true }).click() + await expect(page).toHaveURL(/\/cdn-cgi\/access\/logout$/) + await expect(page.getByRole('heading', { name: 'You are signed out' })).toBeVisible() + await page.goto('/') + await expect(page).toHaveURL(/\/sim-idp/) +}) + +test('cancelling (button or Escape) keeps the session and puts focus back on the Account button', async ({ page }) => { + await openApp(page, uniqueEmail('alice')) + const account = page.getByRole('button', { name: /^Account menu/ }) + for (const way of ['button', 'escape'] as const) { + await chooseFromMenu(page, 'Account', 'Sign out') + const dialog = page.getByRole('alertdialog', { name: SIGN_OUT_TITLE }) + await expect(dialog).toBeVisible() + // Tab stays inside the dialog. + await page.keyboard.press('Tab') + await expect(dialog.getByRole('button', { name: 'Sign out', exact: true })).toBeFocused() + await page.keyboard.press('Tab') + await expect(dialog.getByRole('button', { name: 'Cancel' })).toBeFocused() + if (way === 'button') await dialog.getByRole('button', { name: 'Cancel' }).click() + else await page.keyboard.press('Escape') + await expect(dialog).toHaveCount(0) + await expect(account).toBeFocused() + await expect(page).not.toHaveURL(/\/cdn-cgi\/access\/logout/) + } + // Still signed in: the app works. + expect((await page.request.get('/api/me')).status()).toBe(200) +}) + +test('an account that needs review is refused with a clear message and a way out', async ({ page, request }) => { + const email = uniqueEmail('dana') + await request.get(`/__test/seed-duplicate-email?email=${encodeURIComponent(email)}`) + await page.goto('/') + await edgeSignIn(page, email) + const dialog = page.getByRole('alertdialog', { name: 'This account cannot use CardCap' }) + await expect(dialog).toBeVisible() + await expect(dialog).toContainText('Account access requires review.') + await expect(dialog.getByRole('link', { name: 'cody@copperstateit.com' })).toBeVisible() + await expect(dialog.getByRole('button', { name: 'Sign out and use another account' })).toBeFocused() + // The way out works even though the Worker refuses this account, and it asks first because it is the shared sign-out. + await dialog.getByRole('button', { name: 'Sign out and use another account' }).click() + const confirm = page.getByRole('alertdialog', { name: SIGN_OUT_TITLE }) + await expect(confirm).toBeVisible() + await expect(confirm).toContainText('Blackboard') + // Staying signed in goes back to the refusal message. + await confirm.getByRole('button', { name: 'Cancel' }).click() + await expect(dialog).toBeVisible() + await dialog.getByRole('button', { name: 'Sign out and use another account' }).click() + await confirm.getByRole('button', { name: 'Sign out', exact: true }).click() + await expect(page).toHaveURL(/\/cdn-cgi\/access\/logout$/) +}) + +async function person(browser: Browser, baseURL: string | undefined, name: string) { + const context = await browser.newContext({ baseURL }) + const page = await context.newPage() + await openApp(page, uniqueEmail(name)) + return { context, page } +} + +test("another signed-in person cannot see, open the image of, or change someone else's contact", async ({ browser, baseURL }) => { + const alice = await person(browser, baseURL, 'alice') + await upload(alice.page) + const imageUrl = (await alice.page.locator('.source-body img').getAttribute('src'))! + const contacts = (await (await alice.page.request.get('/api/contacts')).json()) as { contacts: Array<{ id: string }> } + const id = contacts.contacts[0].id + + const bob = await person(browser, baseURL, 'bob') + await expect(bob.page.getByRole('heading', { name: WELCOME_HEADING })).toBeVisible() + expect((await bob.page.request.get(imageUrl)).status()).toBe(404) + expect((await bob.page.request.get(`/api/contacts/${id}`)).status()).toBe(404) + expect((await bob.page.request.put(`/api/contacts/${id}`, { data: { name: 'Hijacked' } })).status()).toBe(404) + expect((await bob.page.request.delete(`/api/contacts/${id}`)).status()).toBe(404) + + await alice.page.reload() + await openContact(alice.page, 'Maria Okafor') + await expect(alice.page.getByLabel('Name', { exact: true })).toHaveValue('Maria Okafor') + await alice.context.close() + await bob.context.close() +}) + +test.describe('on a phone', () => { + test.use({ viewport: { width: 375, height: 812 }, hasTouch: true }) + + test('signed-in screen fits, shows who is signed in, and every control is at least 44px tall', async ({ page }) => { + const email = uniqueEmail('alice') + await openApp(page, email) + await upload(page) + await expectSignedInAs(page, email) + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(375) + const small = await page.evaluate(() => + [...document.querySelectorAll('button, a.btn, input:not([type=file]), select, textarea, .contact-row')] + .filter((e) => e.getBoundingClientRect().width > 0 && e.getBoundingClientRect().height < 44) + .map((e) => `${e.tagName}:${(e.innerText || e.className).slice(0, 24)}:${Math.round(e.getBoundingClientRect().height)}`), + ) + expect(small).toEqual([]) + }) + + test('the sign-out question fits on a phone with 44px buttons and no sideways scroll', async ({ page }) => { + await openApp(page, uniqueEmail('alice')) + await chooseFromMenu(page, 'Account', 'Sign out') + const dialog = page.getByRole('alertdialog', { name: SIGN_OUT_TITLE }) + await expect(dialog).toBeVisible() + const box = (await dialog.boundingBox())! + expect(box.x).toBeGreaterThanOrEqual(0) + expect(box.x + box.width).toBeLessThanOrEqual(375) + expect(box.y).toBeGreaterThanOrEqual(0) + expect(box.y + box.height).toBeLessThanOrEqual(812) + for (const name of ['Cancel', 'Sign out']) { + const button = (await dialog.getByRole('button', { name, exact: true }).boundingBox())! + expect(button.height, name).toBeGreaterThanOrEqual(44) + } + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(375) + }) +}) diff --git a/e2e/browser/cardcap.spec.ts b/e2e/browser/cardcap.spec.ts index ff07091..aa94f00 100644 --- a/e2e/browser/cardcap.spec.ts +++ b/e2e/browser/cardcap.spec.ts @@ -1,6 +1,7 @@ import { readFileSync } from 'node:fs' import { expect, test, type APIRequestContext, type Browser, type Page } from '@playwright/test' import { PNG_BUFFER, PNG_HEIGHT, PNG_WIDTH } from '../fixtures' +import { chooseFromMenu, openContact, WELCOME_HEADING } from './helpers' // Real Chromium against the built SPA + the real Worker on local Miniflare (see e2e/browser/server.ts). // OpenAI/Resend are intercepted by the server; data is synthetic. @@ -19,7 +20,7 @@ async function signIn(page: Page, request: APIRequestContext, email: string) { const link = await (await request.get(`/__test/login-link?email=${encodeURIComponent(email)}`)).text() await page.goto(link) await expect(page.getByRole('button', { name: 'Add Cards' })).toBeVisible() - await expect(page.getByRole('button', { name: 'Sign out' })).toBeVisible() + await expect(page.getByRole('button', { name: /^Account menu/ })).toBeVisible() } async function uploadCard(page: Page, name = 'maria-card.png') { @@ -30,8 +31,8 @@ async function r2Keys(request: APIRequestContext): Promise { return (await request.get('/__test/r2')).json() } -async function downloadText(page: Page, buttonName: string): Promise<{ text: string; filename: string }> { - const [download] = await Promise.all([page.waitForEvent('download'), page.getByRole('button', { name: buttonName, exact: true }).click()]) +async function downloadText(page: Page, itemName: string): Promise<{ text: string; filename: string }> { + const [download] = await Promise.all([page.waitForEvent('download'), chooseFromMenu(page, 'Export', itemName)]) return { text: readFileSync(await download.path(), 'utf8'), filename: download.suggestedFilename() } } @@ -45,7 +46,7 @@ test('upload → review/edit → save → reload → export keeps exactly the co // Draft from the card, flagged for review. await expect(page.getByLabel('Name', { exact: true })).toHaveValue('Maria Okafor') - await expect(page.getByText('Needs review').first()).toBeVisible() + await expect(page.locator('.review-chip')).toBeVisible() await expect(page.getByLabel('Company', { exact: true })).toHaveValue('Sunrise Realty Group') // Review/edit like a person: fix name and role, set status, and use the explicit Save Contact button first. @@ -54,27 +55,29 @@ test('upload → review/edit → save → reload → export keeps exactly the co await page.locator('.form-grid select').selectOption('Active') await page.getByRole('button', { name: 'Save Contact' }).click() await expect(page.getByText('Saved.', { exact: true })).toBeVisible() - await expect(page.getByText('Needs review').first()).toBeVisible() // saving alone does not clear the review flag + await expect(page.locator('.review-chip')).toBeVisible() // saving alone does not clear the review flag // Reload after Save Contact: values persisted on the server, still flagged for review. await page.reload() + await openContact(page, 'Maria Okafor-Lee') await expect(page.getByLabel('Name', { exact: true })).toHaveValue('Maria Okafor-Lee') await expect(page.getByLabel('Role', { exact: true })).toHaveValue('Managing Broker') await expect(page.locator('.form-grid select')).toHaveValue('Active') - await expect(page.getByText('Needs review').first()).toBeVisible() + await expect(page.locator('.review-chip')).toBeVisible() // Then mark reviewed, which clears the flag. await page.getByRole('button', { name: 'Mark Reviewed' }).click() await expect(page.getByText('Marked reviewed.', { exact: true })).toBeVisible() - await expect(page.getByText('Needs review')).toHaveCount(0) + await expect(page.locator('.review-chip')).toHaveCount(0) // Reload: same values come back from the server, not from client memory; the real image renders. await page.reload() + await openContact(page, 'Maria Okafor-Lee') await expect(page.getByLabel('Name', { exact: true })).toHaveValue('Maria Okafor-Lee') await expect(page.getByLabel('Role', { exact: true })).toHaveValue('Managing Broker') await expect(page.locator('.form-grid select')).toHaveValue('Active') - await expect(page.getByText('Needs review')).toHaveCount(0) - const image = page.locator('img').first() + await expect(page.locator('.review-chip')).toHaveCount(0) + const image = page.locator('.source-body img') await expect(image).toBeVisible() await expect.poll(() => image.evaluate((img: HTMLImageElement) => `${img.complete}:${img.naturalWidth}x${img.naturalHeight}`)).toBe(`true:${PNG_WIDTH}x${PNG_HEIGHT}`) @@ -103,7 +106,7 @@ test('a failed extraction shows a safe message, leaves nothing behind, and a ret await expect(banner).not.toContainText(UPSTREAM_SECRET) await expect(banner).not.toContainText('OpenAI') expect(await r2Keys(request)).toEqual(r2Before) - await expect(page.getByText('No contacts yet.')).toBeVisible() + await expect(page.getByRole('heading', { name: WELCOME_HEADING })).toBeVisible() await request.get('/__test/openai?mode=card') await uploadCard(page) @@ -134,13 +137,13 @@ test("cross-user isolation: another user's browser session cannot see, read the const alice = await newUser(browser, request, 'alice') await uploadCard(alice.page) await expect(alice.page.getByLabel('Name', { exact: true })).toHaveValue('Maria Okafor') - const imageUrl = (await alice.page.locator('img').first().getAttribute('src'))! + const imageUrl = (await alice.page.locator('.source-body img').getAttribute('src'))! const contacts = (await (await alice.page.request.get('/api/contacts')).json()) as { contacts: Array<{ id: string }> } const aliceContactId = contacts.contacts[0].id expect((await alice.page.request.get(imageUrl)).status()).toBe(200) const bob = await newUser(browser, request, 'bob') - await expect(bob.page.getByText('No contacts yet.')).toBeVisible() + await expect(bob.page.getByRole('heading', { name: WELCOME_HEADING })).toBeVisible() await expect(bob.page.getByText('Maria Okafor')).toHaveCount(0) expect((await bob.page.request.get(imageUrl)).status()).toBe(404) expect((await bob.page.request.get(`/api/contacts/${aliceContactId}`)).status()).toBe(404) @@ -151,6 +154,7 @@ test("cross-user isolation: another user's browser session cannot see, read the // Alice is untouched and still sees her image. await alice.page.reload() + await openContact(alice.page, 'Maria Okafor') await expect(alice.page.getByLabel('Name', { exact: true })).toHaveValue('Maria Okafor') expect((await alice.page.request.get(imageUrl)).status()).toBe(200) await alice.context.close() @@ -163,7 +167,7 @@ test('password: set it in the account panel, sign out, then sign in with email a await uploadCard(page) await expect(page.getByLabel('Name', { exact: true })).toHaveValue('Maria Okafor') - await page.getByRole('button', { name: 'Set password' }).click() + await chooseFromMenu(page, 'Account', 'Set password') await page.getByLabel('New password').fill('correct horse battery') await page.getByLabel('Confirm password').fill('different password') await page.getByRole('button', { name: 'Save password' }).click() @@ -173,7 +177,7 @@ test('password: set it in the account panel, sign out, then sign in with email a await page.getByRole('button', { name: 'Save password' }).click() await expect(page.getByText('Password saved.')).toBeVisible() - await page.getByRole('button', { name: 'Sign out' }).click() + await chooseFromMenu(page, 'Account', 'Sign out') await expect(page.getByRole('heading', { name: 'Sign in to CardCap' })).toBeVisible() await page.getByPlaceholder('you@company.com').fill(email) @@ -183,9 +187,11 @@ test('password: set it in the account panel, sign out, then sign in with email a await page.getByLabel('Password', { exact: true }).fill('correct horse battery') await page.getByRole('button', { name: 'Sign in', exact: true }).click() + await openContact(page, 'Maria Okafor') await expect(page.getByLabel('Name', { exact: true })).toHaveValue('Maria Okafor') // The session survives a reload. await page.reload() + await openContact(page, 'Maria Okafor') await expect(page.getByLabel('Name', { exact: true })).toHaveValue('Maria Okafor') }) diff --git a/e2e/browser/helpers.ts b/e2e/browser/helpers.ts new file mode 100644 index 0000000..8cf4425 --- /dev/null +++ b/e2e/browser/helpers.ts @@ -0,0 +1,22 @@ +import { expect, type Page } from '@playwright/test' + +export const WELCOME_HEADING = 'Add your first business cards' + +/** Open the Export or Account menu and press one of its items. */ +export async function chooseFromMenu(page: Page, menu: 'Export' | 'Account', item: string) { + const button = menu === 'Export' ? page.getByRole('button', { name: 'Export', exact: true }) : page.getByRole('button', { name: /^Account menu/ }) + await button.click() + await page.getByRole('menuitem', { name: item, exact: true }).click() +} + +/** Open a contact from the list (nothing is open by default after a page load). */ +export async function openContact(page: Page, name: string) { + await page.locator('.contact-row', { hasText: name }).first().click() + await expect(page.getByLabel('Name', { exact: true })).toBeVisible() +} + +export async function expectSignedInAs(page: Page, email: string) { + await page.getByRole('button', { name: /^Account menu/ }).click() + await expect(page.getByRole('menu')).toContainText(`Signed in as ${email}`) + await page.keyboard.press('Escape') +} diff --git a/e2e/browser/layout.spec.ts b/e2e/browser/layout.spec.ts new file mode 100644 index 0000000..b26e28b --- /dev/null +++ b/e2e/browser/layout.spec.ts @@ -0,0 +1,219 @@ +import { expect, test, type APIRequestContext, type Page } from '@playwright/test' +import { PNG_BUFFER } from '../fixtures' +import { WELCOME_HEADING } from './helpers' + +// Layout around the next action: one welcome state when empty, search + list when there are contacts, +// editor + card photo only when a contact is open, menus for Export and Account, one task at a time on phones. + +let counter = 0 +const uniqueEmail = () => `layout-${Date.now()}-${++counter}@example.test` + +async function signIn(page: Page, request: APIRequestContext) { + const email = uniqueEmail() + await request.get('/__test/openai?mode=card') + await page.goto('/') + await page.getByRole('button', { name: 'Use email link' }).click() + await page.getByPlaceholder('you@company.com').fill(email) + await page.getByRole('button', { name: 'Email me a sign-in link' }).click() + await expect.poll(async () => (await request.get(`/__test/login-link?email=${encodeURIComponent(email)}`)).status()).toBe(200) + await page.goto(await (await request.get(`/__test/login-link?email=${encodeURIComponent(email)}`)).text()) + await expect(page.getByRole('button', { name: /^Account menu/ })).toBeVisible() +} + +const csv = [ + 'name,company,role,email,phones,website,address,tags,status,next_step,notes', + 'Ada Lovelace,Analytical Engines,Founder,ada@engines.example,,,,,New,,', + 'Zed Zimmer,Zenith Title,Agent,zed@zenith.example,,,,,Follow up,,', + 'Bea Brandt,Bridge Loans,Broker,bea@bridge.example,,,,,Active,,', +].join('\n') + +async function importContacts(page: Page) { + await page.locator('input[type=file][accept*=".csv"]').setInputFiles({ name: 'people.csv', mimeType: 'text/csv', buffer: Buffer.from(csv) }) + await expect(page.locator('.contact-row')).toHaveCount(3) +} + +async function uploadCard(page: Page) { + await page.locator('input[type=file][accept="image/*"]').setInputFiles({ name: 'maria.png', mimeType: 'image/png', buffer: PNG_BUFFER }) + await expect(page.getByLabel('Name', { exact: true })).toHaveValue('Maria Okafor') + await expect(page.getByRole('status')).toContainText('Cards added') +} + +test('with no contacts there is one welcoming state and nothing else competing with it', async ({ page, request }) => { + await signIn(page, request) + await expect(page.getByRole('heading', { name: WELCOME_HEADING })).toBeVisible() + await expect(page.getByRole('heading')).toHaveCount(2) // the app name and the welcome + // No second empty message, no list tools, no editor, no photo panel. + await expect(page.getByText('Ready for your first batch')).toHaveCount(0) + await expect(page.getByText('No contacts yet')).toHaveCount(0) + await expect(page.getByRole('searchbox')).toHaveCount(0) + await expect(page.locator('.contact-row')).toHaveCount(0) + await expect(page.getByLabel('Name', { exact: true })).toHaveCount(0) + await expect(page.getByRole('complementary', { name: 'Card photo' })).toHaveCount(0) + // The next action is obvious: Add cards is the one primary button; Import is secondary. + await expect(page.getByRole('banner').getByRole('button', { name: 'Add cards' })).toHaveClass(/primary/) + await expect(page.getByRole('banner').getByRole('button', { name: 'Import' })).not.toHaveClass(/primary/) + // Only one primary button on the whole screen: the header's Add cards. The welcome offers just the secondary Import. + await expect(page.locator('.btn.primary')).toHaveCount(1) + await expect(page.getByRole('button', { name: 'Add cards' })).toHaveCount(1) + await expect(page.locator('.welcome-actions .btn')).toHaveText(['Import a file']) + await expect(page.getByRole('button', { name: 'Import a file' })).not.toHaveClass(/primary/) +}) + +test('export formats live in one Export menu and sign out lives in the Account menu', async ({ page, request }) => { + await signIn(page, request) + const header = page.getByRole('banner') + // No loose export or sign-out buttons in the header. + for (const name of ['CSV', 'vCard', 'JSON', 'HTML', 'iContact CSV', 'Sign out', 'Set password']) { + await expect(header.getByRole('button', { name, exact: true })).toHaveCount(0) + } + const exportButton = header.getByRole('button', { name: 'Export', exact: true }) + await expect(exportButton).toHaveAttribute('aria-expanded', 'false') + await exportButton.click() + await expect(exportButton).toHaveAttribute('aria-expanded', 'true') + await expect(page.getByRole('menuitem')).toHaveText(['CSV', 'vCard', 'JSON', 'HTML', 'iContact CSV']) + // Keyboard: the first item is focused, arrows move, Escape closes and puts focus back on the button. + await expect(page.getByRole('menuitem', { name: 'CSV', exact: true })).toBeFocused() + await page.keyboard.press('ArrowDown') + await expect(page.getByRole('menuitem', { name: 'vCard', exact: true })).toBeFocused() + await page.keyboard.press('End') + await expect(page.getByRole('menuitem', { name: 'iContact CSV', exact: true })).toBeFocused() + await page.keyboard.press('Escape') + await expect(page.getByRole('menu')).toHaveCount(0) + await expect(exportButton).toBeFocused() + // Clicking elsewhere closes it too. + await exportButton.click() + await page.getByRole('heading', { name: WELCOME_HEADING }).click() + await expect(page.getByRole('menu')).toHaveCount(0) + + const account = header.getByRole('button', { name: /^Account menu/ }) + await account.click() + await expect(page.getByRole('menu')).toContainText('Signed in as layout-') + await expect(page.getByRole('menuitem')).toHaveText(['Set password', 'Sign out']) +}) + +test('once there are contacts: search and list first; the editor and card photo open only when one is selected', async ({ page, request }) => { + await signIn(page, request) + await importContacts(page) + await page.reload() + + // List state. + await expect(page.getByRole('heading', { name: WELCOME_HEADING })).toHaveCount(0) + await expect(page.getByRole('searchbox')).toBeVisible() + await expect(page.locator('.contact-row')).toHaveCount(3) + await expect(page.getByLabel('Name', { exact: true })).toHaveCount(0) + await expect(page.getByRole('complementary', { name: 'Card photo' })).toHaveCount(0) + + // Search narrows the list. + await page.getByRole('searchbox').fill('zenith') + await expect(page.locator('.contact-row')).toHaveCount(1) + await expect(page.locator('.contact-row')).toContainText('Zed Zimmer') + await page.getByRole('searchbox').fill('no such person') + await expect(page.getByText('No contacts match.')).toBeVisible() + await page.getByRole('button', { name: 'Clear search' }).click() + await expect(page.locator('.contact-row')).toHaveCount(3) + + // Editing state: pick one. + await page.locator('.contact-row', { hasText: 'Zed Zimmer' }).click() + await expect(page.getByLabel('Name', { exact: true })).toHaveValue('Zed Zimmer') + await expect(page.getByRole('complementary', { name: 'Card photo' })).toBeVisible() + await expect(page.locator('.contact-row.active')).toContainText('Zed Zimmer') + // The list stays next to the editor on a wide screen. + await expect(page.getByRole('searchbox')).toBeVisible() +}) + +test('after adding a card the new contact opens for review with its photo', async ({ page, request }) => { + await signIn(page, request) + await uploadCard(page) + await expect(page.getByRole('complementary', { name: 'Card photo' })).toBeVisible() + await expect(page.locator('.source-body img')).toBeVisible() + await expect(page.locator('.review-chip')).toBeVisible() +}) + +test('controls are compact with a mouse', async ({ page, request }) => { + await signIn(page, request) + await importContacts(page) + const heights = await page.evaluate(() => + [...document.querySelectorAll('.toolbar .btn, .filters input, .filters select')].map((e) => Math.round(e.getBoundingClientRect().height)), + ) + expect(heights.length).toBeGreaterThan(3) + for (const height of heights) { + expect(height).toBeGreaterThanOrEqual(40) + expect(height).toBeLessThanOrEqual(41) + } +}) + +test.describe('on a phone: one task at a time', () => { + test.use({ viewport: { width: 375, height: 812 }, hasTouch: true }) + + test('welcome, then list, then one contact at a time with details and photo on separate screens', async ({ page, request }) => { + await signIn(page, request) + // Empty: only the welcome. + await expect(page.getByRole('heading', { name: WELCOME_HEADING })).toBeVisible() + await expect(page.getByRole('searchbox')).toHaveCount(0) + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(375) + + await importContacts(page) + await page.reload() + // List: only the list. + await expect(page.getByRole('searchbox')).toBeVisible() + await expect(page.getByLabel('Name', { exact: true })).toHaveCount(0) + + // Open one: only the editor (the list and photo are not on screen). + await page.locator('.contact-row', { hasText: 'Ada Lovelace' }).click() + await expect(page.getByLabel('Name', { exact: true })).toBeVisible() + await expect(page.getByRole('searchbox')).toBeHidden() + await expect(page.getByRole('complementary', { name: 'Card photo' })).toBeHidden() + + // Photo screen replaces the form. + await page.getByRole('button', { name: 'Card photo', exact: true }).click() + await expect(page.getByRole('complementary', { name: 'Card photo' })).toBeVisible() + await expect(page.getByLabel('Name', { exact: true })).toBeHidden() + await page.getByRole('button', { name: 'Details', exact: true }).click() + await expect(page.getByLabel('Name', { exact: true })).toBeVisible() + + // Back to the list. + await page.getByRole('button', { name: 'All contacts', exact: true }).click() + await expect(page.getByRole('searchbox')).toBeVisible() + await expect(page.getByLabel('Name', { exact: true })).toHaveCount(0) + }) + + test('touch targets are 44px and nothing scrolls sideways in each state', async ({ page, request }) => { + await signIn(page, request) + const small = () => + page.evaluate(() => + [...document.querySelectorAll('button, a.btn, .quick-links a, input:not([type=file]), select, textarea, .contact-row, [role=menuitem]')] + .filter((e) => e.getBoundingClientRect().width > 0 && e.getBoundingClientRect().height > 0 && e.getBoundingClientRect().height < 44) + .map((e) => `${e.tagName}:${(e.innerText || e.className).slice(0, 24)}:${Math.round(e.getBoundingClientRect().height)}`), + ) + expect(await small()).toEqual([]) + await importContacts(page) + await page.reload() + expect(await small()).toEqual([]) + await page.locator('.contact-row', { hasText: 'Ada Lovelace' }).click() + expect(await small()).toEqual([]) + await page.getByRole('banner').getByRole('button', { name: 'Export', exact: true }).click() + expect(await small()).toEqual([]) + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(375) + }) +}) + +test('fonts are served from this site: no request goes to a font provider and the Atkinson faces load', async ({ page, request }) => { + const hosts = new Set() + const fontFiles: Array<{ url: string; status: number }> = [] + page.on('request', (req) => hosts.add(new URL(req.url()).host)) + page.on('response', (res) => { + if (res.url().endsWith('.woff2')) fontFiles.push({ url: new URL(res.url()).pathname, status: res.status() }) + }) + await signIn(page, request) + await page.evaluate(() => document.fonts.ready) + expect([...hosts]).toEqual(['127.0.0.1:5199']) + expect(await page.evaluate(() => document.fonts.check('16px "Atkinson Hyperlegible Next"'))).toBe(true) + expect(await page.evaluate(() => document.fonts.check('12px "Atkinson Hyperlegible Mono"'))).toBe(true) + expect(fontFiles.length).toBeGreaterThan(0) + for (const file of fontFiles) { + expect(file.url).toMatch(/^\/fonts\/atkinson-.*\.woff2$/) + expect(file.status).toBe(200) + } + // The renderer really uses it (not the system fallback). + expect(await page.evaluate(() => getComputedStyle(document.body).fontFamily)).toContain('Atkinson Hyperlegible Next') +}) diff --git a/e2e/browser/server.ts b/e2e/browser/server.ts index b6d2405..6aeba98 100644 --- a/e2e/browser/server.ts +++ b/e2e/browser/server.ts @@ -1,5 +1,8 @@ // Serves the built SPA (dist/client) plus the real Worker on local Miniflare for browser tests. // /__test/* are test-only controls; they are not part of the app. +// With ACCESS_SIM=1 the Worker runs in AUTH_MODE=access behind a SIMULATED Cloudflare Access edge: nothing is served without +// a simulated session cookie, unauthenticated requests (pages and API calls alike) are redirected to a fake sign-in page, +// and signed-in requests reach the Worker with a signed Access JWT. This imitates the edge; it is not live Access. import { existsSync, readFileSync } from 'node:fs' import { createServer } from 'node:http' import { dirname, extname, join, resolve } from 'node:path' @@ -8,10 +11,17 @@ import { startHarness } from '../harness' const port = Number(process.env.PORT || 5199) const dist = resolve(process.env.CARDCAP_DIST || join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'dist', 'client')) -const types: Record = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png' } +const accessSim = process.env.ACCESS_SIM === '1' +const types: Record = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.woff2': 'font/woff2', '.txt': 'text/plain' } async function main() { - const h = await startHarness() + const h = await startHarness({ access: accessSim }) + let epoch = 1 // bumping this ends every simulated session at once + const simCookie = (req: { headers: { cookie?: string } }) => /(?:^|;\s*)sim_access=([^;]+)/.exec(req.headers.cookie || '')?.[1] + const simEmail = (req: { headers: { cookie?: string } }) => { + const [email, seen] = decodeURIComponent(simCookie(req) || '').split('|') + return email && Number(seen) === epoch ? email : '' + } h.openai.mode = { kind: 'card', card: { @@ -24,6 +34,34 @@ async function main() { createServer(async (req, res) => { try { const url = new URL(req.url || '/', `http://127.0.0.1:${port}`) + if (accessSim && !url.pathname.startsWith('/__test/')) { + if (url.pathname === '/sim-idp') { + if (req.method === 'POST') { + const form = new URLSearchParams(Buffer.concat(await collect(req)).toString()) + const email = (form.get('email') || '').trim() + const back = form.get('return') || '/' + res.writeHead(302, { location: back.startsWith('/') && !back.startsWith('//') ? back : '/', 'set-cookie': `sim_access=${encodeURIComponent(`${email}|${epoch}`)}; Path=/; HttpOnly` }).end() + return + } + const back = url.searchParams.get('return') || '/' + res + .writeHead(200, { 'content-type': 'text/html' }) + .end(`Simulated sign-in

Simulated Access sign-in

`) + return + } + if (url.pathname === '/cdn-cgi/access/logout') { + res.writeHead(200, { 'content-type': 'text/html', 'set-cookie': 'sim_access=; Path=/; Max-Age=0' }).end('Signed out

You are signed out

') + return + } + if (url.pathname === '/cdn-cgi/access/login') { + res.writeHead(302, { location: `/sim-idp?return=${encodeURIComponent(url.searchParams.get('redirect_url') || '/')}` }).end() + return + } + if (!simEmail(req)) { + res.writeHead(302, { location: `/sim-idp?return=${encodeURIComponent(url.pathname + url.search)}` }).end() + return + } + } if (url.pathname === '/__test/login-link') { const link = h.lastLoginLink(url.searchParams.get('email') || '') res.writeHead(link ? 200 : 404, { 'content-type': 'text/plain' }).end(link ? new URL(link).pathname + new URL(link).search : '') @@ -37,6 +75,20 @@ async function main() { res.writeHead(200).end('ok') return } + if (url.pathname === '/__test/seed-duplicate-email') { + const email = url.searchParams.get('email') || '' + const db = await h.mf.getD1Database('DB') + for (const id of ['dup-a', 'dup-b']) { + await db.prepare("INSERT OR IGNORE INTO users (id, email, name, created_at, updated_at) VALUES (?, ?, ?, 't', 't')").bind(`${id}-${email}`, email, email).run() + } + res.writeHead(200).end('ok') + return + } + if (url.pathname === '/__test/access-expire') { + epoch += 1 + res.writeHead(200).end('ok') + return + } if (url.pathname === '/__test/r2') { res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(await h.r2Keys())) return @@ -47,9 +99,16 @@ async function main() { const headers = new Headers() for (const [k, v] of Object.entries(req.headers)) if (v && k !== 'host' && k !== 'connection') headers.set(k, Array.isArray(v) ? v.join(', ') : v) const body = chunks.length ? Buffer.concat(chunks) : undefined - const out = (await h.mf.dispatchFetch(`https://cardcap.test${url.pathname}${url.search}`, { - method: req.method, headers, body: req.method === 'GET' || req.method === 'HEAD' ? undefined : body, redirect: 'manual', - } as never)) as unknown as Response + const hasBody = req.method !== 'GET' && req.method !== 'HEAD' + const out = accessSim + ? await h.access.raw( + `${url.pathname}${url.search}`, + { method: req.method, headers: { ...Object.fromEntries(headers), 'Cf-Access-Jwt-Assertion': h.access.token(simEmail(req)) }, body: hasBody ? body : undefined } as never, + 'cardcap.test', + ) + : ((await h.mf.dispatchFetch(`https://cardcap.test${url.pathname}${url.search}`, { + method: req.method, headers, body: hasBody ? body : undefined, redirect: 'manual', + } as never)) as unknown as Response) const outHeaders: Record = {} out.headers.forEach((v, k) => { if (k !== 'set-cookie') outHeaders[k] = v }) const cookies = (out.headers as unknown as { getSetCookie?: () => string[] }).getSetCookie?.() || [] @@ -68,3 +127,9 @@ async function main() { } void main() + +async function collect(req: AsyncIterable): Promise { + const chunks: Buffer[] = [] + for await (const chunk of req) chunks.push(Buffer.from(chunk)) + return chunks +} diff --git a/e2e/browser/session-states.spec.ts b/e2e/browser/session-states.spec.ts new file mode 100644 index 0000000..d69a5c3 --- /dev/null +++ b/e2e/browser/session-states.spec.ts @@ -0,0 +1,162 @@ +import { expect, test, type APIRequestContext, type Page } from '@playwright/test' +import { PNG_BUFFER } from '../fixtures' +import { chooseFromMenu } from './helpers' + +// What a person sees when their sign-in ends, is refused, or the network drops. +// These are SIMULATED: the server's answers are replaced with the responses Cloudflare Access and the Worker are +// expected to send (401 + loginUrl, an edge login redirect, 403, a dead connection). They do not prove live Access behavior. + +let counter = 0 +const uniqueEmail = () => `states-${Date.now()}-${++counter}@example.test` + +async function signIn(page: Page, request: APIRequestContext, email: string) { + await page.goto('/') + await page.getByRole('button', { name: 'Use email link' }).click() + await page.getByPlaceholder('you@company.com').fill(email) + await page.getByRole('button', { name: 'Email me a sign-in link' }).click() + await expect.poll(async () => (await request.get(`/__test/login-link?email=${encodeURIComponent(email)}`)).status()).toBe(200) + await page.goto(await (await request.get(`/__test/login-link?email=${encodeURIComponent(email)}`)).text()) + await expect(page.getByRole('button', { name: /^Account menu/ })).toBeVisible() +} + +async function withContact(page: Page, request: APIRequestContext) { + await request.get('/__test/openai?mode=card') + await signIn(page, request, uniqueEmail()) + await page.locator('input[type=file][accept="image/*"]').setInputFiles({ name: 'maria.png', mimeType: 'image/png', buffer: PNG_BUFFER }) + await expect(page.getByLabel('Name', { exact: true })).toHaveValue('Maria Okafor') + // The upload has fully finished (list reloaded) before the test changes the session underneath it. + await expect(page.getByRole('status')).toContainText('Cards added') +} + +const ended = { + status: 401, + contentType: 'application/json', + body: JSON.stringify({ error: 'Sign in to continue.', loginUrl: '/cdn-cgi/access/login' }), +} + +test('expired session (401 + loginUrl): clear dialog, keeps unsaved edits, restores them after signing in again', async ({ page, request }) => { + await withContact(page, request) + await page.getByLabel('Name', { exact: true }).fill('Maria Okafor-Lee') + + // The session ends: every save now gets the Access-mode 401. + await page.route('**/api/contacts/*', (route) => route.fulfill(ended)) + await page.getByRole('button', { name: 'Save Contact' }).click() + + const dialog = page.getByRole('alertdialog', { name: 'Your session ended' }) + await expect(dialog).toBeVisible() + await expect(dialog).toContainText('Your contacts are saved') + await expect(dialog).toContainText('We kept the edits you had not saved') + await expect(dialog.getByRole('button', { name: 'Sign in again' })).toBeFocused() + // The page behind is not reachable while the dialog is open. + await expect(page.locator('.app-content')).toHaveAttribute('inert', '') + // Tab stays inside the dialog. + await page.keyboard.press('Tab') + await expect(dialog.getByRole('button', { name: 'Sign out' })).toBeFocused() + await page.keyboard.press('Tab') + await expect(dialog.getByRole('button', { name: 'Sign in again' })).toBeFocused() + + // Signing in again (here: the session works again, then the page reloads) brings the edits back, unsaved. + await page.unroute('**/api/contacts/*') + await dialog.getByRole('button', { name: 'Sign in again' }).click() + await expect(page.getByLabel('Name', { exact: true })).toHaveValue('Maria Okafor-Lee') + await expect(page.getByRole('status')).toContainText('We restored the edits you had not saved') + // Nothing was saved behind the person's back. + const saved = (await (await page.request.get('/api/contacts')).json()) as { contacts: Array<{ name: string }> } + expect(saved.contacts.map((c) => c.name)).toEqual(['Maria Okafor']) + await page.getByRole('button', { name: 'Save Contact' }).click() + await expect(page.getByText('Saved.', { exact: true })).toBeVisible() +}) + +test('an edge login redirect instead of JSON is treated as an ended session, not a broken page', async ({ page, request }) => { + await withContact(page, request) + await page.route('**/api/contacts/*', (route) => + route.fulfill({ status: 302, headers: { location: 'https://csi-test.cloudflareaccess.com/cdn-cgi/access/login/cardcap.test?kid=x' }, body: '' }), + ) + await page.getByRole('button', { name: 'Mark Reviewed' }).click() + await expect(page.getByRole('alertdialog', { name: 'Your session ended' })).toBeVisible() + await expect(page.locator('.banner.error')).toHaveCount(0) +}) + +test('an account that is refused (403) sees who to contact and can sign out', async ({ page, request }) => { + await withContact(page, request) + await page.route('**/api/contacts/*', (route) => + route.fulfill({ status: 403, contentType: 'application/json', body: JSON.stringify({ error: 'This account is not allowed to use CardCap.' }) }), + ) + await page.getByRole('button', { name: 'Save Contact' }).click() + const dialog = page.getByRole('alertdialog', { name: 'This account cannot use CardCap' }) + await expect(dialog).toBeVisible() + await expect(dialog).toContainText('This account is not allowed to use CardCap.') + await expect(dialog.getByRole('link', { name: 'cody@copperstateit.com' })).toHaveAttribute('href', 'mailto:cody@copperstateit.com') + await expect(dialog.getByRole('button', { name: 'Sign out and use another account' })).toBeFocused() +}) + +test('a dead connection says so, is not mistaken for a sign-out, and works again after retrying', async ({ page, request }) => { + await withContact(page, request) + await page.route('**/api/contacts/*', (route) => route.abort('connectionfailed')) + await page.getByRole('button', { name: 'Save Contact' }).click() + await expect(page.locator('.banner.error')).toContainText('reach CardCap') + await expect(page.getByRole('alertdialog')).toHaveCount(0) + await page.unroute('**/api/contacts/*') + await page.getByRole('button', { name: 'Save Contact' }).click() + await expect(page.getByText('Saved.', { exact: true })).toBeVisible() +}) + +test('sign out goes to the Access logout address the server returns', async ({ page, request }) => { + await withContact(page, request) + await page.route('**/api/auth/logout', (route) => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, logoutUrl: '/cdn-cgi/access/logout' }) }), + ) + await page.route('**/cdn-cgi/access/logout', (route) => route.fulfill({ status: 200, contentType: 'text/html', body: '

edge logout reached

' })) + await chooseFromMenu(page, 'Account', 'Sign out') + // The current sign-in only ends this app's session, so there is no shared-sign-out question here. + await expect(page.getByRole('alertdialog')).toHaveCount(0) + await expect(page).toHaveURL(/\/cdn-cgi\/access\/logout$/) + await expect(page.getByRole('heading', { name: 'edge logout reached' })).toBeVisible() +}) + +test('a logout address that is not a same-site Access path is never followed', async ({ page, request }) => { + await withContact(page, request) + await page.route('**/api/auth/logout', (route) => + // Like the real logout, this also clears the session cookie, so the sign-in screen is not skipped by an old session. + route.fulfill({ + status: 200, + contentType: 'application/json', + headers: { 'set-cookie': 'cardcap_session=; Path=/; Max-Age=0' }, + body: JSON.stringify({ ok: true, logoutUrl: 'https://evil.example/logout' }), + }), + ) + await chooseFromMenu(page, 'Account', 'Sign out') + await expect(page).toHaveURL('http://127.0.0.1:5199/') + await expect(page.getByRole('heading', { name: 'Sign in to CardCap' })).toBeVisible() +}) + +test.describe('on a phone', () => { + test.use({ viewport: { width: 375, height: 812 }, hasTouch: true }) + + test('the session dialog fits, has 44px buttons, and the page does not scroll sideways', async ({ page, request }) => { + await withContact(page, request) + await page.route('**/api/contacts/*', (route) => route.fulfill(ended)) + await page.getByRole('button', { name: 'Save Contact' }).click() + const dialog = page.getByRole('alertdialog', { name: 'Your session ended' }) + await expect(dialog).toBeVisible() + const box = (await dialog.boundingBox())! + expect(box.x).toBeGreaterThanOrEqual(0) + expect(box.x + box.width).toBeLessThanOrEqual(375) + for (const name of ['Sign in again', 'Sign out']) { + const button = (await dialog.getByRole('button', { name }).boundingBox())! + expect(button.height, name).toBeGreaterThanOrEqual(44) + } + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(375) + }) + + test('the main screen has no sideways scroll and every control is at least 44px tall', async ({ page, request }) => { + await withContact(page, request) + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(375) + const small = await page.evaluate(() => + [...document.querySelectorAll('button, a.btn, input:not([type=file]), select, textarea, .contact-row')] + .filter((e) => e.getBoundingClientRect().width > 0 && e.getBoundingClientRect().height < 44) + .map((e) => `${e.tagName}:${(e.innerText || e.className).slice(0, 24)}:${Math.round(e.getBoundingClientRect().height)}`), + ) + expect(small).toEqual([]) + }) +}) diff --git a/e2e/harness.ts b/e2e/harness.ts index 9cd8536..e0080b5 100644 --- a/e2e/harness.ts +++ b/e2e/harness.ts @@ -1,8 +1,10 @@ -import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { request as httpRequest } from 'node:http' +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { fileURLToPath } from 'node:url' import { dirname, join, resolve } from 'node:path' import { build } from 'esbuild' +import { ACCESS_AUDIENCE, ACCESS_HOSTS, ACCESS_ISSUER, JWKS_URL, defaultClaims, makeAccessKey, mintToken, type AccessKey, type MintOptions, type TokenClaims } from './access' import { PNG_BYTES } from './fixtures' import { FormData as MfFormData, Miniflare, Response as MfResponse } from 'miniflare' @@ -22,7 +24,25 @@ export type OpenAiMode = export type TraceEntry = { at: string; kind: 'request' | 'outbound'; method: string; url: string; status?: number; body?: string; note?: string } +export type HarnessOptions = { + /** Turn on the Cloudflare Access simulator (AUTH_MODE=access with a test issuer, audience, hosts and JWKS). */ + access?: boolean +} + +export type AccessSim = { + keys: AccessKey[] + jwksFetches: number + /** Replace the published key set (e.g. to simulate rotation or an unknown kid). */ + publishKeys(keys: AccessKey[]): void + token(email: string, overrides?: { claims?: TokenClaims; mint?: Partial; sub?: string }): string + /** Request as an Access-authenticated browser: signed JWT in both the header and the CF_Authorization cookie. */ + session(email: string, opts?: { sub?: string; host?: string }): Session + /** Raw request with explicit credentials, for negative tests. */ + raw(path: string, init?: RequestInit & { headers?: Record }, host?: string): Promise +} + export type Harness = { + access: AccessSim mf: Miniflare openai: { mode: OpenAiMode; calls: number } trace: TraceEntry[] @@ -41,7 +61,7 @@ export type Session = { export { PNG_BYTES } -export async function startHarness(): Promise { +export async function startHarness(options: HarnessOptions = {}): Promise { const root = process.env.CARDCAP_SRC ? resolve(process.env.CARDCAP_SRC) : resolve(here, '..') const outDir = mkdtempSync(join(tmpdir(), 'cardcap-e2e-')) const bundle = join(outDir, 'worker.mjs') @@ -60,6 +80,8 @@ export async function startHarness(): Promise { const trace: TraceEntry[] = [] const openai = { mode: { kind: 'card', card: {} } as OpenAiMode, calls: 0 } const sentEmails: Array<{ to: string; text: string }> = [] + const accessKey = makeAccessKey('test-key-1') + const access = { keys: [accessKey], published: [accessKey], jwksFetches: 0 } const mf = new Miniflare({ modules: true, @@ -76,6 +98,9 @@ export async function startHarness(): Promise { OPENAI_MODEL: 'test-model', RESEND_API_KEY: 're_test_not_real', SENDER_EMAIL: 'test@example.invalid', + ...(options.access + ? { AUTH_MODE: 'access', ACCESS_ISSUER, ACCESS_AUDIENCE, ACCESS_ALLOWED_HOSTS: ACCESS_HOSTS.join(',') } + : {}), }, outboundService: async (request: Request) => { const url = new URL(request.url) @@ -86,6 +111,11 @@ export async function startHarness(): Promise { trace.push({ at: new Date().toISOString(), kind: 'outbound', method: request.method, url: request.url, status: 200, note: 'resend intercepted' }) return MfResponse.json({ id: 'email-test' }) } + if (request.url === JWKS_URL) { + access.jwksFetches += 1 + trace.push({ at: new Date().toISOString(), kind: 'outbound', method: request.method, url: request.url, status: 200, note: 'access jwks intercepted' }) + return MfResponse.json({ keys: access.published.map((k) => k.jwk) }) + } if (url.hostname === OPENAI_HOST) { openai.calls += 1 const mode = openai.mode @@ -98,14 +128,17 @@ export async function startHarness(): Promise { return new MfResponse('blocked by e2e harness', { status: 599 }) }, }) - await mf.ready + const runtimeUrl = await mf.ready + await applyMigrations(mf, root) const origin = 'https://cardcap.test' - async function send(cookie: string, path: string, init: RequestInit & { headers?: Record } = {}): Promise { + async function send(cookie: string, path: string, init: RequestInit & { headers?: Record } = {}, host?: string): Promise { const headers = new Headers(init.headers) if (cookie) headers.set('cookie', cookie) - const res = (await mf.dispatchFetch(`${origin}${path}`, { ...init, headers, redirect: 'manual' } as never)) as unknown as Response + const res = options.access + ? await rawHttp(runtimeUrl, path, init, headers, host || 'cardcap.test') + : ((await mf.dispatchFetch(`${host ? `https://${host}` : origin}${path}`, { ...init, headers, redirect: 'manual' } as never)) as unknown as Response) let snippet = '' const type = res.headers.get('content-type') || '' if (/json|text/.test(type)) snippet = (await res.clone().text()).slice(0, 2000) @@ -113,7 +146,29 @@ export async function startHarness(): Promise { return res } + const accessSim: AccessSim = { + get keys() { + return access.keys + }, + get jwksFetches() { + return access.jwksFetches + }, + publishKeys(keys) { + access.published = keys + }, + token(email, overrides = {}) { + return mintToken({ key: accessKey, ...overrides.mint, claims: { ...defaultClaims(email, overrides.sub), ...overrides.claims } }) + }, + session(email, opts = {}) { + const token = accessSim.token(email, { sub: opts.sub }) + const cookie = `CF_Authorization=${token}` + return { email, cookie, request: (path, init) => send(cookie, path, { ...init, headers: { 'Cf-Access-Jwt-Assertion': token, ...init?.headers } }, opts.host) } + }, + raw: (path, init, host) => send('', path, init, host), + } + return { + access: accessSim, mf, openai, trace, @@ -164,3 +219,63 @@ export function uploadForm(fileName: string, type = 'image/png', bytes: Uint8Arr export function emptyForm(): FormData { return new MfFormData() as unknown as FormData } + +// Build the test database the way production is built: apply migrations/*.sql in order (not the Worker's runtime ensureSchema). +// D1 enforces the foreign keys these migrations declare, so ordering bugs surface here like they would in production. +async function applyMigrations(mf: Miniflare, root: string): Promise { + const dir = join(root, 'migrations') + const db = await mf.getD1Database('DB') + const files = readdirSync(dir).filter((f) => /^\d+.*\.sql$/.test(f)).sort() + if (files.length === 0) throw new Error(`no migrations found in ${dir}`) + for (const file of files) { + const sql = readFileSync(join(dir, file), 'utf8').replace(/--.*$/gm, '') + const statements = sql.split(/;\s*(?:\r?\n|$)/).map((stmt) => stmt.trim()).filter(Boolean) + await db.batch(statements.map((stmt) => db.prepare(stmt))) + } +} + +// Miniflare's dispatchFetch keeps the requested URL but sends its own local address as the Host header, which a Worker +// that (rightly) compares the two would refuse. For Access tests we talk to the runtime over plain HTTP instead, with the +// Host header we choose, exactly as the Cloudflare edge would deliver it. An absolute request target lets a test send a +// Host header that disagrees with the URL. +async function rawHttp(runtimeUrl: URL, path: string, init: RequestInit, headers: Headers, host: string): Promise { + let body: Buffer | undefined + if (init.body instanceof MfFormData) { + const encoded = new MfResponse(init.body as never) + headers.set('content-type', encoded.headers.get('content-type') || '') + body = Buffer.from(await encoded.arrayBuffer()) + } else if (typeof init.body === 'string') { + body = Buffer.from(init.body) + } else if (init.body instanceof Uint8Array) { + body = Buffer.from(init.body) + } + headers.set('host', host) + headers.set('connection', 'close') + if (body) headers.set('content-length', String(body.length)) + const outgoing: Record = {} + headers.forEach((value, key) => { + outgoing[key] = value + }) + return new Promise((resolvePromise, reject) => { + const req = httpRequest( + { hostname: runtimeUrl.hostname, port: runtimeUrl.port, path, method: init.method || 'GET', headers: outgoing, agent: false }, + (res) => { + const chunks: Buffer[] = [] + res.on('data', (chunk: Buffer) => chunks.push(chunk)) + res.on('end', () => { + const responseHeaders = new Headers() + for (const [key, value] of Object.entries(res.headers)) { + if (Array.isArray(value)) value.forEach((v) => responseHeaders.append(key, v)) + else if (value !== undefined) responseHeaders.set(key, value) + } + const status = res.statusCode || 0 + const nullBody = status === 204 || status === 304 + resolvePromise(new Response(nullBody ? null : Buffer.concat(chunks), { status, headers: responseHeaders })) + }) + }, + ) + req.on('error', reject) + if (body) req.write(body) + req.end() + }) +} diff --git a/e2e/legacy-ambiguity.e2e.test.ts b/e2e/legacy-ambiguity.e2e.test.ts new file mode 100644 index 0000000..4cbc77d --- /dev/null +++ b/e2e/legacy-ambiguity.e2e.test.ts @@ -0,0 +1,85 @@ +import { afterEach, beforeEach, expect, it, onTestFailed } from 'vitest' +import { saveTrace, startHarness, type Harness } from './harness' + +// Legacy email-link sign-in must fail closed when two accounts share one normalized email, and must keep +// reusing an existing account (whatever its id) instead of creating a parallel one. + +let h: Harness +beforeEach(async () => { + h = await startHarness() +}) +afterEach(async () => { + await h.dispose() +}) + +async function insertUser(id: string, email: string, contact?: string) { + const db = await h.mf.getD1Database('DB') + await db.prepare("INSERT INTO users (id, email, name, created_at, updated_at) VALUES (?, ?, ?, 't', 't')").bind(id, email, email).run() + if (contact) { + await db + .prepare("INSERT INTO contacts (id, user_id, name, created_at, updated_at) VALUES (?, ?, ?, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')") + .bind(`c-${id}`, id, contact) + .run() + } + return db +} + +it('two accounts with the same normalized email: the email link is refused and neither account is exposed', async () => { + onTestFailed((r) => saveTrace('legacy-ambiguity-refused', h.trace, r.errors?.[0]?.message)) + await insertUser('dup-1', 'dana@example.test', 'Dana One') + await insertUser('dup-2', ' Dana@Example.test ', 'Dana Two') + await expect(h.signIn('dana@example.test')).rejects.toThrow(/verify failed: 410/) +}) + +it('an existing account with a non-canonical id is reused by the email link, without creating another', async () => { + onTestFailed((r) => saveTrace('legacy-reuse-noncanonical', h.trace, r.errors?.[0]?.message)) + const db = await insertUser('legacy-42', 'bob@example.test', 'Bob Legacy Contact') + const bob = await h.signIn('bob@example.test') + const list = (await (await bob.request('/api/contacts')).json()) as { contacts: Array<{ name: string }> } + expect(list.contacts.map((c) => c.name)).toEqual(['Bob Legacy Contact']) + expect(((await (await bob.request('/api/me')).json()) as { id: string }).id).toBe('legacy-42') + const count = (await db.prepare("SELECT COUNT(*) AS n FROM users WHERE lower(trim(email)) = 'bob@example.test'").first<{ n: number }>())!.n + expect(count).toBe(1) +}) + +it('a brand-new person still gets the canonical email:
id', async () => { + onTestFailed((r) => saveTrace('legacy-new-canonical', h.trace, r.errors?.[0]?.message)) + const carol = await h.signIn('Carol@Example.Test') + expect(((await (await carol.request('/api/me')).json()) as { id: string }).id).toBe('email:carol@example.test') +}) + +it('email-link tokens still work exactly once after the change', async () => { + onTestFailed((r) => saveTrace('legacy-token-once', h.trace, r.errors?.[0]?.message)) + await insertUser('legacy-42', 'bob@example.test') + await h.signIn('bob@example.test') + const link = h.lastLoginLink('bob@example.test')! + const replay = await h.anonymous().request(new URL(link).pathname + new URL(link).search) + expect(replay.status).toBe(410) +}) + +it('many email links for one new person verified at the same moment still create exactly one account', async () => { + onTestFailed((r) => saveTrace('legacy-concurrent-first-login', h.trace, r.errors?.[0]?.message)) + const email = 'erin@example.test' + const links: string[] = [] + for (let i = 0; i < 8; i += 1) { + const res = await h.anonymous().request('/api/auth/request-link', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email }), + }) + expect(res.status).toBe(200) + links.push(h.lastLoginLink(email)!) + } + expect(new Set(links).size).toBe(8) + // All eight links are opened at the same moment. + const verifies = await Promise.all(links.map((link) => h.anonymous().request(new URL(link).pathname + new URL(link).search))) + expect(verifies.map((r) => r.status)).toEqual(Array(8).fill(302)) + const cookies = verifies.map((r) => (r.headers.get('set-cookie') || '').split(';')[0]) + const ids = await Promise.all( + cookies.map(async (cookie) => ((await (await h.anonymous().request('/api/me', { headers: { Cookie: cookie } })).json()) as { id: string }).id), + ) + expect(new Set(ids)).toEqual(new Set(['email:erin@example.test'])) + const db = await h.mf.getD1Database('DB') + const count = (await db.prepare("SELECT COUNT(*) AS n FROM users WHERE lower(trim(email)) = 'erin@example.test'").first<{ n: number }>())!.n + expect(count).toBe(1) +}) diff --git a/e2e/schema.e2e.test.ts b/e2e/schema.e2e.test.ts new file mode 100644 index 0000000..99046ce --- /dev/null +++ b/e2e/schema.e2e.test.ts @@ -0,0 +1,30 @@ +import { afterEach, beforeEach, expect, it } from 'vitest' +import { startHarness, type Harness } from './harness' + +let h: Harness +beforeEach(async () => { + h = await startHarness() +}) +afterEach(async () => { + await h.dispose() +}) + +// Guards the harness itself: the suites must run on a migration-built database, like production, +// not on the tables the Worker would create at runtime. +it('the test database is built from migrations (indexes and foreign keys that only migrations define)', async () => { + const db = await h.mf.getD1Database('DB') + const indexes = (await db.prepare("SELECT name FROM sqlite_master WHERE type = 'index'").all<{ name: string }>()).results.map((r) => r.name) + expect(indexes).toEqual(expect.arrayContaining(['idx_contacts_user_updated', 'idx_contacts_user_name', 'idx_jobs_contact', 'idx_login_tokens_email', 'idx_users_email'])) + const jobFks = (await db.prepare('PRAGMA foreign_key_list(extraction_jobs)').all<{ table: string }>()).results.map((r) => r.table).sort() + expect(jobFks).toEqual(['contacts', 'users']) +}) + +it('an extraction job cannot reference a contact that does not exist (production foreign key)', async () => { + const db = await h.mf.getD1Database('DB') + await db.prepare("INSERT INTO users (id, email, name, created_at, updated_at) VALUES ('u1','u1@example.test','u1','t','t')").run() + await expect( + db + .prepare("INSERT INTO extraction_jobs (id, user_id, contact_id, source_image_key, status, created_at, updated_at) VALUES ('j1','u1','missing','k','extracted','t','t')") + .run(), + ).rejects.toThrow(/FOREIGN KEY/i) +}) diff --git a/index.html b/index.html index 742e507..41f2536 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,10 @@ - cardcap-app + + + + CardCap | Copper State Intelligence
diff --git a/migrations/0004_access_identities.sql b/migrations/0004_access_identities.sql new file mode 100644 index 0000000..91ec3b8 --- /dev/null +++ b/migrations/0004_access_identities.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS access_identities ( + issuer TEXT NOT NULL, + subject TEXT NOT NULL, + user_id TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (issuer, subject), + FOREIGN KEY (user_id) REFERENCES users(id) +); +CREATE INDEX IF NOT EXISTS idx_access_identities_user ON access_identities(user_id); +CREATE INDEX IF NOT EXISTS idx_users_normalized_email ON users(lower(trim(email))); diff --git a/package-lock.json b/package-lock.json index f5ef71d..f2a3b20 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "cardcap-app", "version": "0.0.0", "dependencies": { + "jose": "6.2.12", "react": "^19.2.6", "react-dom": "^19.2.6" }, @@ -3510,6 +3511,15 @@ "dev": true, "license": "ISC" }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/package.json b/package.json index 3d4a977..7a58f48 100644 --- a/package.json +++ b/package.json @@ -12,9 +12,12 @@ "test": "vitest run --config vitest.config.ts", "test:e2e": "vitest run --config vitest.e2e.config.ts", "test:browser": "playwright test", + "test:browser:access": "playwright test --config playwright.access.config.ts", + "test:rollback": "node --import tsx scripts/test-rollback.ts", "test:browser:server": "tsx e2e/browser/server.ts" }, "dependencies": { + "jose": "6.2.12", "react": "^19.2.6", "react-dom": "^19.2.6" }, diff --git a/playwright.access.config.ts b/playwright.access.config.ts new file mode 100644 index 0000000..e24a246 --- /dev/null +++ b/playwright.access.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from '@playwright/test' + +// This is a synthetic edge for browser tests, never a production authentication bypass. +process.env.ACCESS_SIM = '1' + +export default defineConfig({ + testDir: './e2e/browser', + testMatch: '**/access.spec.ts', + workers: 1, + fullyParallel: false, + retries: 0, + reporter: [['list']], + outputDir: 'test-results/browser-access', + use: { baseURL: 'http://127.0.0.1:5197', trace: 'retain-on-failure', screenshot: 'only-on-failure' }, + webServer: { + command: 'npm run build && npm run test:browser:server', + env: { ACCESS_SIM: '1', PORT: '5197' }, + url: 'http://127.0.0.1:5197', + reuseExistingServer: false, + timeout: 120_000, + }, +}) diff --git a/public/brand/csi-monogram-dark.svg b/public/brand/csi-monogram-dark.svg new file mode 100644 index 0000000..cb60041 --- /dev/null +++ b/public/brand/csi-monogram-dark.svg @@ -0,0 +1 @@ +AAAWgmp1bWIAAAAeanVtZGMycGEAEQAQgAAAqgA4m3EDYzJwYQAAABZcanVtYgAAAEdqdW1kYzJtYQARABCAAACqADibcQN1cm46YzJwYTpjMjkzYWZmMi0xZDk0LTQ5ZDMtYTIxMi0yMjZlNzNkMDI1ZWQAAAADl2p1bWIAAAApanVtZGMyYXMAEQAQgAAAqgA4m3EDYzJwYS5hc3NlcnRpb25zAAAAALxqdW1iAAAARGp1bWRjYm9yABEAEIAAAKoAOJtxE2MycGEuaW5ncmVkaWVudC52MwAAAAAYYzJzaIP9WyX7bAJpQWbl+2h8ocAAAABwY2JvcqNpZGM6Zm9ybWF0bWltYWdlL3N2Zyt4bWxqaW5zdGFuY2VJRHgseG1wOmlpZDo1NjcyN2IyZS1lMzZmLTQ1ODAtOGJhNC1jN2RiNjE0N2MzMDBscmVsYXRpb25zaGlwaHBhcmVudE9mAAAB4mp1bWIAAABBanVtZGNib3IAEQAQgAAAqgA4m3ETYzJwYS5hY3Rpb25zLnYyAAAAABhjMnNoCm8GxqMfk489Fei+7AbfdgAAAZljYm9yomdhY3Rpb25zgqJmYWN0aW9ua2MycGEub3BlbmVkanBhcmFtZXRlcnOha2luZ3JlZGllbnRzgaJjdXJseC1zZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmluZ3JlZGllbnQudjNkaGFzaFggt+dKYNIKgCNsslykJfZl1jLZNVKOvq7PsNIuPKIGwRqkZmFjdGlvbngdY29tLmFudGhyb3BpYy5jbGF1ZGUucHJvdmlkZWRqcGFyYW1ldGVyc6F4H2NvbS5hbnRocm9waWMub3JpZ2luLWNvbmZpZGVuY2VndW5rbm93bmtkZXNjcmlwdGlvbnhmQ2xhdWRlIHByb3ZpZGVkIHRoaXMgZmlsZSBhdCB0aGUgcmVxdWVzdCBvZiBhIHVzZXIgYW5kIG1heSBoYXZlIGNyZWF0ZWQgb3IgbW9kaWZpZWQgdGhlIGZpbGUgY29udGVudHMubXNvZnR3YXJlQWdlbnShZG5hbWVmQ2xhdWRlcmFsbEFjdGlvbnNJbmNsdWRlZPUAAADIanVtYgAAAEBqdW1kY2JvcgARABCAAACqADibcRNjMnBhLmhhc2guZGF0YQAAAAAYYzJzaDOIg7+p+fcOpYlj1LM8/5UAAACAY2JvcqVjYWxnZnNoYTI1NmNwYWRNAAAAAAAAAAAAAAAAAGRoYXNoWCDCcyxGtjWOBT8wk6LMy2NxBY85rjUNZjJkPyut8uRwKWRuYW1lbmp1bWJmIG1hbmlmZXN0amV4Y2x1c2lvbnOBomVzdGFydBiSZmxlbmd0aBkeBAAAAj5qdW1iAAAAJ2p1bWRjMmNsABEAEIAAAKoAOJtxA2MycGEuY2xhaW0udjIAAAACD2Nib3KlY2FsZ2ZzaGEyNTZpc2lnbmF0dXJleE1zZWxmI2p1bWJmPS9jMnBhL3VybjpjMnBhOmMyOTNhZmYyLTFkOTQtNDlkMy1hMjEyLTIyNmU3M2QwMjVlZC9jMnBhLnNpZ25hdHVyZWppbnN0YW5jZUlEeCx4bXA6aWlkOjM5Y2IxNDQ1LTU4MjAtNGFiYS1hNTc5LTFlMTU0ZTIzZTc5YXJjcmVhdGVkX2Fzc2VydGlvbnODomN1cmx4LXNlbGYjanVtYmY9YzJwYS5hc3NlcnRpb25zL2MycGEuaW5ncmVkaWVudC52M2RoYXNoWCC350pg0gqAI2yyXKQl9mXWMtk1Uo6+rs+w0i48ogbBGqJjdXJseCpzZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmFjdGlvbnMudjJkaGFzaFgg7etWS5/SkiyKYd0If3aHQdNk0x2EoMrVyL6JfaY62QCiY3VybHgpc2VsZiNqdW1iZj1jMnBhLmFzc2VydGlvbnMvYzJwYS5oYXNoLmRhdGFkaGFzaFgg6Vw0ifRDj76Vq5LOj0HHSoztrtWHGf8M4wVprb26rKJ0Y2xhaW1fZ2VuZXJhdG9yX2luZm+jZG5hbWVvQW50aHJvcGljIEZpbGVzZ3ZlcnNpb25lMS4wLjBrc3BlY1ZlcnNpb25lMi40LjAAABA4anVtYgAAAChqdW1kYzJjcwARABCAAACqADibcQNjMnBhLnNpZ25hdHVyZQAAABAIY2JvctKEWQISogEmGCFZAgowggIGMIIBjaADAgECAhRA5aAK7sI50L64g/oGQgU9Z1UTADAKBggqhkjOPQQDAzBJMRcwFQYDVQQKEw5BbnRocm9waWMsIFBCQzEuMCwGA1UEAxMlQW50aHJvcGljIENvbnRlbnQgQ3JlZGVudGlhbHMgUm9vdCBDQTAeFw0yNjA4MDcxODQzNTZaFw0yODA4MDYxOTQzNTZaMEQxFzAVBgNVBAoTDkFudGhyb3BpYywgUEJDMSkwJwYDVQQDEyBBbnRocm9waWMgQ2xhdWRlIENvbnRlbnQgU2lnbmluZzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJh6CmvLUBgFFNU0vUKlOVtE6djd17L5SuwX0LemFisBM3dkd/3cyjxFA3Qo5S46fX0/ihY0VZ7mfb9KF703t5OjWDBWMA4GA1UdDwEB/wQEAwIHgDAVBgNVHSUEDjAMBgorBgEEAYPoXgIBMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUzlHiBIFOZFsj+OPEz5o+nMHXXMIwCgYIKoZIzj0EAwMDZwAwZAIwMXMdFJ4BetLLVY7ORuE9noqbbAZOZn/aArXyTwFAZfKrPzxF2vPoJNf1+UCdg1XGAjBwX1zd9WGqYkqmL5SFqw1QySjr1zJfpJM9+1rdDwSPLMOPOjKuiXjoU/pUUeG9RwmhY3BhZFkNngAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPZYQKFz0OdbEJXz5OcQ8nTwyLG8qEHaR+7sLZ7dDTXBetsF9/zWdZu2u2+TqbnCwb4K5xUsHJPmHocqrHbTk+7u7NY=CSI \ No newline at end of file diff --git a/public/brand/csi-monogram-light.svg b/public/brand/csi-monogram-light.svg new file mode 100644 index 0000000..d83dd6b --- /dev/null +++ b/public/brand/csi-monogram-light.svg @@ -0,0 +1 @@ +AAAWgmp1bWIAAAAeanVtZGMycGEAEQAQgAAAqgA4m3EDYzJwYQAAABZcanVtYgAAAEdqdW1kYzJtYQARABCAAACqADibcQN1cm46YzJwYTo4MzM4YzhlYy0wMmQ4LTQ2NjgtYTMxMS0wNzdjNjNkMTZmODgAAAADl2p1bWIAAAApanVtZGMyYXMAEQAQgAAAqgA4m3EDYzJwYS5hc3NlcnRpb25zAAAAALxqdW1iAAAARGp1bWRjYm9yABEAEIAAAKoAOJtxE2MycGEuaW5ncmVkaWVudC52MwAAAAAYYzJzaF767vFsvmKCTTjDkXDvlW4AAABwY2JvcqNpZGM6Zm9ybWF0bWltYWdlL3N2Zyt4bWxqaW5zdGFuY2VJRHgseG1wOmlpZDo3MjVkN2VmMy04NzQyLTQyZWQtOWNkYi00MmEzNTIxNTkwNjVscmVsYXRpb25zaGlwaHBhcmVudE9mAAAB4mp1bWIAAABBanVtZGNib3IAEQAQgAAAqgA4m3ETYzJwYS5hY3Rpb25zLnYyAAAAABhjMnNoRgUtxe69wjxmCDl5o26akwAAAZljYm9yomdhY3Rpb25zgqJmYWN0aW9ua2MycGEub3BlbmVkanBhcmFtZXRlcnOha2luZ3JlZGllbnRzgaJjdXJseC1zZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmluZ3JlZGllbnQudjNkaGFzaFggue+sdmGV0fEkOAjPcWntzR646ESkFk7yM1IBuZGf3VCkZmFjdGlvbngdY29tLmFudGhyb3BpYy5jbGF1ZGUucHJvdmlkZWRqcGFyYW1ldGVyc6F4H2NvbS5hbnRocm9waWMub3JpZ2luLWNvbmZpZGVuY2VndW5rbm93bmtkZXNjcmlwdGlvbnhmQ2xhdWRlIHByb3ZpZGVkIHRoaXMgZmlsZSBhdCB0aGUgcmVxdWVzdCBvZiBhIHVzZXIgYW5kIG1heSBoYXZlIGNyZWF0ZWQgb3IgbW9kaWZpZWQgdGhlIGZpbGUgY29udGVudHMubXNvZnR3YXJlQWdlbnShZG5hbWVmQ2xhdWRlcmFsbEFjdGlvbnNJbmNsdWRlZPUAAADIanVtYgAAAEBqdW1kY2JvcgARABCAAACqADibcRNjMnBhLmhhc2guZGF0YQAAAAAYYzJzaH7lCZ04pdC4cH4zhvaLy6EAAACAY2JvcqVjYWxnZnNoYTI1NmNwYWRNAAAAAAAAAAAAAAAAAGRoYXNoWCBwT46O7yRGvu1wvNNfz39zHSUDvFTyVa44vlQ3ZmplmWRuYW1lbmp1bWJmIG1hbmlmZXN0amV4Y2x1c2lvbnOBomVzdGFydBiSZmxlbmd0aBkeBAAAAj5qdW1iAAAAJ2p1bWRjMmNsABEAEIAAAKoAOJtxA2MycGEuY2xhaW0udjIAAAACD2Nib3KlY2FsZ2ZzaGEyNTZpc2lnbmF0dXJleE1zZWxmI2p1bWJmPS9jMnBhL3VybjpjMnBhOjgzMzhjOGVjLTAyZDgtNDY2OC1hMzExLTA3N2M2M2QxNmY4OC9jMnBhLnNpZ25hdHVyZWppbnN0YW5jZUlEeCx4bXA6aWlkOjdhZDFhZmQ2LTQ2MTAtNGE0OC1hZGQ5LWE2MjYzY2JlZDQ2OHJjcmVhdGVkX2Fzc2VydGlvbnODomN1cmx4LXNlbGYjanVtYmY9YzJwYS5hc3NlcnRpb25zL2MycGEuaW5ncmVkaWVudC52M2RoYXNoWCC576x2YZXR8SQ4CM9xae3NHrjoRKQWTvIzUgG5kZ/dUKJjdXJseCpzZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmFjdGlvbnMudjJkaGFzaFggHnFn9nVNI+JSMpP6LoyawGSUnjUxyQ6s66LvofsF9aKiY3VybHgpc2VsZiNqdW1iZj1jMnBhLmFzc2VydGlvbnMvYzJwYS5oYXNoLmRhdGFkaGFzaFgg7GLqwj8snwE/zvVK4wCGndkiof+bJRVZI6pbO/ByWsd0Y2xhaW1fZ2VuZXJhdG9yX2luZm+jZG5hbWVvQW50aHJvcGljIEZpbGVzZ3ZlcnNpb25lMS4wLjBrc3BlY1ZlcnNpb25lMi40LjAAABA4anVtYgAAAChqdW1kYzJjcwARABCAAACqADibcQNjMnBhLnNpZ25hdHVyZQAAABAIY2JvctKEWQISogEmGCFZAgowggIGMIIBjaADAgECAhRA5aAK7sI50L64g/oGQgU9Z1UTADAKBggqhkjOPQQDAzBJMRcwFQYDVQQKEw5BbnRocm9waWMsIFBCQzEuMCwGA1UEAxMlQW50aHJvcGljIENvbnRlbnQgQ3JlZGVudGlhbHMgUm9vdCBDQTAeFw0yNjA4MDcxODQzNTZaFw0yODA4MDYxOTQzNTZaMEQxFzAVBgNVBAoTDkFudGhyb3BpYywgUEJDMSkwJwYDVQQDEyBBbnRocm9waWMgQ2xhdWRlIENvbnRlbnQgU2lnbmluZzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJh6CmvLUBgFFNU0vUKlOVtE6djd17L5SuwX0LemFisBM3dkd/3cyjxFA3Qo5S46fX0/ihY0VZ7mfb9KF703t5OjWDBWMA4GA1UdDwEB/wQEAwIHgDAVBgNVHSUEDjAMBgorBgEEAYPoXgIBMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUzlHiBIFOZFsj+OPEz5o+nMHXXMIwCgYIKoZIzj0EAwMDZwAwZAIwMXMdFJ4BetLLVY7ORuE9noqbbAZOZn/aArXyTwFAZfKrPzxF2vPoJNf1+UCdg1XGAjBwX1zd9WGqYkqmL5SFqw1QySjr1zJfpJM9+1rdDwSPLMOPOjKuiXjoU/pUUeG9RwmhY3BhZFkNngAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPZYQEFhxiupsZkJtrgXRBmFUc9/KBwudyFUJmUL8kpY5+8p/kRPjLgIILC329djLvzJEkZl4sY7UU0Vu4o4dq5JXh0=CSI \ No newline at end of file diff --git a/public/brand/csi-wordmark-dark.svg b/public/brand/csi-wordmark-dark.svg new file mode 100644 index 0000000..0879302 --- /dev/null +++ b/public/brand/csi-wordmark-dark.svg @@ -0,0 +1 @@ +AAAWgmp1bWIAAAAeanVtZGMycGEAEQAQgAAAqgA4m3EDYzJwYQAAABZcanVtYgAAAEdqdW1kYzJtYQARABCAAACqADibcQN1cm46YzJwYTozM2I5MTczZi02NzU3LTQ2OTgtOGYzMC0wZTU3MTkxMmVhNzgAAAADl2p1bWIAAAApanVtZGMyYXMAEQAQgAAAqgA4m3EDYzJwYS5hc3NlcnRpb25zAAAAALxqdW1iAAAARGp1bWRjYm9yABEAEIAAAKoAOJtxE2MycGEuaW5ncmVkaWVudC52MwAAAAAYYzJzaL9iQ7GaID1ZjFDCSja/04gAAABwY2JvcqNpZGM6Zm9ybWF0bWltYWdlL3N2Zyt4bWxqaW5zdGFuY2VJRHgseG1wOmlpZDpjYjIxMDhkMS01ODIzLTQ0YTAtYTZlMS0wYWI2ZTZlN2UyZjRscmVsYXRpb25zaGlwaHBhcmVudE9mAAAB4mp1bWIAAABBanVtZGNib3IAEQAQgAAAqgA4m3ETYzJwYS5hY3Rpb25zLnYyAAAAABhjMnNo2akkLAL2MiRrOQdOLnNj5QAAAZljYm9yomdhY3Rpb25zgqJmYWN0aW9ua2MycGEub3BlbmVkanBhcmFtZXRlcnOha2luZ3JlZGllbnRzgaJjdXJseC1zZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmluZ3JlZGllbnQudjNkaGFzaFggjIu4+rF9f0O3u6NKDvxtYa6GO6tmZwZXnNm655IOTj2kZmFjdGlvbngdY29tLmFudGhyb3BpYy5jbGF1ZGUucHJvdmlkZWRqcGFyYW1ldGVyc6F4H2NvbS5hbnRocm9waWMub3JpZ2luLWNvbmZpZGVuY2VndW5rbm93bmtkZXNjcmlwdGlvbnhmQ2xhdWRlIHByb3ZpZGVkIHRoaXMgZmlsZSBhdCB0aGUgcmVxdWVzdCBvZiBhIHVzZXIgYW5kIG1heSBoYXZlIGNyZWF0ZWQgb3IgbW9kaWZpZWQgdGhlIGZpbGUgY29udGVudHMubXNvZnR3YXJlQWdlbnShZG5hbWVmQ2xhdWRlcmFsbEFjdGlvbnNJbmNsdWRlZPUAAADIanVtYgAAAEBqdW1kY2JvcgARABCAAACqADibcRNjMnBhLmhhc2guZGF0YQAAAAAYYzJzaE4SZshH72zpTkVxkioC4dkAAACAY2JvcqVjYWxnZnNoYTI1NmNwYWRNAAAAAAAAAAAAAAAAAGRoYXNoWCCUA70mW1ODMQemvT7ETaCpE3VaywelCYTChlRTcJ7yQGRuYW1lbmp1bWJmIG1hbmlmZXN0amV4Y2x1c2lvbnOBomVzdGFydBiUZmxlbmd0aBkeBAAAAj5qdW1iAAAAJ2p1bWRjMmNsABEAEIAAAKoAOJtxA2MycGEuY2xhaW0udjIAAAACD2Nib3KlY2FsZ2ZzaGEyNTZpc2lnbmF0dXJleE1zZWxmI2p1bWJmPS9jMnBhL3VybjpjMnBhOjMzYjkxNzNmLTY3NTctNDY5OC04ZjMwLTBlNTcxOTEyZWE3OC9jMnBhLnNpZ25hdHVyZWppbnN0YW5jZUlEeCx4bXA6aWlkOjg4MGQzYWJiLTM2NTYtNDAxOS1iNmVjLTFhNzJlMTNmMGMxMXJjcmVhdGVkX2Fzc2VydGlvbnODomN1cmx4LXNlbGYjanVtYmY9YzJwYS5hc3NlcnRpb25zL2MycGEuaW5ncmVkaWVudC52M2RoYXNoWCCMi7j6sX1/Q7e7o0oO/G1hroY7q2ZnBlec2brnkg5OPaJjdXJseCpzZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmFjdGlvbnMudjJkaGFzaFggbFn+u0U1pEcNPcXj0apQT6tknCLpjEhqiXHcz+XJjl+iY3VybHgpc2VsZiNqdW1iZj1jMnBhLmFzc2VydGlvbnMvYzJwYS5oYXNoLmRhdGFkaGFzaFggRgFmWCr48ArkYSOk1YFdlNALQglY6Sq71MYODAW/Tgl0Y2xhaW1fZ2VuZXJhdG9yX2luZm+jZG5hbWVvQW50aHJvcGljIEZpbGVzZ3ZlcnNpb25lMS4wLjBrc3BlY1ZlcnNpb25lMi40LjAAABA4anVtYgAAAChqdW1kYzJjcwARABCAAACqADibcQNjMnBhLnNpZ25hdHVyZQAAABAIY2JvctKEWQISogEmGCFZAgowggIGMIIBjaADAgECAhRA5aAK7sI50L64g/oGQgU9Z1UTADAKBggqhkjOPQQDAzBJMRcwFQYDVQQKEw5BbnRocm9waWMsIFBCQzEuMCwGA1UEAxMlQW50aHJvcGljIENvbnRlbnQgQ3JlZGVudGlhbHMgUm9vdCBDQTAeFw0yNjA4MDcxODQzNTZaFw0yODA4MDYxOTQzNTZaMEQxFzAVBgNVBAoTDkFudGhyb3BpYywgUEJDMSkwJwYDVQQDEyBBbnRocm9waWMgQ2xhdWRlIENvbnRlbnQgU2lnbmluZzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJh6CmvLUBgFFNU0vUKlOVtE6djd17L5SuwX0LemFisBM3dkd/3cyjxFA3Qo5S46fX0/ihY0VZ7mfb9KF703t5OjWDBWMA4GA1UdDwEB/wQEAwIHgDAVBgNVHSUEDjAMBgorBgEEAYPoXgIBMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUzlHiBIFOZFsj+OPEz5o+nMHXXMIwCgYIKoZIzj0EAwMDZwAwZAIwMXMdFJ4BetLLVY7ORuE9noqbbAZOZn/aArXyTwFAZfKrPzxF2vPoJNf1+UCdg1XGAjBwX1zd9WGqYkqmL5SFqw1QySjr1zJfpJM9+1rdDwSPLMOPOjKuiXjoU/pUUeG9RwmhY3BhZFkNngAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPZYQN1Q+9apQqFT9Zo92/SfSA7mZwL5OKhhzfHrTzYWFIkFI9VvbW5UXvtjcWN0eW2Lnmn0wHkz46ge4d0LivjRmAI=Copper State Intelligence \ No newline at end of file diff --git a/public/brand/csi-wordmark-light.svg b/public/brand/csi-wordmark-light.svg new file mode 100644 index 0000000..6bbe129 --- /dev/null +++ b/public/brand/csi-wordmark-light.svg @@ -0,0 +1 @@ +AAAWgmp1bWIAAAAeanVtZGMycGEAEQAQgAAAqgA4m3EDYzJwYQAAABZcanVtYgAAAEdqdW1kYzJtYQARABCAAACqADibcQN1cm46YzJwYTowYTUyYzY5Ni1jNjQ2LTQ0OGQtOTNiOS0yYjllODI5MjE5MGYAAAADl2p1bWIAAAApanVtZGMyYXMAEQAQgAAAqgA4m3EDYzJwYS5hc3NlcnRpb25zAAAAALxqdW1iAAAARGp1bWRjYm9yABEAEIAAAKoAOJtxE2MycGEuaW5ncmVkaWVudC52MwAAAAAYYzJzaMij/ZZ/GeX7M3ZZPoWDq4gAAABwY2JvcqNpZGM6Zm9ybWF0bWltYWdlL3N2Zyt4bWxqaW5zdGFuY2VJRHgseG1wOmlpZDpiMTA4Y2M1NS03Y2YzLTQ2NTYtYjFkMy03ZTFlODQ0ZWQ4MTVscmVsYXRpb25zaGlwaHBhcmVudE9mAAAB4mp1bWIAAABBanVtZGNib3IAEQAQgAAAqgA4m3ETYzJwYS5hY3Rpb25zLnYyAAAAABhjMnNovgYE+lsMTggVGMf/EO2jbQAAAZljYm9yomdhY3Rpb25zgqJmYWN0aW9ua2MycGEub3BlbmVkanBhcmFtZXRlcnOha2luZ3JlZGllbnRzgaJjdXJseC1zZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmluZ3JlZGllbnQudjNkaGFzaFggwL9TGh0r0cWyvRG0NF8HVSWvmG5XMLQ20j7k8co+48ykZmFjdGlvbngdY29tLmFudGhyb3BpYy5jbGF1ZGUucHJvdmlkZWRqcGFyYW1ldGVyc6F4H2NvbS5hbnRocm9waWMub3JpZ2luLWNvbmZpZGVuY2VndW5rbm93bmtkZXNjcmlwdGlvbnhmQ2xhdWRlIHByb3ZpZGVkIHRoaXMgZmlsZSBhdCB0aGUgcmVxdWVzdCBvZiBhIHVzZXIgYW5kIG1heSBoYXZlIGNyZWF0ZWQgb3IgbW9kaWZpZWQgdGhlIGZpbGUgY29udGVudHMubXNvZnR3YXJlQWdlbnShZG5hbWVmQ2xhdWRlcmFsbEFjdGlvbnNJbmNsdWRlZPUAAADIanVtYgAAAEBqdW1kY2JvcgARABCAAACqADibcRNjMnBhLmhhc2guZGF0YQAAAAAYYzJzaKEfC4ukSCS82tTXSvp1dG0AAACAY2JvcqVjYWxnZnNoYTI1NmNwYWRNAAAAAAAAAAAAAAAAAGRoYXNoWCDHEUoSteMQJ4EJGlBK+QHHSKhXrCX603DVLcoud5bqyGRuYW1lbmp1bWJmIG1hbmlmZXN0amV4Y2x1c2lvbnOBomVzdGFydBiUZmxlbmd0aBkeBAAAAj5qdW1iAAAAJ2p1bWRjMmNsABEAEIAAAKoAOJtxA2MycGEuY2xhaW0udjIAAAACD2Nib3KlY2FsZ2ZzaGEyNTZpc2lnbmF0dXJleE1zZWxmI2p1bWJmPS9jMnBhL3VybjpjMnBhOjBhNTJjNjk2LWM2NDYtNDQ4ZC05M2I5LTJiOWU4MjkyMTkwZi9jMnBhLnNpZ25hdHVyZWppbnN0YW5jZUlEeCx4bXA6aWlkOjFkMGVhMWIyLTRlNTgtNDA4Zi1hMzY4LTAzMjVmZWRiNGViNHJjcmVhdGVkX2Fzc2VydGlvbnODomN1cmx4LXNlbGYjanVtYmY9YzJwYS5hc3NlcnRpb25zL2MycGEuaW5ncmVkaWVudC52M2RoYXNoWCDAv1MaHSvRxbK9EbQ0XwdVJa+YblcwtDbSPuTxyj7jzKJjdXJseCpzZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmFjdGlvbnMudjJkaGFzaFggJReha+nDlXXUnxPUWJH6eifLLIH1TMzy06hr9LpX2eSiY3VybHgpc2VsZiNqdW1iZj1jMnBhLmFzc2VydGlvbnMvYzJwYS5oYXNoLmRhdGFkaGFzaFggAJZ7XYehhAU6lhBkNt5OJn0iD5kBNz7rUkZWApSp3250Y2xhaW1fZ2VuZXJhdG9yX2luZm+jZG5hbWVvQW50aHJvcGljIEZpbGVzZ3ZlcnNpb25lMS4wLjBrc3BlY1ZlcnNpb25lMi40LjAAABA4anVtYgAAAChqdW1kYzJjcwARABCAAACqADibcQNjMnBhLnNpZ25hdHVyZQAAABAIY2JvctKEWQISogEmGCFZAgowggIGMIIBjaADAgECAhRA5aAK7sI50L64g/oGQgU9Z1UTADAKBggqhkjOPQQDAzBJMRcwFQYDVQQKEw5BbnRocm9waWMsIFBCQzEuMCwGA1UEAxMlQW50aHJvcGljIENvbnRlbnQgQ3JlZGVudGlhbHMgUm9vdCBDQTAeFw0yNjA4MDcxODQzNTZaFw0yODA4MDYxOTQzNTZaMEQxFzAVBgNVBAoTDkFudGhyb3BpYywgUEJDMSkwJwYDVQQDEyBBbnRocm9waWMgQ2xhdWRlIENvbnRlbnQgU2lnbmluZzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJh6CmvLUBgFFNU0vUKlOVtE6djd17L5SuwX0LemFisBM3dkd/3cyjxFA3Qo5S46fX0/ihY0VZ7mfb9KF703t5OjWDBWMA4GA1UdDwEB/wQEAwIHgDAVBgNVHSUEDjAMBgorBgEEAYPoXgIBMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUzlHiBIFOZFsj+OPEz5o+nMHXXMIwCgYIKoZIzj0EAwMDZwAwZAIwMXMdFJ4BetLLVY7ORuE9noqbbAZOZn/aArXyTwFAZfKrPzxF2vPoJNf1+UCdg1XGAjBwX1zd9WGqYkqmL5SFqw1QySjr1zJfpJM9+1rdDwSPLMOPOjKuiXjoU/pUUeG9RwmhY3BhZFkNngAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPZYQEeejjCIyfhTlPS2If07Y01SohFhNVHU1DN/kla6TbEgLjKoJnoZ8cJSEnNDxzW5S/nSdjYfaon+6Olj8NdEg6I=Copper State Intelligence \ No newline at end of file diff --git a/public/favicon.svg b/public/favicon.svg index 6893eb1..89bcb9b 100644 --- a/public/favicon.svg +++ b/public/favicon.svg @@ -1 +1 @@ - \ No newline at end of file +AAAWgmp1bWIAAAAeanVtZGMycGEAEQAQgAAAqgA4m3EDYzJwYQAAABZcanVtYgAAAEdqdW1kYzJtYQARABCAAACqADibcQN1cm46YzJwYTpkODFhMzQyMC1kZmFiLTQ2NjAtODFhZi05YTQ0YTVjYWIyY2IAAAADl2p1bWIAAAApanVtZGMyYXMAEQAQgAAAqgA4m3EDYzJwYS5hc3NlcnRpb25zAAAAALxqdW1iAAAARGp1bWRjYm9yABEAEIAAAKoAOJtxE2MycGEuaW5ncmVkaWVudC52MwAAAAAYYzJzaKVJonBNEtEfLreUq47Ke5UAAABwY2JvcqNpZGM6Zm9ybWF0bWltYWdlL3N2Zyt4bWxqaW5zdGFuY2VJRHgseG1wOmlpZDowMGYwZmNkNy1mNzQ2LTQ2ZjYtOTc3OC04MTAwMzg1YTZmMzRscmVsYXRpb25zaGlwaHBhcmVudE9mAAAB4mp1bWIAAABBanVtZGNib3IAEQAQgAAAqgA4m3ETYzJwYS5hY3Rpb25zLnYyAAAAABhjMnNoYpDGKq8qAk8SEhbimxk1+gAAAZljYm9yomdhY3Rpb25zgqJmYWN0aW9ua2MycGEub3BlbmVkanBhcmFtZXRlcnOha2luZ3JlZGllbnRzgaJjdXJseC1zZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmluZ3JlZGllbnQudjNkaGFzaFgg+ncvTnS9Ku3XG9SlCNSBw0TMRXFAuR+gyqx6ziFdvpmkZmFjdGlvbngdY29tLmFudGhyb3BpYy5jbGF1ZGUucHJvdmlkZWRqcGFyYW1ldGVyc6F4H2NvbS5hbnRocm9waWMub3JpZ2luLWNvbmZpZGVuY2VndW5rbm93bmtkZXNjcmlwdGlvbnhmQ2xhdWRlIHByb3ZpZGVkIHRoaXMgZmlsZSBhdCB0aGUgcmVxdWVzdCBvZiBhIHVzZXIgYW5kIG1heSBoYXZlIGNyZWF0ZWQgb3IgbW9kaWZpZWQgdGhlIGZpbGUgY29udGVudHMubXNvZnR3YXJlQWdlbnShZG5hbWVmQ2xhdWRlcmFsbEFjdGlvbnNJbmNsdWRlZPUAAADIanVtYgAAAEBqdW1kY2JvcgARABCAAACqADibcRNjMnBhLmhhc2guZGF0YQAAAAAYYzJzaIGjdyTJwt8EWawqTHOif0IAAACAY2JvcqVjYWxnZnNoYTI1NmNwYWRNAAAAAAAAAAAAAAAAAGRoYXNoWCCodUJY5rn78nGqQTAqa+jjN5mjI2EvAvQT7m25rdwHDmRuYW1lbmp1bWJmIG1hbmlmZXN0amV4Y2x1c2lvbnOBomVzdGFydBiSZmxlbmd0aBkeBAAAAj5qdW1iAAAAJ2p1bWRjMmNsABEAEIAAAKoAOJtxA2MycGEuY2xhaW0udjIAAAACD2Nib3KlY2FsZ2ZzaGEyNTZpc2lnbmF0dXJleE1zZWxmI2p1bWJmPS9jMnBhL3VybjpjMnBhOmQ4MWEzNDIwLWRmYWItNDY2MC04MWFmLTlhNDRhNWNhYjJjYi9jMnBhLnNpZ25hdHVyZWppbnN0YW5jZUlEeCx4bXA6aWlkOmNkZDJmNDJiLTg1NzMtNGEyNS1hZmVhLWE1YWFjODdlY2VjY3JjcmVhdGVkX2Fzc2VydGlvbnODomN1cmx4LXNlbGYjanVtYmY9YzJwYS5hc3NlcnRpb25zL2MycGEuaW5ncmVkaWVudC52M2RoYXNoWCD6dy9OdL0q7dcb1KUI1IHDRMxFcUC5H6DKrHrOIV2+maJjdXJseCpzZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmFjdGlvbnMudjJkaGFzaFggv969psq5wFcSbLjTlkKFBzE2cPnQMRcCjuNC5J4acEKiY3VybHgpc2VsZiNqdW1iZj1jMnBhLmFzc2VydGlvbnMvYzJwYS5oYXNoLmRhdGFkaGFzaFggiAYYoznsjDBnX/QAccktfA19IaGeNV5vb/3GUKoqyG90Y2xhaW1fZ2VuZXJhdG9yX2luZm+jZG5hbWVvQW50aHJvcGljIEZpbGVzZ3ZlcnNpb25lMS4wLjBrc3BlY1ZlcnNpb25lMi40LjAAABA4anVtYgAAAChqdW1kYzJjcwARABCAAACqADibcQNjMnBhLnNpZ25hdHVyZQAAABAIY2JvctKEWQISogEmGCFZAgowggIGMIIBjaADAgECAhRA5aAK7sI50L64g/oGQgU9Z1UTADAKBggqhkjOPQQDAzBJMRcwFQYDVQQKEw5BbnRocm9waWMsIFBCQzEuMCwGA1UEAxMlQW50aHJvcGljIENvbnRlbnQgQ3JlZGVudGlhbHMgUm9vdCBDQTAeFw0yNjA4MDcxODQzNTZaFw0yODA4MDYxOTQzNTZaMEQxFzAVBgNVBAoTDkFudGhyb3BpYywgUEJDMSkwJwYDVQQDEyBBbnRocm9waWMgQ2xhdWRlIENvbnRlbnQgU2lnbmluZzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJh6CmvLUBgFFNU0vUKlOVtE6djd17L5SuwX0LemFisBM3dkd/3cyjxFA3Qo5S46fX0/ihY0VZ7mfb9KF703t5OjWDBWMA4GA1UdDwEB/wQEAwIHgDAVBgNVHSUEDjAMBgorBgEEAYPoXgIBMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUzlHiBIFOZFsj+OPEz5o+nMHXXMIwCgYIKoZIzj0EAwMDZwAwZAIwMXMdFJ4BetLLVY7ORuE9noqbbAZOZn/aArXyTwFAZfKrPzxF2vPoJNf1+UCdg1XGAjBwX1zd9WGqYkqmL5SFqw1QySjr1zJfpJM9+1rdDwSPLMOPOjKuiXjoU/pUUeG9RwmhY3BhZFkNngAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPZYQHRsL2lR4cfVNkv3rUBu17LyQL5C4L6qhvKLRs9auL3KByX5U9KWdnKJTKNuYRR9smrj/h0hLonFtOFXvqo+QhI=C \ No newline at end of file diff --git a/public/fonts/OFL.txt b/public/fonts/OFL.txt new file mode 100644 index 0000000..8895573 --- /dev/null +++ b/public/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020-2024 The Atkinson Hyperlegible Next Project Authors (https://github.com/googlefonts/atkinson-hyperlegible-next) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/public/fonts/atkinson-mono-latin-ext.woff2 b/public/fonts/atkinson-mono-latin-ext.woff2 new file mode 100644 index 0000000..a60e131 Binary files /dev/null and b/public/fonts/atkinson-mono-latin-ext.woff2 differ diff --git a/public/fonts/atkinson-mono-latin.woff2 b/public/fonts/atkinson-mono-latin.woff2 new file mode 100644 index 0000000..f5c1081 Binary files /dev/null and b/public/fonts/atkinson-mono-latin.woff2 differ diff --git a/public/fonts/atkinson-next-latin-ext.woff2 b/public/fonts/atkinson-next-latin-ext.woff2 new file mode 100644 index 0000000..d2b4185 Binary files /dev/null and b/public/fonts/atkinson-next-latin-ext.woff2 differ diff --git a/public/fonts/atkinson-next-latin.woff2 b/public/fonts/atkinson-next-latin.woff2 new file mode 100644 index 0000000..a9ca4bd Binary files /dev/null and b/public/fonts/atkinson-next-latin.woff2 differ diff --git a/scripts/test-rollback.ts b/scripts/test-rollback.ts new file mode 100644 index 0000000..4d8110d --- /dev/null +++ b/scripts/test-rollback.ts @@ -0,0 +1,7 @@ +import { spawnSync } from 'node:child_process' + +const result = spawnSync(process.execPath, ['node_modules/vitest/vitest.mjs', 'run', '--config', 'vitest.e2e.config.ts', 'e2e/access-rollback.e2e.test.ts'], { + stdio: 'inherit', + env: { ...process.env, ROLLBACK_CHECK: '1' }, +}) +process.exit(result.status ?? 1) diff --git a/src/App.css b/src/App.css index 81085d4..9843517 100644 --- a/src/App.css +++ b/src/App.css @@ -1,171 +1,322 @@ -:root { - --bg: #f5f6f8; - --panel: #ffffff; - --text: #1f252d; - --muted: #68717d; - --line: #dfe3e8; - --line-strong: #c7ced6; - --accent: #146c5f; - --accent-dark: #0f5b51; - --accent-weak: #dff2ee; - --danger: #a33a35; - --shadow: 0 12px 30px rgba(28, 38, 48, 0.1); +/* CardCap on the CSI "Ledger" system (src/csi.css, from csiOS/csi-brand). + Only --csi-* tokens are used for color, type and space. Square corners, 1px rules, no shadows or gradients. + Controls are 40px tall with a mouse and 44px on touch screens and phones. */ + +/* The brand kit only ships dark values behind [data-theme="dark"]; apply the same values when the OS asks for dark. */ +@media (prefers-color-scheme: dark) { + :root:not([data-theme='light']) { + --csi-paper: #111315; + --csi-surface: #181b1e; + --csi-ink: #ecedee; + --csi-text: #c9ccd0; + --csi-muted: #979ca2; + --csi-rule: #2a2e33; + --csi-rule-strong: #3a3f45; + --csi-turquoise: #4cc3bc; + --csi-turquoise-deep: #7ad6d0; + --csi-turquoise-tint: #132726; + --csi-on-turquoise: #111315; + --csi-copper: #c98663; + --csi-copper-tint: #221a16; + --csi-rose: #e0668c; + --csi-rose-tint: #2a1820; + --csi-focus: 0 0 0 3px var(--csi-paper), 0 0 0 5px var(--csi-turquoise); + } +} + +html { + color-scheme: light dark; +} + +:root[data-theme='light'] { + color-scheme: light; } .app-shell { + --ctl: 40px; + --header: 57px; min-height: 100vh; - background: var(--bg); - color: var(--text); + background: var(--csi-surface); + color: var(--csi-text); + font: var(--csi-small); +} + +@media (pointer: coarse), (max-width: 760px) { + .app-shell { + --ctl: 44px; + } +} + +.app-shell *, +.app-shell *::before, +.app-shell *::after { + border-radius: 0; +} + +.skip-link { + position: absolute; + left: var(--csi-s2); + top: -100px; + z-index: 50; + padding: var(--csi-s3) var(--csi-s4); + background: var(--csi-paper); + color: var(--csi-ink); + border: 1px solid var(--csi-ink); +} + +.skip-link:focus { + top: var(--csi-s2); +} + +.app-shell :focus-visible { + outline: none; + box-shadow: var(--csi-focus); +} + +/* Header */ +.brand-bar { + height: var(--csi-bar); + background: var(--csi-turquoise); } .topbar { - min-height: 64px; + position: sticky; + top: 0; + z-index: 10; display: flex; align-items: center; justify-content: space-between; - gap: 16px; - padding: 12px 16px; - background: var(--panel); - border-bottom: 1px solid var(--line); + gap: var(--csi-s4); + padding: var(--csi-s2) var(--csi-s4); + background: var(--csi-paper); + border-bottom: var(--csi-border); } -.topbar h1, -.topbar p, -.editor-head h2, -.editor-head p, -.source-head h2 { - margin: 0; +.brand { + display: flex; + align-items: center; + gap: var(--csi-s3); + min-width: 0; } -.topbar h1 { - font-size: 22px; - font-weight: 800; +.brand-mark { + flex: none; + width: 36px; + height: 36px; + display: block; } -.topbar p, -.editor-head p { - color: var(--muted); - font-size: 13px; +.brand-text { + display: grid; + min-width: 0; } -.toolbar, -.quick-links, -.actions { +.brand-org { + font: var(--csi-label); + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--csi-muted); +} + +.topbar h1 { + margin: 0; + font: 700 20px/1.2 var(--csi-font); + color: var(--csi-ink); +} + +.toolbar { display: flex; align-items: center; flex-wrap: wrap; - gap: 8px; + justify-content: flex-end; + gap: var(--csi-s2); } +/* Buttons (same look as .csi-btn, a little smaller) */ .btn, .quick-links a { - border: 1px solid var(--line-strong); - background: #fff; - color: var(--text); - border-radius: 7px; - padding: 8px 12px; - min-height: 36px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--csi-s2); + min-height: var(--ctl); + padding: 0 14px; + border: 1px solid var(--csi-rule-strong); + background: var(--csi-paper); + color: var(--csi-ink); + font: 700 14px/1 var(--csi-font); text-decoration: none; - font: inherit; + cursor: pointer; } .btn:hover, .quick-links a:hover { - background: #f1f3f5; + border-color: var(--csi-ink); + color: var(--csi-ink); +} + +.btn:disabled { + cursor: not-allowed; + color: var(--csi-muted); + background: var(--csi-surface); } .btn.primary { - color: #fff; - background: var(--accent); - border-color: var(--accent); + color: var(--csi-on-turquoise); + background: var(--csi-turquoise); + border-color: var(--csi-turquoise); } .btn.primary:hover { - background: var(--accent-dark); + background: var(--csi-turquoise-deep); + border-color: var(--csi-turquoise-deep); + color: var(--csi-on-turquoise); +} + +.btn.primary:disabled { + background: var(--csi-rule-strong); + border-color: var(--csi-rule-strong); + color: var(--csi-paper); } .btn.danger { - color: var(--danger); - border-color: #e2c0bd; + color: var(--csi-rose); + border-color: var(--csi-rose); +} + +.btn.danger:hover { + color: #ffffff; + background: var(--csi-rose); + border-color: var(--csi-rose); } .hidden-input { display: none; } +/* Menus (Export, Account) */ +.menu { + position: relative; +} + +.menu-caret { + width: 0; + height: 0; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 5px solid currentColor; +} + +.menu-panel { + position: absolute; + right: 0; + top: calc(100% + var(--csi-s1)); + z-index: 30; + width: max-content; + min-width: 200px; + max-width: min(360px, calc(100vw - 24px)); + padding: var(--csi-s1) 0; + background: var(--csi-paper); + border: 1px solid var(--csi-ink); +} + +.menu-heading { + padding: var(--csi-s2) var(--csi-s4); + color: var(--csi-muted); + font: var(--csi-small); + overflow-wrap: break-word; + word-break: break-word; + border-bottom: var(--csi-border); + margin-bottom: var(--csi-s1); +} + +.menu-item { + display: block; + width: 100%; + min-height: var(--ctl); + padding: 0 var(--csi-s4); + border: 0; + background: transparent; + color: var(--csi-ink); + font: 500 14px/1 var(--csi-font); + text-align: left; + cursor: pointer; +} + +.menu-item:hover, +.menu-item:focus-visible { + background: var(--csi-turquoise-tint); +} + +/* Messages */ .banner { - padding: 10px 16px; - color: var(--accent-dark); - background: var(--accent-weak); - border-bottom: 1px solid #bfe0d9; + padding: var(--csi-s2) var(--csi-s4); + color: var(--csi-turquoise-deep); + background: var(--csi-turquoise-tint); + border-bottom: var(--csi-border); + font: var(--csi-small); } .banner.error { - color: #7f1d1d; - background: #fff2f1; - border-bottom-color: #efc6c2; + color: var(--csi-text); + background: var(--csi-rose-tint); + border-left: 4px solid var(--csi-rose); +} + +.banner.error::before { + content: 'Problem: '; + font-weight: 700; + color: var(--csi-rose); } +/* Sign-in and session screens */ .unlock-screen { - min-height: calc(100vh - 64px); + min-height: calc(100vh - var(--header)); display: grid; place-items: center; - padding: 24px; + padding: var(--csi-s5) var(--csi-s4); } .unlock-panel { - width: min(100%, 380px); + width: min(100%, 420px); display: grid; - gap: 14px; - padding: 22px; - background: var(--panel); - border: 1px solid var(--line); - border-radius: 8px; - box-shadow: var(--shadow); + gap: var(--csi-s4); + padding: var(--csi-s5); + background: var(--csi-paper); + border: var(--csi-border); + border-top: var(--csi-bar) solid var(--csi-turquoise); } -.unlock-panel h2, -.unlock-panel p { +.unlock-panel h2 { margin: 0; + font: var(--csi-h2); + color: var(--csi-ink); } .unlock-panel p { - color: var(--muted); -} - -.workspace { - min-height: calc(100vh - 64px); - display: grid; - grid-template-columns: 300px minmax(420px, 1fr) 420px; -} - -.sidebar, -.editor, -.source-panel { - min-height: 0; - background: var(--panel); + margin: 0; + color: var(--csi-muted); } -.sidebar { - border-right: 1px solid var(--line); - display: flex; - flex-direction: column; +.privacy-note { + font: var(--csi-small); + color: var(--csi-muted); + margin-top: var(--csi-s4); } -.filters { - display: grid; - gap: 10px; - padding: 14px; - border-bottom: 1px solid var(--line); +.turnstile-slot { + min-height: 65px; + margin: var(--csi-s2) 0; } +/* Forms */ label { display: grid; - gap: 6px; - color: var(--muted); - font-size: 11px; - font-weight: 800; - letter-spacing: 0; + gap: 4px; + color: var(--csi-muted); + font: var(--csi-label); + letter-spacing: 0.08em; text-transform: uppercase; } @@ -173,98 +324,248 @@ input, select, textarea { width: 100%; - min-height: 38px; - border: 1px solid var(--line-strong); - border-radius: 7px; - padding: 8px 10px; - color: var(--text); - background: #fff; - font: 14px/1.4 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + min-height: var(--ctl); + border: 1px solid var(--csi-rule-strong); + padding: 0 var(--csi-s3); + color: var(--csi-ink); + background: var(--csi-paper); + font: 400 15px/1.4 var(--csi-font); text-transform: none; letter-spacing: 0; } textarea { - min-height: 94px; + min-height: 72px; + padding: var(--csi-s2) var(--csi-s3); resize: vertical; } +input::placeholder, +textarea::placeholder { + color: var(--csi-muted); +} + input:focus, select:focus, textarea:focus { - outline: 2px solid rgba(20, 108, 95, 0.18); - border-color: var(--accent); + border-color: var(--csi-turquoise); +} + +/* Workspace: one view at a time */ +.workspace { + min-height: calc(100vh - var(--header)); +} + +.view-loading, +.view-empty { + display: grid; + place-items: start center; + padding: var(--csi-s7) var(--csi-s4); +} + +.view-list { + display: grid; + grid-template-columns: minmax(0, 760px); + justify-content: center; + align-items: start; + padding: var(--csi-s4); +} + +.view-edit { + display: grid; + grid-template-columns: 300px minmax(0, 1fr) 320px; + align-items: start; +} + +.empty { + display: grid; + gap: var(--csi-s2); + padding: var(--csi-s5) var(--csi-s4); + color: var(--csi-muted); + text-align: center; + justify-items: center; +} + +.empty strong { + color: var(--csi-ink); + font: var(--csi-h3); +} + +/* Welcome (no contacts yet) */ +.welcome { + width: min(100%, 560px); + display: grid; + gap: var(--csi-s4); + padding: var(--csi-s6); + background: var(--csi-paper); + border: var(--csi-border); + border-top: var(--csi-bar) solid var(--csi-turquoise); +} + +.welcome h2 { + margin: 0; + font: var(--csi-h1); + color: var(--csi-ink); +} + +.welcome p { + margin: 0; + max-width: var(--csi-measure); + font: var(--csi-body); + color: var(--csi-text); +} + +.welcome-steps { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--csi-s3); + margin: 0; + padding: 0; + list-style: none; + counter-reset: step; +} + +.welcome-steps li { + counter-increment: step; + display: grid; + gap: 2px; + padding-top: var(--csi-s3); + border-top: 1px solid var(--csi-rule-strong); +} + +.welcome-steps li::before { + content: counter(step, decimal-leading-zero); + font: var(--csi-label); + letter-spacing: 0.08em; + color: var(--csi-turquoise-deep); +} + +.welcome-steps strong { + color: var(--csi-ink); + font: 700 15px/1.3 var(--csi-font); +} + +.welcome-steps span { + color: var(--csi-muted); +} + +.welcome-actions { + display: flex; + flex-wrap: wrap; + gap: var(--csi-s2); +} + +/* Contact list */ +.sidebar { + background: var(--csi-paper); + border: var(--csi-border); + display: flex; + flex-direction: column; + min-width: 0; +} + +.view-edit .sidebar { + position: sticky; + top: var(--header); + height: calc(100vh - var(--header)); + border-width: 0 1px 0 0; +} + +.filters { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 140px); + gap: var(--csi-s2); + padding: var(--csi-s3); + border-bottom: var(--csi-border); +} + +.view-edit .filters { + grid-template-columns: 1fr; } .contact-list { min-height: 0; + padding: var(--csi-s2); +} + +.view-edit .contact-list { overflow-y: auto; - padding: 8px; } .list-head { display: flex; align-items: center; justify-content: space-between; - gap: 10px; - padding: 6px 6px 10px; - color: var(--muted); - font-size: 12px; + gap: var(--csi-s3); + padding: 4px 6px 8px; + color: var(--csi-muted); + font: var(--csi-small); } .list-head strong { - color: var(--text); - font-size: 13px; + color: var(--csi-ink); + font: var(--csi-label); + letter-spacing: 0.08em; + text-transform: uppercase; } .contact-group { display: grid; - gap: 3px; + gap: 2px; } .letter-row { - position: sticky; - top: 0; - z-index: 1; - padding: 8px 7px 4px; - color: var(--muted); - background: var(--panel); - font-size: 11px; - font-weight: 800; - letter-spacing: 0; + padding: var(--csi-s2) 6px var(--csi-s1); + color: var(--csi-muted); + font: var(--csi-label); + letter-spacing: 0.08em; + border-bottom: var(--csi-border); } .contact-row { width: 100%; + min-height: 56px; display: grid; - grid-template-columns: 48px 1fr; - gap: 10px; + grid-template-columns: 40px minmax(0, 1fr) auto; + gap: var(--csi-s3); align-items: center; border: 1px solid transparent; - border-radius: 8px; - padding: 8px; - color: var(--text); + border-left: 4px solid transparent; + padding: var(--csi-s1) var(--csi-s2); + color: var(--csi-text); background: transparent; text-align: left; + font: var(--csi-small); + cursor: pointer; } .contact-row:hover { - background: #f3f5f7; + background: var(--csi-surface); } .contact-row.active { - background: var(--accent-weak); - border-color: #a8d9cf; + background: var(--csi-turquoise-tint); + border-left-color: var(--csi-turquoise); } .contact-row img, .avatar-fallback { - width: 48px; - height: 48px; - border: 1px solid var(--line); - border-radius: 7px; + width: 40px; + height: 40px; + border: var(--csi-border); object-fit: cover; - background: #edf0f3; + background: var(--csi-surface); +} + +.avatar-fallback { + display: grid; + place-items: center; + color: var(--csi-ink); + font: 700 16px/1 var(--csi-font); +} + +.contact-text { + min-width: 0; } .contact-row strong, @@ -275,77 +576,109 @@ textarea:focus { white-space: nowrap; } +.contact-row strong { + color: var(--csi-ink); +} + .contact-row small { - color: var(--muted); + color: var(--csi-muted); margin-top: 2px; } -.empty { - display: grid; - gap: 6px; - padding: 28px 16px; - color: var(--muted); - text-align: center; +.row-flag { + padding: 1px var(--csi-s2); + color: var(--csi-copper); + background: var(--csi-copper-tint); + font: var(--csi-label); + letter-spacing: 0.06em; + text-transform: uppercase; } +/* Editor */ .editor { min-width: 0; - overflow-y: auto; + background: var(--csi-paper); + display: flex; + flex-direction: column; + min-height: calc(100vh - var(--header)); } -.editor-empty { - min-height: 420px; - align-content: center; +.editor-body { + flex: 1; +} + +.phone-bar { + display: none; } .editor-head { - position: sticky; - top: 0; - z-index: 1; display: flex; align-items: flex-start; justify-content: space-between; - gap: 16px; - padding: 18px 22px; - background: rgba(255, 255, 255, 0.95); - border-bottom: 1px solid var(--line); + gap: var(--csi-s4); + padding: var(--csi-s3) var(--csi-s5); + border-bottom: var(--csi-border); +} + +.editor-head h2, +.editor-head p { + margin: 0; } .editor-head h2 { - font-size: 26px; + font: 700 22px/1.25 var(--csi-font); + color: var(--csi-ink); + overflow-wrap: anywhere; } -.quick-links { - margin-top: 12px; +.editor-head p { + color: var(--csi-muted); + font: var(--csi-small); +} + +.head-flags { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--csi-s2); + margin-top: var(--csi-s2); } .review-chip { - display: inline-flex; - width: fit-content; - margin-top: 10px; - border: 1px solid #ead19b; - border-radius: 999px; - padding: 4px 9px; - color: #7a5209; - background: #fff7df; - font-size: 12px; - font-weight: 800; + padding: 2px var(--csi-s2); + color: var(--csi-copper); + background: var(--csi-copper-tint); + border-left: 4px solid var(--csi-copper); + font: 700 13px/1.5 var(--csi-font); } .confidence { - border-radius: 999px; - padding: 5px 10px; - color: var(--accent); - background: var(--accent-weak); - font-size: 12px; - font-weight: 800; + padding: 2px var(--csi-s2); + color: var(--csi-muted); + background: var(--csi-surface); + border: var(--csi-border); + font: var(--csi-label); + letter-spacing: 0.08em; +} + +.quick-links { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: var(--csi-s2); +} + +.quick-links a { + min-height: 32px; + padding: 0 var(--csi-s3); + font-size: 13px; } .form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 14px; - padding: 18px 22px 0; + gap: var(--csi-s3) var(--csi-s4); + padding: var(--csi-s4) var(--csi-s5); } .form-grid .wide { @@ -353,172 +686,342 @@ textarea:focus { } .actions { - padding: 18px 22px 28px; + position: sticky; + bottom: 0; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--csi-s2); + padding: var(--csi-s3) var(--csi-s5); + background: var(--csi-paper); + border-top: var(--csi-border); } +.actions .danger { + margin-left: auto; +} + +/* Card photo */ .source-panel { - border-left: 1px solid var(--line); + position: sticky; + top: var(--header); + height: calc(100vh - var(--header)); + background: var(--csi-paper); + border-left: var(--csi-border); display: flex; flex-direction: column; } .source-head { - min-height: 54px; + min-height: 48px; display: flex; align-items: center; justify-content: space-between; - gap: 12px; - padding: 14px 16px; - border-bottom: 1px solid var(--line); + gap: var(--csi-s3); + padding: var(--csi-s2) var(--csi-s4); + border-bottom: var(--csi-border); +} + +.source-head h2 { + margin: 0; + font: var(--csi-label); + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--csi-muted); } .source-head a { - color: var(--accent); - text-decoration: none; + color: var(--csi-turquoise); + text-decoration: underline; + text-underline-offset: 3px; } .source-body { flex: 1; overflow: auto; - padding: 12px; - background: #e9edf1; + padding: var(--csi-s3); + background: var(--csi-surface); } .source-body img { display: block; width: 100%; height: auto; - border-radius: 8px; - box-shadow: var(--shadow); - background: white; + border: var(--csi-border); + background: var(--csi-paper); +} + +/* Dialogs */ +.modal-backdrop { + position: fixed; + inset: 0; + z-index: 40; + display: grid; + place-items: center; + padding: var(--csi-s4); + background: rgba(21, 23, 26, 0.5); } +.modal-panel { + width: min(100%, 440px); + max-height: 100%; + overflow: auto; + display: grid; + gap: var(--csi-s4); + padding: var(--csi-s5); + background: var(--csi-paper); + border: 1px solid var(--csi-ink); + border-top: var(--csi-bar) solid var(--csi-turquoise); +} + +.modal-head, +.modal-actions { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--csi-s3); +} + +.modal-head h2, +.modal-head p { + margin: 0; +} + +.modal-head h2 { + font: var(--csi-h3); + color: var(--csi-ink); +} + +.modal-head p { + color: var(--csi-muted); + font: var(--csi-small); +} + +.modal-actions { + align-items: center; + justify-content: flex-start; + flex-wrap: wrap; +} + +.session-panel { + border-top-color: var(--csi-copper); +} + +.session-body { + display: grid; + gap: var(--csi-s3); +} + +.session-body p { + margin: 0; + max-width: var(--csi-measure); + font: var(--csi-body); + color: var(--csi-text); +} + +.app-content[inert] { + opacity: 0.4; +} + +/* Medium screens: photo moves under the form */ @media (max-width: 1120px) { - .workspace { - grid-template-columns: 280px minmax(0, 1fr); + .view-edit { + grid-template-columns: 260px minmax(0, 1fr); } .source-panel { + position: static; + height: auto; grid-column: 2; - border-top: 1px solid var(--line); border-left: 0; + border-top: var(--csi-border); } .source-body { - max-height: 360px; + max-height: 420px; + } + + .actions { + position: static; } } +/* Phones: one task at a time */ @media (max-width: 760px) { + .app-shell { + --header: 101px; + } + .topbar { - align-items: flex-start; + position: static; flex-direction: column; + align-items: stretch; + gap: var(--csi-s2); + padding: var(--csi-s2) var(--csi-s3); } - .workspace { - display: flex; - flex-direction: column; + .toolbar { + flex-wrap: nowrap; + justify-content: stretch; + gap: 6px; } - .sidebar { - order: 1; - border-right: 0; - border-bottom: 1px solid var(--line); + .toolbar .btn { + white-space: nowrap; } - .source-panel { - order: 2; - border-bottom: 1px solid var(--line); + .toolbar > .btn.primary { + flex: 1 1 auto; } - .editor { - order: 3; + .toolbar .menu-button, + .toolbar > .btn:not(.primary) { + padding: 0 10px; + font-size: 13px; } - .contact-list { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - align-items: start; - max-height: none; + .view-loading, + .view-empty { + padding: var(--csi-s5) var(--csi-s3); } - .list-head { - grid-column: 1 / -1; + .welcome { + padding: var(--csi-s4); } - .letter-row { - position: static; + .welcome h2 { + font: var(--csi-h2); + } + + .welcome-steps { + grid-template-columns: 1fr; + gap: var(--csi-s2); + } + + .welcome-steps li { + grid-template-columns: auto 1fr; + align-items: baseline; + column-gap: var(--csi-s3); + } + + .welcome-steps li span { + grid-column: 2; + } + + .welcome-actions .btn { + flex: 1 1 100%; + } + + .view-list { + padding: 0; + grid-template-columns: minmax(0, 1fr); + } + + .view-list .sidebar { + border-width: 0; + } + + .view-edit { + display: block; + } + + .view-edit .sidebar { + display: none; + } + + .phone-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--csi-s2); + padding: var(--csi-s2) var(--csi-s3); + border-bottom: var(--csi-border); + } + + .segmented { + display: flex; + border: 1px solid var(--csi-rule-strong); + } + + .segmented button { + min-height: var(--ctl); + padding: 0 var(--csi-s3); + border: 0; + background: var(--csi-paper); + color: var(--csi-ink); + font: 700 14px/1 var(--csi-font); + cursor: pointer; + } + + .segmented button[aria-pressed='true'] { + background: var(--csi-ink); + color: var(--csi-paper); + } + + .segmented button + button { + border-left: 1px solid var(--csi-rule-strong); + } + + .editor { + min-height: calc(100vh - var(--header) - 61px); } .form-grid { grid-template-columns: 1fr; + padding: var(--csi-s3); } .editor-head { - position: static; + flex-direction: column; + padding: var(--csi-s3); } - .source-body { - max-height: 260px; + .quick-links { + justify-content: flex-start; } -} -.turnstile-slot { - min-height: 65px; - margin: 8px 0; -} + .quick-links a { + min-height: var(--ctl); + } -.privacy-note { - font-size: 12px; - opacity: 0.7; - margin-top: 16px; -} + .actions { + position: sticky; + padding: var(--csi-s3); + } -.modal-backdrop { - position: fixed; - inset: 0; - z-index: 20; - display: grid; - place-items: center; - padding: 18px; - background: rgba(31, 37, 45, 0.34); -} + .actions .btn { + flex: 1 1 auto; + } -.modal-panel { - width: min(100%, 420px); - display: grid; - gap: 14px; - padding: 18px; - background: var(--panel); - border: 1px solid var(--line); - border-radius: 8px; - box-shadow: var(--shadow); -} + .actions .danger { + margin-left: 0; + } -.modal-head, -.modal-actions { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 12px; -} + .view-edit .source-panel { + display: none; + } -.modal-head h2, -.modal-head p { - margin: 0; -} + .view-edit.show-photo .source-panel { + display: flex; + position: static; + height: auto; + border-top: 0; + } -.modal-head h2 { - font-size: 18px; -} + .view-edit.show-photo .editor-body, + .view-edit.show-photo .actions { + display: none; + } -.modal-head p { - color: var(--muted); - font-size: 13px; + .view-edit.show-photo .editor { + min-height: 0; + } } -.modal-actions { - align-items: center; - justify-content: flex-start; - flex-wrap: wrap; +@media (prefers-reduced-motion: reduce) { + * { + animation: none !important; + transition: none !important; + scroll-behavior: auto !important; + } } diff --git a/src/App.tsx b/src/App.tsx index 59f37f2..0073986 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import './App.css' import { LoginGate } from './components/LoginGate' -import { apiGet, apiJson, apiUpload } from './lib/api' +import { Menu } from './components/Menu' +import { SessionPanel, type SessionProblem } from './components/SessionPanel' +import { SignOutConfirm } from './components/SignOutConfirm' +import { ApiError, apiGet, apiJson, apiUpload, relativeEdgeUrl } from './lib/api' import { contactDisplayName, groupContactsByInitial, sortContactsByName } from './shared/contacts' import { contactNeedsReview, reviewedContact } from './shared/review' import type { Contact, ContactStatus } from './shared/types' @@ -16,11 +19,18 @@ function App() { const [busy, setBusy] = useState(false) const [authRequired, setAuthRequired] = useState(false) const [userEmail, setUserEmail] = useState('') + const [accessMode, setAccessMode] = useState(false) + const [photoTab, setPhotoTab] = useState(false) + const [confirmSignOut, setConfirmSignOut] = useState(false) const [passwordPanelOpen, setPasswordPanelOpen] = useState(false) const [accountPassword, setAccountPassword] = useState('') const [accountPasswordConfirm, setAccountPasswordConfirm] = useState('') const [notice, setNotice] = useState('') const [error, setError] = useState('') + const [sessionProblem, setSessionProblem] = useState(null) + const signedInRef = useRef(false) + const signedOutRef = useRef(false) + const draftRef = useRef(null) const fileInput = useRef(null) const importInput = useRef(null) const selectedIdRef = useRef('') @@ -29,6 +39,7 @@ function App() { const nextId = contact?.id || '' selectedIdRef.current = nextId setSelectedId(nextId) + setPhotoTab(false) setDraft(contact ? cloneContact(contact) : null) }, []) @@ -38,23 +49,27 @@ function App() { setError('') try { const data = await apiGet<{ contacts: Contact[] }>('/api/contacts') + if (signedOutRef.current) return undefined // a slow load must not bring the screen back after sign out const sortedContacts = sortContactsByName(data.contacts) setContacts(sortedContacts) setAuthRequired(false) const nextId = preferredId || selectedIdRef.current - selectContact(sortedContacts.find((contact) => contact.id === nextId) || sortedContacts[0] || null) + selectContact(sortedContacts.find((contact) => contact.id === nextId) || null) + return sortedContacts } catch (err) { - const message = messageFrom(err) - if (message.includes('Sign in to continue')) { + if (signedInRef.current && err instanceof ApiError && (err.kind === 'session-ended' || err.kind === 'denied')) { + reportSessionProblem(err) + } else if (messageFrom(err).includes('Sign in to continue')) { setAuthRequired(true) setError('') } else { - setError(message) + setError(messageFrom(err)) } } finally { setLoading(false) } }, + // eslint-disable-next-line react-hooks/exhaustive-deps [selectContact], ) @@ -65,10 +80,20 @@ function App() { const me = await apiGet<{ email: string }>('/api/me') if (cancelled) return setUserEmail(me.email) - await loadContacts() - } catch { + signedInRef.current = true + // In Access mode the edge handles sign-in, so the password screens are not offered. + const config = await apiGet<{ mode?: string }>('/api/auth/config').catch(() => ({}) as { mode?: string }) + if (!cancelled) setAccessMode(config.mode === 'access') + const loaded = await loadContacts() + if (loaded) restorePendingDraft(loaded) + } catch (err) { if (cancelled) return - setAuthRequired(true) + if (err instanceof ApiError && err.kind === 'denied') { + // Signed in at the edge but refused by CardCap: explain it instead of offering a sign-in form. + reportSessionProblem(err) + } else { + setAuthRequired(true) + } setLoading(false) } } @@ -76,6 +101,7 @@ function App() { return () => { cancelled = true } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [loadContacts]) const filteredContacts = useMemo(() => { @@ -105,6 +131,46 @@ function App() { const contactGroups = useMemo(() => groupContactsByInitial(filteredContacts), [filteredContacts]) const reviewCount = useMemo(() => contacts.filter(contactNeedsReview).length, [contacts]) + draftRef.current = draft + + function reportSessionProblem(err: ApiError) { + if (err.kind === 'denied') { + // A 403 only comes from Access mode, where the edge logout path is the way out. + setSessionProblem({ kind: 'denied', message: err.message, logoutUrl: err.logoutUrl || '/cdn-cgi/access/logout' }) + return + } + const pending = draftRef.current + const original = pending ? contacts.find((contact) => contact.id === pending.id) : null + const hasUnsavedEdits = Boolean(pending && original && JSON.stringify(pending) !== JSON.stringify(original)) + if (pending && hasUnsavedEdits) writePendingDraft(pending) + setSessionProblem({ kind: 'ended', hasUnsavedEdits }) + } + + // One place decides how a failed request is shown: session ended, account refused, offline, or a plain message. + function fail(err: unknown) { + if (err instanceof ApiError && signedInRef.current && (err.kind === 'session-ended' || err.kind === 'denied')) { + reportSessionProblem(err) + return + } + setError(messageFrom(err)) + } + + function restorePendingDraft(loaded: Contact[]) { + const saved = readPendingDraft() + if (!saved) return + clearPendingDraft() + if (!loaded.some((contact) => contact.id === saved.id)) return + setSelectedId(saved.id) + selectedIdRef.current = saved.id + setDraft(saved) + setNotice('Welcome back. We restored the edits you had not saved. Review them, then save.') + } + + function signInAgain() { + // Reloading this page sends the browser through Access sign-in and back to the same page. + window.location.reload() + } + async function handleFiles(files: FileList | null) { if (!files || files.length === 0) return setBusy(true) @@ -128,7 +194,7 @@ function App() { : 'Cards added. Review the extracted fields before follow-up.', ) } catch (err) { - setError(messageFrom(err)) + fail(err) } finally { setBusy(false) if (fileInput.current) fileInput.current.value = '' @@ -155,7 +221,7 @@ function App() { `${data.created} new, ${data.merged} merged${data.skipped ? `, ${data.skipped} skipped` : ''}.`, ) } catch (err) { - setError(messageFrom(err)) + fail(err) } finally { setBusy(false) if (importInput.current) importInput.current.value = '' @@ -163,14 +229,38 @@ function App() { } async function handleSignedIn() { + signedOutRef.current = false + signedInRef.current = true setAuthRequired(false) const me = await apiGet<{ email: string }>('/api/me').catch(() => null) setUserEmail(me?.email || '') await loadContacts() } + // In Access mode signing out ends the shared CSI sign-in (Blackboard too), so ask first; the old sign-in only ends this app's session. + function askSignOut() { + if (accessMode || sessionProblem?.kind === 'denied') setConfirmSignOut(true) + else void signOut() + } + + function cancelSignOut() { + setConfirmSignOut(false) + // Put focus back on the Account button (when that is where the person came from). + requestAnimationFrame(() => document.querySelector('[aria-label^="Account menu"]')?.focus()) + } + async function signOut() { - await apiJson<{ ok: boolean }>('/api/auth/logout', 'POST').catch(() => undefined) + setConfirmSignOut(false) + const result = await apiJson<{ ok: boolean; logoutUrl?: string }>('/api/auth/logout', 'POST').catch(() => undefined) + const logoutUrl = relativeEdgeUrl(result?.logoutUrl) || (sessionProblem?.kind === 'denied' ? relativeEdgeUrl(sessionProblem.logoutUrl) : undefined) + if (logoutUrl) { + clearPendingDraft() + window.location.assign(logoutUrl) + return + } + signedOutRef.current = true + signedInRef.current = false + setSessionProblem(null) setUserEmail('') setContacts([]) setPasswordPanelOpen(false) @@ -194,7 +284,7 @@ function App() { setAccountPasswordConfirm('') setNotice('Password saved.') } catch (err) { - setError(messageFrom(err)) + fail(err) } finally { setBusy(false) } @@ -216,7 +306,7 @@ function App() { selectContact(data.contact) setNotice('Saved.') } catch (err) { - setError(messageFrom(err)) + fail(err) } finally { setBusy(false) } @@ -232,10 +322,10 @@ function App() { await apiJson<{ deleted: boolean }>(`/api/contacts/${encodeURIComponent(draft.id)}`, 'DELETE') const next = sortContactsByName(contacts.filter((contact) => contact.id !== draft.id)) setContacts(next) - selectContact(next[0] || null) + selectContact(null) setNotice('Deleted.') } catch (err) { - setError(messageFrom(err)) + fail(err) } finally { setBusy(false) } @@ -258,7 +348,7 @@ function App() { selectContact(data.contact) setNotice('Marked reviewed.') } catch (err) { - setError(messageFrom(err)) + fail(err) } finally { setBusy(false) } @@ -272,65 +362,81 @@ function App() { window.location.href = path } + const view: 'loading' | 'empty' | 'list' | 'edit' = + contacts.length === 0 ? (loading ? 'loading' : 'empty') : draft ? 'edit' : 'list' + return (
+ + Skip to contacts + +
+