Gate admin menu and authoring with shared JWT
This commit is contained in:
@@ -2,4 +2,5 @@ DATABASE_URL=postgres://osint:osint_secret@localhost:5433/osint_dev
|
||||
PORT=8787
|
||||
CORS_ORIGIN=http://localhost:5173
|
||||
LEVEL_EDITING_ENABLED=true
|
||||
JWT_SECRET=osint-local-dev-secret
|
||||
MAX_DOCUMENT_BYTES=26214400
|
||||
|
||||
@@ -76,17 +76,20 @@ The API surface is:
|
||||
- `POST /api/levels/:id/templates` (save a new immutable version; editor only)
|
||||
- `POST /api/levels/:id/documents` (editor only)
|
||||
- `GET /api/assets/:id`
|
||||
- `GET /api/session` (verified session and admin capability summary)
|
||||
- `GET /api/health`
|
||||
|
||||
The browser also keeps a local emergency copy so a network interruption does not lose an in-progress board.
|
||||
|
||||
## Level editor
|
||||
|
||||
Set `LEVEL_EDITING_ENABLED=true` and open `/?edit=1`. If the database is empty, this surface creates the first blank level. Editing and playing are the same operation against a mutable level. Authoring additionally exposes **Save Template** and **New From Template**. Saving creates a new immutable version; instantiating gives every exhibit a fresh ID while sharing immutable binary assets. Reset restores the exact template version from which a level was created.
|
||||
Set `LEVEL_EDITING_ENABLED=true` and open `/?edit=1` while signed in with a JWT carrying `role: "admin"`. The shared `auth_token` cookie is verified with `JWT_SECRET`; the legacy `isAdmin: true` claim remains compatible. If the database is empty, this surface creates the first blank level. Editing and playing are the same operation against a mutable level. Authoring actions live under the **Admin** menu. Saving a template creates a new immutable version; instantiating gives every exhibit a fresh ID while sharing immutable binary assets. Reset restores the exact template version from which a level was created.
|
||||
|
||||
For the standalone development Compose stack, visit `/api/dev/admin-session?returnTo=/?edit=1` once to receive a local signed admin cookie. This helper does not exist in production.
|
||||
|
||||
In edit mode, files can be dragged from the desktop onto the board or selected with **Import Document**. Images, PDFs, and text files render inside document windows; unknown formats remain downloadable source files. Extracted evidence becomes an editable folder widget. Its editor controls the title, annotation, contained documents, and each source document's publication time. The default upload limit is 25 MB and can be changed with `MAX_DOCUMENT_BYTES`.
|
||||
|
||||
Production defaults editing to disabled. Set `LEVEL_EDITING_ENABLED=true` in `/opt/gu_common/.env.prod` only when the authoring surface should be available. This is a capability switch, not authentication; add authentication before exposing production editing to untrusted users.
|
||||
Production defaults editing to disabled. Set `LEVEL_EDITING_ENABLED=true` in `/opt/gu_common/.env.prod` only when the authoring surface should be available. `JWT_SECRET` is inherited from that shared environment, and authoring endpoints additionally require a verified admin claim.
|
||||
|
||||
## POC exhibit and widget contract
|
||||
|
||||
@@ -113,7 +116,7 @@ On desktop, an ordinary mouse wheel or two-finger trackpad scroll zooms the boar
|
||||
|
||||
## Deploy at osint.glitch.university
|
||||
|
||||
The production service depends on `gu_common`: it joins the external `gnommo` Docker network, uses `gnommo-db`, and is routed by the shared nginx container. Deploy `gu_common` after its nginx configuration changes, then deploy this repository:
|
||||
The production service depends on `gu_common`: it joins the external `gnommo` Docker network, uses `gnommo-db`, shares its JWT secret and cross-subdomain authentication cookie, and is routed by the shared nginx container. Deploy `gu_common` after its nginx configuration changes, then deploy this repository:
|
||||
|
||||
```bash
|
||||
./deploy.sh
|
||||
@@ -123,4 +126,4 @@ The deploy script builds and syncs the application, reads production database cr
|
||||
|
||||
## Deliberate POC boundaries
|
||||
|
||||
There are no accounts, arbitrary uploads, real-world web browsing, OCR, or collaboration yet. The server data model and provenance fields leave room for those later without making them part of the first playability test.
|
||||
Authentication is supplied by the shared Glitch University account system. There is no OSINT-specific account model, real-world web browsing, OCR, or collaboration yet. The server data model and provenance fields leave room for those later without making them part of the first playability test.
|
||||
|
||||
@@ -23,10 +23,11 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
container_name: osint-board-app
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
NODE_ENV: development
|
||||
PORT: 8787
|
||||
DATABASE_URL: postgres://osint:osint_secret@db:5432/osint_dev
|
||||
CORS_ORIGIN: http://localhost:8787
|
||||
JWT_SECRET: ${JWT_SECRET:-osint-local-dev-secret}
|
||||
LEVEL_EDITING_ENABLED: "true"
|
||||
MAX_DOCUMENT_BYTES: 26214400
|
||||
ports:
|
||||
|
||||
@@ -10,6 +10,7 @@ services:
|
||||
PORT: 8787
|
||||
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@gnommo-db:5432/${POSTGRES_DB}
|
||||
CORS_ORIGIN: https://osint.${DOMAIN}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
LEVEL_EDITING_ENABLED: ${LEVEL_EDITING_ENABLED:-false}
|
||||
MAX_DOCUMENT_BYTES: ${MAX_DOCUMENT_BYTES:-26214400}
|
||||
expose:
|
||||
|
||||
+16
-2
@@ -27,6 +27,8 @@ async function waitForSave(page: Page, action: () => Promise<void>) {
|
||||
test('move, folder expansion, empty-board pan, desktop wheel zoom, mobile pinch, and reload persistence', async ({ page }) => {
|
||||
await page.goto('/?level=e2e-level&edit=1')
|
||||
await expect(page.getByRole('heading', { name: 'Browser Safety Test' })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'ADMIN', exact: true })).toBeVisible()
|
||||
expect((await page.locator('.menubar nav > button, .menubar nav > .admin-menu > button').allTextContents()).map(label => label.replace(/\d+$/, ''))).toEqual(['EVIDENCE', 'CASE BRIEF', 'TIMELINE', 'HELP', 'ADMIN'])
|
||||
const temporalLayering = await page.evaluate(() => ({
|
||||
links: Number.parseInt(getComputedStyle(document.querySelector('.temporal-links')!).zIndex, 10),
|
||||
timeline: Number.parseInt(getComputedStyle(document.querySelector('.timeline')!).zIndex, 10),
|
||||
@@ -193,7 +195,8 @@ test('move, folder expansion, empty-board pan, desktop wheel zoom, mobile pinch,
|
||||
|
||||
page.once('dialog', dialog => dialog.accept('Browser Template'))
|
||||
const templateSaved = page.waitForResponse(candidate => candidate.request().method() === 'POST' && candidate.url().includes('/templates?edit=1') && candidate.ok())
|
||||
await page.getByRole('button', { name: 'SAVE TEMPLATE', exact: true }).click()
|
||||
await page.getByRole('button', { name: 'ADMIN', exact: true }).click()
|
||||
await page.getByRole('menuitem', { name: 'SAVE AS TEMPLATE', exact: true }).click()
|
||||
await templateSaved
|
||||
await expect(page.locator('.terminal-status')).toContainText('VERSION 1')
|
||||
|
||||
@@ -201,7 +204,8 @@ test('move, folder expansion, empty-board pan, desktop wheel zoom, mobile pinch,
|
||||
const prompts = ['browser-template', 'Browser Template Clone']
|
||||
const promptHandler = (dialog: { accept(promptText?: string): Promise<void> }) => dialog.accept(prompts[promptIndex++])
|
||||
page.on('dialog', promptHandler)
|
||||
await page.getByRole('button', { name: 'NEW FROM TEMPLATE', exact: true }).click()
|
||||
await page.getByRole('button', { name: 'ADMIN', exact: true }).click()
|
||||
await page.getByRole('menuitem', { name: 'NEW FROM TEMPLATE', exact: true }).click()
|
||||
await page.waitForURL(url => url.searchParams.get('level')?.startsWith('browser-template-') === true)
|
||||
page.off('dialog', promptHandler)
|
||||
await expect(page.getByRole('heading', { name: 'Browser Template Clone' })).toBeVisible()
|
||||
@@ -223,3 +227,13 @@ test('move, folder expansion, empty-board pan, desktop wheel zoom, mobile pinch,
|
||||
if (!landscapeToolbar) throw new Error('Landscape toolbar is not visible')
|
||||
expect(landscapeToolbar.width).toBeGreaterThan(landscapeToolbar.height * 2)
|
||||
})
|
||||
|
||||
test('hides the admin menu without a verified admin JWT', async ({ browser }) => {
|
||||
const context = await browser.newContext({ baseURL: 'http://127.0.0.1:18788', viewport: { width: 1280, height: 720 }, extraHTTPHeaders: { Cookie: 'anonymous_session=1' } })
|
||||
const page = await context.newPage()
|
||||
await page.goto('/?level=e2e-level&edit=1')
|
||||
await expect(page.getByRole('heading', { name: 'Browser Safety Test' })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'ADMIN', exact: true })).toHaveCount(0)
|
||||
expect((await page.locator('.menubar nav > button').allTextContents()).map(label => label.replace(/\d+$/, ''))).toEqual(['EVIDENCE', 'CASE BRIEF', 'TIMELINE', 'HELP'])
|
||||
await context.close()
|
||||
})
|
||||
|
||||
Generated
+164
-1
@@ -8,9 +8,11 @@
|
||||
"name": "gupi-osint-board",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "2.8.5",
|
||||
"dotenv": "16.5.0",
|
||||
"express": "5.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lucide-react": "0.468.0",
|
||||
"multer": "^2.2.0",
|
||||
"pg": "8.16.3",
|
||||
@@ -20,8 +22,10 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@types/cookie-parser": "^1.4.9",
|
||||
"@types/cors": "2.8.18",
|
||||
"@types/express": "5.0.3",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/multer": "2.0.0",
|
||||
"@types/node": "22.15.30",
|
||||
"@types/pg": "8.15.4",
|
||||
@@ -34,7 +38,7 @@
|
||||
"vitest": "^3.2.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": "^20.0.0 || >=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
@@ -1252,6 +1256,16 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cookie-parser": {
|
||||
"version": "1.4.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.9.tgz",
|
||||
"integrity": "sha512-tGZiZ2Gtc4m3wIdLkZ8mkj1T6CEHb35+VApbL2T14Dew8HA7c+04dmKqsKRNC+8RJPm16JEK0tFSwdZqubfc4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/express": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cors": {
|
||||
"version": "2.8.18",
|
||||
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.18.tgz",
|
||||
@@ -1308,6 +1322,24 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/jsonwebtoken": {
|
||||
"version": "9.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
|
||||
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/ms": "*",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ms": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/multer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz",
|
||||
@@ -1670,6 +1702,12 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
@@ -1927,6 +1965,25 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-parser": {
|
||||
"version": "1.4.7",
|
||||
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
|
||||
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "0.7.2",
|
||||
"cookie-signature": "1.0.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-parser/node_modules/cookie-signature": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie-signature": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
||||
@@ -2018,6 +2075,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
@@ -2507,6 +2573,61 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonwebtoken": {
|
||||
"version": "9.0.2",
|
||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
|
||||
"integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jws": "^3.2.2",
|
||||
"lodash.includes": "^4.3.0",
|
||||
"lodash.isboolean": "^3.0.3",
|
||||
"lodash.isinteger": "^4.0.4",
|
||||
"lodash.isnumber": "^3.0.3",
|
||||
"lodash.isplainobject": "^4.0.6",
|
||||
"lodash.isstring": "^4.0.1",
|
||||
"lodash.once": "^4.0.0",
|
||||
"ms": "^2.1.1",
|
||||
"semver": "^7.5.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12",
|
||||
"npm": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonwebtoken/node_modules/semver": {
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
|
||||
"integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jws": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.3.tgz",
|
||||
"integrity": "sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^1.4.2",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
@@ -2514,6 +2635,48 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.includes": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isboolean": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isinteger": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
||||
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isnumber": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
|
||||
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isplainobject": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isstring": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
|
||||
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.once": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
|
||||
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/loupe": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
|
||||
|
||||
@@ -17,9 +17,11 @@
|
||||
"test:e2e": "npm run build && playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "2.8.5",
|
||||
"dotenv": "16.5.0",
|
||||
"express": "5.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lucide-react": "0.468.0",
|
||||
"multer": "^2.2.0",
|
||||
"pg": "8.16.3",
|
||||
@@ -29,8 +31,10 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@types/cookie-parser": "^1.4.9",
|
||||
"@types/cors": "2.8.18",
|
||||
"@types/express": "5.0.3",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/multer": "2.0.0",
|
||||
"@types/node": "22.15.30",
|
||||
"@types/pg": "8.15.4",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { defineConfig } from '@playwright/test'
|
||||
import jwt from 'jsonwebtoken'
|
||||
|
||||
const port = 18788
|
||||
const adminToken = jwt.sign({ sub: 'e2e-admin', role: 'admin' }, 'osint-e2e-jwt-secret')
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
@@ -12,6 +14,7 @@ export default defineConfig({
|
||||
baseURL: `http://127.0.0.1:${port}`,
|
||||
headless: true,
|
||||
viewport: { width: 1280, height: 720 },
|
||||
extraHTTPHeaders: { Cookie: `auth_token=${adminToken}` },
|
||||
},
|
||||
webServer: {
|
||||
command: 'npm run e2e:serve',
|
||||
|
||||
@@ -33,29 +33,31 @@ function documentKind(type: SourceFileType) {
|
||||
return type === 'web_capture' ? 'WEB CAPTURE' : type.toUpperCase()
|
||||
}
|
||||
|
||||
async function uploadAsset(baseUrl: string, levelId: string, manifestDir: string, document: MysteryDocument) {
|
||||
async function uploadAsset(baseUrl: string, levelId: string, manifestDir: string, document: MysteryDocument, authorization?: string) {
|
||||
if (!document.asset) return undefined
|
||||
const assetPath = path.resolve(manifestDir, document.asset)
|
||||
const form = new FormData()
|
||||
form.append('file', new Blob([await readFile(assetPath)]), path.basename(assetPath))
|
||||
const response = await requireOk(await fetch(`${baseUrl}/api/levels/${levelId}/documents?edit=1`, { method: 'POST', body: form }), `Upload ${document.asset}`)
|
||||
const response = await requireOk(await fetch(`${baseUrl}/api/levels/${levelId}/documents?edit=1`, { method: 'POST', headers: authorization ? { authorization } : undefined, body: form }), `Upload ${document.asset}`)
|
||||
return await response.json() as CaseDocument
|
||||
}
|
||||
|
||||
export async function importMysteryTemplate(manifestPath: string, baseUrl = 'http://localhost:8787') {
|
||||
export async function importMysteryTemplate(manifestPath: string, baseUrl = 'http://localhost:8787', adminJwt = process.env.OSINT_ADMIN_JWT) {
|
||||
const absoluteManifest = path.resolve(manifestPath)
|
||||
const manifest = JSON.parse(await readFile(absoluteManifest, 'utf8')) as MysteryManifest
|
||||
const manifestDir = path.dirname(absoluteManifest)
|
||||
const authoringId = `${manifest.slug}-authoring-${Date.now()}`
|
||||
const authorization = adminJwt ? `Bearer ${adminJwt}` : undefined
|
||||
const headers = { 'content-type': 'application/json', ...(authorization ? { authorization } : {}) }
|
||||
const createdResponse = await requireOk(await fetch(`${baseUrl}/api/levels`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' },
|
||||
method: 'POST', headers,
|
||||
body: JSON.stringify({ id: authoringId, title: manifest.title, subtitle: manifest.subtitle }),
|
||||
}), 'Create authoring level')
|
||||
const state = await createdResponse.json() as CaseState
|
||||
|
||||
const documents = new Map<string, CaseDocument>()
|
||||
for (const source of manifest.documents) {
|
||||
const uploaded = await uploadAsset(baseUrl, state.id, manifestDir, source)
|
||||
const uploaded = await uploadAsset(baseUrl, state.id, manifestDir, source, authorization)
|
||||
documents.set(source.key, {
|
||||
id: uploaded?.id || randomUUID(), title: source.title, kind: documentKind(source.fileType),
|
||||
date: source.publishedAt.slice(0, 10), publishedAt: source.publishedAt,
|
||||
@@ -87,15 +89,15 @@ export async function importMysteryTemplate(manifestPath: string, baseUrl = 'htt
|
||||
state.viewport = { x: 0, y: 28, zoom: 0.7 }
|
||||
|
||||
await requireOk(await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
|
||||
method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(state),
|
||||
method: 'PUT', headers, body: JSON.stringify(state),
|
||||
}), 'Save authored mystery')
|
||||
const templateResponse = await requireOk(await fetch(`${baseUrl}/api/levels/${state.id}/templates?edit=1`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ slug: manifest.slug, name: manifest.name }),
|
||||
method: 'POST', headers, body: JSON.stringify({ slug: manifest.slug, name: manifest.name }),
|
||||
}), 'Freeze mystery template')
|
||||
const template = await templateResponse.json() as { slug: string; currentVersion: number }
|
||||
const playableId = `${manifest.slug}-case-${Date.now()}`
|
||||
const playableResponse = await requireOk(await fetch(`${baseUrl}/api/templates/${manifest.slug}/levels?edit=1`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: playableId, title: manifest.title }),
|
||||
method: 'POST', headers, body: JSON.stringify({ id: playableId, title: manifest.title }),
|
||||
}), 'Instantiate playable mystery')
|
||||
const playable = await playableResponse.json() as CaseState
|
||||
return { manifest, template, authoringLevelId: state.id, playableLevel: playable }
|
||||
|
||||
@@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import pg from 'pg'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import type { CaseState } from '../src/types.js'
|
||||
import { runMigrations } from './migrations.js'
|
||||
@@ -15,6 +16,13 @@ let adminClient: InstanceType<typeof Client>
|
||||
let appServer: Awaited<typeof import('./index.js')>['server']
|
||||
let appPool: Awaited<typeof import('./index.js')>['pool']
|
||||
let baseUrl = ''
|
||||
let adminAuthorization = ''
|
||||
|
||||
function adminFetch(url: string, init: RequestInit = {}) {
|
||||
const headers = new Headers(init.headers)
|
||||
headers.set('authorization', adminAuthorization)
|
||||
return fetch(url, { ...init, headers })
|
||||
}
|
||||
|
||||
async function availablePort() {
|
||||
return new Promise<number>((resolve, reject) => {
|
||||
@@ -44,11 +52,13 @@ suite('level persistence API', () => {
|
||||
const port = await availablePort()
|
||||
process.env.DATABASE_URL = databaseUrl
|
||||
process.env.LEVEL_EDITING_ENABLED = 'true'
|
||||
process.env.JWT_SECRET = 'osint-integration-jwt-secret'
|
||||
process.env.PORT = String(port)
|
||||
const serverModule = await import('./index.js')
|
||||
appServer = serverModule.server
|
||||
appPool = serverModule.pool
|
||||
baseUrl = `http://127.0.0.1:${port}`
|
||||
adminAuthorization = `Bearer ${jwt.sign({ sub: 'integration-admin', role: 'admin' }, process.env.JWT_SECRET)}`
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -60,7 +70,10 @@ suite('level persistence API', () => {
|
||||
})
|
||||
|
||||
it('persists one normalized level across authoring and play views', async () => {
|
||||
const createResponse = await fetch(`${baseUrl}/api/levels`, {
|
||||
expect(await (await fetch(`${baseUrl}/api/session`)).json()).toEqual({ authenticated: false, isAdmin: false })
|
||||
expect(await (await adminFetch(`${baseUrl}/api/session`)).json()).toEqual({ authenticated: true, isAdmin: true })
|
||||
expect((await fetch(`${baseUrl}/api/levels`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })).status).toBe(403)
|
||||
const createResponse = await adminFetch(`${baseUrl}/api/levels`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ id: 'api-smoke-level', title: 'API Smoke Level' }),
|
||||
@@ -88,14 +101,14 @@ suite('level persistence API', () => {
|
||||
state.relations = [{ id: `contains:${folderId}:${documentId}`, fromWidgetId: folderId, toWidgetId: documentId, type: 'contains', sortOrder: 0, config: { x: 1051, y: 417 } }]
|
||||
state.connections = [{ id: randomUUID(), fromEvidenceId: folderId, toEvidenceId: documentId, label: 'Primary source', tightness: 85, tagStyle: 'compact', tagPosition: 72, tagOffset: -12 }]
|
||||
|
||||
const saveResponse = await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
|
||||
const saveResponse = await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(state),
|
||||
})
|
||||
expect(await saveResponse.json()).toEqual({ ok: true, mode: 'author' })
|
||||
|
||||
const loaded = await (await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState
|
||||
const loaded = await (await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState
|
||||
expect(loaded.viewport).toEqual(state.viewport)
|
||||
expect(loaded.timelineRange).toEqual(state.timelineRange)
|
||||
expect(loaded.evidence[0]).toMatchObject({ id: folderId, x: 685, y: 417, config: { open: true } })
|
||||
@@ -115,25 +128,25 @@ suite('level persistence API', () => {
|
||||
|
||||
const upload = new FormData()
|
||||
upload.append('file', new Blob(['OSINT smoke evidence'], { type: 'text/plain' }), 'smoke-evidence.txt')
|
||||
const uploadResponse = await fetch(`${baseUrl}/api/levels/${state.id}/documents?edit=1`, { method: 'POST', body: upload })
|
||||
const uploadResponse = await adminFetch(`${baseUrl}/api/levels/${state.id}/documents?edit=1`, { method: 'POST', body: upload })
|
||||
expect(uploadResponse.status).toBe(201)
|
||||
const uploaded = await uploadResponse.json() as CaseState['documents'][number]
|
||||
expect(uploaded).toMatchObject({ title: 'smoke-evidence.txt', fileName: 'smoke-evidence.txt', mimeType: 'text/plain', fileType: 'text' })
|
||||
expect(uploaded.assetId).toBeTruthy()
|
||||
expect(await (await fetch(`${baseUrl}/api/assets/${uploaded.assetId}`)).text()).toBe('OSINT smoke evidence')
|
||||
|
||||
const withUpload = await (await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState
|
||||
const withUpload = await (await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState
|
||||
const uploadedDocument = withUpload.documents.find(document => document.id === uploaded.id)!
|
||||
uploadedDocument.title = 'Renamed smoke evidence'
|
||||
uploadedDocument.publishedAt = '2022-06-15T10:30:00.000Z'
|
||||
uploadedDocument.metadata = { witness: 'Integration test', confidence: 'high' }
|
||||
const metadataSave = await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
|
||||
const metadataSave = await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(withUpload),
|
||||
})
|
||||
expect(metadataSave.ok).toBe(true)
|
||||
const afterMetadataSave = await (await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState
|
||||
const afterMetadataSave = await (await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState
|
||||
expect(afterMetadataSave.documents.find(document => document.id === uploaded.id)).toMatchObject({
|
||||
title: 'Renamed smoke evidence',
|
||||
publishedAt: '2022-06-15T10:30:00.000Z',
|
||||
@@ -143,15 +156,15 @@ suite('level persistence API', () => {
|
||||
const undatedState = structuredClone(afterMetadataSave)
|
||||
const undatedEvent = undatedState.evidence.find(exhibit => exhibit.id === eventId)!
|
||||
delete undatedEvent.eventDate
|
||||
const undatedSave = await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
|
||||
const undatedSave = await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
|
||||
method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(undatedState),
|
||||
})
|
||||
expect(undatedSave.ok).toBe(true)
|
||||
const loadedUndated = await (await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState
|
||||
const loadedUndated = await (await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState
|
||||
expect(loadedUndated.evidence.find(exhibit => exhibit.id === eventId)?.eventDate).toBeUndefined()
|
||||
expect((await appPool.query<{ occurred_at: Date | null }>('SELECT occurred_at FROM osint.event_exhibits WHERE exhibit_id=$1', [eventId])).rows[0].occurred_at).toBeNull()
|
||||
loadedUndated.evidence.find(exhibit => exhibit.id === eventId)!.eventDate = '2021-04-18T14:30:00Z'
|
||||
expect((await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
|
||||
expect((await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
|
||||
method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(loadedUndated),
|
||||
})).ok).toBe(true)
|
||||
|
||||
@@ -176,7 +189,7 @@ suite('level persistence API', () => {
|
||||
const savedPlayerState = await (await fetch(`${baseUrl}/api/levels/${state.id}`)).json() as CaseState
|
||||
expect(savedPlayerState.viewport).toEqual(playerState.viewport)
|
||||
expect(savedPlayerState.evidence[0]).toMatchObject({ id: folderId, x: 812, y: 533 })
|
||||
const sameLevelInEditView = await (await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState
|
||||
const sameLevelInEditView = await (await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState
|
||||
expect(sameLevelInEditView.viewport).toEqual(playerState.viewport)
|
||||
expect(sameLevelInEditView.evidence[0]).toMatchObject({ id: folderId, x: 812, y: 533 })
|
||||
|
||||
@@ -205,7 +218,7 @@ suite('level persistence API', () => {
|
||||
expect(resetState.timelineRange).toEqual(state.timelineRange)
|
||||
expect(resetState.evidence[0]).toMatchObject({ id: folderId, x: 812, y: 533 })
|
||||
|
||||
const templateResponse = await fetch(`${baseUrl}/api/levels/${state.id}/templates?edit=1`, {
|
||||
const templateResponse = await adminFetch(`${baseUrl}/api/levels/${state.id}/templates?edit=1`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'Smoke Template' }),
|
||||
})
|
||||
expect(templateResponse.status).toBe(201)
|
||||
@@ -217,10 +230,10 @@ suite('level persistence API', () => {
|
||||
const changedSource = structuredClone(savedPlayerState)
|
||||
changedSource.title = 'Changed after template freeze'
|
||||
changedSource.evidence[0].x = 999
|
||||
await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
|
||||
await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
|
||||
method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(changedSource),
|
||||
})
|
||||
const cloneResponse = await fetch(`${baseUrl}/api/templates/smoke-template/levels?edit=1`, {
|
||||
const cloneResponse = await adminFetch(`${baseUrl}/api/templates/smoke-template/levels?edit=1`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: 'smoke-template-copy', title: 'Playable copy' }),
|
||||
})
|
||||
expect(cloneResponse.status).toBe(201)
|
||||
@@ -242,7 +255,7 @@ suite('level persistence API', () => {
|
||||
expect(clone.evidence.filter(item => item.type === 'party')).toHaveLength(2)
|
||||
expect(clone.brief.concepts.every(concept => Boolean(concept.resolvedPartyExhibitId))).toBe(true)
|
||||
expect(clone.brief.concepts.map(concept => concept.resolvedPartyExhibitId)).not.toContain(personPartyId)
|
||||
const authoredClone = await (await fetch(`${baseUrl}/api/levels/${clone.id}?edit=1`)).json() as CaseState
|
||||
const authoredClone = await (await adminFetch(`${baseUrl}/api/levels/${clone.id}?edit=1`)).json() as CaseState
|
||||
expect(authoredClone.brief.concepts.map(concept => concept.expectedPartyKind).sort()).toEqual(['organization', 'person'])
|
||||
|
||||
const clonedFolder = clone.evidence.find(item => item.type === 'folder')!
|
||||
@@ -256,14 +269,14 @@ suite('level persistence API', () => {
|
||||
expect(cloneReset.evidence.find(item => item.type === 'folder')).toMatchObject({ x: 812, y: 533 })
|
||||
expect(cloneReset.evidence.map(item => item.id)).not.toContain(clonedFolder.id)
|
||||
|
||||
const versionTwoResponse = await fetch(`${baseUrl}/api/levels/${state.id}/templates?edit=1`, {
|
||||
const versionTwoResponse = await adminFetch(`${baseUrl}/api/levels/${state.id}/templates?edit=1`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'Smoke Template' }),
|
||||
})
|
||||
expect(await versionTwoResponse.json()).toMatchObject({ currentVersion: 2, versionCount: 2 })
|
||||
const oldVersion = await (await fetch(`${baseUrl}/api/templates/smoke-template/levels?edit=1`, {
|
||||
const oldVersion = await (await adminFetch(`${baseUrl}/api/templates/smoke-template/levels?edit=1`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: 'old-version-copy', version: 1 }),
|
||||
})).json() as CaseState
|
||||
const currentVersion = await (await fetch(`${baseUrl}/api/templates/smoke-template/levels?edit=1`, {
|
||||
const currentVersion = await (await adminFetch(`${baseUrl}/api/templates/smoke-template/levels?edit=1`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: 'current-version-copy' }),
|
||||
})).json() as CaseState
|
||||
expect(oldVersion.evidence.find(item => item.type === 'folder')).toMatchObject({ x: 812 })
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
import jwt, { type JwtPayload } from 'jsonwebtoken'
|
||||
|
||||
export type OsintClaims = JwtPayload & { role?: string; isAdmin?: boolean }
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request { authClaims?: OsintClaims }
|
||||
}
|
||||
}
|
||||
|
||||
export function authenticateJwt(req: Request, _res: Response, next: NextFunction) {
|
||||
const authorization = req.headers.authorization
|
||||
const token = req.cookies?.auth_token || (authorization?.startsWith('Bearer ') ? authorization.slice(7) : undefined)
|
||||
const secret = process.env.JWT_SECRET
|
||||
if (token && secret) {
|
||||
try {
|
||||
const decoded = jwt.verify(token, secret)
|
||||
if (typeof decoded !== 'string') req.authClaims = decoded as OsintClaims
|
||||
} catch { /* An absent, expired, or invalid cookie is an anonymous session. */ }
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
export function hasAdminClaim(req: Request) {
|
||||
return req.authClaims?.role === 'admin' || req.authClaims?.isAdmin === true
|
||||
}
|
||||
|
||||
export function requireAdmin(req: Request, res: Response, next: NextFunction) {
|
||||
if (!hasAdminClaim(req)) return res.status(403).json({ error: 'Administrator claim required' })
|
||||
next()
|
||||
}
|
||||
|
||||
export function createDevelopmentAdminToken() {
|
||||
if (process.env.NODE_ENV === 'production') throw new Error('Development sessions are disabled in production')
|
||||
if (!process.env.JWT_SECRET) throw new Error('JWT_SECRET is required')
|
||||
return jwt.sign({ sub: 'osint-local-admin', role: 'admin', isAdmin: true }, process.env.JWT_SECRET, { expiresIn: '7d' })
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { once } from 'node:events'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import pg from 'pg'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import type { CaseState } from '../src/types.js'
|
||||
import { importMysteryTemplate } from '../scripts/importMysteryTemplate.js'
|
||||
import { runMigrations } from './migrations.js'
|
||||
@@ -26,17 +27,20 @@ await runMigrations(databaseUrl, migrationsDir, () => undefined)
|
||||
const port = Number(process.env.E2E_PORT || 18788)
|
||||
process.env.DATABASE_URL = databaseUrl
|
||||
process.env.LEVEL_EDITING_ENABLED = 'true'
|
||||
process.env.JWT_SECRET = 'osint-e2e-jwt-secret'
|
||||
process.env.PORT = String(port)
|
||||
process.env.OSINT_MANAGED_SERVER = 'true'
|
||||
const { server, pool } = await import('./index.js')
|
||||
if (!server.listening) await once(server, 'listening')
|
||||
const baseUrl = `http://127.0.0.1:${port}`
|
||||
const adminToken = jwt.sign({ sub: 'e2e-admin', role: 'admin' }, process.env.JWT_SECRET)
|
||||
const adminHeaders = { 'content-type': 'application/json', authorization: `Bearer ${adminToken}` }
|
||||
const documentId = '22222222-2222-4222-8222-222222222222'
|
||||
const folderId = '11111111-1111-4111-8111-111111111111'
|
||||
|
||||
const created = await fetch(`${baseUrl}/api/levels`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
headers: adminHeaders,
|
||||
body: JSON.stringify({ id: 'e2e-level', title: 'Browser Safety Test', subtitle: 'Disposable test level' }),
|
||||
})
|
||||
if (!created.ok) throw new Error(`Could not create browser test level: ${created.status}`)
|
||||
@@ -61,12 +65,12 @@ state.connections = []
|
||||
state.viewport = { x: 0, y: 28, zoom: 0.7 }
|
||||
const saved = await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
headers: adminHeaders,
|
||||
body: JSON.stringify(state),
|
||||
})
|
||||
if (!saved.ok) throw new Error(`Could not seed browser test level: ${saved.status}`)
|
||||
|
||||
await importMysteryTemplate(path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'mysteries', 'glass-harbor', 'mystery.json'), baseUrl)
|
||||
await importMysteryTemplate(path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'mysteries', 'glass-harbor', 'mystery.json'), baseUrl, adminToken)
|
||||
|
||||
let shuttingDown = false
|
||||
async function shutdown(exitCode: number) {
|
||||
|
||||
+19
-8
@@ -1,5 +1,6 @@
|
||||
import 'dotenv/config'
|
||||
import cors from 'cors'
|
||||
import cookieParser from 'cookie-parser'
|
||||
import express from 'express'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
@@ -7,6 +8,7 @@ import { fileURLToPath } from 'node:url'
|
||||
import multer from 'multer'
|
||||
import pg from 'pg'
|
||||
import type { CaseState } from '../src/types.js'
|
||||
import { authenticateJwt, createDevelopmentAdminToken, hasAdminClaim, requireAdmin } from './auth.js'
|
||||
import { createLevelRepository } from './levelRepository.js'
|
||||
|
||||
const { Pool } = pg
|
||||
@@ -21,7 +23,7 @@ const editingEnabled = process.env.LEVEL_EDITING_ENABLED === 'true'
|
||||
const levels = createLevelRepository(pool, editingEnabled)
|
||||
|
||||
function wantsEdit(req: express.Request) {
|
||||
return editingEnabled && req.query.edit === '1'
|
||||
return editingEnabled && req.query.edit === '1' && hasAdminClaim(req)
|
||||
}
|
||||
function slug(value: unknown, fallback: string) {
|
||||
return String(value || fallback).trim().toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '')
|
||||
@@ -29,7 +31,9 @@ function slug(value: unknown, fallback: string) {
|
||||
|
||||
export const app = express()
|
||||
app.disable('x-powered-by')
|
||||
app.use(cors({ origin: process.env.CORS_ORIGIN || true }))
|
||||
app.use(cors({ origin: process.env.CORS_ORIGIN || true, credentials: true }))
|
||||
app.use(cookieParser())
|
||||
app.use(authenticateJwt)
|
||||
app.use(express.json({ limit: '2mb' }))
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
@@ -40,6 +44,13 @@ app.get('/api/health', async (_req, res) => {
|
||||
try { await pool.query('SELECT 1'); res.json({ ok: true, database: 'connected', schema: 'osint', editingEnabled }) }
|
||||
catch { res.status(503).json({ ok: false, database: 'unavailable' }) }
|
||||
})
|
||||
app.get('/api/session', (req, res) => res.json({ authenticated: Boolean(req.authClaims), isAdmin: hasAdminClaim(req) }))
|
||||
if (process.env.NODE_ENV !== 'production') app.get('/api/dev/admin-session', (req, res) => {
|
||||
const requestedReturn = String(req.query.returnTo || '/')
|
||||
const returnTo = requestedReturn.startsWith('/') && !requestedReturn.startsWith('//') ? requestedReturn : '/'
|
||||
res.cookie('auth_token', createDevelopmentAdminToken(), { httpOnly: true, sameSite: 'lax', path: '/', maxAge: 7 * 24 * 60 * 60 * 1000 })
|
||||
res.redirect(returnTo)
|
||||
})
|
||||
app.get('/api/levels', async (_req, res, next) => {
|
||||
try { res.json(await levels.listLevels()) }
|
||||
catch (error) { next(error) }
|
||||
@@ -48,16 +59,16 @@ app.get('/api/templates', async (_req, res, next) => {
|
||||
try { res.json(await levels.listTemplates()) }
|
||||
catch (error) { next(error) }
|
||||
})
|
||||
app.post('/api/templates/:slug/levels', async (req, res, next) => {
|
||||
app.post('/api/templates/:slug/levels', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
if (!wantsEdit(req)) return res.status(403).json({ error: 'Level editing is disabled' })
|
||||
const title = String(req.body?.title || '').trim() || undefined
|
||||
const levelSlug = slug(req.body?.id, `${req.params.slug}-${Date.now()}`)
|
||||
const level = await levels.instantiateTemplate(req.params.slug, { id: levelSlug, title, version: req.body?.version })
|
||||
const level = await levels.instantiateTemplate(String(req.params.slug), { id: levelSlug, title, version: req.body?.version })
|
||||
level ? res.status(201).json(level) : res.status(404).json({ error: 'Template version not found' })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
app.post('/api/levels', async (req, res, next) => {
|
||||
app.post('/api/levels', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
if (!editingEnabled) return res.status(403).json({ error: 'Level editing is disabled' })
|
||||
const title = String(req.body?.title || 'Untitled Investigation').trim()
|
||||
@@ -65,12 +76,12 @@ app.post('/api/levels', async (req, res, next) => {
|
||||
res.status(201).json(await levels.createLevel({ id, title, subtitle: String(req.body?.subtitle || '') }))
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
app.post('/api/levels/:id/templates', async (req, res, next) => {
|
||||
app.post('/api/levels/:id/templates', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
if (!wantsEdit(req)) return res.status(403).json({ error: 'Level editing is disabled' })
|
||||
const name = String(req.body?.name || 'Untitled Template').trim()
|
||||
const templateSlug = slug(req.body?.slug, name)
|
||||
const template = await levels.saveLevelAsTemplate(req.params.id, { slug: templateSlug, name })
|
||||
const template = await levels.saveLevelAsTemplate(String(req.params.id), { slug: templateSlug, name })
|
||||
template ? res.status(201).json(template) : res.status(404).json({ error: 'Level not found' })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
@@ -86,7 +97,7 @@ app.get('/api/assets/:id', async (req, res, next) => {
|
||||
res.send(asset.content)
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
app.post('/api/levels/:id/documents', upload.single('file'), async (req, res, next) => {
|
||||
app.post('/api/levels/:id/documents', requireAdmin, upload.single('file'), async (req, res, next) => {
|
||||
try {
|
||||
if (!wantsEdit(req)) return res.status(403).json({ error: 'Level editing is disabled' })
|
||||
if (!req.file) return res.status(400).json({ error: 'A file is required' })
|
||||
|
||||
@@ -160,7 +160,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
|
||||
tagPosition: row.tag_position_percent, tagOffset: row.tag_lateral_offset })),
|
||||
viewport: { x: level.viewport_x, y: level.viewport_y, zoom: level.viewport_zoom }, updatedAt: level.updated_at.toISOString(),
|
||||
timelineRange: timelineResult.rows[0] ? { start: timelineResult.rows[0].range_start, end: timelineResult.rows[0].range_end } : undefined,
|
||||
brief: { body: briefResult.rows[0]?.body || '', concepts }, levelStatus: level.status, editingAllowed: editingEnabled,
|
||||
brief: { body: briefResult.rows[0]?.body || '', concepts }, levelStatus: level.status, editingAllowed: editingEnabled && authorMode,
|
||||
sourceTemplateVersionId: level.source_template_version_id || undefined }
|
||||
}
|
||||
|
||||
|
||||
+61
-6
@@ -25,6 +25,7 @@ type TemporalItem = { id: string; sourceTemporalId: string; date: string; label:
|
||||
|
||||
export function App() {
|
||||
const [caseState, setCaseState] = useState<CaseState | null>(null)
|
||||
const [isAdmin, setIsAdmin] = useState(false)
|
||||
const [noLevels, setNoLevels] = useState(false)
|
||||
const [openDoc, setOpenDoc] = useState<CaseDocument | null>(null)
|
||||
const [selected, setSelected] = useState<string | null>(null)
|
||||
@@ -32,6 +33,7 @@ export function App() {
|
||||
const [docsOpen, setDocsOpen] = useState(false)
|
||||
const [documentQuery, setDocumentQuery] = useState('')
|
||||
const [helpOpen, setHelpOpen] = useState(false)
|
||||
const [adminMenuOpen, setAdminMenuOpen] = useState(false)
|
||||
const [status, setStatus] = useState('CONNECTING TO ARCHIVE…')
|
||||
const [clock, setClock] = useState('')
|
||||
const [draggingFiles, setDraggingFiles] = useState(false)
|
||||
@@ -52,10 +54,12 @@ export function App() {
|
||||
const saveTimer = useRef<number | undefined>(undefined)
|
||||
const boardRef = useRef<HTMLDivElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const adminMenuRef = useRef<HTMLDivElement>(null)
|
||||
const requestedEditMode = new URLSearchParams(window.location.search).get('edit') === '1'
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
fetch('/api/session').then(response => response.ok ? response.json() : null).then(session => setIsAdmin(Boolean(session?.isAdmin))).catch(() => setIsAdmin(false))
|
||||
fetch('/api/levels').then(r => {
|
||||
if (!r.ok) throw new Error('Server unavailable')
|
||||
return r.json()
|
||||
@@ -80,6 +84,13 @@ export function App() {
|
||||
return () => clearInterval(timer)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!adminMenuOpen) return
|
||||
const close = (event: PointerEvent) => { if (!adminMenuRef.current?.contains(event.target as Node)) setAdminMenuOpen(false) }
|
||||
document.addEventListener('pointerdown', close)
|
||||
return () => document.removeEventListener('pointerdown', close)
|
||||
}, [adminMenuOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (!recentlyCreatedExhibitId) return
|
||||
const timer = window.setTimeout(() => setRecentlyCreatedExhibitId(null), 1400)
|
||||
@@ -277,6 +288,12 @@ export function App() {
|
||||
window.location.assign(`?level=${encodeURIComponent(level.id)}&edit=1`)
|
||||
}
|
||||
|
||||
const enterLevelEditor = () => {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
params.set('edit', '1')
|
||||
window.location.assign(`${window.location.pathname}?${params.toString()}`)
|
||||
}
|
||||
|
||||
const uploadFiles = async (files: FileList | File[]) => {
|
||||
if (!caseState || !requestedEditMode || !caseState.editingAllowed) return
|
||||
const queue = Array.from(files)
|
||||
@@ -297,13 +314,14 @@ export function App() {
|
||||
}
|
||||
}
|
||||
|
||||
if (noLevels) return <EmptyArchive canEdit={requestedEditMode} onCreated={level => { setCaseState(level); setNoLevels(false); window.history.replaceState({}, '', `?level=${encodeURIComponent(level.id)}&edit=1`) }} />
|
||||
if (noLevels) return <EmptyArchive canEdit={isAdmin} onCreated={level => { window.location.assign(`?level=${encodeURIComponent(level.id)}&edit=1`) }} />
|
||||
if (!caseState) return <div className="boot"><div className="seal">GU</div><p>GLITCH UNIVERSITY NETWORK TERMINAL</p><small>{status}</small></div>
|
||||
|
||||
const documentById = new Map(caseState.documents.map(document => [document.id, document]))
|
||||
const normalizedDocumentQuery = documentQuery.trim().toLocaleLowerCase()
|
||||
const filteredDocuments = normalizedDocumentQuery ? caseState.documents.filter(document => documentSearchText(document).includes(normalizedDocumentQuery)) : caseState.documents
|
||||
const unresolvedConceptCount = caseState.brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length
|
||||
const canAuthor = isAdmin && requestedEditMode && Boolean(caseState.editingAllowed)
|
||||
const containedDocumentIds = new Set(caseState.relations.filter(relation => relation.type === 'contains').map(relation => relation.toWidgetId))
|
||||
const temporalItems: TemporalItem[] = [
|
||||
...caseState.evidence.flatMap(folder => folder.type !== 'folder' ? [] : containedIds(caseState, folder.id).flatMap(documentId => {
|
||||
@@ -324,7 +342,21 @@ export function App() {
|
||||
<header className="menubar">
|
||||
<div className="brand"><span className="brand-mark">GU</span><span>OSINT BOARD <em>/ {requestedEditMode && caseState.editingAllowed ? 'LEVEL EDITOR' : 'CASE TERMINAL'}</em></span></div>
|
||||
<nav>
|
||||
<button onClick={() => setDocsOpen(v => !v)}>FILE</button><button className={briefOpen ? 'active' : ''} aria-label="Case brief" onClick={() => briefOpen ? closeBrief() : setBriefOpen(true)}>CASE BRIEF{unresolvedConceptCount > 0 && <b className="brief-count">{unresolvedConceptCount}</b>}</button><button onClick={addNote}>EVIDENCE</button><button onClick={() => update(s => ({ ...s, viewport: { x: 0, y: 28, zoom: .7 } }))}>VIEW</button><button onClick={() => setEditingTimeline(true)}>TIMELINE</button>{requestedEditMode && caseState.editingAllowed && <><button onClick={saveAsTemplate}>SAVE TEMPLATE</button><button onClick={instantiateTemplate}>NEW FROM TEMPLATE</button></>}<button onClick={() => setHelpOpen(true)}>HELP</button>
|
||||
<button className={docsOpen ? 'active' : ''} onClick={() => setDocsOpen(true)}>EVIDENCE</button>
|
||||
<button className={briefOpen ? 'active' : ''} aria-label="Case brief" onClick={() => briefOpen ? closeBrief() : setBriefOpen(true)}>CASE BRIEF{unresolvedConceptCount > 0 && <b className="brief-count">{unresolvedConceptCount}</b>}</button>
|
||||
<button onClick={() => setEditingTimeline(true)}>TIMELINE</button>
|
||||
<button onClick={() => setHelpOpen(true)}>HELP</button>
|
||||
{isAdmin && <div className="admin-menu" ref={adminMenuRef}>
|
||||
<button className={adminMenuOpen ? 'active' : ''} aria-haspopup="menu" aria-expanded={adminMenuOpen} onClick={() => setAdminMenuOpen(open => !open)}>ADMIN</button>
|
||||
{adminMenuOpen && <div className="admin-menu-items" role="menu">
|
||||
{!canAuthor ? <button role="menuitem" onClick={enterLevelEditor}>ENTER LEVEL EDITOR</button> : <>
|
||||
<button role="menuitem" onClick={() => { setEditingBrief(true); setAdminMenuOpen(false) }}>EDIT BRIEF & CONCEPTS</button>
|
||||
<button role="menuitem" onClick={() => { fileInputRef.current?.click(); setAdminMenuOpen(false) }}>IMPORT DOCUMENTS</button>
|
||||
<button role="menuitem" onClick={() => { setAdminMenuOpen(false); void saveAsTemplate() }}>SAVE AS TEMPLATE</button>
|
||||
<button role="menuitem" onClick={() => { setAdminMenuOpen(false); void instantiateTemplate() }}>NEW FROM TEMPLATE</button>
|
||||
</>}
|
||||
</div>}
|
||||
</div>}
|
||||
</nav>
|
||||
<div className="terminal-status"><i /> {status}<span>{clock}</span></div>
|
||||
</header>
|
||||
@@ -333,7 +365,7 @@ export function App() {
|
||||
<aside className={`documents-panel ${docsOpen ? '' : 'closed'}`}>
|
||||
<div className="panel-heading"><div><small>CASE MATERIALS</small><h2>DOCUMENTS <sup>{normalizedDocumentQuery ? `${filteredDocuments.length}/${caseState.documents.length}` : caseState.documents.length}</sup></h2></div><button aria-label="Close documents" onClick={() => setDocsOpen(false)}><X size={17}/></button></div>
|
||||
<label className="search"><Search size={15}/><input type="search" aria-label="Search inside documents" placeholder="Search inside documents…" value={documentQuery} onChange={event => setDocumentQuery(event.target.value)}/>{documentQuery && <button type="button" aria-label="Clear document search" onClick={() => setDocumentQuery('')}><X size={13}/></button>}</label>
|
||||
{requestedEditMode && caseState.editingAllowed && <><button className="import-document" onClick={() => fileInputRef.current?.click()}><Upload size={15}/>{uploading ? `IMPORTING ${uploading}…` : 'IMPORT DOCUMENT'}</button><input ref={fileInputRef} className="file-input" type="file" multiple onChange={e => { if (e.target.files) uploadFiles(e.target.files); e.target.value = '' }} /></>}
|
||||
{canAuthor && <><button className="import-document" onClick={() => fileInputRef.current?.click()}><Upload size={15}/>{uploading ? `IMPORTING ${uploading}…` : 'IMPORT DOCUMENT'}</button><input ref={fileInputRef} className="file-input" type="file" multiple onChange={e => { if (e.target.files) uploadFiles(e.target.files); e.target.value = '' }} /></>}
|
||||
<div className="doc-list">
|
||||
{filteredDocuments.map((doc, index) => <button className="doc-row" data-temporal-id={`document:${doc.id}`} key={doc.id} onDoubleClick={() => setOpenDoc(doc)} onClick={() => setOpenDoc(doc)}>
|
||||
<div className={`doc-icon tint-${index % 3}`}><FileText size={24}/><b>{doc.kind.slice(0, 3)}</b></div>
|
||||
@@ -344,14 +376,14 @@ export function App() {
|
||||
<div className="panel-foot"><FolderOpen size={15}/> ARCHIVE MOUNTED <span>{requestedEditMode && caseState.editingAllowed ? 'AUTHORING' : 'READ ONLY'}</span></div>
|
||||
</aside>
|
||||
|
||||
<div className="board-shell" onDragEnter={e => { if (e.dataTransfer.types.includes('Files') && requestedEditMode && caseState.editingAllowed) { e.preventDefault(); setDraggingFiles(true) } }} onDragOver={e => { if (requestedEditMode && caseState.editingAllowed) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy' } }} onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDraggingFiles(false) }} onDrop={e => { e.preventDefault(); if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files) }}>
|
||||
<div className="board-shell" onDragEnter={e => { if (e.dataTransfer.types.includes('Files') && canAuthor) { e.preventDefault(); setDraggingFiles(true) } }} onDragOver={e => { if (canAuthor) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy' } }} onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDraggingFiles(false) }} onDrop={e => { e.preventDefault(); if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files) }}>
|
||||
<div className="case-heading"><div><small>{requestedEditMode && caseState.editingAllowed ? 'AUTHORING LEVEL' : 'ACTIVE INVESTIGATION'}</small><h1>{caseState.title}</h1><p>{caseState.subtitle || caseState.id.toUpperCase()}</p></div><div className="case-number">{requestedEditMode && caseState.editingAllowed ? 'DRAFT' : 'CASE'}<br/><b>{caseState.levelStatus?.toUpperCase() || 'ACTIVE'}</b></div></div>
|
||||
<Board state={caseState} selected={selected} linkFrom={linkFrom} recentlyCreatedExhibitId={recentlyCreatedExhibitId} recentlyCreatedConnectionId={recentlyCreatedConnectionId} tool={boardTool} boardRef={boardRef} update={update} onCardClick={handleCardClick} onConnectionTarget={completeThread} onEditConnection={connection => setThreadDraft(connection)} onDiscardExhibit={removeExhibit} onOpenSource={id => setOpenDoc(caseState.documents.find(d => d.id === id) || null)} onUpdateDocumentCue={(id, cue) => update(state => ({ ...state, documents: state.documents.map(document => document.id === id ? { ...document, metadata: { ...document.metadata, memory_cue: cue } } : document) }))} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} onEditEvent={setEditingEventId} onEditParty={setEditingPartyId} />
|
||||
{briefOpen && <BriefPanel
|
||||
brief={caseState.brief}
|
||||
parties={caseState.evidence.filter(item => item.type === 'party')}
|
||||
recentlyCreatedExhibitId={recentlyCreatedExhibitId}
|
||||
canEdit={requestedEditMode && Boolean(caseState.editingAllowed)}
|
||||
canEdit={canAuthor}
|
||||
onClose={closeBrief}
|
||||
onEdit={() => setEditingBrief(true)}
|
||||
onClassify={classifyConcept}
|
||||
@@ -382,7 +414,30 @@ export function App() {
|
||||
<TemporalLinks items={temporalItems} layoutKey={`${caseState.viewport.x}:${caseState.viewport.y}:${caseState.viewport.zoom}:${docsOpen}:${caseState.evidence.map(e => `${e.id}:${e.x}:${e.y}:${String(e.config?.open)}`).join('|')}:${caseState.relations.map(r => `${r.id}:${String(r.config?.x)}:${String(r.config?.y)}`).join('|')}`}/>
|
||||
<Timeline items={temporalItems} range={caseState.timelineRange} selected={selected} onEdit={() => setEditingTimeline(true)} onSelect={item => { if (item.documentId) setOpenDoc(caseState.documents.find(doc => doc.id === item.documentId) || null); else if (item.evidenceId) focusEvidence(item.evidenceId) }}/>
|
||||
{openDoc && <DocumentWindow doc={openDoc} onClose={() => setOpenDoc(null)} onExtract={id => extract(openDoc, id)} extracted={caseState.evidence.filter(e => e.sourceDocumentId === openDoc.id).map(e => e.sourceRegionId)} />}
|
||||
{editingFolderId && <FolderEditor key={editingFolderId} folder={caseState.evidence.find(widget => widget.id === editingFolderId)!} memberIds={containedIds(caseState, editingFolderId)} documents={caseState.documents} canManageContents={requestedEditMode && Boolean(caseState.editingAllowed)} onClose={() => setEditingFolderId(null)} onSave={(folder, members) => { update(state => ({ ...state, evidence: state.evidence.map(widget => widget.id === folder.id ? { ...folder, containedDocumentIds: members } : widget), relations: [...state.relations.filter(relation => relation.type !== 'contains' || relation.fromWidgetId !== folder.id), ...members.map((documentId, index) => { const existing = state.relations.find(relation => relation.type === 'contains' && relation.fromWidgetId === folder.id && relation.toWidgetId === documentId); const position = relationPosition(state, existing || { id: '', fromWidgetId: folder.id, toWidgetId: documentId, type: 'contains', sortOrder: index }); return { id: existing?.id || `contains:${folder.id}:${documentId}`, fromWidgetId: folder.id, toWidgetId: documentId, type: 'contains', sortOrder: index, config: existing?.config || position } })] })); setEditingFolderId(null); setStatus('FOLDER UPDATED') }}/>}
|
||||
{editingFolderId && <FolderEditor
|
||||
key={editingFolderId}
|
||||
folder={caseState.evidence.find(widget => widget.id === editingFolderId)!}
|
||||
memberIds={containedIds(caseState, editingFolderId)}
|
||||
documents={caseState.documents}
|
||||
canManageContents={canAuthor}
|
||||
onClose={() => setEditingFolderId(null)}
|
||||
onSave={(folder, members) => {
|
||||
update(state => ({
|
||||
...state,
|
||||
evidence: state.evidence.map(widget => widget.id === folder.id ? { ...folder, containedDocumentIds: members } : widget),
|
||||
relations: [
|
||||
...state.relations.filter(relation => relation.type !== 'contains' || relation.fromWidgetId !== folder.id),
|
||||
...members.map((documentId, index) => {
|
||||
const existing = state.relations.find(relation => relation.type === 'contains' && relation.fromWidgetId === folder.id && relation.toWidgetId === documentId)
|
||||
const position = relationPosition(state, existing || { id: '', fromWidgetId: folder.id, toWidgetId: documentId, type: 'contains', sortOrder: index })
|
||||
return { id: existing?.id || `contains:${folder.id}:${documentId}`, fromWidgetId: folder.id, toWidgetId: documentId, type: 'contains', sortOrder: index, config: existing?.config || position }
|
||||
}),
|
||||
],
|
||||
}))
|
||||
setEditingFolderId(null)
|
||||
setStatus('FOLDER UPDATED')
|
||||
}}
|
||||
/>}
|
||||
{editingFileId && <FileEditor key={editingFileId} document={caseState.documents.find(document => document.id === editingFileId)!} onClose={() => setEditingFileId(null)} onSave={document => { update(state => ({ ...state, documents: state.documents.map(candidate => candidate.id === document.id ? document : candidate) })); setEditingFileId(null); setStatus('FILE METADATA UPDATED') }}/>}
|
||||
{editingEventId && <EventEditor
|
||||
key={editingEventId}
|
||||
|
||||
@@ -13,6 +13,7 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
||||
.menubar nav { display: flex; height: 100%; align-items: stretch; }
|
||||
.menubar nav button { background: none; border: 0; padding: 0 18px; font: 500 11px IBM Plex Mono; letter-spacing: .1em; color: #94aaa4; cursor: pointer; }
|
||||
.menubar nav button:hover, .menubar nav button.active { background: #112f2a; color: white; }
|
||||
.admin-menu { position: relative; display: flex; align-items: stretch; }.admin-menu > button { height: 100%; }.admin-menu-items { position: absolute; z-index: 30; top: calc(100% - 1px); right: 0; width: 235px; padding: 5px; display: grid; background: #c5c9c2; border: 2px outset #e5e8e2; box-shadow: 6px 8px 0 #020907aa; }.menubar nav .admin-menu-items button { min-height: 37px; padding: 0 12px; color: #263d36; border: 1px solid transparent; text-align: left; font-size: 9px; }.menubar nav .admin-menu-items button:hover, .menubar nav .admin-menu-items button:focus-visible { background: #173d34; border-color: #526c64; color: #f1f3ef; }
|
||||
.brief-count { display: inline-grid; min-width: 17px; height: 17px; margin-left: 7px; padding: 0 4px; place-items: center; border-radius: 9px; background: #b56d30; color: #fff3df; font: 600 9px IBM Plex Mono; }
|
||||
.terminal-status { font: 10px IBM Plex Mono; color: #769087; letter-spacing: .06em; display: flex; gap: 8px; align-items: center; white-space: nowrap; }
|
||||
.terminal-status i { width: 7px; height: 7px; background: #71c888; border-radius: 50%; box-shadow: 0 0 8px #71c888; }
|
||||
|
||||
Reference in New Issue
Block a user