diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7bfdf8f..2d2d6b5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,9 @@ jobs: - name: Lint run: pnpm lint + - name: Database behaviour tests + run: pnpm test:db + - name: Build run: pnpm build env: diff --git a/dev-plans/utility-share-protocol.md b/dev-plans/utility-share-protocol.md index 483c7ab..2ce799e 100644 --- a/dev-plans/utility-share-protocol.md +++ b/dev-plans/utility-share-protocol.md @@ -36,6 +36,15 @@ utility iframe; apps should verify `event.origin` the same way. | Message | When | Payload | |---|---|---| | `{type: 'cas:restore-state', version, state}` | In response to `cas:share-support`, if the URL carries a valid `?calc=` token | `version`: schema version of the decoded envelope; `state`: the decoded state | +| `{type: 'cas:report-defaults', version: 1, defaults}` | After `cas:share-support` for signed-in users in a workspace, and again whenever the user changes the "Save to" target (issue #122) | `defaults`: `{companyName, preferredStandards}` (each a string or `null`) from the selected workspace, or `null` when saving to personal history | + +`cas:report-defaults` is optional for apps. An app that prints title blocks or +reports should use `companyName` as the default for its company field and may +preselect `preferredStandards` where it offers a standard choice. **User +input always wins**: never overwrite a value the user has typed, and treat +`defaults: null` as "no workspace defaults", not as "clear the fields". Like +restored state, treat both values as untrusted text: render them as text, +never as HTML. ## App-side reference implementation @@ -79,6 +88,9 @@ saved entry builds a `?calc=` link from it and navigates to the tool, so restore always goes through the validation path documented here — apps need no extra work to support history beyond speaking this protocol. +Workspace history (issue #122) stores the same object in +`public.workspace_calculations`, so shared entries reopen the same way. + ## Wiring status | App | Repo | Status | diff --git a/docusaurus.config.ts b/docusaurus.config.ts index c88588f..7fcea81 100644 --- a/docusaurus.config.ts +++ b/docusaurus.config.ts @@ -113,8 +113,17 @@ const config: Config = { filename: 'sitemap.xml', // Search result pages are noindex-style utility pages, keep them // out of the sitemap (locale variants included). - // Embed routes are noindex widgets for third-party sites (issue #121). - ignorePatterns: ['/admin/**', '/search/**', '/*/search/**', '/embed/**', '/*/embed/**'], + // Embed routes are noindex widgets for third-party sites (issue #121); + // workspace invite pages carry one-time codes (issue #122). + ignorePatterns: [ + '/admin/**', + '/search/**', + '/*/search/**', + '/embed/**', + '/*/embed/**', + '/workspaces/**', + '/*/workspaces/**', + ], }, theme: { customCss: ['./src/css/custom.css', './src/css/light-theme.css'], diff --git a/package.json b/package.json index 9127e19..58767ed 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,8 @@ "write-translations": "docusaurus write-translations", "write-heading-ids": "docusaurus write-heading-ids", "typecheck": "tsc", - "lint": "eslint src api scripts *.ts *.js *.mjs" + "lint": "eslint src api scripts *.ts *.js *.mjs", + "test:db": "node supabase/tests/workspaces.test.mjs" }, "dependencies": { "@dimforge/rapier3d-compat": "^0.14.0", @@ -71,6 +72,7 @@ "@docusaurus/module-type-aliases": "3.10.2", "@docusaurus/tsconfig": "3.10.2", "@docusaurus/types": "3.10.2", + "@electric-sql/pglite": "^0.5.8", "@eslint/js": "^10.0.1", "@tailwindcss/postcss": "^4.2.1", "@types/node": "^26.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a2a7b3..57835ad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -170,6 +170,9 @@ importers: '@docusaurus/types': specifier: 3.10.2 version: 3.10.2(@swc/core@1.15.43)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.24))(html-minifier-terser@7.2.0)(postcss@8.5.24)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + '@electric-sql/pglite': + specifier: ^0.5.8 + version: 0.5.8 '@eslint/js': specifier: ^10.0.1 version: 10.0.1(eslint@10.7.0(jiti@2.6.1)) @@ -1678,6 +1681,9 @@ packages: resolution: {integrity: sha512-0dEVyRLM/lG4gp1R/Ik5bfPl/1wX00xFwd5KcNH602tzBa09oF7pbTKETEhR1GjZ75K6OJnYFu8II2dyMhONMw==} engines: {node: '>=16'} + '@electric-sql/pglite@0.5.8': + resolution: {integrity: sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==} + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -10707,6 +10713,8 @@ snapshots: dependencies: '@edge-runtime/primitives': 4.1.0 + '@electric-sql/pglite@0.5.8': {} + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 diff --git a/src/components/Utilities/UtilityShellPage.tsx b/src/components/Utilities/UtilityShellPage.tsx index 3cea21a..4ca508f 100644 --- a/src/components/Utilities/UtilityShellPage.tsx +++ b/src/components/Utilities/UtilityShellPage.tsx @@ -25,6 +25,16 @@ import homeStyles from '@site/src/pages/index.module.css'; import UtilityErrorBoundary from './UtilityErrorBoundary'; import {saveCalculation} from '@site/src/shared/calculation-history'; import { + listMyWorkspaces, + PERSONAL_TARGET, + readSaveTarget, + reportDefaultsOf, + saveWorkspaceCalculation, + writeSaveTarget, + type Workspace, +} from '@site/src/shared/workspaces'; +import { + SHARE_MESSAGE_REPORT_DEFAULTS, SHARE_MESSAGE_RESTORE, SHARE_MESSAGE_STATE_UPDATE, SHARE_MESSAGE_SUPPORT, @@ -140,15 +150,58 @@ export default function UtilityShellPage({tool, ...config}: UtilityShellPageProp const userId = user?.id ?? null; + // Team workspaces (issue #122): a signed-in member can save to a workspace's + // shared history instead of their personal one. The selected workspace also + // supplies report defaults to apps that accept them. + const [workspaces, setWorkspaces] = React.useState([]); + const [saveTarget, setSaveTarget] = React.useState(PERSONAL_TARGET); + + React.useEffect(() => { + if (!shareSupported || !userId) { + setWorkspaces([]); + return undefined; + } + let isMounted = true; + void listMyWorkspaces() + .then((rows) => { + if (!isMounted) return; + setWorkspaces(rows); + const remembered = readSaveTarget(); + setSaveTarget(rows.some((w) => w.id === remembered) ? remembered : PERSONAL_TARGET); + }) + .catch((err) => { + const message = err instanceof Error ? err.message : 'Unable to load workspaces.'; + logger.warn('[Workspaces] Unable to load workspaces for save target', message); + }); + return () => { + isMounted = false; + }; + }, [shareSupported, userId]); + + const targetWorkspace = workspaces.find((w) => w.id === saveTarget) ?? null; + + React.useEffect(() => { + const frame = iframeRef.current?.contentWindow; + if (!shareSupported || !frame || workspaces.length === 0) return; + frame.postMessage( + {type: SHARE_MESSAGE_REPORT_DEFAULTS, version: 1, defaults: reportDefaultsOf(targetWorkspace)}, + window.location.origin, + ); + }, [shareSupported, workspaces.length, targetWorkspace]); + + const changeSaveTarget = (target: string) => { + setSaveTarget(target); + writeSaveTarget(target); + }; + const saveCurrentCalculation = React.useCallback(() => { if (!userId || latestToolState.current === null) return; setSaveState('saving'); - void saveCalculation({ - userId, - utilityId: slug, - state: latestToolState.current, - label: title, - }) + const snapshot = {utilityId: slug, state: latestToolState.current, label: title}; + const request = targetWorkspace + ? saveWorkspaceCalculation({...snapshot, userId, workspaceId: targetWorkspace.id}) + : saveCalculation({...snapshot, userId}); + void request .then(() => { setSaveState('saved'); window.setTimeout(() => setSaveState('idle'), 2000); @@ -159,7 +212,7 @@ export default function UtilityShellPage({tool, ...config}: UtilityShellPageProp const message = err instanceof Error ? err.message : 'Unable to save calculation.'; logger.error('[CalculationHistory] Unable to save calculation', message); }); - }, [slug, title, userId]); + }, [slug, title, userId, targetWorkspace]); React.useEffect(() => { document.body.classList.add('utility-shell-page'); @@ -315,6 +368,17 @@ export default function UtilityShellPage({tool, ...config}: UtilityShellPageProp {linkCopied ? t('utility.linkCopied') : t('utility.copyLink')} ) : null} + {shareSupported && isAuthenticated && workspaces.length > 0 ? ( + + ) : null} {shareSupported && isAuthenticated ? ( + + + {actionError ? ( +

{t('workspaces.actionFailed')}

+ ) : null} + + {workspaces.length === 0 ? ( +

{t('workspaces.empty')}

+ ) : ( + <> + {workspaces.length > 1 ? ( + + ) : null} + + {selected ? ( + + ) : null} + + )} + + )} + + ); +} + +type PanelProps = { + workspace: Workspace; + userId: string | null; + runAction: (scope: string, action: () => Promise, preferId?: string | null) => Promise; +}; + +function WorkspacePanel({workspace, userId, runAction}: PanelProps): React.JSX.Element { + const {t} = useI18n(); + const isOwner = getMyRole(workspace, userId) === 'owner'; + + const [inviteUrl, setInviteUrl] = useState(null); + const [inviteCopied, setInviteCopied] = useState(false); + const [invitesRevoked, setInvitesRevoked] = useState(false); + + const [form, setForm] = useState({ + name: workspace.name, + companyName: workspace.companyName ?? '', + preferredStandards: workspace.preferredStandards ?? '', + }); + const [savedDefaults, setSavedDefaults] = useState(false); + + const handleInvite = () => + runAction('Unable to create invite', async () => { + setInvitesRevoked(false); + setInviteCopied(false); + setInviteUrl(buildInviteUrl(await createInviteCode(workspace.id))); + }, workspace.id); + + const handleCopyInvite = () => { + if (!inviteUrl) return; + void navigator.clipboard.writeText(inviteUrl).then( + () => setInviteCopied(true), + (err) => logError('Unable to copy invite link', err), + ); + }; + + const handleRevoke = () => + runAction('Unable to revoke invites', async () => { + await revokeInvites(workspace.id); + setInviteUrl(null); + setInvitesRevoked(true); + }, workspace.id); + + const handleSaveDefaults = (event: React.FormEvent) => { + event.preventDefault(); + void runAction('Unable to update workspace', async () => { + await updateWorkspace(workspace.id, form); + setSavedDefaults(true); + window.setTimeout(() => setSavedDefaults(false), 2000); + }, workspace.id); + }; + + const handleRemove = (memberId: string, name: string) => { + if (!window.confirm(t('workspaces.removeConfirm', {name}))) return; + void runAction('Unable to remove member', () => removeMember(workspace.id, memberId), workspace.id); + }; + + const handleLeave = () => { + if (!window.confirm(t('workspaces.leaveConfirm', {name: workspace.name}))) return; + void runAction('Unable to leave workspace', () => leaveWorkspace(workspace.id), null); + }; + + const handleDelete = () => { + if (!window.confirm(t('workspaces.deleteConfirm', {name: workspace.name}))) return; + void runAction('Unable to delete workspace', () => deleteWorkspace(workspace.id), null); + }; + + return ( +
+

{workspace.name}

+ +
+

+ {t('workspaces.membersTitle', {count: String(workspace.members.length)})} +

+
    + {workspace.members.map((member) => ( +
  • +
    + + {member.displayName} + {member.userId === userId ? ` (${t('workspaces.you')})` : ''} + + + {member.role === 'owner' ? t('workspaces.roleOwner') : t('workspaces.roleMember')} + +
    + {isOwner && member.userId !== userId ? ( +
    + +
    + ) : null} +
  • + ))} +
+
+ + {isOwner ? ( +
+

{t('workspaces.inviteTitle')}

+

{t('workspaces.inviteHint')}

+ {inviteUrl ? ( +
+ e.target.select()} /> + +
+ ) : null} +
+ + +
+ {invitesRevoked ?

{t('workspaces.invitesRevoked')}

: null} +
+ ) : null} + +
+

{t('workspaces.defaultsTitle')}

+

+ {t('workspaces.defaultsHint')} {isOwner ? '' : t('workspaces.ownerOnly')} +

+
+ + + + {isOwner ? ( +
+ +
+ ) : null} +
+
+ +
+

{t('workspaces.historyTitle')}

+ +
+ +
+ + {isOwner ? ( + + ) : null} +
+
+ ); +} + +function SharedHistory({workspaceId, userId, isOwner}: {workspaceId: string; userId: string | null; isOwner: boolean}) { + const {t, tu} = useI18n(); + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(true); + const [failed, setFailed] = useState(false); + + useEffect(() => { + let isMounted = true; + void listWorkspaceCalculations(workspaceId) + .then((rows) => { + if (isMounted) setEntries(rows); + }) + .catch((err) => { + if (isMounted) setFailed(true); + logError('Unable to load shared history', err); + }) + .finally(() => { + if (isMounted) setLoading(false); + }); + return () => { + isMounted = false; + }; + }, [workspaceId]); + + const handleDelete = (id: string) => { + if (!window.confirm(t('history.deleteConfirm'))) return; + const previous = entries; + // Optimistic, restored on failure — same pattern as personal history. + setEntries((rows) => rows.filter((row) => row.id !== id)); + void deleteWorkspaceCalculation(id).catch((err) => { + setEntries(previous); + logError('Unable to delete shared entry', err); + }); + }; + + const displayName = (entry: WorkspaceCalculation) => { + const known = utilities.find((utility) => utility.id === entry.utilityId); + return known ? tu(known.id).name || known.name : entry.label || entry.utilityId; + }; + + if (loading) return

{t('history.loading')}

; + if (failed) return

{t('history.error')}

; + + return ( + <> +

{t('workspaces.historySubtitle', {max: String(MAX_WORKSPACE_ENTRIES)})}

+ {entries.length === 0 ? ( +

{t('workspaces.historyEmpty')}

+ ) : ( +
    + {entries.map((entry) => { + const encoded = encodeUtilityState(entry.state); + const canDelete = isOwner || entry.createdBy === userId; + return ( +
  • +
    + {displayName(entry)} + +
    +
    + {encoded ? ( + + {t('history.open')} + + ) : null} + {canDelete ? ( + + ) : null} +
    +
  • + ); + })} +
+ )} + + ); +} diff --git a/src/components/Workspaces/styles.module.css b/src/components/Workspaces/styles.module.css new file mode 100644 index 0000000..9bd7d38 --- /dev/null +++ b/src/components/Workspaces/styles.module.css @@ -0,0 +1,75 @@ +.row { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 0.6rem; + margin: 0 0 1rem; +} + +.field { + display: flex; + flex-direction: column; + gap: 0.3rem; + flex: 1 1 16rem; + min-width: 0; + margin-bottom: 0.75rem; +} + +.row .field { + margin-bottom: 0; +} + +.label { + font-size: 0.8rem; + opacity: 0.75; +} + +.input { + width: 100%; + padding: 0.45rem 0.65rem; + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: 8px; + background: var(--ifm-background-surface-color); + color: inherit; + font: inherit; + font-size: 0.9rem; +} + +.input:disabled { + opacity: 0.7; +} + +.error { + color: var(--ifm-color-danger); + font-size: 0.9rem; +} + +.panel { + margin-top: 1rem; + padding: 1rem 1.1rem; + border: 1px solid var(--ifm-color-emphasis-200); + border-radius: 12px; +} + +.workspaceName { + margin: 0 0 1rem; +} + +.block { + margin-bottom: 1.5rem; +} + +.blockTitle { + margin: 0 0 0.5rem; + font-size: 0.95rem; +} + +.defaults { + display: flex; + flex-wrap: wrap; + gap: 0 0.75rem; +} + +.defaults .row { + flex-basis: 100%; +} diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 652b27c..e980fda 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -90,6 +90,60 @@ export const de: TranslationDict = { deleteConfirm: 'Diese gespeicherte Berechnung löschen?', error: 'Gespeicherte Berechnungen konnten nicht geladen werden. Bitte erneut versuchen.', }, + workspaces: { + eyebrow: 'Team', + title: 'Arbeitsbereiche', + subtitle: 'Teilen Sie Berechnungsverlauf und Berichtsvorgaben mit Kollegen. Laden Sie sie per Link ein.', + loading: 'Arbeitsbereiche werden geladen…', + error: 'Arbeitsbereiche konnten nicht geladen werden.', + empty: 'Sie sind noch in keinem Arbeitsbereich.', + createLabel: 'Name des neuen Arbeitsbereichs', + createPlaceholder: 'z. B. Team Druckbehälter', + create: 'Arbeitsbereich erstellen', + creating: 'Wird erstellt…', + select: 'Arbeitsbereich', + roleOwner: 'Inhaber', + roleMember: 'Mitglied', + you: 'Sie', + membersTitle: 'Mitglieder ({count})', + remove: 'Entfernen', + removeConfirm: '{name} aus diesem Arbeitsbereich entfernen?', + inviteTitle: 'Kollegen einladen', + inviteHint: 'Jeder mit diesem Link kann 7 Tage lang beitreten. Er wird nur einmal angezeigt – kopieren Sie ihn jetzt.', + createInvite: 'Einladungslink erstellen', + copyInvite: 'Link kopieren', + inviteCopied: 'Kopiert', + revokeInvites: 'Alle Einladungslinks widerrufen', + invitesRevoked: 'Alle Einladungslinks wurden widerrufen.', + defaultsTitle: 'Berichtsvorgaben', + defaultsHint: 'Für Schriftfelder und Berichte in Werkzeugen, die dies unterstützen.', + name: 'Name des Arbeitsbereichs', + companyName: 'Firmenname', + preferredStandards: 'Bevorzugte Normen', + preferredStandardsPlaceholder: 'z. B. EN 13445, DIN 28011', + save: 'Speichern', + saved: 'Gespeichert', + ownerOnly: 'Nur der Inhaber kann dies ändern.', + historyTitle: 'Gemeinsamer Verlauf', + historySubtitle: 'Die neuesten {max} von Mitgliedern gespeicherten Einträge.', + historyEmpty: 'Noch nichts geteilt. Wählen Sie diesen Arbeitsbereich in einem Werkzeug unter „Speichern in“.', + savedBy: 'von {name}', + leave: 'Arbeitsbereich verlassen', + leaveConfirm: '{name} verlassen? Sie verlieren den Zugriff auf den gemeinsamen Verlauf.', + deleteWorkspace: 'Arbeitsbereich löschen', + deleteConfirm: '{name} und den gemeinsamen Verlauf für alle Mitglieder löschen? Dies kann nicht rückgängig gemacht werden.', + actionFailed: 'Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut.', + joinTitle: 'Einem Arbeitsbereich beitreten', + joinSignIn: 'Melden Sie sich an, um diese Einladung anzunehmen.', + joinAccept: 'Arbeitsbereich beitreten', + joining: 'Beitritt läuft…', + joinSuccess: 'Sie sind dem Arbeitsbereich beigetreten.', + joinOpenWorkspaces: 'Arbeitsbereiche öffnen', + joinInvalid: 'Dieser Einladungslink ist ungültig oder abgelaufen. Bitten Sie den Inhaber um einen neuen.', + joinMissingCode: 'Dieser Link enthält keinen Einladungscode.', + saveTo: 'Speichern in', + personalHistory: 'Persönlicher Verlauf', + }, newsletter: { title: 'Neue Tools, direkt per E-Mail', description: diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index b5a6b41..83d02ee 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -88,6 +88,60 @@ export const en = { deleteConfirm: 'Delete this saved calculation?', error: 'Unable to load your saved calculations. Please try again.', }, + workspaces: { + eyebrow: 'Team', + title: 'Workspaces', + subtitle: 'Share calculation history and report defaults with colleagues. Invite them with a link.', + loading: 'Loading workspaces…', + error: 'Unable to load workspaces.', + empty: 'You are not in a workspace yet.', + createLabel: 'New workspace name', + createPlaceholder: 'e.g. Pressure vessel team', + create: 'Create workspace', + creating: 'Creating…', + select: 'Workspace', + roleOwner: 'Owner', + roleMember: 'Member', + you: 'you', + membersTitle: 'Members ({count})', + remove: 'Remove', + removeConfirm: 'Remove {name} from this workspace?', + inviteTitle: 'Invite colleagues', + inviteHint: 'Anyone with this link can join for 7 days. It is shown only once, so copy it now.', + createInvite: 'Create invite link', + copyInvite: 'Copy link', + inviteCopied: 'Copied', + revokeInvites: 'Revoke all invite links', + invitesRevoked: 'All invite links have been revoked.', + defaultsTitle: 'Report defaults', + defaultsHint: 'Used for title blocks and reports by utilities that support them.', + name: 'Workspace name', + companyName: 'Company name', + preferredStandards: 'Preferred standards', + preferredStandardsPlaceholder: 'e.g. EN 13445, DIN 28011', + save: 'Save', + saved: 'Saved', + ownerOnly: 'Only the owner can change these.', + historyTitle: 'Shared history', + historySubtitle: 'The latest {max} entries saved by members.', + historyEmpty: 'Nothing shared yet. Choose this workspace under "Save to" in a utility.', + savedBy: 'by {name}', + leave: 'Leave workspace', + leaveConfirm: 'Leave {name}? You will lose access to its shared history.', + deleteWorkspace: 'Delete workspace', + deleteConfirm: 'Delete {name} and its shared history for all members? This cannot be undone.', + actionFailed: 'Something went wrong. Please try again.', + joinTitle: 'Join a workspace', + joinSignIn: 'Sign in to accept this invitation.', + joinAccept: 'Join workspace', + joining: 'Joining…', + joinSuccess: 'You have joined the workspace.', + joinOpenWorkspaces: 'Open workspaces', + joinInvalid: 'This invite link is invalid or has expired. Ask the workspace owner for a new one.', + joinMissingCode: 'This link does not contain an invite code.', + saveTo: 'Save to', + personalHistory: 'Personal history', + }, newsletter: { title: 'New tools, in your inbox', description: diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 241db22..a06b5ec 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -90,6 +90,60 @@ export const es: TranslationDict = { deleteConfirm: '¿Eliminar este cálculo guardado?', error: 'No se pudieron cargar tus cálculos guardados. Inténtalo de nuevo.', }, + workspaces: { + eyebrow: 'Equipo', + title: 'Espacios de trabajo', + subtitle: 'Comparta el historial de cálculos y los valores predeterminados de informes con sus colegas. Invítelos con un enlace.', + loading: 'Cargando espacios de trabajo…', + error: 'No se pudieron cargar los espacios de trabajo.', + empty: 'Todavía no pertenece a ningún espacio de trabajo.', + createLabel: 'Nombre del nuevo espacio de trabajo', + createPlaceholder: 'p. ej. Equipo de recipientes a presión', + create: 'Crear espacio de trabajo', + creating: 'Creando…', + select: 'Espacio de trabajo', + roleOwner: 'Propietario', + roleMember: 'Miembro', + you: 'usted', + membersTitle: 'Miembros ({count})', + remove: 'Quitar', + removeConfirm: '¿Quitar a {name} de este espacio de trabajo?', + inviteTitle: 'Invitar a colegas', + inviteHint: 'Cualquier persona con este enlace puede unirse durante 7 días. Solo se muestra una vez, así que cópielo ahora.', + createInvite: 'Crear enlace de invitación', + copyInvite: 'Copiar enlace', + inviteCopied: 'Copiado', + revokeInvites: 'Revocar todos los enlaces de invitación', + invitesRevoked: 'Se han revocado todos los enlaces de invitación.', + defaultsTitle: 'Valores predeterminados de informes', + defaultsHint: 'Se usan en cajetines e informes de las utilidades que los admiten.', + name: 'Nombre del espacio de trabajo', + companyName: 'Nombre de la empresa', + preferredStandards: 'Normas preferidas', + preferredStandardsPlaceholder: 'p. ej. EN 13445, DIN 28011', + save: 'Guardar', + saved: 'Guardado', + ownerOnly: 'Solo el propietario puede cambiar esto.', + historyTitle: 'Historial compartido', + historySubtitle: 'Las últimas {max} entradas guardadas por los miembros.', + historyEmpty: 'Aún no se ha compartido nada. Elija este espacio de trabajo en «Guardar en» dentro de una utilidad.', + savedBy: 'por {name}', + leave: 'Salir del espacio de trabajo', + leaveConfirm: '¿Salir de {name}? Perderá el acceso a su historial compartido.', + deleteWorkspace: 'Eliminar espacio de trabajo', + deleteConfirm: '¿Eliminar {name} y su historial compartido para todos los miembros? Esta acción no se puede deshacer.', + actionFailed: 'Algo salió mal. Inténtelo de nuevo.', + joinTitle: 'Unirse a un espacio de trabajo', + joinSignIn: 'Inicie sesión para aceptar esta invitación.', + joinAccept: 'Unirse al espacio de trabajo', + joining: 'Uniéndose…', + joinSuccess: 'Se ha unido al espacio de trabajo.', + joinOpenWorkspaces: 'Abrir espacios de trabajo', + joinInvalid: 'Este enlace de invitación no es válido o ha caducado. Pida uno nuevo al propietario.', + joinMissingCode: 'Este enlace no contiene un código de invitación.', + saveTo: 'Guardar en', + personalHistory: 'Historial personal', + }, newsletter: { title: 'Nuevas herramientas, en tu correo', description: diff --git a/src/i18n/locales/et.ts b/src/i18n/locales/et.ts index 8d192a3..c077c39 100644 --- a/src/i18n/locales/et.ts +++ b/src/i18n/locales/et.ts @@ -90,6 +90,60 @@ export const et: TranslationDict = { deleteConfirm: 'Kas kustutada see salvestatud arvutus?', error: 'Salvestatud arvutusi ei õnnestunud laadida. Palun proovi uuesti.', }, + workspaces: { + eyebrow: 'Meeskond', + title: 'Tööruumid', + subtitle: 'Jagage arvutuste ajalugu ja aruannete vaikeväärtusi kolleegidega. Kutsuge nad lingiga.', + loading: 'Tööruumide laadimine…', + error: 'Tööruume ei õnnestunud laadida.', + empty: 'Te ei kuulu veel ühtegi tööruumi.', + createLabel: 'Uue tööruumi nimi', + createPlaceholder: 'nt Surveanumate meeskond', + create: 'Loo tööruum', + creating: 'Loomine…', + select: 'Tööruum', + roleOwner: 'Omanik', + roleMember: 'Liige', + you: 'teie', + membersTitle: 'Liikmed ({count})', + remove: 'Eemalda', + removeConfirm: 'Kas eemaldada {name} sellest tööruumist?', + inviteTitle: 'Kutsu kolleege', + inviteHint: 'Selle lingiga saab liituda 7 päeva jooksul. Seda näidatakse ainult üks kord, seega kopeerige see kohe.', + createInvite: 'Loo kutselink', + copyInvite: 'Kopeeri link', + inviteCopied: 'Kopeeritud', + revokeInvites: 'Tühista kõik kutselingid', + invitesRevoked: 'Kõik kutselingid on tühistatud.', + defaultsTitle: 'Aruannete vaikeväärtused', + defaultsHint: 'Kasutatakse kirjanurkades ja aruannetes tööriistades, mis seda toetavad.', + name: 'Tööruumi nimi', + companyName: 'Ettevõtte nimi', + preferredStandards: 'Eelistatud standardid', + preferredStandardsPlaceholder: 'nt EN 13445, DIN 28011', + save: 'Salvesta', + saved: 'Salvestatud', + ownerOnly: 'Neid saab muuta ainult omanik.', + historyTitle: 'Jagatud ajalugu', + historySubtitle: 'Liikmete salvestatud viimased {max} kirjet.', + historyEmpty: 'Midagi pole veel jagatud. Valige see tööruum tööriistas valiku „Salvesta asukohta“ alt.', + savedBy: 'autor {name}', + leave: 'Lahku tööruumist', + leaveConfirm: 'Kas lahkuda tööruumist {name}? Kaotate juurdepääsu selle jagatud ajaloole.', + deleteWorkspace: 'Kustuta tööruum', + deleteConfirm: 'Kas kustutada {name} ja selle jagatud ajalugu kõigi liikmete jaoks? Seda ei saa tagasi võtta.', + actionFailed: 'Midagi läks valesti. Palun proovige uuesti.', + joinTitle: 'Liitu tööruumiga', + joinSignIn: 'Kutse vastuvõtmiseks logige sisse.', + joinAccept: 'Liitu tööruumiga', + joining: 'Liitumine…', + joinSuccess: 'Olete tööruumiga liitunud.', + joinOpenWorkspaces: 'Ava tööruumid', + joinInvalid: 'See kutselink on kehtetu või aegunud. Paluge omanikult uus.', + joinMissingCode: 'See link ei sisalda kutsekoodi.', + saveTo: 'Salvesta asukohta', + personalHistory: 'Isiklik ajalugu', + }, newsletter: { title: 'Uued tööriistad sinu postkasti', description: diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index 20c9ca7..2caa649 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -90,6 +90,60 @@ export const ru: TranslationDict = { deleteConfirm: 'Удалить этот сохранённый расчёт?', error: 'Не удалось загрузить сохранённые расчёты. Попробуйте снова.', }, + workspaces: { + eyebrow: 'Команда', + title: 'Рабочие пространства', + subtitle: 'Делитесь историей расчётов и реквизитами отчётов с коллегами. Приглашайте их по ссылке.', + loading: 'Загрузка рабочих пространств…', + error: 'Не удалось загрузить рабочие пространства.', + empty: 'Вы пока не состоите ни в одном рабочем пространстве.', + createLabel: 'Название нового пространства', + createPlaceholder: 'например, Группа сосудов давления', + create: 'Создать пространство', + creating: 'Создание…', + select: 'Рабочее пространство', + roleOwner: 'Владелец', + roleMember: 'Участник', + you: 'вы', + membersTitle: 'Участники ({count})', + remove: 'Удалить', + removeConfirm: 'Удалить {name} из этого пространства?', + inviteTitle: 'Пригласить коллег', + inviteHint: 'Любой, у кого есть эта ссылка, может присоединиться в течение 7 дней. Ссылка показывается один раз — скопируйте её сейчас.', + createInvite: 'Создать ссылку-приглашение', + copyInvite: 'Копировать ссылку', + inviteCopied: 'Скопировано', + revokeInvites: 'Отозвать все ссылки-приглашения', + invitesRevoked: 'Все ссылки-приглашения отозваны.', + defaultsTitle: 'Реквизиты отчётов', + defaultsHint: 'Используются в штампах и отчётах утилит, которые это поддерживают.', + name: 'Название пространства', + companyName: 'Название компании', + preferredStandards: 'Предпочтительные стандарты', + preferredStandardsPlaceholder: 'например, EN 13445, DIN 28011', + save: 'Сохранить', + saved: 'Сохранено', + ownerOnly: 'Изменять это может только владелец.', + historyTitle: 'Общая история', + historySubtitle: 'Последние {max} записей, сохранённых участниками.', + historyEmpty: 'Пока ничего не сохранено. Выберите это пространство в поле «Сохранить в» внутри утилиты.', + savedBy: 'автор: {name}', + leave: 'Покинуть пространство', + leaveConfirm: 'Покинуть {name}? Вы потеряете доступ к общей истории.', + deleteWorkspace: 'Удалить пространство', + deleteConfirm: 'Удалить {name} и общую историю для всех участников? Это действие нельзя отменить.', + actionFailed: 'Что-то пошло не так. Попробуйте ещё раз.', + joinTitle: 'Присоединиться к пространству', + joinSignIn: 'Войдите, чтобы принять приглашение.', + joinAccept: 'Присоединиться', + joining: 'Присоединение…', + joinSuccess: 'Вы присоединились к рабочему пространству.', + joinOpenWorkspaces: 'Открыть рабочие пространства', + joinInvalid: 'Ссылка-приглашение недействительна или устарела. Попросите у владельца новую.', + joinMissingCode: 'В этой ссылке нет кода приглашения.', + saveTo: 'Сохранить в', + personalHistory: 'Личная история', + }, newsletter: { title: 'Новые инструменты — на вашу почту', description: diff --git a/src/i18n/locales/ua.ts b/src/i18n/locales/ua.ts index 92fb212..b94c72e 100644 --- a/src/i18n/locales/ua.ts +++ b/src/i18n/locales/ua.ts @@ -90,6 +90,60 @@ export const ua: TranslationDict = { deleteConfirm: 'Видалити цей збережений розрахунок?', error: 'Не вдалося завантажити збережені розрахунки. Спробуйте знову.', }, + workspaces: { + eyebrow: 'Команда', + title: 'Робочі простори', + subtitle: 'Діліться історією розрахунків і реквізитами звітів з колегами. Запрошуйте їх за посиланням.', + loading: 'Завантаження робочих просторів…', + error: 'Не вдалося завантажити робочі простори.', + empty: 'Ви ще не належите до жодного робочого простору.', + createLabel: 'Назва нового простору', + createPlaceholder: 'наприклад, Група посудин під тиском', + create: 'Створити простір', + creating: 'Створення…', + select: 'Робочий простір', + roleOwner: 'Власник', + roleMember: 'Учасник', + you: 'ви', + membersTitle: 'Учасники ({count})', + remove: 'Видалити', + removeConfirm: 'Видалити {name} з цього простору?', + inviteTitle: 'Запросити колег', + inviteHint: 'Будь-хто з цим посиланням може приєднатися протягом 7 днів. Посилання показується лише один раз — скопіюйте його зараз.', + createInvite: 'Створити посилання-запрошення', + copyInvite: 'Копіювати посилання', + inviteCopied: 'Скопійовано', + revokeInvites: 'Відкликати всі посилання-запрошення', + invitesRevoked: 'Усі посилання-запрошення відкликано.', + defaultsTitle: 'Реквізити звітів', + defaultsHint: 'Використовуються в штампах і звітах утиліт, які це підтримують.', + name: 'Назва простору', + companyName: 'Назва компанії', + preferredStandards: 'Бажані стандарти', + preferredStandardsPlaceholder: 'наприклад, EN 13445, DIN 28011', + save: 'Зберегти', + saved: 'Збережено', + ownerOnly: 'Змінювати це може лише власник.', + historyTitle: 'Спільна історія', + historySubtitle: 'Останні {max} записів, збережених учасниками.', + historyEmpty: 'Поки нічого не збережено. Виберіть цей простір у полі «Зберегти в» всередині утиліти.', + savedBy: 'автор: {name}', + leave: 'Покинути простір', + leaveConfirm: 'Покинути {name}? Ви втратите доступ до спільної історії.', + deleteWorkspace: 'Видалити простір', + deleteConfirm: 'Видалити {name} і спільну історію для всіх учасників? Цю дію неможливо скасувати.', + actionFailed: 'Щось пішло не так. Спробуйте ще раз.', + joinTitle: 'Приєднатися до простору', + joinSignIn: 'Увійдіть, щоб прийняти запрошення.', + joinAccept: 'Приєднатися', + joining: 'Приєднання…', + joinSuccess: 'Ви приєдналися до робочого простору.', + joinOpenWorkspaces: 'Відкрити робочі простори', + joinInvalid: 'Посилання-запрошення недійсне або застаріле. Попросіть у власника нове.', + joinMissingCode: 'У цьому посиланні немає коду запрошення.', + saveTo: 'Зберегти в', + personalHistory: 'Особиста історія', + }, newsletter: { title: 'Нові інструменти — на вашу пошту', description: diff --git a/src/lib/utilityShare.ts b/src/lib/utilityShare.ts index 5c88d3d..5e82bef 100644 --- a/src/lib/utilityShare.ts +++ b/src/lib/utilityShare.ts @@ -13,6 +13,8 @@ export const SHARE_PARAM = 'calc'; export const SHARE_MESSAGE_SUPPORT = 'cas:share-support'; export const SHARE_MESSAGE_STATE_UPDATE = 'cas:state-update'; export const SHARE_MESSAGE_RESTORE = 'cas:restore-state'; +/** Shell → app: workspace report defaults (issue #122); `defaults` may be null. */ +export const SHARE_MESSAGE_REPORT_DEFAULTS = 'cas:report-defaults'; export const SHARE_SCHEMA_VERSION = 1; /** Keep links comfortably under browser/proxy URL limits. */ diff --git a/src/pages/profile/index.tsx b/src/pages/profile/index.tsx index 47b2a1f..e93dce8 100644 --- a/src/pages/profile/index.tsx +++ b/src/pages/profile/index.tsx @@ -8,6 +8,7 @@ import {useAuthModal} from '@site/src/contexts/AuthModalContext'; import {listUtilityUsage, type UtilityUsageStat} from '@site/src/shared/utility-usage'; import UsageRanking from '@site/src/components/UtilityUsage/UsageRanking'; import CalculationHistory from '@site/src/components/CalculationHistory'; +import Workspaces from '@site/src/components/Workspaces'; import {useI18n} from '@site/src/contexts/I18nContext'; import styles from './index.module.css'; import {logger} from '../../lib/logger'; @@ -613,6 +614,18 @@ export default function ProfilePage(): React.JSX.Element { ) : null} + + {user ? ( +
+
+
+

{t('workspaces.eyebrow')}

+

{t('workspaces.title')}

+
+
+ +
+ ) : null} ); diff --git a/src/pages/workspaces/join.tsx b/src/pages/workspaces/join.tsx new file mode 100644 index 0000000..041f6b9 --- /dev/null +++ b/src/pages/workspaces/join.tsx @@ -0,0 +1,93 @@ +import React, {useEffect, useState} from 'react'; +import Layout from '@theme/Layout'; +import Head from '@docusaurus/Head'; +import Link from '@docusaurus/Link'; +import {useI18n} from '@site/src/contexts/I18nContext'; +import {useAuthStatus} from '@site/src/hooks/useAuthStatus'; +import {useAuthModal} from '@site/src/contexts/AuthModalContext'; +import {acceptInvite, INVITE_PARAM} from '@site/src/shared/workspaces'; +import {logger} from '@site/src/lib/logger'; + +type JoinState = 'ready' | 'joining' | 'joined' | 'invalid'; + +/** + * Landing page for workspace invite links (issue #122). Joining is an explicit + * click, never automatic on page load, so opening a link cannot add someone + * to a workspace without their consent. Sign-in returns here with the code + * intact (the login modal remembers the current URL). + */ +export default function JoinWorkspace(): React.JSX.Element { + const {t} = useI18n(); + const {isAuthenticated, authChecked} = useAuthStatus(); + const {openLoginModal} = useAuthModal(); + const [code, setCode] = useState(null); + const [state, setState] = useState('ready'); + const [workspaceId, setWorkspaceId] = useState(null); + + useEffect(() => { + setCode(new URLSearchParams(window.location.search).get(INVITE_PARAM)); + }, []); + + const join = () => { + if (!code) return; + setState('joining'); + void acceptInvite(code) + .then((id) => { + setWorkspaceId(id); + setState('joined'); + }) + .catch((err) => { + setState('invalid'); + logger.warn('[Workspaces] Unable to accept invite', err instanceof Error ? err.message : String(err)); + }); + }; + + const workspacesHref = workspaceId ? `/profile/?workspace=${workspaceId}#workspaces` : '/profile/#workspaces'; + + let body: React.ReactNode; + if (code === null) { + body =

{t('workspaces.joinMissingCode')}

; + } else if (!authChecked) { + body = null; + } else if (!isAuthenticated) { + body = ( + <> +

{t('workspaces.joinSignIn')}

+ + + ); + } else if (state === 'joined') { + body = ( + <> +

{t('workspaces.joinSuccess')}

+ + {t('workspaces.joinOpenWorkspaces')} + + + ); + } else if (state === 'invalid') { + body =

{t('workspaces.joinInvalid')}

; + } else { + body = ( + + ); + } + + return ( + + + + {/* Keep the invite code out of Referer headers sent to other sites. */} + + +
+

{t('workspaces.joinTitle')}

+ {body} +
+
+ ); +} diff --git a/src/shared/workspaces/index.ts b/src/shared/workspaces/index.ts new file mode 100644 index 0000000..2f19a7b --- /dev/null +++ b/src/shared/workspaces/index.ts @@ -0,0 +1,254 @@ +import {supabase} from '@site/src/lib/supabaseClient'; +import {SHARE_SCHEMA_VERSION} from '@site/src/lib/utilityShare'; + +/** + * Shared workspaces (issue #122). + * + * Access is enforced in the database (supabase/migrations/20260915000000): + * membership gates every read, structural changes go through security definer + * RPCs that take the caller from auth.uid(), and shared entries are attributed + * server-side. Nothing here is a security boundary. + */ + +export type WorkspaceRole = 'owner' | 'member'; + +export type WorkspaceMember = { + userId: string; + role: WorkspaceRole; + displayName: string; + joinedAt: string; +}; + +export type Workspace = { + id: string; + name: string; + companyName: string | null; + preferredStandards: string | null; + members: WorkspaceMember[]; +}; + +export type WorkspaceCalculation = { + id: string; + workspaceId: string; + utilityId: string; + state: unknown; + label: string | null; + createdBy: string | null; + createdByName: string; + createdAt: string; +}; + +export type ReportDefaults = { + companyName: string | null; + preferredStandards: string | null; +}; + +export const MAX_WORKSPACE_ENTRIES = 200; +export const WORKSPACE_NAME_MAX = 80; +export const COMPANY_NAME_MAX = 120; +export const PREFERRED_STANDARDS_MAX = 200; +// Not 'code': supabase-js treats ?code= as an OAuth PKCE code and tries to +// exchange it for a session on page load. +export const INVITE_PARAM = 'invite'; + +const MAX_LABEL_LENGTH = 120; +const MAX_STATE_CHARS = 8000; + +type WorkspaceRow = { + id: string; + name: string; + company_name: string | null; + preferred_standards: string | null; + workspace_members: {user_id: string; role: WorkspaceRole; display_name: string; joined_at: string}[] | null; +}; + +type CalculationRow = { + id: string; + workspace_id: string; + utility_id: string; + state: unknown; + label: string | null; + created_by: string | null; + created_by_name: string; + created_at: string; +}; + +function fail(scope: string, message: string): never { + throw new Error(`${scope}: ${message}`); +} + +export function getMyRole(workspace: Workspace, userId: string | null | undefined): WorkspaceRole | null { + if (!userId) return null; + return workspace.members.find((member) => member.userId === userId)?.role ?? null; +} + +export async function listMyWorkspaces(): Promise { + const {data, error} = await supabase + .from('workspaces') + .select('id, name, company_name, preferred_standards, workspace_members(user_id, role, display_name, joined_at)') + .order('created_at', {ascending: true}); + + if (error) fail('listMyWorkspaces', error.message); + + return ((data ?? []) as WorkspaceRow[]).map((row) => ({ + id: row.id, + name: row.name, + companyName: row.company_name, + preferredStandards: row.preferred_standards, + members: (row.workspace_members ?? []) + .map((member) => ({ + userId: member.user_id, + role: member.role, + displayName: member.display_name, + joinedAt: member.joined_at, + })) + .sort((a, b) => (a.role === b.role ? a.joinedAt.localeCompare(b.joinedAt) : a.role === 'owner' ? -1 : 1)), + })); +} + +export async function createWorkspace(name: string): Promise { + const trimmed = name.trim(); + if (!trimmed) fail('createWorkspace', 'name is required'); + const {data, error} = await supabase.rpc('create_workspace', {p_name: trimmed.slice(0, WORKSPACE_NAME_MAX)}); + if (error) fail('createWorkspace', error.message); + return data as string; +} + +export async function updateWorkspace( + id: string, + values: {name: string; companyName: string; preferredStandards: string}, +): Promise { + const name = values.name.trim(); + if (!name) fail('updateWorkspace', 'name is required'); + const {error} = await supabase + .from('workspaces') + .update({ + name: name.slice(0, WORKSPACE_NAME_MAX), + company_name: values.companyName.trim().slice(0, COMPANY_NAME_MAX) || null, + preferred_standards: values.preferredStandards.trim().slice(0, PREFERRED_STANDARDS_MAX) || null, + }) + .eq('id', id); + if (error) fail('updateWorkspace', error.message); +} + +/** Returns a single-display invite code; only its hash is stored. */ +export async function createInviteCode(workspaceId: string): Promise { + const {data, error} = await supabase.rpc('create_workspace_invite', {p_workspace: workspaceId}); + if (error) fail('createInviteCode', error.message); + return data as string; +} + +export function buildInviteUrl(code: string): string { + const url = new URL('/workspaces/join/', window.location.origin); + url.searchParams.set(INVITE_PARAM, code); + return url.toString(); +} + +export async function revokeInvites(workspaceId: string): Promise { + const {error} = await supabase.rpc('revoke_workspace_invites', {p_workspace: workspaceId}); + if (error) fail('revokeInvites', error.message); +} + +export async function acceptInvite(code: string): Promise { + const {data, error} = await supabase.rpc('accept_workspace_invite', {p_code: code.trim()}); + if (error) fail('acceptInvite', error.message); + return data as string; +} + +export async function leaveWorkspace(workspaceId: string): Promise { + const {error} = await supabase.rpc('leave_workspace', {p_workspace: workspaceId}); + if (error) fail('leaveWorkspace', error.message); +} + +export async function removeMember(workspaceId: string, userId: string): Promise { + const {error} = await supabase.rpc('remove_workspace_member', {p_workspace: workspaceId, p_user: userId}); + if (error) fail('removeMember', error.message); +} + +export async function deleteWorkspace(workspaceId: string): Promise { + const {error} = await supabase.rpc('delete_workspace', {p_workspace: workspaceId}); + if (error) fail('deleteWorkspace', error.message); +} + +export async function listWorkspaceCalculations(workspaceId: string): Promise { + const {data, error} = await supabase + .from('workspace_calculations') + .select('id, workspace_id, utility_id, state, label, created_by, created_by_name, created_at') + .eq('workspace_id', workspaceId) + .order('created_at', {ascending: false}); + + if (error) fail('listWorkspaceCalculations', error.message); + + return ((data ?? []) as CalculationRow[]).map((row) => ({ + id: row.id, + workspaceId: row.workspace_id, + utilityId: row.utility_id, + state: row.state, + label: row.label, + createdBy: row.created_by, + createdByName: row.created_by_name, + createdAt: row.created_at, + })); +} + +/** + * Shares a snapshot with a workspace. `userId` must be the signed-in user: the + * insert policy checks it against auth.uid(), and the author name is stamped + * by the database. + */ +export async function saveWorkspaceCalculation(params: { + workspaceId: string; + userId: string; + utilityId: string; + state: unknown; + label?: string | null; +}): Promise { + if (params.state === null || params.state === undefined) { + fail('saveWorkspaceCalculation', 'nothing to save'); + } + if (JSON.stringify(params.state).length > MAX_STATE_CHARS) { + fail('saveWorkspaceCalculation', 'state too large to store'); + } + + const {error} = await supabase.from('workspace_calculations').insert({ + workspace_id: params.workspaceId, + created_by: params.userId, + utility_id: params.utilityId.trim(), + schema_version: SHARE_SCHEMA_VERSION, + state: params.state, + label: params.label?.trim().slice(0, MAX_LABEL_LENGTH) || null, + }); + + if (error) fail('saveWorkspaceCalculation', error.message); +} + +export async function deleteWorkspaceCalculation(id: string): Promise { + const {error} = await supabase.from('workspace_calculations').delete().eq('id', id); + if (error) fail('deleteWorkspaceCalculation', error.message); +} + +export function reportDefaultsOf(workspace: Workspace | null | undefined): ReportDefaults | null { + if (!workspace || (!workspace.companyName && !workspace.preferredStandards)) return null; + return {companyName: workspace.companyName, preferredStandards: workspace.preferredStandards}; +} + +// The save target chosen in a utility shell is remembered per browser, so a +// team member keeps saving to their team without re-selecting it every time. +const SAVE_TARGET_KEY = 'cas-save-target'; +export const PERSONAL_TARGET = 'personal'; + +export function readSaveTarget(): string { + try { + return window.localStorage.getItem(SAVE_TARGET_KEY) || PERSONAL_TARGET; + } catch { + return PERSONAL_TARGET; + } +} + +export function writeSaveTarget(target: string): void { + try { + window.localStorage.setItem(SAVE_TARGET_KEY, target); + } catch { + /* storage unavailable: the choice simply is not remembered */ + } +} diff --git a/static/utilities/util-shell.css b/static/utilities/util-shell.css index dd3a3b2..e3f0a0c 100644 --- a/static/utilities/util-shell.css +++ b/static/utilities/util-shell.css @@ -229,6 +229,31 @@ body.utility-shell-page .navbar__link:hover { background: #111935; } +/* "Save to" target for workspace members (issue #122). */ +.utility-save-target { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 0.85rem; + color: inherit; +} + +.utility-save-target select { + border-radius: 999px; + border: 1px solid rgba(15, 23, 42, 0.8); + background: #0b1224; + color: #f5f7ff; + padding: 7px 14px; + font: inherit; + cursor: pointer; +} + +:root[data-theme='light'] .utility-shell .utility-save-target select { + background: #ffffff; + border: 1px solid rgba(15, 23, 42, 0.15); + color: #0f172a; +} + .utility-info { width: min(1440px, calc(100vw - 64px)); max-width: 1440px; diff --git a/supabase/migrations/20260915000000_create_workspaces.sql b/supabase/migrations/20260915000000_create_workspaces.sql new file mode 100644 index 0000000..8b5a5fd --- /dev/null +++ b/supabase/migrations/20260915000000_create_workspaces.sql @@ -0,0 +1,529 @@ +-- Shared workspaces for small teams (issue #122). +-- +-- A signed-in user creates a workspace and invites colleagues with a link. +-- Members share a calculation history and the report metadata defaults +-- (company name for title blocks, preferred standards). +-- +-- Design notes: +-- * Membership is the only access path. Every policy goes through +-- is_workspace_member()/is_workspace_owner(), which are security definer so +-- the membership lookup does not recurse into workspace_members' own RLS. +-- * Structural changes (create, invite, join, leave, remove, delete) happen +-- only through the security definer RPCs below. The caller's identity always +-- comes from auth.uid(), never from a parameter. +-- * Invites are links, not emails: no mail provider, nothing to send. The +-- code is 244 random bits, returned once to the owner. Only its SHA-256 is +-- stored, so a database read does not reveal a usable invite. +-- * Shared entries are immutable snapshots, like personal history +-- (20260723000000). created_by plus a display-name snapshot keep entries +-- attributable after a member leaves or deletes their account. + +-- --------------------------------------------------------------------------- +-- Tables +-- --------------------------------------------------------------------------- + +create table if not exists public.workspaces ( + id uuid primary key default gen_random_uuid(), + name text not null check (char_length(btrim(name)) between 1 and 80), + company_name text check (char_length(company_name) <= 120), + preferred_standards text check (char_length(preferred_standards) <= 200), + created_by uuid references auth.users(id) on delete set null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table if not exists public.workspace_members ( + workspace_id uuid not null references public.workspaces(id) on delete cascade, + user_id uuid not null references auth.users(id) on delete cascade, + role text not null default 'member' check (role in ('owner', 'member')), + display_name text not null check (char_length(display_name) between 1 and 120), + joined_at timestamptz not null default now(), + primary key (workspace_id, user_id) +); + +create index if not exists workspace_members_user_idx + on public.workspace_members (user_id); + +create table if not exists public.workspace_invites ( + id uuid primary key default gen_random_uuid(), + workspace_id uuid not null references public.workspaces(id) on delete cascade, + code_hash text not null unique, + created_by uuid references auth.users(id) on delete set null, + created_at timestamptz not null default now(), + expires_at timestamptz not null, + revoked_at timestamptz +); + +create index if not exists workspace_invites_workspace_idx + on public.workspace_invites (workspace_id); + +create table if not exists public.workspace_calculations ( + id uuid primary key default gen_random_uuid(), + workspace_id uuid not null references public.workspaces(id) on delete cascade, + created_by uuid references auth.users(id) on delete set null, + -- Filled by trigger from the member row; clients cannot set it. + created_by_name text not null default '' check (char_length(created_by_name) <= 120), + utility_id text not null check (utility_id ~ '^[a-z0-9][a-z0-9-]{0,99}$'), + schema_version integer not null default 1 check (schema_version between 1 and 100), + state jsonb not null check (char_length(state::text) <= 8000), + label text check (char_length(label) <= 120), + created_at timestamptz not null default now() +); + +create index if not exists workspace_calculations_workspace_created_idx + on public.workspace_calculations (workspace_id, created_at desc); + +-- --------------------------------------------------------------------------- +-- Membership helpers (used by policies and RPCs) +-- --------------------------------------------------------------------------- + +create or replace function public.is_workspace_member(p_workspace uuid) +returns boolean +language sql +stable +security definer +set search_path to 'public' +as $$ + select exists ( + select 1 from public.workspace_members + where workspace_id = p_workspace and user_id = auth.uid() + ); +$$; + +create or replace function public.is_workspace_owner(p_workspace uuid) +returns boolean +language sql +stable +security definer +set search_path to 'public' +as $$ + select exists ( + select 1 from public.workspace_members + where workspace_id = p_workspace and user_id = auth.uid() and role = 'owner' + ); +$$; + +revoke execute on function public.is_workspace_member(uuid) from public, anon; +revoke execute on function public.is_workspace_owner(uuid) from public, anon; +grant execute on function public.is_workspace_member(uuid) to authenticated; +grant execute on function public.is_workspace_owner(uuid) to authenticated; + +-- Name shown to other members: the profile's full name if the auth metadata +-- carries one, otherwise the local part of the e-mail. Internal only. +create or replace function public.workspace_display_name(p_user uuid) +returns text +language sql +stable +security definer +set search_path to 'public' +as $$ + select left(coalesce( + nullif(btrim(u.raw_user_meta_data ->> 'full_name'), ''), + nullif(btrim(u.raw_user_meta_data ->> 'name'), ''), + nullif(split_part(u.email, '@', 1), ''), + 'Member' + ), 120) + from auth.users u + where u.id = p_user; +$$; + +revoke execute on function public.workspace_display_name(uuid) from public, anon, authenticated; + +-- --------------------------------------------------------------------------- +-- Row level security +-- --------------------------------------------------------------------------- + +alter table public.workspaces enable row level security; +alter table public.workspace_members enable row level security; +alter table public.workspace_invites enable row level security; +alter table public.workspace_calculations enable row level security; + +drop policy if exists "Members can read their workspaces" on public.workspaces; +create policy "Members can read their workspaces" + on public.workspaces + for select + to authenticated + using (public.is_workspace_member(id)); + +drop policy if exists "Owners can update their workspaces" on public.workspaces; +create policy "Owners can update their workspaces" + on public.workspaces + for update + to authenticated + using (public.is_workspace_owner(id)) + with check (public.is_workspace_owner(id)); + +drop policy if exists "Members can read fellow members" on public.workspace_members; +create policy "Members can read fellow members" + on public.workspace_members + for select + to authenticated + using (public.is_workspace_member(workspace_id)); + +-- workspace_invites: no policies. Invites are created, accepted and revoked +-- only through the RPCs below. + +drop policy if exists "Members can read shared calculations" on public.workspace_calculations; +create policy "Members can read shared calculations" + on public.workspace_calculations + for select + to authenticated + using (public.is_workspace_member(workspace_id)); + +drop policy if exists "Members can share calculations" on public.workspace_calculations; +create policy "Members can share calculations" + on public.workspace_calculations + for insert + to authenticated + with check (created_by = auth.uid() and public.is_workspace_member(workspace_id)); + +drop policy if exists "Authors and owners can delete shared calculations" on public.workspace_calculations; +create policy "Authors and owners can delete shared calculations" + on public.workspace_calculations + for delete + to authenticated + using (created_by = auth.uid() or public.is_workspace_owner(workspace_id)); + +revoke all on public.workspaces from public, anon, authenticated; +revoke all on public.workspace_members from public, anon, authenticated; +revoke all on public.workspace_invites from public, anon, authenticated; +revoke all on public.workspace_calculations from public, anon, authenticated; + +grant select on public.workspaces to authenticated; +grant update (name, company_name, preferred_standards) on public.workspaces to authenticated; +grant select on public.workspace_members to authenticated; +-- No UPDATE grant: shared entries are immutable snapshots. +grant select, delete on public.workspace_calculations to authenticated; +grant insert (workspace_id, created_by, utility_id, schema_version, state, label) + on public.workspace_calculations to authenticated; + +-- --------------------------------------------------------------------------- +-- Triggers +-- --------------------------------------------------------------------------- + +create or replace function public.touch_workspace_updated_at() +returns trigger +language plpgsql +set search_path to 'public' +as $$ +begin + new.updated_at := now(); + return new; +end; +$$; + +drop trigger if exists touch_workspace_updated_at_trigger on public.workspaces; +create trigger touch_workspace_updated_at_trigger + before update on public.workspaces + for each row execute function public.touch_workspace_updated_at(); + +-- Attribute a shared entry to the member who saved it, from the server side. +create or replace function public.stamp_workspace_calculation_author() +returns trigger +language plpgsql +security definer +set search_path to 'public' +as $$ +begin + select m.display_name into new.created_by_name + from public.workspace_members m + where m.workspace_id = new.workspace_id and m.user_id = new.created_by; + + new.created_by_name := coalesce(new.created_by_name, 'Member'); + return new; +end; +$$; + +drop trigger if exists stamp_workspace_calculation_author_trigger on public.workspace_calculations; +create trigger stamp_workspace_calculation_author_trigger + before insert on public.workspace_calculations + for each row execute function public.stamp_workspace_calculation_author(); + +-- Cap shared history per workspace, evicting oldest first. +create or replace function public.trim_workspace_calculations() +returns trigger +language plpgsql +security definer +set search_path to 'public' +as $$ +declare + max_entries constant integer := 200; +begin + delete from public.workspace_calculations + where workspace_id = new.workspace_id + and id not in ( + select id + from public.workspace_calculations + where workspace_id = new.workspace_id + order by created_at desc + limit max_entries + ); + return null; +end; +$$; + +drop trigger if exists trim_workspace_calculations_trigger on public.workspace_calculations; +create trigger trim_workspace_calculations_trigger + after insert on public.workspace_calculations + for each row execute function public.trim_workspace_calculations(); + +-- A workspace always has an owner while it has members: when the last owner +-- goes (leaves, is removed, or deletes their account), the longest-standing +-- member is promoted; when the last member goes, the workspace is deleted. +create or replace function public.reconcile_workspace_ownership() +returns trigger +language plpgsql +security definer +set search_path to 'public' +as $$ +declare + v_successor uuid; +begin + -- The workspace itself is being deleted (cascade): nothing to reconcile. + if not exists (select 1 from public.workspaces where id = old.workspace_id) then + return null; + end if; + + if exists ( + select 1 from public.workspace_members + where workspace_id = old.workspace_id and role = 'owner' + ) then + return null; + end if; + + select user_id into v_successor + from public.workspace_members + where workspace_id = old.workspace_id + order by joined_at, user_id + limit 1; + + if v_successor is null then + delete from public.workspaces where id = old.workspace_id; + else + update public.workspace_members + set role = 'owner' + where workspace_id = old.workspace_id and user_id = v_successor; + end if; + + return null; +end; +$$; + +drop trigger if exists reconcile_workspace_ownership_trigger on public.workspace_members; +create trigger reconcile_workspace_ownership_trigger + after delete on public.workspace_members + for each row execute function public.reconcile_workspace_ownership(); + +revoke execute on function public.touch_workspace_updated_at() from public, anon, authenticated; +revoke execute on function public.stamp_workspace_calculation_author() from public, anon, authenticated; +revoke execute on function public.trim_workspace_calculations() from public, anon, authenticated; +revoke execute on function public.reconcile_workspace_ownership() from public, anon, authenticated; + +-- --------------------------------------------------------------------------- +-- RPCs +-- --------------------------------------------------------------------------- + +create or replace function public.create_workspace(p_name text) +returns uuid +language plpgsql +security definer +set search_path to 'public' +as $$ +declare + v_uid uuid := auth.uid(); + v_workspace uuid; + max_owned constant integer := 10; +begin + if v_uid is null then + raise exception 'Not authenticated'; + end if; + + if (select count(*) from public.workspace_members where user_id = v_uid and role = 'owner') >= max_owned then + raise exception 'Workspace limit reached'; + end if; + + insert into public.workspaces (name, created_by) + values (btrim(p_name), v_uid) + returning id into v_workspace; + + insert into public.workspace_members (workspace_id, user_id, role, display_name) + values (v_workspace, v_uid, 'owner', coalesce(public.workspace_display_name(v_uid), 'Member')); + + return v_workspace; +end; +$$; + +create or replace function public.create_workspace_invite(p_workspace uuid) +returns text +language plpgsql +security definer +set search_path to 'public' +as $$ +declare + v_uid uuid := auth.uid(); + v_code text; + max_active constant integer := 20; +begin + if v_uid is null then + raise exception 'Not authenticated'; + end if; + if not public.is_workspace_owner(p_workspace) then + raise exception 'Not authorized'; + end if; + + delete from public.workspace_invites + where workspace_id = p_workspace and (expires_at < now() or revoked_at is not null); + + if (select count(*) from public.workspace_invites where workspace_id = p_workspace) >= max_active then + raise exception 'Too many active invites'; + end if; + + -- Two v4 UUIDs: 244 random bits, hex-encoded, URL-safe. + v_code := replace(gen_random_uuid()::text || gen_random_uuid()::text, '-', ''); + + insert into public.workspace_invites (workspace_id, code_hash, created_by, expires_at) + values ( + p_workspace, + encode(sha256(convert_to(v_code, 'UTF8')), 'hex'), + v_uid, + now() + interval '7 days' + ); + + return v_code; +end; +$$; + +create or replace function public.accept_workspace_invite(p_code text) +returns uuid +language plpgsql +security definer +set search_path to 'public' +as $$ +declare + v_uid uuid := auth.uid(); + v_workspace uuid; + max_members constant integer := 25; +begin + if v_uid is null then + raise exception 'Not authenticated'; + end if; + + select i.workspace_id into v_workspace + from public.workspace_invites i + where i.code_hash = encode(sha256(convert_to(coalesce(p_code, ''), 'UTF8')), 'hex') + and i.revoked_at is null + and i.expires_at > now(); + + if v_workspace is null then + raise exception 'Invite is invalid or has expired'; + end if; + + if exists ( + select 1 from public.workspace_members + where workspace_id = v_workspace and user_id = v_uid + ) then + return v_workspace; + end if; + + if (select count(*) from public.workspace_members where workspace_id = v_workspace) >= max_members then + raise exception 'Workspace is full'; + end if; + + insert into public.workspace_members (workspace_id, user_id, role, display_name) + values (v_workspace, v_uid, 'member', coalesce(public.workspace_display_name(v_uid), 'Member')); + + return v_workspace; +end; +$$; + +create or replace function public.revoke_workspace_invites(p_workspace uuid) +returns void +language plpgsql +security definer +set search_path to 'public' +as $$ +begin + if auth.uid() is null then + raise exception 'Not authenticated'; + end if; + if not public.is_workspace_owner(p_workspace) then + raise exception 'Not authorized'; + end if; + + update public.workspace_invites + set revoked_at = now() + where workspace_id = p_workspace and revoked_at is null; +end; +$$; + +create or replace function public.leave_workspace(p_workspace uuid) +returns void +language plpgsql +security definer +set search_path to 'public' +as $$ +begin + if auth.uid() is null then + raise exception 'Not authenticated'; + end if; + + delete from public.workspace_members + where workspace_id = p_workspace and user_id = auth.uid(); +end; +$$; + +create or replace function public.remove_workspace_member(p_workspace uuid, p_user uuid) +returns void +language plpgsql +security definer +set search_path to 'public' +as $$ +begin + if auth.uid() is null then + raise exception 'Not authenticated'; + end if; + if not public.is_workspace_owner(p_workspace) then + raise exception 'Not authorized'; + end if; + if p_user = auth.uid() then + raise exception 'Use leave_workspace to leave'; + end if; + + delete from public.workspace_members + where workspace_id = p_workspace and user_id = p_user; +end; +$$; + +create or replace function public.delete_workspace(p_workspace uuid) +returns void +language plpgsql +security definer +set search_path to 'public' +as $$ +begin + if auth.uid() is null then + raise exception 'Not authenticated'; + end if; + if not public.is_workspace_owner(p_workspace) then + raise exception 'Not authorized'; + end if; + + delete from public.workspaces where id = p_workspace; +end; +$$; + +revoke execute on function public.create_workspace(text) from public, anon; +revoke execute on function public.create_workspace_invite(uuid) from public, anon; +revoke execute on function public.accept_workspace_invite(text) from public, anon; +revoke execute on function public.revoke_workspace_invites(uuid) from public, anon; +revoke execute on function public.leave_workspace(uuid) from public, anon; +revoke execute on function public.remove_workspace_member(uuid, uuid) from public, anon; +revoke execute on function public.delete_workspace(uuid) from public, anon; + +grant execute on function public.create_workspace(text) to authenticated; +grant execute on function public.create_workspace_invite(uuid) to authenticated; +grant execute on function public.accept_workspace_invite(text) to authenticated; +grant execute on function public.revoke_workspace_invites(uuid) to authenticated; +grant execute on function public.leave_workspace(uuid) to authenticated; +grant execute on function public.remove_workspace_member(uuid, uuid) to authenticated; +grant execute on function public.delete_workspace(uuid) to authenticated; diff --git a/supabase/tests/workspaces.test.mjs b/supabase/tests/workspaces.test.mjs new file mode 100644 index 0000000..89edaa6 --- /dev/null +++ b/supabase/tests/workspaces.test.mjs @@ -0,0 +1,181 @@ +// Behavioural checks for supabase/migrations/20260915000000_create_workspaces.sql +// on a real Postgres (PGlite, in-process WASM) with a minimal Supabase scaffold: +// API roles, auth.users and auth.uid() driven by request.jwt.claim.sub. +// +// Run with: pnpm test:db +import {PGlite} from '@electric-sql/pglite'; +import fs from 'node:fs'; +import assert from 'node:assert/strict'; + +const MIGRATION = new URL('../migrations/20260915000000_create_workspaces.sql', import.meta.url); +const db = new PGlite(); + +await db.exec(` + create role anon nologin; + create role authenticated nologin; + create role service_role nologin bypassrls; + create schema auth; + create table auth.users (id uuid primary key, email text, raw_user_meta_data jsonb default '{}'::jsonb); + create function auth.uid() returns uuid language sql stable as + $$ select nullif(current_setting('request.jwt.claim.sub', true), '')::uuid $$; + grant usage on schema auth to anon, authenticated; + grant execute on function auth.uid() to anon, authenticated; + grant usage on schema public to anon, authenticated; + -- Supabase grants table/function privileges to API roles by default. + alter default privileges in schema public grant all on tables to anon, authenticated; + alter default privileges in schema public grant execute on functions to anon, authenticated; +`); + +await db.exec(fs.readFileSync(MIGRATION, 'utf8')); + +const U = { + alice: '00000000-0000-4000-8000-00000000000a', + bob: '00000000-0000-4000-8000-00000000000b', + carol: '00000000-0000-4000-8000-00000000000c', + mallory: '00000000-0000-4000-8000-00000000000d', +}; +await db.exec(` + insert into auth.users (id, email, raw_user_meta_data) values + ('${U.alice}', 'alice@example.com', '{"full_name":"Alice Engineer"}'), + ('${U.bob}', 'bob.smith@example.com', '{}'), + ('${U.carol}', 'carol@example.com', '{}'), + ('${U.mallory}', 'mallory@example.com', '{}'); +`); + +let passed = 0; +const as = async (who, sql, params = []) => { + await db.exec(`reset role; select set_config('request.jwt.claim.sub', '${who ? U[who] : ''}', false);`); + await db.exec(who ? 'set role authenticated' : 'set role anon'); + try { + return (await db.query(sql, params)).rows; + } finally { + await db.exec('reset role'); + } +}; +const fails = async (label, who, sql, params, pattern) => { + await assert.rejects(() => as(who, sql, params), pattern, label); + passed += 1; +}; +const check = (label, cond) => { + assert.ok(cond, label); + passed += 1; +}; + +// --- create ---------------------------------------------------------------- +const [{create_workspace: ws}] = await as('alice', 'select public.create_workspace($1)', [' Vessel team ']); +const alicesView = await as('alice', 'select name from public.workspaces'); +check('owner sees the new workspace with a trimmed name', alicesView.length === 1 && alicesView[0].name === 'Vessel team'); +const owner = await as('alice', 'select role, display_name from public.workspace_members'); +check('creator is owner, named from auth metadata', owner[0].role === 'owner' && owner[0].display_name === 'Alice Engineer'); +await fails('anon cannot create', null, 'select public.create_workspace($1)', ['x'], /permission denied|Not authenticated/); +await fails('empty name rejected', 'alice', 'select public.create_workspace($1)', [' '], /check constraint/); + +// --- isolation --------------------------------------------------------------- +check('non-member sees no workspace', (await as('mallory', 'select * from public.workspaces')).length === 0); +check('non-member sees no members', (await as('mallory', 'select * from public.workspace_members')).length === 0); +await fails('non-member cannot invite', 'mallory', 'select public.create_workspace_invite($1)', [ws], /Not authorized/); +await fails('invites table is not readable', 'alice', 'select * from public.workspace_invites', [], /permission denied/); +await fails('members cannot be inserted directly', 'mallory', + 'insert into public.workspace_members (workspace_id, user_id, role, display_name) values ($1, $2, $3, $4)', + [ws, U.mallory, 'owner', 'x'], /permission denied/); + +// --- invite & join ----------------------------------------------------------- +const [{create_workspace_invite: code}] = await as('alice', 'select public.create_workspace_invite($1)', [ws]); +check('invite code is 64 hex chars', /^[0-9a-f]{64}$/.test(code)); +const stored = await db.query('select code_hash from public.workspace_invites'); +check('only the hash of the code is stored', stored.rows[0].code_hash !== code && stored.rows[0].code_hash.length === 64); + +await fails('wrong code rejected', 'bob', 'select public.accept_workspace_invite($1)', ['0'.repeat(64)], /invalid or has expired/); +const [{accept_workspace_invite: joined}] = await as('bob', 'select public.accept_workspace_invite($1)', [code]); +check('bob joins the workspace', joined === ws); +await as('bob', 'select public.accept_workspace_invite($1)', [code]); +check('accepting twice is idempotent', (await db.query('select count(*)::int n from public.workspace_members where user_id = $1', [U.bob])).rows[0].n === 1); +const bobRow = (await as('bob', 'select role, display_name from public.workspace_members where user_id = $1', [U.bob]))[0]; +check('joiner is a member named from e-mail local part', bobRow.role === 'member' && bobRow.display_name === 'bob.smith'); +await fails('member cannot invite', 'bob', 'select public.create_workspace_invite($1)', [ws], /Not authorized/); + +// --- defaults ---------------------------------------------------------------- +await as('alice', 'update public.workspaces set company_name = $1, preferred_standards = $2 where id = $3', ['ACME Vessels', 'EN 13445', ws]); +const defaults = (await as('bob', 'select company_name, preferred_standards from public.workspaces'))[0]; +check('members see owner-set report defaults', defaults.company_name === 'ACME Vessels' && defaults.preferred_standards === 'EN 13445'); +const bobUpdate = await as('bob', 'update public.workspaces set company_name = $1 where id = $2 returning id', ['Hijacked', ws]); +check('member update affects no rows', bobUpdate.length === 0); +await fails('owner cannot change created_by', 'alice', 'update public.workspaces set created_by = $1', [U.mallory], /permission denied/); + +// --- shared history ------------------------------------------------------------ +await as('bob', `insert into public.workspace_calculations (workspace_id, created_by, utility_id, state, label) + values ($1, $2, 'shell-rolling', '{"v":1}', 'Shell')`, [ws, U.bob]); +const shared = await as('alice', 'select created_by, created_by_name from public.workspace_calculations'); +check('owner sees the member entry, attributed server-side', shared.length === 1 && shared[0].created_by === U.bob && shared[0].created_by_name === 'bob.smith'); +await fails('cannot save on behalf of another member', 'bob', + `insert into public.workspace_calculations (workspace_id, created_by, utility_id, state) values ($1, $2, 'x', '{}')`, + [ws, U.alice], /row-level security/); +await fails('non-member cannot save into the workspace', 'mallory', + `insert into public.workspace_calculations (workspace_id, created_by, utility_id, state) values ($1, $2, 'x', '{}')`, + [ws, U.mallory], /row-level security/); +await fails('author name cannot be forged', 'bob', + `insert into public.workspace_calculations (workspace_id, created_by, created_by_name, utility_id, state) values ($1, $2, 'Alice', 'x', '{}')`, + [ws, U.bob], /permission denied/); +await fails('entries are immutable', 'bob', `update public.workspace_calculations set label = 'x'`, [], /permission denied/); +check('non-member reads no shared entries', (await as('mallory', 'select * from public.workspace_calculations')).length === 0); + +await as('alice', `insert into public.workspace_calculations (workspace_id, created_by, utility_id, state) values ($1, $2, 'blind-flange-calculator', '{}')`, [ws, U.alice]); +const bobDeletesAlice = await as('bob', 'delete from public.workspace_calculations where created_by = $1 returning id', [U.alice]); +check('member cannot delete another member entry', bobDeletesAlice.length === 0); +const aliceDeletesBob = await as('alice', 'delete from public.workspace_calculations where created_by = $1 returning id', [U.bob]); +check('owner can delete any entry', aliceDeletesBob.length === 1); + +// --- removal, ownership hand-over, deletion ------------------------------------- +const [{create_workspace_invite: code2}] = await as('alice', 'select public.create_workspace_invite($1)', [ws]); +await as('carol', 'select public.accept_workspace_invite($1)', [code2]); +await fails('member cannot remove others', 'bob', 'select public.remove_workspace_member($1, $2)', [ws, U.carol], /Not authorized/); +await as('alice', 'select public.remove_workspace_member($1, $2)', [ws, U.carol]); +check('owner removes a member', (await as('carol', 'select * from public.workspaces')).length === 0); + +await as('alice', 'select public.revoke_workspace_invites($1)', [ws]); +await fails('revoked invite no longer works', 'carol', 'select public.accept_workspace_invite($1)', [code2], /invalid or has expired/); + +await db.exec(`update public.workspace_invites set expires_at = now() - interval '1 second'`); +await as('alice', 'select public.create_workspace_invite($1)', [ws]); +check('expired and revoked invites are purged on next invite', (await db.query('select count(*)::int n from public.workspace_invites')).rows[0].n === 1); +await db.exec(`update public.workspace_invites set expires_at = now() - interval '1 second'`); +const expiredCode = (await as('alice', 'select public.create_workspace_invite($1) c', [ws]))[0].c; +await db.exec(`update public.workspace_invites set expires_at = now() - interval '1 second'`); +await fails('expired invite rejected', 'carol', 'select public.accept_workspace_invite($1)', [expiredCode], /invalid or has expired/); + +await as('alice', 'select public.leave_workspace($1)', [ws]); +const afterLeave = await db.query('select user_id, role from public.workspace_members where workspace_id = $1', [ws]); +check('owner leaving promotes the remaining member', afterLeave.rows.length === 1 && afterLeave.rows[0].user_id === U.bob && afterLeave.rows[0].role === 'owner'); +check('entries of a departed member stay attributed', (await as('bob', 'select created_by_name from public.workspace_calculations'))[0]?.created_by_name === 'Alice Engineer'); + +await as('bob', 'select public.leave_workspace($1)', [ws]); +check('last member leaving deletes the workspace', (await db.query('select count(*)::int n from public.workspaces')).rows[0].n === 0); +check('deleting the workspace cascades shared entries', (await db.query('select count(*)::int n from public.workspace_calculations')).rows[0].n === 0); + +// account deletion hands ownership over +const [{create_workspace: ws2}] = await as('carol', 'select public.create_workspace($1)', ['Pipes']); +const inv = (await as('carol', 'select public.create_workspace_invite($1) c', [ws2]))[0].c; +await as('mallory', 'select public.accept_workspace_invite($1)', [inv]); +await db.exec(`delete from auth.users where id = '${U.carol}'`); +const ws2Members = await db.query('select user_id, role from public.workspace_members where workspace_id = $1', [ws2]); +check('owner account deletion promotes a member', ws2Members.rows.length === 1 && ws2Members.rows[0].role === 'owner'); + +await fails('member cannot delete the workspace', null, 'select public.delete_workspace($1)', [ws2], /permission denied|Not authenticated/); +await as('mallory', 'select public.delete_workspace($1)', [ws2]); +check('owner deletes the workspace', (await db.query('select count(*)::int n from public.workspaces')).rows[0].n === 0); + +// --- limits ------------------------------------------------------------------- +for (let i = 0; i < 10; i += 1) await as('alice', 'select public.create_workspace($1)', [`W${i}`]); +await fails('owned workspace limit', 'alice', 'select public.create_workspace($1)', ['W10'], /limit reached/); + +const [{id: big}] = (await db.query('select id from public.workspaces limit 1')).rows; +for (let i = 0; i < 205; i += 1) { + await as('alice', `insert into public.workspace_calculations (workspace_id, created_by, utility_id, state) values ($1, $2, 'x', '{}')`, [big, U.alice]); +} +check('shared history is capped at 200', (await db.query('select count(*)::int n from public.workspace_calculations where workspace_id = $1', [big])).rows[0].n === 200); + +// --- functions not callable by API roles ---------------------------------------- +await fails('internal display-name helper is not callable', 'alice', 'select public.workspace_display_name($1)', [U.bob], /permission denied/); +await fails('trigger function is not callable', 'alice', 'select public.trim_workspace_calculations()', [], /permission denied|trigger functions can only be called/); + +console.log(`ALL ${passed} CHECKS PASSED`);