@elytracms/studio 0.0.47 → 0.0.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/convex/auth.ts +61 -12
- package/convex/authority.ts +40 -2
- package/convex/functions.ts +9 -0
- package/convex/invites.ts +75 -0
- package/convex/members.ts +44 -18
- package/convex/schema.ts +37 -0
- package/convex/users.ts +89 -2
- package/dist/components/account/account-settings.d.ts +13 -0
- package/dist/components/account/account-settings.js +54 -0
- package/dist/components/account/account-settings.js.map +1 -0
- package/dist/components/account/credential-panel.d.ts +14 -0
- package/dist/components/account/credential-panel.js +52 -0
- package/dist/components/account/credential-panel.js.map +1 -0
- package/dist/components/account/self-account-schema.d.ts +19 -0
- package/dist/components/account/self-account-schema.js +52 -0
- package/dist/components/account/self-account-schema.js.map +1 -0
- package/dist/components/account/self-account-view-source.d.ts +35 -0
- package/dist/components/account/self-account-view-source.js +110 -0
- package/dist/components/account/self-account-view-source.js.map +1 -0
- package/dist/components/auth-context.d.ts +5 -1
- package/dist/components/auth-context.js +9 -0
- package/dist/components/auth-context.js.map +1 -1
- package/dist/components/current-user-menu.js +11 -2
- package/dist/components/current-user-menu.js.map +1 -1
- package/dist/components/deployment-setup-gate.d.ts +31 -0
- package/dist/components/deployment-setup-gate.js +92 -0
- package/dist/components/deployment-setup-gate.js.map +1 -0
- package/dist/components/project-session.d.ts +1 -1
- package/dist/components/project-session.js.map +1 -1
- package/dist/components/use-hydration-phase.js +1 -1
- package/dist/components/use-hydration-phase.js.map +1 -1
- package/dist/components/users/invite-user-bar.d.ts +3 -0
- package/dist/components/users/invite-user-bar.js +91 -0
- package/dist/components/users/invite-user-bar.js.map +1 -0
- package/dist/components/users/users-module.js +13 -2
- package/dist/components/users/users-module.js.map +1 -1
- package/dist/lib/auth/auth-adapter.d.ts +50 -2
- package/dist/lib/auth/auth-adapter.js.map +1 -1
- package/dist/lib/auth/convex-auth-adapter.d.ts +28 -1
- package/dist/lib/auth/convex-auth-adapter.js +113 -1
- package/dist/lib/auth/convex-auth-adapter.js.map +1 -1
- package/dist/lib/auth/dev-auto-login-adapter.d.ts +3 -1
- package/dist/lib/auth/dev-auto-login-adapter.js +6 -0
- package/dist/lib/auth/dev-auto-login-adapter.js.map +1 -1
- package/dist/lib/auth/index.d.ts +2 -2
- package/dist/lib/auth/index.js +1 -1
- package/dist/lib/auth/index.js.map +1 -1
- package/dist/lib/auth/local-auth-adapter.d.ts +3 -1
- package/dist/lib/auth/local-auth-adapter.js +16 -0
- package/dist/lib/auth/local-auth-adapter.js.map +1 -1
- package/dist/lib/i18n/messages.en.js +45 -0
- package/dist/lib/i18n/messages.en.js.map +1 -1
- package/dist/lib/operations/studio-operations.js +1 -1
- package/dist/lib/operations/studio-operations.js.map +1 -1
- package/dist/lib/persistence/backend-mode.d.ts +1 -1
- package/dist/lib/persistence/backend-mode.js +1 -1
- package/dist/lib/persistence/backend-mode.js.map +1 -1
- package/dist/lib/persistence/convex-adapter.d.ts +2 -1
- package/dist/lib/persistence/convex-adapter.js +69 -1
- package/dist/lib/persistence/convex-adapter.js.map +1 -1
- package/dist/lib/persistence/deployment-readiness.d.ts +36 -0
- package/dist/lib/persistence/deployment-readiness.js +26 -0
- package/dist/lib/persistence/deployment-readiness.js.map +1 -0
- package/dist/lib/persistence/index.d.ts +3 -1
- package/dist/lib/persistence/index.js +4 -1
- package/dist/lib/persistence/index.js.map +1 -1
- package/dist/lib/workspace/switcher-source.js +1 -1
- package/dist/lib/workspace/switcher-source.js.map +1 -1
- package/dist/lib/workspace/workspace-source.d.ts +1 -1
- package/dist/lib/workspace/workspace-source.js +1 -1
- package/dist/lib/workspace/workspace-source.js.map +1 -1
- package/package.json +2 -2
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"project-session.js","sourceRoot":"","sources":["../../src/components/project-session.tsx"],"names":[],"mappings":"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","sourcesContent":["import { useCallback, useEffect } from 'react'\nimport type { ReactNode } from 'react'\nimport { getStudioOperations, loadHydratedCms, loadProjectSession } from '@elytracms/studio/lib/operations'\nimport type { HydratedCms, HydratedProjectSession, OperationResult } from '@elytracms/studio/lib/operations'\nimport { isPlaygroundMode } from '@elytracms/studio/lib/persistence'\nimport { configureContentSession } from '@elytracms/studio/lib/data'\nimport { resolveDeploymentRoutes } from '@elytracms/studio/lib/workspace'\nimport { createRequiredContext } from '@elytracms/studio/lib/required-context'\nimport { HydrationBoundary, useHydrationPhase } from './use-hydration-phase'\n\n/**\n * Project-session hydration gate (Milestone B sign-off fix, EC-177 gap 1).\n *\n * In Convex mode every project session must BOOT from persistence: the\n * builder from the latest persisted graph revision, the CMS from the saved\n * schema + documents, publishing from the persisted environment states.\n * This provider runs the `@elytracms/core/operations` hydration queries\n * (`lib/operations/session-hydration`) and — like `
|
|
1
|
+
{"version":3,"file":"project-session.js","sourceRoot":"","sources":["../../src/components/project-session.tsx"],"names":[],"mappings":"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","sourcesContent":["import { useCallback, useEffect } from 'react'\nimport type { ReactNode } from 'react'\nimport { getStudioOperations, loadHydratedCms, loadProjectSession } from '@elytracms/studio/lib/operations'\nimport type { HydratedCms, HydratedProjectSession, OperationResult } from '@elytracms/studio/lib/operations'\nimport { isPlaygroundMode } from '@elytracms/studio/lib/persistence'\nimport { configureContentSession } from '@elytracms/studio/lib/data'\nimport { resolveDeploymentRoutes } from '@elytracms/studio/lib/workspace'\nimport { createRequiredContext } from '@elytracms/studio/lib/required-context'\nimport { HydrationBoundary, useHydrationPhase } from './use-hydration-phase'\n\n/**\n * Project-session hydration gate (Milestone B sign-off fix, EC-177 gap 1).\n *\n * In Convex mode every project session must BOOT from persistence: the\n * builder from the latest persisted graph revision, the CMS from the saved\n * schema + documents, publishing from the persisted environment states.\n * This provider runs the `@elytracms/core/operations` hydration queries\n * (`lib/operations/session-hydration`) and — like `DeploymentSetupGate` — gates its\n * children on a deterministic loading state, so fixture content never\n * flashes (and is never editable) before the persisted state arrives.\n *\n * Playground mode renders children synchronously with `session: null`\n * (fixtures), byte-identical to the pre-hydration behavior — the EC-138\n * determinism contract.\n *\n * Side effect on hydration: the content preview engine\n * (`lib/data/collection-content`) is pointed at the hydrated documents\n * BEFORE children render, so \"Preview with\" lists and section previews show\n * live entries, not fixture titles.\n *\n * EC-308/AD-17 note — this gate deliberately does NOT read through the\n * `useOperationQuery` cache seam. It is boot/session orchestration, not a\n * cacheable server read: (1) the synchronous playground first-render is the\n * EC-138 determinism contract (a pull-query is async even when disabled);\n * (2) `configureContentSession` must run synchronously BEFORE children mount,\n * which a post-render query effect cannot guarantee; (3) `CmsSessionGate` is\n * intentionally fresh-per-visit (it remounts to show edits persisted since\n * project open) — the opposite of a cached read. It still reads through the\n * operations client (no transport bypass), and uses a user-driven retry epoch,\n * not freshness machinery — so it is outside the patchwork AD-17 retires.\n */\n\nexport interface ProjectSessionStore {\n /** Hydrated persisted state (Convex mode); `null` in playground (fixtures). */\n session: HydratedProjectSession | null\n /**\n * Load the CURRENT persisted CMS state (fresh query — the CMS section\n * remounts per visit and must show edits persisted since project open).\n * Playground resolves `null` immediately (fixtures).\n */\n loadCms(): Promise<OperationResult<HydratedCms | null>>\n}\n\nconst { Context: ProjectSessionContext, useRequired: useProjectSession } =\n createRequiredContext<ProjectSessionStore>('ProjectSession')\n\nexport function ProjectSessionProvider({\n projectId,\n children,\n}: {\n projectId: string\n children: ReactNode\n}) {\n const playground = isPlaygroundMode()\n\n const loader = useCallback(\n () => getStudioOperations().then(({ operations }) => loadProjectSession(operations, projectId)),\n [projectId],\n )\n // Point the preview content engine at the hydrated documents BEFORE any\n // child renders (panels read it module-level, synchronously). EC-291:\n // include the config-sourced routes so the canvas `resolveLink` derives a\n // rich-text link's URL exactly as delivery does. `enabled: true` below runs\n // this in the playground fast-path too (with `session: null`) — the one\n // divergence from `CmsSessionGate`, which owns no such side effect.\n const onResolved = useCallback((session: HydratedProjectSession | null) => {\n configureContentSession(\n session?.cms ? { ...session.cms, routes: resolveDeploymentRoutes() } : null,\n )\n }, [])\n const { phase, retry } = useHydrationPhase<HydratedProjectSession | null>(loader, {\n playground,\n playgroundValue: null,\n enabled: true,\n onResolved,\n })\n\n // Leaving the project resets the preview engine to fixtures; the next\n // project's gate reconfigures it before rendering its own children.\n useEffect(() => () => configureContentSession(null), [])\n\n const loadCms = useCallback(async (): Promise<OperationResult<HydratedCms | null>> => {\n if (playground) return { ok: true, value: null }\n const { operations } = await getStudioOperations()\n const result = await loadHydratedCms(operations, projectId)\n // Keep the preview engine in sync with the freshest persisted content.\n if (result.ok && result.value !== null)\n configureContentSession({ ...result.value, routes: resolveDeploymentRoutes() })\n return result\n }, [playground, projectId])\n\n return (\n <HydrationBoundary phase={phase} retry={retry}>\n {(session) => (\n <ProjectSessionContext.Provider value={{ session, loadCms }}>\n {children}\n </ProjectSessionContext.Provider>\n )}\n </HydrationBoundary>\n )\n}\n\nexport { useProjectSession }\n\n/**\n * Gate one CMS surface mount on a FRESH persisted CMS load (Convex mode).\n * Render-prop: hands the hydrated content (or `null` → fixture fallback,\n * first-run) to the caller once it resolved. Playground renders synchronously\n * with `null`. Unlike `ProjectSessionProvider`, this gate owns no\n * `configureContentSession` side effect of its own — `loadCms` (from\n * `useProjectSession`) already calls it internally when it resolves — so no\n * `onResolved`/`enabled` is passed here.\n */\nexport function CmsSessionGate({\n children,\n}: {\n children: (cms: HydratedCms | null) => ReactNode\n}) {\n const { loadCms } = useProjectSession()\n const playground = isPlaygroundMode()\n const { phase, retry } = useHydrationPhase<HydratedCms | null>(loadCms, {\n playground,\n playgroundValue: null,\n })\n return (\n <HydrationBoundary phase={phase} retry={retry}>\n {children}\n </HydrationBoundary>\n )\n}\n"]}
|
|
@@ -53,7 +53,7 @@ export function HydrationBoundary({ phase, retry, children, }) {
|
|
|
53
53
|
return _jsx(SessionHydrationError, { error: phase.error, onRetry: retry });
|
|
54
54
|
return _jsx(_Fragment, { children: children(phase.value) });
|
|
55
55
|
}
|
|
56
|
-
/** Deterministic loading frame while persisted state is fetched (mirrors
|
|
56
|
+
/** Deterministic loading frame while persisted state is fetched (mirrors DeploymentSetupGate). */
|
|
57
57
|
function SessionHydrating() {
|
|
58
58
|
return (_jsx("div", { className: "grid h-full min-h-64 flex-1 place-items-center p-8", "data-testid": "session-hydrating", children: _jsx(Text, { size: "sm", tone: "muted", children: translate('shell.session.loading') }) }));
|
|
59
59
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-hydration-phase.js","sourceRoot":"","sources":["../../src/components/use-hydration-phase.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAA;AAEhE,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,sBAAsB,CAAA;AAC1D,OAAO,EAAE,SAAS,EAAE,MAAM,4BAA4B,CAAA;AAEtD,OAAO,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAA;AA+CxD;;;;GAIG;AACH,MAAM,UAAU,iBAAiB,CAC/B,MAAyC,EACzC,OAAoC;IAEpC,MAAM,EAAE,UAAU,EAAE,eAAe,EAAE,OAAO,GAAG,KAAK,EAAE,GAAG,OAAO,CAAA;IAChE,MAAM,aAAa,GAAG,MAAM,CAAC,OAAO,CAAC,UAAU,CAAC,CAAA;IAChD,aAAa,CAAC,OAAO,GAAG,OAAO,CAAC,UAAU,CAAA;IAE1C,MAAM,CAAC,KAAK,EAAE,QAAQ,CAAC,GAAG,QAAQ,CAAoB,GAAG,EAAE,CACzD,UAAU,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,eAAe,EAAE,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,CAC7E,CAAA;IACD,MAAM,CAAC,OAAO,EAAE,UAAU,CAAC,GAAG,QAAQ,CAAC,CAAC,CAAC,CAAA;IAEzC,SAAS,CAAC,GAAG,EAAE;QACb,IAAI,UAAU,EAAE,CAAC;YACf,IAAI,OAAO;gBAAE,aAAa,CAAC,OAAO,EAAE,CAAC,eAAe,CAAC,CAAA;YACrD,OAAM;QACR,CAAC;QACD,IAAI,SAAS,GAAG,KAAK,CAAA;QACrB,QAAQ,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,CAAC,CAAA;QAC7B,KAAK,MAAM,EAAE,CAAC,IAAI,CAAC,CAAC,MAAM,EAAE,EAAE;YAC5B,IAAI,SAAS;gBAAE,OAAM;YACrB,IAAI,CAAC,MAAM,CAAC,EAAE,EAAE,CAAC;gBACf,QAAQ,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,CAAC,KAAK,EAAE,CAAC,CAAA;gBAChD,OAAM;YACR,CAAC;YACD,aAAa,CAAC,OAAO,EAAE,CAAC,MAAM,CAAC,KAAK,CAAC,CAAA;YACrC,QAAQ,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,CAAC,KAAK,EAAE,CAAC,CAAA;QAClD,CAAC,CAAC,CAAA;QACF,OAAO,GAAG,EAAE;YACV,SAAS,GAAG,IAAI,CAAA;QAClB,CAAC,CAAA;QACD,qEAAqE;QACrE,yDAAyD;IAC3D,CAAC,EAAE,CAAC,MAAM,EAAE,UAAU,EAAE,OAAO,EAAE,OAAO,EAAE,eAAe,CAAC,CAAC,CAAA;IAE3D,MAAM,KAAK,GAAG,WAAW,CAAC,GAAG,EAAE,CAAC,UAAU,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,EAAE,EAAE,CAAC,CAAA;IAC7D,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,CAAA;AACzB,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,iBAAiB,CAAI,EACnC,KAAK,EACL,KAAK,EACL,QAAQ,GAKT;IACC,IAAI,KAAK,CAAC,IAAI,KAAK,SAAS;QAAE,OAAO,KAAC,gBAAgB,KAAG,CAAA;IACzD,IAAI,KAAK,CAAC,IAAI,KAAK,OAAO;QAAE,OAAO,KAAC,qBAAqB,IAAC,KAAK,EAAE,KAAK,CAAC,KAAK,EAAE,OAAO,EAAE,KAAK,GAAI,CAAA;IAChG,OAAO,4BAAG,QAAQ,CAAC,KAAK,CAAC,KAAK,CAAC,GAAI,CAAA;AACrC,CAAC;AAED,
|
|
1
|
+
{"version":3,"file":"use-hydration-phase.js","sourceRoot":"","sources":["../../src/components/use-hydration-phase.tsx"],"names":[],"mappings":"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","sourcesContent":["import { useCallback, useEffect, useRef, useState } from 'react'\nimport type { ReactNode } from 'react'\nimport { Button, Stack, Text } from '@elytracms/studio/ui'\nimport { translate } from '@elytracms/studio/lib/i18n'\nimport type { OperationError, OperationResult } from '@elytracms/studio/lib/operations'\nimport { OperationErrorBanner } from './operation-error'\n\n/**\n * The shared hydration state machine (EC-329) behind both\n * `ProjectSessionProvider` and `CmsSessionGate`: playground short-circuit,\n * phase union, attempt-driven retry, cancelled-flag cleanup — a specific\n * multi-part shape the two providers used to hand-roll identically. Real\n * asymmetries between them stay explicit HOOK OPTIONS rather than being\n * flattened away:\n *\n * - `ProjectSessionProvider`'s playground branch calls its `onResolved` side\n * effect (`configureContentSession(null)`); `CmsSessionGate` passes no\n * `onResolved` at all (it owns no such side effect — `loadCms`'s own body\n * already calls it). `enabled` toggles whether `onResolved` fires in the\n * playground fast-path — the ONE point of divergence between the two.\n * - `onResolved` runs synchronously BEFORE the phase flips to `'ready'` (both\n * the playground fast-path and the real load), so children reading a\n * module global (e.g. `configureContentSession`) see it updated on the\n * SAME render they first see `'ready'` — a bare `loader().then(setPhase)`\n * cannot guarantee that ordering.\n */\nexport type HydrationPhase<T> =\n | { kind: 'loading' }\n | { kind: 'ready'; value: T }\n | { kind: 'error'; error: OperationError }\n\nexport interface UseHydrationPhaseOptions<T> {\n /** Playground (fixture) sessions resolve synchronously to `playgroundValue` — no loader call. */\n playground: boolean\n /** The value `phase` resolves to in playground mode. */\n playgroundValue: T\n /** Call `onResolved` for the playground fast-path too (default `false`). */\n enabled?: boolean\n /**\n * Run synchronously before the phase flips to `'ready'` — in BOTH the\n * playground fast-path (when `enabled`) and the real load. Read via a ref\n * internally, so an inline (non-memoized) callback never re-triggers a fetch.\n */\n onResolved?: (value: T) => void\n}\n\nexport interface UseHydrationPhaseResult<T> {\n phase: HydrationPhase<T>\n /** Re-run the loader (the retry button's handler). */\n retry: () => void\n}\n\n/**\n * `loader`'s IDENTITY is the refetch trigger — memoize it (`useCallback`) over\n * whatever inputs should re-hydrate (e.g. `projectId`), exactly like the\n * original two effects keyed their dependency arrays.\n */\nexport function useHydrationPhase<T>(\n loader: () => Promise<OperationResult<T>>,\n options: UseHydrationPhaseOptions<T>,\n): UseHydrationPhaseResult<T> {\n const { playground, playgroundValue, enabled = false } = options\n const onResolvedRef = useRef(options.onResolved)\n onResolvedRef.current = options.onResolved\n\n const [phase, setPhase] = useState<HydrationPhase<T>>(() =>\n playground ? { kind: 'ready', value: playgroundValue } : { kind: 'loading' },\n )\n const [attempt, setAttempt] = useState(0)\n\n useEffect(() => {\n if (playground) {\n if (enabled) onResolvedRef.current?.(playgroundValue)\n return\n }\n let cancelled = false\n setPhase({ kind: 'loading' })\n void loader().then((result) => {\n if (cancelled) return\n if (!result.ok) {\n setPhase({ kind: 'error', error: result.error })\n return\n }\n onResolvedRef.current?.(result.value)\n setPhase({ kind: 'ready', value: result.value })\n })\n return () => {\n cancelled = true\n }\n // `onResolved` is read via the ref above (see the doc comment) — its\n // identity intentionally does not retrigger this effect.\n }, [loader, playground, attempt, enabled, playgroundValue])\n\n const retry = useCallback(() => setAttempt((n) => n + 1), [])\n return { phase, retry }\n}\n\n/**\n * Renders `phase`'s loading/error frames, or `children(value)` once ready —\n * the identical 3-way branch both `ProjectSessionProvider` and\n * `CmsSessionGate` used to repeat verbatim.\n */\nexport function HydrationBoundary<T>({\n phase,\n retry,\n children,\n}: {\n phase: HydrationPhase<T>\n retry: () => void\n children: (value: T) => ReactNode\n}): ReactNode {\n if (phase.kind === 'loading') return <SessionHydrating />\n if (phase.kind === 'error') return <SessionHydrationError error={phase.error} onRetry={retry} />\n return <>{children(phase.value)}</>\n}\n\n/** Deterministic loading frame while persisted state is fetched (mirrors DeploymentSetupGate). */\nfunction SessionHydrating() {\n return (\n <div className=\"grid h-full min-h-64 flex-1 place-items-center p-8\" data-testid=\"session-hydrating\">\n <Text size=\"sm\" tone=\"muted\">\n {translate('shell.session.loading')}\n </Text>\n </div>\n )\n}\n\n/** A failed hydration is a visible, retryable state — never a fixture fallback. */\nfunction SessionHydrationError({\n error,\n onRetry,\n}: {\n error: OperationError\n onRetry: () => void\n}) {\n return (\n <div className=\"grid h-full min-h-64 flex-1 place-items-center p-8\">\n <Stack gap=\"3\" className=\"w-full max-w-xl\">\n <Text size=\"sm\" weight=\"semibold\">\n {translate('shell.session.error.title')}\n </Text>\n <OperationErrorBanner error={error} />\n <Text size=\"xs\" tone=\"muted\">\n {translate('shell.session.error.body')}\n </Text>\n <Button variant=\"outline\" size=\"sm\" onClick={onRetry} className=\"self-start\">\n {translate('common.retry')}\n </Button>\n </Stack>\n </div>\n )\n}\n"]}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { Mail, Plus, X } from 'lucide-react';
|
|
4
|
+
import { roleOptions } from '@elytracms/core/persistence';
|
|
5
|
+
import { Badge, Button, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, Field, Input, Row, Stack, Text, toast, } from '@elytracms/studio/ui';
|
|
6
|
+
import { getStudioOperations } from '@elytracms/studio/lib/operations';
|
|
7
|
+
import { listStoreState, useOperationCache, useOperationQuery } from '@elytracms/studio/lib/query';
|
|
8
|
+
import { translate } from '@elytracms/studio/lib/i18n';
|
|
9
|
+
/**
|
|
10
|
+
* Invite-based onboarding UI (EC-324) — the admin "Invite User" action that
|
|
11
|
+
* REPLACES open sign-up, plus the list of pending invites. An admin invites an
|
|
12
|
+
* email + role; the invite is consumed when that email first creates their
|
|
13
|
+
* account. All three operations (`invites.create`/`list`/`revoke`) are admin-gated
|
|
14
|
+
* at the chokepoint AND server-side, so this UI is only mounted for admins (the
|
|
15
|
+
* Users module already gates non-admins).
|
|
16
|
+
*
|
|
17
|
+
* The invite form is a small ACTION dialog (email + role) — not a record property
|
|
18
|
+
* form — so a dialog is appropriate here (the house "no modal property forms" rule
|
|
19
|
+
* governs editing a record's fields, not a create action).
|
|
20
|
+
*/
|
|
21
|
+
const ROLE_OPTIONS = roleOptions();
|
|
22
|
+
const DEFAULT_INVITE_ROLE = ROLE_OPTIONS.find((r) => r.value === 'editor')?.value ?? ROLE_OPTIONS[0]?.value ?? 'viewer';
|
|
23
|
+
function invitesQueryKey(projectId) {
|
|
24
|
+
return ['invites', projectId];
|
|
25
|
+
}
|
|
26
|
+
export function InviteUserBar({ projectId }) {
|
|
27
|
+
const cache = useOperationCache();
|
|
28
|
+
const query = useOperationQuery({
|
|
29
|
+
queryKey: invitesQueryKey(projectId),
|
|
30
|
+
loader: async (operations) => {
|
|
31
|
+
const result = await operations.invites.list({ projectId });
|
|
32
|
+
if (!result.ok)
|
|
33
|
+
throw new Error(result.error.message);
|
|
34
|
+
return [...result.value];
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
const { status } = listStoreState(query);
|
|
38
|
+
const invites = query.data ?? [];
|
|
39
|
+
const [open, setOpen] = useState(false);
|
|
40
|
+
async function revoke(email) {
|
|
41
|
+
const { operations } = await getStudioOperations();
|
|
42
|
+
const result = await operations.invites.revoke({ projectId, email });
|
|
43
|
+
if (!result.ok) {
|
|
44
|
+
toast.error(result.error.message);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
cache.patch(invitesQueryKey(projectId), (prev) => (prev ?? []).filter((invite) => invite.email !== email));
|
|
48
|
+
toast.success(translate('invites.revoked', { email }));
|
|
49
|
+
}
|
|
50
|
+
return (_jsxs(Row, { gap: "3", align: "center", className: "shrink-0 flex-wrap border-b border-border px-4 py-2 sm:px-6", children: [_jsxs(Button, { size: "sm", onClick: () => setOpen(true), "data-testid": "invite-user-button", children: [_jsx(Plus, {}), translate('invites.invite')] }), status === 'ready' && invites.length > 0 ? (_jsxs(Row, { gap: "2", align: "center", className: "min-w-0 flex-wrap", children: [_jsx(Text, { size: "xs", tone: "muted", children: translate('invites.pending') }), invites.map((invite) => (_jsxs(Badge, { variant: "outline", className: "gap-1 font-normal", children: [_jsx(Mail, { className: "size-3" }), _jsx("span", { className: "truncate", children: invite.email }), _jsxs("span", { className: "text-muted-foreground", children: ["\u00B7 ", invite.role] }), _jsx("button", { type: "button", "aria-label": translate('invites.revokeAria', { email: invite.email }), onClick: () => void revoke(invite.email), className: "ml-0.5 rounded-sm text-muted-foreground hover:text-foreground", children: _jsx(X, { className: "size-3" }) })] }, invite.email)))] })) : null, _jsx(InviteDialog, { projectId: projectId, open: open, onOpenChange: setOpen, onInvited: (invite) => {
|
|
51
|
+
cache.patch(invitesQueryKey(projectId), (prev) => {
|
|
52
|
+
const rest = (prev ?? []).filter((i) => i.email !== invite.email);
|
|
53
|
+
return [...rest, invite].sort((a, b) => (a.email < b.email ? -1 : a.email > b.email ? 1 : 0));
|
|
54
|
+
});
|
|
55
|
+
} })] }));
|
|
56
|
+
}
|
|
57
|
+
function InviteDialog({ projectId, open, onOpenChange, onInvited, }) {
|
|
58
|
+
const [email, setEmail] = useState('');
|
|
59
|
+
const [role, setRole] = useState(DEFAULT_INVITE_ROLE);
|
|
60
|
+
const [phase, setPhase] = useState('idle');
|
|
61
|
+
const [error, setError] = useState(null);
|
|
62
|
+
function reset() {
|
|
63
|
+
setEmail('');
|
|
64
|
+
setRole(DEFAULT_INVITE_ROLE);
|
|
65
|
+
setError(null);
|
|
66
|
+
setPhase('idle');
|
|
67
|
+
}
|
|
68
|
+
async function handleSubmit(event) {
|
|
69
|
+
event.preventDefault();
|
|
70
|
+
setError(null);
|
|
71
|
+
setPhase('submitting');
|
|
72
|
+
const { operations } = await getStudioOperations();
|
|
73
|
+
const result = await operations.invites.create({ projectId, email, role });
|
|
74
|
+
setPhase('idle');
|
|
75
|
+
if (!result.ok) {
|
|
76
|
+
setError(result.error.message);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
onInvited(result.value);
|
|
80
|
+
toast.success(translate('invites.sent', { email: result.value.email }));
|
|
81
|
+
reset();
|
|
82
|
+
onOpenChange(false);
|
|
83
|
+
}
|
|
84
|
+
const submitting = phase === 'submitting';
|
|
85
|
+
return (_jsx(Dialog, { open: open, onOpenChange: (next) => {
|
|
86
|
+
if (!next)
|
|
87
|
+
reset();
|
|
88
|
+
onOpenChange(next);
|
|
89
|
+
}, children: _jsxs(DialogContent, { children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: translate('invites.dialogTitle') }), _jsx(DialogDescription, { children: translate('invites.dialogBody') })] }), _jsx("form", { onSubmit: handleSubmit, noValidate: true, children: _jsxs(Stack, { gap: "4", children: [_jsx(Field, { label: translate('invites.emailLabel'), htmlFor: "invite-email", required: true, error: error, children: _jsx(Input, { id: "invite-email", type: "email", autoComplete: "off", placeholder: "teammate@example.com", value: email, onChange: (e) => setEmail(e.target.value), disabled: submitting }) }), _jsx(Field, { label: translate('invites.roleLabel'), htmlFor: "invite-role", children: _jsx("select", { id: "invite-role", value: role, onChange: (e) => setRole(e.target.value), disabled: submitting, className: "h-9 w-full rounded-md border border-input bg-background px-3 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", children: ROLE_OPTIONS.map((option) => (_jsx("option", { value: option.value, children: option.label }, option.value))) }) }), _jsxs(DialogFooter, { children: [_jsx(Button, { type: "button", variant: "ghost", onClick: () => onOpenChange(false), disabled: submitting, children: translate('common.cancel') }), _jsx(Button, { type: "submit", disabled: submitting || email.trim().length === 0, children: submitting ? translate('invites.sending') : translate('invites.send') })] })] }) })] }) }));
|
|
90
|
+
}
|
|
91
|
+
//# sourceMappingURL=invite-user-bar.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"invite-user-bar.js","sourceRoot":"","sources":["../../../src/components/users/invite-user-bar.tsx"],"names":[],"mappings":"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","sourcesContent":["import { useState } from 'react'\nimport type { FormEvent } from 'react'\nimport { Mail, Plus, X } from 'lucide-react'\nimport type { InviteRecord } from '@elytracms/core/persistence'\nimport { roleOptions } from '@elytracms/core/persistence'\nimport {\n Badge,\n Button,\n Dialog,\n DialogContent,\n DialogDescription,\n DialogFooter,\n DialogHeader,\n DialogTitle,\n Field,\n Input,\n Row,\n Stack,\n Text,\n toast,\n} from '@elytracms/studio/ui'\nimport { getStudioOperations } from '@elytracms/studio/lib/operations'\nimport { listStoreState, useOperationCache, useOperationQuery } from '@elytracms/studio/lib/query'\nimport { translate } from '@elytracms/studio/lib/i18n'\n\n/**\n * Invite-based onboarding UI (EC-324) — the admin \"Invite User\" action that\n * REPLACES open sign-up, plus the list of pending invites. An admin invites an\n * email + role; the invite is consumed when that email first creates their\n * account. All three operations (`invites.create`/`list`/`revoke`) are admin-gated\n * at the chokepoint AND server-side, so this UI is only mounted for admins (the\n * Users module already gates non-admins).\n *\n * The invite form is a small ACTION dialog (email + role) — not a record property\n * form — so a dialog is appropriate here (the house \"no modal property forms\" rule\n * governs editing a record's fields, not a create action).\n */\nconst ROLE_OPTIONS = roleOptions()\nconst DEFAULT_INVITE_ROLE = ROLE_OPTIONS.find((r) => r.value === 'editor')?.value ?? ROLE_OPTIONS[0]?.value ?? 'viewer'\n\nfunction invitesQueryKey(projectId: string): readonly [string, string] {\n return ['invites', projectId]\n}\n\nexport function InviteUserBar({ projectId }: { projectId: string }) {\n const cache = useOperationCache()\n const query = useOperationQuery<InviteRecord[]>({\n queryKey: invitesQueryKey(projectId),\n loader: async (operations) => {\n const result = await operations.invites.list({ projectId })\n if (!result.ok) throw new Error(result.error.message)\n return [...result.value]\n },\n })\n const { status } = listStoreState(query)\n const invites = query.data ?? []\n\n const [open, setOpen] = useState(false)\n\n async function revoke(email: string) {\n const { operations } = await getStudioOperations()\n const result = await operations.invites.revoke({ projectId, email })\n if (!result.ok) {\n toast.error(result.error.message)\n return\n }\n cache.patch<InviteRecord[]>(invitesQueryKey(projectId), (prev) =>\n (prev ?? []).filter((invite) => invite.email !== email),\n )\n toast.success(translate('invites.revoked', { email }))\n }\n\n return (\n <Row\n gap=\"3\"\n align=\"center\"\n className=\"shrink-0 flex-wrap border-b border-border px-4 py-2 sm:px-6\"\n >\n <Button size=\"sm\" onClick={() => setOpen(true)} data-testid=\"invite-user-button\">\n <Plus />\n {translate('invites.invite')}\n </Button>\n\n {status === 'ready' && invites.length > 0 ? (\n <Row gap=\"2\" align=\"center\" className=\"min-w-0 flex-wrap\">\n <Text size=\"xs\" tone=\"muted\">\n {translate('invites.pending')}\n </Text>\n {invites.map((invite) => (\n <Badge key={invite.email} variant=\"outline\" className=\"gap-1 font-normal\">\n <Mail className=\"size-3\" />\n <span className=\"truncate\">{invite.email}</span>\n <span className=\"text-muted-foreground\">· {invite.role}</span>\n <button\n type=\"button\"\n aria-label={translate('invites.revokeAria', { email: invite.email })}\n onClick={() => void revoke(invite.email)}\n className=\"ml-0.5 rounded-sm text-muted-foreground hover:text-foreground\"\n >\n <X className=\"size-3\" />\n </button>\n </Badge>\n ))}\n </Row>\n ) : null}\n\n <InviteDialog\n projectId={projectId}\n open={open}\n onOpenChange={setOpen}\n onInvited={(invite) => {\n cache.patch<InviteRecord[]>(invitesQueryKey(projectId), (prev) => {\n const rest = (prev ?? []).filter((i) => i.email !== invite.email)\n return [...rest, invite].sort((a, b) => (a.email < b.email ? -1 : a.email > b.email ? 1 : 0))\n })\n }}\n />\n </Row>\n )\n}\n\nfunction InviteDialog({\n projectId,\n open,\n onOpenChange,\n onInvited,\n}: {\n projectId: string\n open: boolean\n onOpenChange: (open: boolean) => void\n onInvited: (invite: InviteRecord) => void\n}) {\n const [email, setEmail] = useState('')\n const [role, setRole] = useState<string>(DEFAULT_INVITE_ROLE)\n const [phase, setPhase] = useState<'idle' | 'submitting'>('idle')\n const [error, setError] = useState<string | null>(null)\n\n function reset() {\n setEmail('')\n setRole(DEFAULT_INVITE_ROLE)\n setError(null)\n setPhase('idle')\n }\n\n async function handleSubmit(event: FormEvent<HTMLFormElement>) {\n event.preventDefault()\n setError(null)\n setPhase('submitting')\n const { operations } = await getStudioOperations()\n const result = await operations.invites.create({ projectId, email, role })\n setPhase('idle')\n if (!result.ok) {\n setError(result.error.message)\n return\n }\n onInvited(result.value)\n toast.success(translate('invites.sent', { email: result.value.email }))\n reset()\n onOpenChange(false)\n }\n\n const submitting = phase === 'submitting'\n\n return (\n <Dialog\n open={open}\n onOpenChange={(next) => {\n if (!next) reset()\n onOpenChange(next)\n }}\n >\n <DialogContent>\n <DialogHeader>\n <DialogTitle>{translate('invites.dialogTitle')}</DialogTitle>\n <DialogDescription>{translate('invites.dialogBody')}</DialogDescription>\n </DialogHeader>\n <form onSubmit={handleSubmit} noValidate>\n <Stack gap=\"4\">\n <Field label={translate('invites.emailLabel')} htmlFor=\"invite-email\" required error={error}>\n <Input\n id=\"invite-email\"\n type=\"email\"\n autoComplete=\"off\"\n placeholder=\"teammate@example.com\"\n value={email}\n onChange={(e) => setEmail(e.target.value)}\n disabled={submitting}\n />\n </Field>\n <Field label={translate('invites.roleLabel')} htmlFor=\"invite-role\">\n <select\n id=\"invite-role\"\n value={role}\n onChange={(e) => setRole(e.target.value)}\n disabled={submitting}\n className=\"h-9 w-full rounded-md border border-input bg-background px-3 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring\"\n >\n {ROLE_OPTIONS.map((option) => (\n <option key={option.value} value={option.value}>\n {option.label}\n </option>\n ))}\n </select>\n </Field>\n <DialogFooter>\n <Button type=\"button\" variant=\"ghost\" onClick={() => onOpenChange(false)} disabled={submitting}>\n {translate('common.cancel')}\n </Button>\n <Button type=\"submit\" disabled={submitting || email.trim().length === 0}>\n {submitting ? translate('invites.sending') : translate('invites.send')}\n </Button>\n </DialogFooter>\n </Stack>\n </form>\n </DialogContent>\n </Dialog>\n )\n}\n"]}
|
|
@@ -2,13 +2,15 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { ShieldAlert } from 'lucide-react';
|
|
3
3
|
import { Heading, Stack, Text } from '@elytracms/studio/ui';
|
|
4
4
|
import { translate } from '@elytracms/studio/lib/i18n';
|
|
5
|
-
import { useProjectRole } from '../auth-context';
|
|
5
|
+
import { useAuth, useProjectRole } from '../auth-context';
|
|
6
6
|
import { UsersProvider, useUsers } from './users-store';
|
|
7
7
|
import { useUsersCollectionViewSource } from './users-collection-view-source';
|
|
8
8
|
import { CollectionViewProvider } from '../collection-view/collection-view-context';
|
|
9
9
|
import { RailStoreStatus } from '../rail-store-status';
|
|
10
10
|
import { LoadedCollectionTable } from '../cms/collection-table';
|
|
11
11
|
import { PortableDetailEditor } from '../cms/detail-editor';
|
|
12
|
+
import { AccountSettings } from '../account/account-settings';
|
|
13
|
+
import { InviteUserBar } from './invite-user-bar';
|
|
12
14
|
/**
|
|
13
15
|
* The studio Users module (EC-240). An admin-only section for managing who has
|
|
14
16
|
* access to this deployment and their role. AD-19: it is the **reuse proof** for
|
|
@@ -29,7 +31,16 @@ import { PortableDetailEditor } from '../cms/detail-editor';
|
|
|
29
31
|
* authorization error, and we skip firing a query we know will be denied.
|
|
30
32
|
*/
|
|
31
33
|
export function UsersModule({ projectId, userId = null, }) {
|
|
34
|
+
const auth = useAuth();
|
|
32
35
|
const role = useProjectRole(projectId);
|
|
36
|
+
const currentUserId = auth.session?.user?.id ?? null;
|
|
37
|
+
// Self-access (EC-240 H-tail item 1): ANY member — admin or not — may open their
|
|
38
|
+
// OWN record, and only that. This is the account-settings surface (self-permitted
|
|
39
|
+
// read + identity/credential writes), routed here from the user menu. A non-admin
|
|
40
|
+
// opening someone ELSE's `users/<id>` still falls through to the restricted state.
|
|
41
|
+
if (userId !== null && currentUserId !== null && userId === currentUserId) {
|
|
42
|
+
return _jsx(AccountSettings, { projectId: projectId });
|
|
43
|
+
}
|
|
33
44
|
// `useProjectRole` conflates "loading" and "not a member" as null; in the studio
|
|
34
45
|
// a member always reaches here, so null resolves to the real role momentarily.
|
|
35
46
|
if (role === null) {
|
|
@@ -55,6 +66,6 @@ function UsersSurface({ projectId, userId }) {
|
|
|
55
66
|
if (userId !== null) {
|
|
56
67
|
return _jsx(PortableDetailEditor, { source: source, projectId: projectId, documentId: userId });
|
|
57
68
|
}
|
|
58
|
-
return (_jsx(CollectionViewProvider, { source: source, children: _jsx(LoadedCollectionTable, { projectId: projectId }) }));
|
|
69
|
+
return (_jsxs(Stack, { gap: "none", className: "h-full min-h-0", children: [_jsx(InviteUserBar, { projectId: projectId }), _jsx("div", { className: "min-h-0 flex-1", children: _jsx(CollectionViewProvider, { source: source, children: _jsx(LoadedCollectionTable, { projectId: projectId }) }) })] }));
|
|
59
70
|
}
|
|
60
71
|
//# sourceMappingURL=users-module.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"users-module.js","sourceRoot":"","sources":["../../../src/components/users/users-module.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AAC1C,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,sBAAsB,CAAA;AAC3D,OAAO,EAAE,SAAS,EAAE,MAAM,4BAA4B,CAAA;AACtD,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAA;
|
|
1
|
+
{"version":3,"file":"users-module.js","sourceRoot":"","sources":["../../../src/components/users/users-module.tsx"],"names":[],"mappings":"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","sourcesContent":["import { ShieldAlert } from 'lucide-react'\nimport { Heading, Stack, Text } from '@elytracms/studio/ui'\nimport { translate } from '@elytracms/studio/lib/i18n'\nimport { useAuth, useProjectRole } from '../auth-context'\nimport { UsersProvider, useUsers } from './users-store'\nimport { useUsersCollectionViewSource } from './users-collection-view-source'\nimport { CollectionViewProvider } from '../collection-view/collection-view-context'\nimport { RailStoreStatus } from '../rail-store-status'\nimport { LoadedCollectionTable } from '../cms/collection-table'\nimport { PortableDetailEditor } from '../cms/detail-editor'\nimport { AccountSettings } from '../account/account-settings'\nimport { InviteUserBar } from './invite-user-bar'\n\n/**\n * The studio Users module (EC-240). An admin-only section for managing who has\n * access to this deployment and their role. AD-19: it is the **reuse proof** for\n * the portable interaction surfaces — it renders the SAME `LoadedCollectionTable`\n * the CMS uses for the roster, and the SAME `DetailEditor` body (via\n * {@link PortableDetailEditor}) for a member's detail, both over a users\n * {@link useUsersCollectionViewSource} instead of a CMS source. No bespoke roster,\n * no bespoke editor: filtering, columns, the count, and the edit form all come\n * from the shared surfaces. The users capability profile (admin-only, no publish/\n * versions/create/import) drives which regions appear.\n *\n * `userId === null` is the roster; a `userId` opens that member's detail (routed\n * via `studioPath.user`). Both render under one `UsersProvider` + one source.\n *\n * Gating is layered: the operations chokepoint AND the Convex handlers enforce\n * admin-only server-side (a non-admin `users.list` is denied). This UI gate is UX\n * politeness — a non-admin sees a clear \"restricted\" state instead of a raw\n * authorization error, and we skip firing a query we know will be denied.\n */\nexport function UsersModule({\n projectId,\n userId = null,\n}: {\n projectId: string\n userId?: string | null\n}) {\n const auth = useAuth()\n const role = useProjectRole(projectId)\n const currentUserId = auth.session?.user?.id ?? null\n\n // Self-access (EC-240 H-tail item 1): ANY member — admin or not — may open their\n // OWN record, and only that. This is the account-settings surface (self-permitted\n // read + identity/credential writes), routed here from the user menu. A non-admin\n // opening someone ELSE's `users/<id>` still falls through to the restricted state.\n if (userId !== null && currentUserId !== null && userId === currentUserId) {\n return <AccountSettings projectId={projectId} />\n }\n\n // `useProjectRole` conflates \"loading\" and \"not a member\" as null; in the studio\n // a member always reaches here, so null resolves to the real role momentarily.\n if (role === null) {\n return (\n <Stack className=\"h-full place-items-center p-8\" align=\"center\" justify=\"center\">\n <Text size=\"sm\" tone=\"muted\">\n {translate('users.checkingAccess')}\n </Text>\n </Stack>\n )\n }\n if (role !== 'admin') return <RestrictedState />\n\n return (\n <UsersProvider projectId={projectId}>\n <UsersSurface projectId={projectId} userId={userId} />\n </UsersProvider>\n )\n}\n\n/** The non-admin gate: honest about why the surface is empty. */\nfunction RestrictedState() {\n return (\n <Stack gap=\"2\" align=\"center\" justify=\"center\" className=\"h-full p-8 text-center\">\n <ShieldAlert className=\"size-6 text-muted-foreground\" />\n <Heading level={2} size=\"h3\">\n {translate('users.restrictedTitle')}\n </Heading>\n <Text size=\"sm\" tone=\"muted\" className=\"max-w-sm\">\n {translate('users.restrictedBody')}\n </Text>\n </Stack>\n )\n}\n\n/** The loaded surface: the portable list (or a member's detail) over the users source. */\nfunction UsersSurface({ projectId, userId }: { projectId: string; userId: string | null }) {\n const store = useUsers()\n const source = useUsersCollectionViewSource(projectId)\n\n if (store.status !== 'ready') {\n return (\n <RailStoreStatus\n store={store}\n errorLabel={translate('users.loadFailed')}\n loadingLabel={translate('users.loading')}\n errorTestId=\"users-error\"\n />\n )\n }\n\n // A member's detail opens in the portable edit shell (AD-19); the list otherwise.\n // The shell mounts its own CollectionViewProvider, so only the list wraps here.\n if (userId !== null) {\n return <PortableDetailEditor source={source} projectId={projectId} documentId={userId} />\n }\n\n return (\n <Stack gap=\"none\" className=\"h-full min-h-0\">\n {/* EC-324: invite-based onboarding — the admin action that replaces open sign-up. */}\n <InviteUserBar projectId={projectId} />\n <div className=\"min-h-0 flex-1\">\n <CollectionViewProvider source={source}>\n <LoadedCollectionTable projectId={projectId} />\n </CollectionViewProvider>\n </div>\n </Stack>\n )\n}\n"]}
|
|
@@ -31,13 +31,46 @@ export interface SignInCredentials {
|
|
|
31
31
|
readonly email: string;
|
|
32
32
|
readonly password: string;
|
|
33
33
|
}
|
|
34
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* Structured sign-in outcome — invalid credentials are a state, not a throw.
|
|
36
|
+
*
|
|
37
|
+
* `deployment-misconfigured` (EC-257) is distinct from `invalid-credentials`: the
|
|
38
|
+
* deployment can't sign the session JWT (missing `JWT_PRIVATE_KEY`/`JWKS`/
|
|
39
|
+
* `CONVEX_SITE_URL`, or a malformed signing key), so EVERY sign-in fails regardless
|
|
40
|
+
* of the password. Surfacing it as its own reason lets the sign-in screen tell the
|
|
41
|
+
* truth ("run `npx @convex-dev/auth`") instead of the old lying "password too short".
|
|
42
|
+
*/
|
|
35
43
|
export type SignInResult = {
|
|
36
44
|
readonly ok: true;
|
|
37
45
|
readonly session: AuthSession;
|
|
38
46
|
} | {
|
|
39
47
|
readonly ok: false;
|
|
40
|
-
readonly reason: 'invalid-credentials' | 'account-exists' | 'unavailable';
|
|
48
|
+
readonly reason: 'invalid-credentials' | 'account-exists' | 'deployment-misconfigured' | 'not-invited' | 'unavailable';
|
|
49
|
+
readonly message: string;
|
|
50
|
+
};
|
|
51
|
+
/** Self-service identity edit (EC-240 H-tail item 2) — auth-owned name/avatar. */
|
|
52
|
+
export interface UpdateProfileInput {
|
|
53
|
+
readonly name?: string;
|
|
54
|
+
readonly avatar?: string;
|
|
55
|
+
}
|
|
56
|
+
export type UpdateProfileResult = {
|
|
57
|
+
readonly ok: true;
|
|
58
|
+
readonly session: AuthSession;
|
|
59
|
+
} | {
|
|
60
|
+
readonly ok: false;
|
|
61
|
+
readonly reason: 'not-authenticated' | 'unavailable' | 'unsupported';
|
|
62
|
+
readonly message: string;
|
|
63
|
+
};
|
|
64
|
+
/** Self-service password change (EC-240 H-tail item 3) — provider-delegated. */
|
|
65
|
+
export interface ChangePasswordInput {
|
|
66
|
+
readonly currentPassword: string;
|
|
67
|
+
readonly newPassword: string;
|
|
68
|
+
}
|
|
69
|
+
export type ChangePasswordResult = {
|
|
70
|
+
readonly ok: true;
|
|
71
|
+
} | {
|
|
72
|
+
readonly ok: false;
|
|
73
|
+
readonly reason: 'invalid-credentials' | 'weak-password' | 'not-authenticated' | 'unavailable' | 'unsupported';
|
|
41
74
|
readonly message: string;
|
|
42
75
|
};
|
|
43
76
|
export interface AuthAdapter {
|
|
@@ -54,4 +87,19 @@ export interface AuthAdapter {
|
|
|
54
87
|
signUp(credentials: SignInCredentials): Promise<SignInResult>;
|
|
55
88
|
/** End the session (idempotent). */
|
|
56
89
|
signOut(): Promise<void>;
|
|
90
|
+
/**
|
|
91
|
+
* Update the signed-in user's OWN identity — name / avatar (EC-240 H-tail item
|
|
92
|
+
* 2). These are AUTH-owned (the provider's `users` row), so they flow through
|
|
93
|
+
* the adapter, not the members-rail `users.save`. Self-scoped: the provider
|
|
94
|
+
* patches only the caller's record. Returns the refreshed session so the shell
|
|
95
|
+
* reflects the new name immediately.
|
|
96
|
+
*/
|
|
97
|
+
updateProfile(input: UpdateProfileInput): Promise<UpdateProfileResult>;
|
|
98
|
+
/**
|
|
99
|
+
* Change the signed-in user's OWN password (EC-240 H-tail item 3), gated by the
|
|
100
|
+
* current password. **Seam only:** the raw credential values are handed straight
|
|
101
|
+
* to the provider adapter — never logged, stored, or inspected by app code, and
|
|
102
|
+
* never routed through the operations layer. Self-scoped.
|
|
103
|
+
*/
|
|
104
|
+
changePassword(input: ChangePasswordInput): Promise<ChangePasswordResult>;
|
|
57
105
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth-adapter.js","sourceRoot":"","sources":["../../../src/lib/auth/auth-adapter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG","sourcesContent":["/**\n * Project-owned auth boundary (EC-150, vision AD-7). The studio talks to\n * authentication ONLY through {@link AuthAdapter} — the Convex Auth\n * implementation (`convex-auth-adapter.ts`) and the deterministic local\n * implementation (`local-auth-adapter.ts`) both satisfy it, mirroring how\n * `PersistenceAdapter` keeps Convex out of the app's type graph.\n *\n * Swapping providers (e.g. Convex Auth → Clerk) means writing one new\n * implementation of this interface and teaching `selectAuthAdapter` to pick\n * it; no UI or store code changes.\n */\n\n/** The signed-in user as the studio sees it. */\nexport interface AuthUser {\n /** Stable user id — the key `projectMembers.userId` is matched against. */\n readonly id: string\n readonly email: string\n readonly name?: string\n /**\n * Global (workspace-level) capability flags forwarded onto the operations\n * `Principal.roles`, e.g. `system` (internal tooling only). Per-project roles\n * do NOT live here — they live in the members repository. Currently no\n * browser-session flag is granted: project creation is config/CLI-owned\n * (AD-11), so there is no `create-projects` capability.\n */\n readonly globalFlags?: readonly string[]\n}\n\nexport interface AuthSession {\n readonly user: AuthUser\n}\n\nexport interface SignInCredentials {\n readonly email: string\n readonly password: string\n}\n\n
|
|
1
|
+
{"version":3,"file":"auth-adapter.js","sourceRoot":"","sources":["../../../src/lib/auth/auth-adapter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG","sourcesContent":["/**\n * Project-owned auth boundary (EC-150, vision AD-7). The studio talks to\n * authentication ONLY through {@link AuthAdapter} — the Convex Auth\n * implementation (`convex-auth-adapter.ts`) and the deterministic local\n * implementation (`local-auth-adapter.ts`) both satisfy it, mirroring how\n * `PersistenceAdapter` keeps Convex out of the app's type graph.\n *\n * Swapping providers (e.g. Convex Auth → Clerk) means writing one new\n * implementation of this interface and teaching `selectAuthAdapter` to pick\n * it; no UI or store code changes.\n */\n\n/** The signed-in user as the studio sees it. */\nexport interface AuthUser {\n /** Stable user id — the key `projectMembers.userId` is matched against. */\n readonly id: string\n readonly email: string\n readonly name?: string\n /**\n * Global (workspace-level) capability flags forwarded onto the operations\n * `Principal.roles`, e.g. `system` (internal tooling only). Per-project roles\n * do NOT live here — they live in the members repository. Currently no\n * browser-session flag is granted: project creation is config/CLI-owned\n * (AD-11), so there is no `create-projects` capability.\n */\n readonly globalFlags?: readonly string[]\n}\n\nexport interface AuthSession {\n readonly user: AuthUser\n}\n\nexport interface SignInCredentials {\n readonly email: string\n readonly password: string\n}\n\n/**\n * Structured sign-in outcome — invalid credentials are a state, not a throw.\n *\n * `deployment-misconfigured` (EC-257) is distinct from `invalid-credentials`: the\n * deployment can't sign the session JWT (missing `JWT_PRIVATE_KEY`/`JWKS`/\n * `CONVEX_SITE_URL`, or a malformed signing key), so EVERY sign-in fails regardless\n * of the password. Surfacing it as its own reason lets the sign-in screen tell the\n * truth (\"run `npx @convex-dev/auth`\") instead of the old lying \"password too short\".\n */\nexport type SignInResult =\n | { readonly ok: true; readonly session: AuthSession }\n | {\n readonly ok: false\n readonly reason:\n | 'invalid-credentials'\n | 'account-exists'\n | 'deployment-misconfigured'\n // Onboarding is invite-based (EC-324): a sign-up whose email is neither the\n // first-user bootstrap nor on the admin's invite allowlist is rejected\n // server-side. Distinct from `invalid-credentials` so the screen tells the\n // truth (\"ask an admin to invite you\") instead of \"password too short\".\n | 'not-invited'\n | 'unavailable'\n readonly message: string\n }\n\n/** Self-service identity edit (EC-240 H-tail item 2) — auth-owned name/avatar. */\nexport interface UpdateProfileInput {\n readonly name?: string\n readonly avatar?: string\n}\n\nexport type UpdateProfileResult =\n | { readonly ok: true; readonly session: AuthSession }\n | {\n readonly ok: false\n readonly reason: 'not-authenticated' | 'unavailable' | 'unsupported'\n readonly message: string\n }\n\n/** Self-service password change (EC-240 H-tail item 3) — provider-delegated. */\nexport interface ChangePasswordInput {\n readonly currentPassword: string\n readonly newPassword: string\n}\n\nexport type ChangePasswordResult =\n | { readonly ok: true }\n | {\n readonly ok: false\n readonly reason:\n | 'invalid-credentials'\n | 'weak-password'\n | 'not-authenticated'\n | 'unavailable'\n | 'unsupported'\n readonly message: string\n }\n\nexport interface AuthAdapter {\n /** Restore the current session, if any (page load / refresh). */\n currentSession(): Promise<AuthSession | null>\n /** Password sign-in. Resolves a structured result; never throws for bad input. */\n signIn(credentials: SignInCredentials): Promise<SignInResult>\n /**\n * Create a password account and sign in (EC-179). One project per backend, so\n * a freshly-provisioned deployment starts with no users — the first member\n * registers here, then is granted a role. `account-exists` is a state, not a\n * throw.\n */\n signUp(credentials: SignInCredentials): Promise<SignInResult>\n /** End the session (idempotent). */\n signOut(): Promise<void>\n /**\n * Update the signed-in user's OWN identity — name / avatar (EC-240 H-tail item\n * 2). These are AUTH-owned (the provider's `users` row), so they flow through\n * the adapter, not the members-rail `users.save`. Self-scoped: the provider\n * patches only the caller's record. Returns the refreshed session so the shell\n * reflects the new name immediately.\n */\n updateProfile(input: UpdateProfileInput): Promise<UpdateProfileResult>\n /**\n * Change the signed-in user's OWN password (EC-240 H-tail item 3), gated by the\n * current password. **Seam only:** the raw credential values are handed straight\n * to the provider adapter — never logged, stored, or inspected by app code, and\n * never routed through the operations layer. Self-scoped.\n */\n changePassword(input: ChangePasswordInput): Promise<ChangePasswordResult>\n}\n"]}
|
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
import type { ConvexHttpClient } from 'convex/browser';
|
|
2
|
-
import type { AuthAdapter, AuthSession, SignInCredentials, SignInResult } from './auth-adapter';
|
|
2
|
+
import type { AuthAdapter, AuthSession, ChangePasswordInput, ChangePasswordResult, SignInCredentials, SignInResult, UpdateProfileInput, UpdateProfileResult } from './auth-adapter';
|
|
3
3
|
import type { SessionStore } from './local-auth-adapter';
|
|
4
|
+
/**
|
|
5
|
+
* Classify a thrown sign-in error as a DEPLOYMENT-setup failure rather than a
|
|
6
|
+
* credential problem (EC-257). Matches the Convex Auth signing-path throws:
|
|
7
|
+
* `requireEnv('JWT_PRIVATE_KEY'|'JWKS'|'CONVEX_SITE_URL')` (→ "Missing environment
|
|
8
|
+
* variable …") and `importPKCS8` on a malformed key. Deliberately narrow — a genuine
|
|
9
|
+
* wrong-password error carries none of these tokens — and checked BEFORE the credential
|
|
10
|
+
* regex so it always wins. Note: a PRODUCTION deployment redacts non-`ConvexError`
|
|
11
|
+
* messages to a generic "Server Error", which this can't match; there the honest
|
|
12
|
+
* fallback is the neutral "unavailable" branch (never "password too short").
|
|
13
|
+
*/
|
|
14
|
+
export declare function isDeploymentMisconfigured(message: string): boolean;
|
|
4
15
|
/**
|
|
5
16
|
* Per-deployment token key (EC-179, vision AD-8). One project per backend, so a
|
|
6
17
|
* session is scoped to its deployment: signing into Aurora's backend must not
|
|
@@ -34,6 +45,22 @@ export declare class ConvexAuthAdapter implements AuthAdapter {
|
|
|
34
45
|
*/
|
|
35
46
|
private authenticate;
|
|
36
47
|
signOut(): Promise<void>;
|
|
48
|
+
/**
|
|
49
|
+
* Self-service identity edit (EC-240 H-tail item 2). Patches the caller's OWN
|
|
50
|
+
* auth `users` row (name / avatar) via the self-scoped `users:updateProfile`
|
|
51
|
+
* mutation, then re-reads `users:me` so the returned session carries the new
|
|
52
|
+
* name. Auth-owned fields never flow through the members-rail `users.save`.
|
|
53
|
+
*/
|
|
54
|
+
updateProfile(input: UpdateProfileInput): Promise<UpdateProfileResult>;
|
|
55
|
+
/**
|
|
56
|
+
* Self-service password change (EC-240 H-tail item 3). Delegates entirely to
|
|
57
|
+
* the provider via the self-scoped `users:changePassword` ACTION (which calls
|
|
58
|
+
* Convex Auth's `retrieveAccount` to verify the current password, then
|
|
59
|
+
* `modifyAccountCredentials` to set the new one). **Seam only:** the raw
|
|
60
|
+
* password values are forwarded to the provider and NEVER logged or inspected
|
|
61
|
+
* here.
|
|
62
|
+
*/
|
|
63
|
+
changePassword(input: ChangePasswordInput): Promise<ChangePasswordResult>;
|
|
37
64
|
private toUser;
|
|
38
65
|
private clearToken;
|
|
39
66
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { actionRef, queryRef } from '../persistence/convex-client';
|
|
1
|
+
import { actionRef, mutationRef, queryRef } from '../persistence/convex-client';
|
|
2
2
|
/**
|
|
3
3
|
* Convex Auth implementation of the project-owned {@link AuthAdapter}
|
|
4
4
|
* (EC-150). Decision record (also in `convex/auth.ts`): **Convex Auth** over
|
|
@@ -22,6 +22,19 @@ import { actionRef, queryRef } from '../persistence/convex-client';
|
|
|
22
22
|
* adapter's shared interface.
|
|
23
23
|
*/
|
|
24
24
|
const TOKEN_STORAGE_PREFIX = 'elytra.convex-auth.jwt';
|
|
25
|
+
/**
|
|
26
|
+
* Classify a thrown sign-in error as a DEPLOYMENT-setup failure rather than a
|
|
27
|
+
* credential problem (EC-257). Matches the Convex Auth signing-path throws:
|
|
28
|
+
* `requireEnv('JWT_PRIVATE_KEY'|'JWKS'|'CONVEX_SITE_URL')` (→ "Missing environment
|
|
29
|
+
* variable …") and `importPKCS8` on a malformed key. Deliberately narrow — a genuine
|
|
30
|
+
* wrong-password error carries none of these tokens — and checked BEFORE the credential
|
|
31
|
+
* regex so it always wins. Note: a PRODUCTION deployment redacts non-`ConvexError`
|
|
32
|
+
* messages to a generic "Server Error", which this can't match; there the honest
|
|
33
|
+
* fallback is the neutral "unavailable" branch (never "password too short").
|
|
34
|
+
*/
|
|
35
|
+
export function isDeploymentMisconfigured(message) {
|
|
36
|
+
return /JWT_PRIVATE_KEY|JWKS|CONVEX_SITE_URL|Missing environment variable|PKCS8|private key/i.test(message);
|
|
37
|
+
}
|
|
25
38
|
/**
|
|
26
39
|
* Per-deployment token key (EC-179, vision AD-8). One project per backend, so a
|
|
27
40
|
* session is scoped to its deployment: signing into Aurora's backend must not
|
|
@@ -108,6 +121,31 @@ export class ConvexAuthAdapter {
|
|
|
108
121
|
catch (error) {
|
|
109
122
|
// Convex Auth surfaces credential/registration problems as thrown errors.
|
|
110
123
|
const message = error instanceof Error ? error.message : String(error);
|
|
124
|
+
// EC-257: a DEPLOYMENT-misconfiguration — not a credential problem — must be
|
|
125
|
+
// classified FIRST so it never falls through to the lying "password too short".
|
|
126
|
+
// Convex Auth signs the session JWT with the deployment's `JWT_PRIVATE_KEY`
|
|
127
|
+
// (+ `JWKS`/`CONVEX_SITE_URL`); when any is unset (`npx @convex-dev/auth`
|
|
128
|
+
// never run) the `requireEnv`/`importPKCS8` call throws *after* the password
|
|
129
|
+
// is validated, so EVERY sign-in fails regardless of the password. This is
|
|
130
|
+
// the exact footgun that cost hours on the Konditorei proof (2026-06-22).
|
|
131
|
+
if (isDeploymentMisconfigured(message)) {
|
|
132
|
+
return {
|
|
133
|
+
ok: false,
|
|
134
|
+
reason: 'deployment-misconfigured',
|
|
135
|
+
message: 'This deployment can’t sign you in: its auth signing key is not set. Run `npx @convex-dev/auth` against this deployment, then retry. (The deployment-setup check reports the same.)',
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
// EC-324: an uninvited sign-up is rejected server-side (`beforeSessionCreation`
|
|
139
|
+
// throws a `ConvexError('not-invited: …')`, whose data survives to the client
|
|
140
|
+
// even in production). Classified BEFORE the credential regex so it never
|
|
141
|
+
// degrades to "password too short".
|
|
142
|
+
if (/not-invited|invite-only|invite only/i.test(message)) {
|
|
143
|
+
return {
|
|
144
|
+
ok: false,
|
|
145
|
+
reason: 'not-invited',
|
|
146
|
+
message: 'This deployment is invite-only. Ask an admin to invite your email, then create your account.',
|
|
147
|
+
};
|
|
148
|
+
}
|
|
111
149
|
if (flow === 'signUp' && /already|exists|InvalidAccountId/i.test(message)) {
|
|
112
150
|
return {
|
|
113
151
|
ok: false,
|
|
@@ -152,6 +190,80 @@ export class ConvexAuthAdapter {
|
|
|
152
190
|
}
|
|
153
191
|
this.clearToken();
|
|
154
192
|
}
|
|
193
|
+
/**
|
|
194
|
+
* Self-service identity edit (EC-240 H-tail item 2). Patches the caller's OWN
|
|
195
|
+
* auth `users` row (name / avatar) via the self-scoped `users:updateProfile`
|
|
196
|
+
* mutation, then re-reads `users:me` so the returned session carries the new
|
|
197
|
+
* name. Auth-owned fields never flow through the members-rail `users.save`.
|
|
198
|
+
*/
|
|
199
|
+
async updateProfile(input) {
|
|
200
|
+
const token = this.store.getItem(this.tokenKey);
|
|
201
|
+
if (!token) {
|
|
202
|
+
return { ok: false, reason: 'not-authenticated', message: 'You are not signed in.' };
|
|
203
|
+
}
|
|
204
|
+
this.client.setAuth(token);
|
|
205
|
+
try {
|
|
206
|
+
await this.client.mutation(mutationRef('users:updateProfile'), {
|
|
207
|
+
...(input.name !== undefined ? { name: input.name } : {}),
|
|
208
|
+
...(input.avatar !== undefined ? { avatar: input.avatar } : {}),
|
|
209
|
+
});
|
|
210
|
+
const me = (await this.client.query(queryRef('users:me'), {}));
|
|
211
|
+
if (!me) {
|
|
212
|
+
return { ok: false, reason: 'unavailable', message: 'Profile update is currently unavailable.' };
|
|
213
|
+
}
|
|
214
|
+
return { ok: true, session: { user: this.toUser(me) } };
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
return { ok: false, reason: 'unavailable', message: 'Profile update is currently unavailable.' };
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Self-service password change (EC-240 H-tail item 3). Delegates entirely to
|
|
222
|
+
* the provider via the self-scoped `users:changePassword` ACTION (which calls
|
|
223
|
+
* Convex Auth's `retrieveAccount` to verify the current password, then
|
|
224
|
+
* `modifyAccountCredentials` to set the new one). **Seam only:** the raw
|
|
225
|
+
* password values are forwarded to the provider and NEVER logged or inspected
|
|
226
|
+
* here.
|
|
227
|
+
*/
|
|
228
|
+
async changePassword(input) {
|
|
229
|
+
const token = this.store.getItem(this.tokenKey);
|
|
230
|
+
if (!token) {
|
|
231
|
+
return { ok: false, reason: 'not-authenticated', message: 'You are not signed in.' };
|
|
232
|
+
}
|
|
233
|
+
this.client.setAuth(token);
|
|
234
|
+
try {
|
|
235
|
+
await this.client.action(actionRef('users:changePassword'), {
|
|
236
|
+
currentPassword: input.currentPassword,
|
|
237
|
+
newPassword: input.newPassword,
|
|
238
|
+
});
|
|
239
|
+
return { ok: true };
|
|
240
|
+
}
|
|
241
|
+
catch (error) {
|
|
242
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
243
|
+
if (isDeploymentMisconfigured(message)) {
|
|
244
|
+
return {
|
|
245
|
+
ok: false,
|
|
246
|
+
reason: 'unavailable',
|
|
247
|
+
message: 'This deployment can’t change your password: its auth signing key is not set. Run `npx @convex-dev/auth`.',
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
if (/InvalidSecret|InvalidAccountId|current password|incorrect/i.test(message)) {
|
|
251
|
+
return {
|
|
252
|
+
ok: false,
|
|
253
|
+
reason: 'invalid-credentials',
|
|
254
|
+
message: 'Your current password is incorrect.',
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
if (/at least|too short|8 characters|password requirement|weak/i.test(message)) {
|
|
258
|
+
return {
|
|
259
|
+
ok: false,
|
|
260
|
+
reason: 'weak-password',
|
|
261
|
+
message: 'Choose a stronger password (at least 8 characters).',
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
return { ok: false, reason: 'unavailable', message: 'Password change is currently unavailable.' };
|
|
265
|
+
}
|
|
266
|
+
}
|
|
155
267
|
toUser(me) {
|
|
156
268
|
return {
|
|
157
269
|
id: me.id,
|