@elytracms/studio 0.0.47 → 0.0.49

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.
Files changed (82) hide show
  1. package/convex/auth.ts +61 -12
  2. package/convex/authority.ts +40 -2
  3. package/convex/functions.ts +9 -0
  4. package/convex/invites.ts +75 -0
  5. package/convex/members.ts +44 -18
  6. package/convex/schema.ts +37 -0
  7. package/convex/users.ts +89 -2
  8. package/dist/components/account/account-settings.d.ts +13 -0
  9. package/dist/components/account/account-settings.js +54 -0
  10. package/dist/components/account/account-settings.js.map +1 -0
  11. package/dist/components/account/credential-panel.d.ts +14 -0
  12. package/dist/components/account/credential-panel.js +52 -0
  13. package/dist/components/account/credential-panel.js.map +1 -0
  14. package/dist/components/account/self-account-schema.d.ts +19 -0
  15. package/dist/components/account/self-account-schema.js +52 -0
  16. package/dist/components/account/self-account-schema.js.map +1 -0
  17. package/dist/components/account/self-account-view-source.d.ts +35 -0
  18. package/dist/components/account/self-account-view-source.js +110 -0
  19. package/dist/components/account/self-account-view-source.js.map +1 -0
  20. package/dist/components/auth-context.d.ts +5 -1
  21. package/dist/components/auth-context.js +9 -0
  22. package/dist/components/auth-context.js.map +1 -1
  23. package/dist/components/cms/cms-store.d.ts +7 -2
  24. package/dist/components/cms/cms-store.js +35 -4
  25. package/dist/components/cms/cms-store.js.map +1 -1
  26. package/dist/components/cms/view-bar.js +4 -2
  27. package/dist/components/cms/view-bar.js.map +1 -1
  28. package/dist/components/current-user-menu.js +11 -2
  29. package/dist/components/current-user-menu.js.map +1 -1
  30. package/dist/components/deployment-setup-gate.d.ts +31 -0
  31. package/dist/components/deployment-setup-gate.js +92 -0
  32. package/dist/components/deployment-setup-gate.js.map +1 -0
  33. package/dist/components/project-session.d.ts +1 -1
  34. package/dist/components/project-session.js.map +1 -1
  35. package/dist/components/use-hydration-phase.js +1 -1
  36. package/dist/components/use-hydration-phase.js.map +1 -1
  37. package/dist/components/users/invite-user-bar.d.ts +3 -0
  38. package/dist/components/users/invite-user-bar.js +91 -0
  39. package/dist/components/users/invite-user-bar.js.map +1 -0
  40. package/dist/components/users/users-module.js +13 -2
  41. package/dist/components/users/users-module.js.map +1 -1
  42. package/dist/lib/auth/auth-adapter.d.ts +50 -2
  43. package/dist/lib/auth/auth-adapter.js.map +1 -1
  44. package/dist/lib/auth/convex-auth-adapter.d.ts +28 -1
  45. package/dist/lib/auth/convex-auth-adapter.js +113 -1
  46. package/dist/lib/auth/convex-auth-adapter.js.map +1 -1
  47. package/dist/lib/auth/dev-auto-login-adapter.d.ts +3 -1
  48. package/dist/lib/auth/dev-auto-login-adapter.js +6 -0
  49. package/dist/lib/auth/dev-auto-login-adapter.js.map +1 -1
  50. package/dist/lib/auth/index.d.ts +2 -2
  51. package/dist/lib/auth/index.js +1 -1
  52. package/dist/lib/auth/index.js.map +1 -1
  53. package/dist/lib/auth/local-auth-adapter.d.ts +3 -1
  54. package/dist/lib/auth/local-auth-adapter.js +16 -0
  55. package/dist/lib/auth/local-auth-adapter.js.map +1 -1
  56. package/dist/lib/cms-views/view-model.d.ts +33 -4
  57. package/dist/lib/cms-views/view-model.js +66 -10
  58. package/dist/lib/cms-views/view-model.js.map +1 -1
  59. package/dist/lib/collection-view/collection-view-source.d.ts +3 -2
  60. package/dist/lib/collection-view/collection-view-source.js.map +1 -1
  61. package/dist/lib/i18n/messages.en.js +45 -0
  62. package/dist/lib/i18n/messages.en.js.map +1 -1
  63. package/dist/lib/operations/studio-operations.js +1 -1
  64. package/dist/lib/operations/studio-operations.js.map +1 -1
  65. package/dist/lib/persistence/backend-mode.d.ts +1 -1
  66. package/dist/lib/persistence/backend-mode.js +1 -1
  67. package/dist/lib/persistence/backend-mode.js.map +1 -1
  68. package/dist/lib/persistence/convex-adapter.d.ts +2 -1
  69. package/dist/lib/persistence/convex-adapter.js +69 -1
  70. package/dist/lib/persistence/convex-adapter.js.map +1 -1
  71. package/dist/lib/persistence/deployment-readiness.d.ts +36 -0
  72. package/dist/lib/persistence/deployment-readiness.js +26 -0
  73. package/dist/lib/persistence/deployment-readiness.js.map +1 -0
  74. package/dist/lib/persistence/index.d.ts +3 -1
  75. package/dist/lib/persistence/index.js +4 -1
  76. package/dist/lib/persistence/index.js.map +1 -1
  77. package/dist/lib/workspace/switcher-source.js +1 -1
  78. package/dist/lib/workspace/switcher-source.js.map +1 -1
  79. package/dist/lib/workspace/workspace-source.d.ts +1 -1
  80. package/dist/lib/workspace/workspace-source.js +1 -1
  81. package/dist/lib/workspace/workspace-source.js.map +1 -1
  82. package/package.json +2 -2
@@ -0,0 +1,92 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useMemo, useState } from 'react';
3
+ import { Button, Card, CardContent, CardHeader, CardTitle, Code, Stack, Text } from '@elytracms/studio/ui';
4
+ import { resolveBackendConfig, selectPersistenceAdapter, studioDeploymentReadiness, } from '@elytracms/studio/lib/persistence';
5
+ import { getWorkspaceState, resolveDeploymentCollections, resolveDeploymentUrl, } from '@elytracms/studio/lib/workspace';
6
+ import { translate } from '@elytracms/studio/lib/i18n';
7
+ /**
8
+ * Deployment-setup boot gate (EC-258) — the single readiness surface that
9
+ * supersedes the separate `BackendGate` (missing `VITE_CONVEX_URL` / unreachable)
10
+ * and `WorkspaceGate` (invalid config) screens by composing the shared
11
+ * `checkDeploymentReady` contract. It renders ONE deterministic "Deployment setup"
12
+ * screen listing each failing precondition with its copy-pasteable fix, instead of
13
+ * letting the app boot into a misleading downstream error (most painfully the
14
+ * Konditorei JWT-key footgun). All checks passing → children render untouched.
15
+ *
16
+ * Runs before auth and any adapter is constructed (it is the thing that keeps
17
+ * `selectAuthAdapter`/`selectPersistenceAdapter` from throwing on a misconfigured
18
+ * backend). Convex mode probes reachability once on the client (`adapter.status()`)
19
+ * with a retry; playground and unconfigured resolve synchronously. SSR renders the
20
+ * deterministic "checking" state. Auth-signing-keys is reported as `unknown` at boot
21
+ * (the JWKS endpoint is CORS-blocked from the browser — EC-257's honest sign-in error
22
+ * is the browser's actionable surface; `elytra doctor` probes it from node), and the
23
+ * per-user bucket stays `unknown` (this gate runs pre-auth), so neither ever blocks boot.
24
+ */
25
+ export function DeploymentSetupGate({ children }) {
26
+ const config = useMemo(() => resolveBackendConfig(), []);
27
+ const staticReport = useCallback(() => studioDeploymentReadiness({
28
+ backend: config,
29
+ workspace: getWorkspaceState(),
30
+ collections: resolveDeploymentCollections(),
31
+ }), [config]);
32
+ const [state, setState] = useState(() => config.kind === 'convex' ? { phase: 'checking' } : { phase: 'resolved', report: staticReport() });
33
+ const check = useCallback(() => {
34
+ if (config.kind !== 'convex') {
35
+ setState({ phase: 'resolved', report: staticReport() });
36
+ return;
37
+ }
38
+ const effectiveUrl = resolveDeploymentUrl(config.url);
39
+ const resolve = (reachable) => setState({
40
+ phase: 'resolved',
41
+ report: studioDeploymentReadiness({
42
+ backend: config,
43
+ effectiveUrl,
44
+ workspace: getWorkspaceState(),
45
+ collections: resolveDeploymentCollections(),
46
+ reachable,
47
+ }),
48
+ });
49
+ setState({ phase: 'checking' });
50
+ void selectPersistenceAdapter()
51
+ .then((adapter) => adapter.status())
52
+ .then((status) => resolve({ available: status.available, ...(status.detail ? { detail: status.detail } : {}) }))
53
+ .catch((error) => resolve({ available: false, detail: error instanceof Error ? error.message : String(error) }));
54
+ }, [config, staticReport]);
55
+ useEffect(() => {
56
+ if (config.kind === 'convex')
57
+ check();
58
+ }, [config.kind, check]);
59
+ if (state.phase === 'checking')
60
+ return _jsx(DeploymentChecking, {});
61
+ if (blockingFailures(state.report).length > 0) {
62
+ return (_jsx(DeploymentSetupScreen, { report: state.report, onRetry: config.kind === 'convex' ? check : undefined }));
63
+ }
64
+ return _jsx(_Fragment, { children: children });
65
+ }
66
+ /**
67
+ * The checks that BLOCK boot: a failed deployment- or app-level precondition. The
68
+ * per-user bucket ("not a member") is not a boot blocker — it is surfaced post-auth
69
+ * ("ask an admin"), and at boot it is `unknown` (pre-auth) anyway.
70
+ */
71
+ function blockingFailures(report) {
72
+ return report.checks.filter((c) => c.status === 'fail' && (c.bucket === 'deployment' || c.bucket === 'app'));
73
+ }
74
+ /** Deterministic boot state while the backend probe is in flight (also SSR output). */
75
+ function DeploymentChecking() {
76
+ return (_jsx(BootScreenFrame, { children: _jsx(Text, { size: "sm", tone: "muted", children: translate('shell.deploymentSetup.checking') }) }));
77
+ }
78
+ /**
79
+ * The unified "Deployment setup" screen (EC-258): every failing check + its exact
80
+ * fix. Pure/presentational so it is fixture-reproducible and static-render testable.
81
+ */
82
+ export function DeploymentSetupScreen({ report, onRetry, }) {
83
+ const failures = report.checks.filter((c) => c.status === 'fail');
84
+ return (_jsx(BootScreenFrame, { children: _jsxs(Card, { className: "max-w-2xl border-destructive/40", children: [_jsx(CardHeader, { children: _jsx(CardTitle, { children: translate('shell.deploymentSetup.title') }) }), _jsx(CardContent, { children: _jsxs(Stack, { gap: "3", children: [_jsx(Text, { size: "sm", tone: "muted", children: translate('shell.deploymentSetup.description') }), _jsx(Stack, { gap: "2", role: "list", "aria-label": translate('shell.deploymentSetup.issuesLabel'), children: failures.map((check) => (_jsx(CheckRow, { check: check }, check.id))) }), onRetry ? (_jsx(Button, { variant: "outline", size: "sm", onClick: onRetry, className: "self-start", children: translate('shell.deploymentSetup.retry') })) : null] }) })] }) }));
85
+ }
86
+ function CheckRow({ check }) {
87
+ return (_jsxs("div", { role: "listitem", className: "rounded-md border border-destructive/30 bg-destructive/5 p-2", children: [_jsxs(Text, { size: "xs", mono: true, className: "text-destructive", children: ["[", check.id, "]"] }), _jsx(Text, { size: "sm", children: check.detail }), check.fix ? (_jsxs(Text, { size: "xs", tone: "muted", className: "mt-1", children: [translate('shell.deploymentSetup.fixLabel'), ": ", _jsx(Code, { className: "break-all", children: check.fix })] })) : null] }));
88
+ }
89
+ function BootScreenFrame({ children }) {
90
+ return _jsx("div", { className: "grid min-h-dvh place-items-center bg-background p-6", children: children });
91
+ }
92
+ //# sourceMappingURL=deployment-setup-gate.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"deployment-setup-gate.js","sourceRoot":"","sources":["../../src/components/deployment-setup-gate.tsx"],"names":[],"mappings":"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","sourcesContent":["import { useCallback, useEffect, useMemo, useState } from 'react'\nimport type { ReactNode } from 'react'\nimport { Button, Card, CardContent, CardHeader, CardTitle, Code, Stack, Text } from '@elytracms/studio/ui'\nimport {\n resolveBackendConfig,\n selectPersistenceAdapter,\n studioDeploymentReadiness,\n} from '@elytracms/studio/lib/persistence'\nimport {\n getWorkspaceState,\n resolveDeploymentCollections,\n resolveDeploymentUrl,\n} from '@elytracms/studio/lib/workspace'\nimport { translate } from '@elytracms/studio/lib/i18n'\nimport type {\n DeploymentReadinessReport,\n ReadinessCheck,\n} from '@elytracms/core/export-sync/deployment-ready'\n\n/**\n * Deployment-setup boot gate (EC-258) — the single readiness surface that\n * supersedes the separate `BackendGate` (missing `VITE_CONVEX_URL` / unreachable)\n * and `WorkspaceGate` (invalid config) screens by composing the shared\n * `checkDeploymentReady` contract. It renders ONE deterministic \"Deployment setup\"\n * screen listing each failing precondition with its copy-pasteable fix, instead of\n * letting the app boot into a misleading downstream error (most painfully the\n * Konditorei JWT-key footgun). All checks passing → children render untouched.\n *\n * Runs before auth and any adapter is constructed (it is the thing that keeps\n * `selectAuthAdapter`/`selectPersistenceAdapter` from throwing on a misconfigured\n * backend). Convex mode probes reachability once on the client (`adapter.status()`)\n * with a retry; playground and unconfigured resolve synchronously. SSR renders the\n * deterministic \"checking\" state. Auth-signing-keys is reported as `unknown` at boot\n * (the JWKS endpoint is CORS-blocked from the browser — EC-257's honest sign-in error\n * is the browser's actionable surface; `elytra doctor` probes it from node), and the\n * per-user bucket stays `unknown` (this gate runs pre-auth), so neither ever blocks boot.\n */\nexport function DeploymentSetupGate({ children }: { children: ReactNode }) {\n const config = useMemo(() => resolveBackendConfig(), [])\n\n const staticReport = useCallback(\n (): DeploymentReadinessReport =>\n studioDeploymentReadiness({\n backend: config,\n workspace: getWorkspaceState(),\n collections: resolveDeploymentCollections(),\n }),\n [config],\n )\n\n const [state, setState] = useState<GateState>(() =>\n config.kind === 'convex' ? { phase: 'checking' } : { phase: 'resolved', report: staticReport() },\n )\n\n const check = useCallback(() => {\n if (config.kind !== 'convex') {\n setState({ phase: 'resolved', report: staticReport() })\n return\n }\n const effectiveUrl = resolveDeploymentUrl(config.url)\n const resolve = (reachable: { available: boolean; detail?: string }) =>\n setState({\n phase: 'resolved',\n report: studioDeploymentReadiness({\n backend: config,\n effectiveUrl,\n workspace: getWorkspaceState(),\n collections: resolveDeploymentCollections(),\n reachable,\n }),\n })\n setState({ phase: 'checking' })\n void selectPersistenceAdapter()\n .then((adapter) => adapter.status())\n .then((status) =>\n resolve({ available: status.available, ...(status.detail ? { detail: status.detail } : {}) }),\n )\n .catch((error: unknown) =>\n resolve({ available: false, detail: error instanceof Error ? error.message : String(error) }),\n )\n }, [config, staticReport])\n\n useEffect(() => {\n if (config.kind === 'convex') check()\n }, [config.kind, check])\n\n if (state.phase === 'checking') return <DeploymentChecking />\n if (blockingFailures(state.report).length > 0) {\n return (\n <DeploymentSetupScreen\n report={state.report}\n onRetry={config.kind === 'convex' ? check : undefined}\n />\n )\n }\n return <>{children}</>\n}\n\ntype GateState = { phase: 'checking' } | { phase: 'resolved'; report: DeploymentReadinessReport }\n\n/**\n * The checks that BLOCK boot: a failed deployment- or app-level precondition. The\n * per-user bucket (\"not a member\") is not a boot blocker — it is surfaced post-auth\n * (\"ask an admin\"), and at boot it is `unknown` (pre-auth) anyway.\n */\nfunction blockingFailures(report: DeploymentReadinessReport): ReadinessCheck[] {\n return report.checks.filter(\n (c) => c.status === 'fail' && (c.bucket === 'deployment' || c.bucket === 'app'),\n )\n}\n\n/** Deterministic boot state while the backend probe is in flight (also SSR output). */\nfunction DeploymentChecking() {\n return (\n <BootScreenFrame>\n <Text size=\"sm\" tone=\"muted\">\n {translate('shell.deploymentSetup.checking')}\n </Text>\n </BootScreenFrame>\n )\n}\n\n/**\n * The unified \"Deployment setup\" screen (EC-258): every failing check + its exact\n * fix. Pure/presentational so it is fixture-reproducible and static-render testable.\n */\nexport function DeploymentSetupScreen({\n report,\n onRetry,\n}: {\n report: DeploymentReadinessReport\n onRetry?: () => void\n}) {\n const failures = report.checks.filter((c) => c.status === 'fail')\n return (\n <BootScreenFrame>\n <Card className=\"max-w-2xl border-destructive/40\">\n <CardHeader>\n <CardTitle>{translate('shell.deploymentSetup.title')}</CardTitle>\n </CardHeader>\n <CardContent>\n <Stack gap=\"3\">\n <Text size=\"sm\" tone=\"muted\">\n {translate('shell.deploymentSetup.description')}\n </Text>\n <Stack gap=\"2\" role=\"list\" aria-label={translate('shell.deploymentSetup.issuesLabel')}>\n {failures.map((check) => (\n <CheckRow key={check.id} check={check} />\n ))}\n </Stack>\n {onRetry ? (\n <Button variant=\"outline\" size=\"sm\" onClick={onRetry} className=\"self-start\">\n {translate('shell.deploymentSetup.retry')}\n </Button>\n ) : null}\n </Stack>\n </CardContent>\n </Card>\n </BootScreenFrame>\n )\n}\n\nfunction CheckRow({ check }: { check: ReadinessCheck }) {\n return (\n <div role=\"listitem\" className=\"rounded-md border border-destructive/30 bg-destructive/5 p-2\">\n <Text size=\"xs\" mono className=\"text-destructive\">\n [{check.id}]\n </Text>\n <Text size=\"sm\">{check.detail}</Text>\n {check.fix ? (\n <Text size=\"xs\" tone=\"muted\" className=\"mt-1\">\n {translate('shell.deploymentSetup.fixLabel')}: <Code className=\"break-all\">{check.fix}</Code>\n </Text>\n ) : null}\n </div>\n )\n}\n\nfunction BootScreenFrame({ children }: { children: ReactNode }) {\n return <div className=\"grid min-h-dvh place-items-center bg-background p-6\">{children}</div>\n}\n"]}
@@ -7,7 +7,7 @@ import type { HydratedCms, HydratedProjectSession, OperationResult } from '@elyt
7
7
  * builder from the latest persisted graph revision, the CMS from the saved
8
8
  * schema + documents, publishing from the persisted environment states.
9
9
  * This provider runs the `@elytracms/core/operations` hydration queries
10
- * (`lib/operations/session-hydration`) and — like `BackendGate` — gates its
10
+ * (`lib/operations/session-hydration`) and — like `DeploymentSetupGate` — gates its
11
11
  * children on a deterministic loading state, so fixture content never
12
12
  * flashes (and is never editable) before the persisted state arrives.
13
13
  *
@@ -1 +1 @@
1
- {"version":3,"file":"project-session.js","sourceRoot":"","sources":["../../src/components/project-session.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAE9C,OAAO,EAAE,mBAAmB,EAAE,eAAe,EAAE,kBAAkB,EAAE,MAAM,kCAAkC,CAAA;AAE3G,OAAO,EAAE,gBAAgB,EAAE,MAAM,mCAAmC,CAAA;AACpE,OAAO,EAAE,uBAAuB,EAAE,MAAM,4BAA4B,CAAA;AACpE,OAAO,EAAE,uBAAuB,EAAE,MAAM,iCAAiC,CAAA;AACzE,OAAO,EAAE,qBAAqB,EAAE,MAAM,wCAAwC,CAAA;AAC9E,OAAO,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAA;AA6C5E,MAAM,EAAE,OAAO,EAAE,qBAAqB,EAAE,WAAW,EAAE,iBAAiB,EAAE,GACtE,qBAAqB,CAAsB,gBAAgB,CAAC,CAAA;AAE9D,MAAM,UAAU,sBAAsB,CAAC,EACrC,SAAS,EACT,QAAQ,GAIT;IACC,MAAM,UAAU,GAAG,gBAAgB,EAAE,CAAA;IAErC,MAAM,MAAM,GAAG,WAAW,CACxB,GAAG,EAAE,CAAC,mBAAmB,EAAE,CAAC,IAAI,CAAC,CAAC,EAAE,UAAU,EAAE,EAAE,EAAE,CAAC,kBAAkB,CAAC,UAAU,EAAE,SAAS,CAAC,CAAC,EAC/F,CAAC,SAAS,CAAC,CACZ,CAAA;IACD,wEAAwE;IACxE,sEAAsE;IACtE,0EAA0E;IAC1E,4EAA4E;IAC5E,wEAAwE;IACxE,oEAAoE;IACpE,MAAM,UAAU,GAAG,WAAW,CAAC,CAAC,OAAsC,EAAE,EAAE;QACxE,uBAAuB,CACrB,OAAO,EAAE,GAAG,CAAC,CAAC,CAAC,EAAE,GAAG,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,uBAAuB,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,CAC5E,CAAA;IACH,CAAC,EAAE,EAAE,CAAC,CAAA;IACN,MAAM,EAAE,KAAK,EAAE,KAAK,EAAE,GAAG,iBAAiB,CAAgC,MAAM,EAAE;QAChF,UAAU;QACV,eAAe,EAAE,IAAI;QACrB,OAAO,EAAE,IAAI;QACb,UAAU;KACX,CAAC,CAAA;IAEF,sEAAsE;IACtE,oEAAoE;IACpE,SAAS,CAAC,GAAG,EAAE,CAAC,GAAG,EAAE,CAAC,uBAAuB,CAAC,IAAI,CAAC,EAAE,EAAE,CAAC,CAAA;IAExD,MAAM,OAAO,GAAG,WAAW,CAAC,KAAK,IAAkD,EAAE;QACnF,IAAI,UAAU;YAAE,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,CAAA;QAChD,MAAM,EAAE,UAAU,EAAE,GAAG,MAAM,mBAAmB,EAAE,CAAA;QAClD,MAAM,MAAM,GAAG,MAAM,eAAe,CAAC,UAAU,EAAE,SAAS,CAAC,CAAA;QAC3D,uEAAuE;QACvE,IAAI,MAAM,CAAC,EAAE,IAAI,MAAM,CAAC,KAAK,KAAK,IAAI;YACpC,uBAAuB,CAAC,EAAE,GAAG,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,uBAAuB,EAAE,EAAE,CAAC,CAAA;QACjF,OAAO,MAAM,CAAA;IACf,CAAC,EAAE,CAAC,UAAU,EAAE,SAAS,CAAC,CAAC,CAAA;IAE3B,OAAO,CACL,KAAC,iBAAiB,IAAC,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,YAC1C,CAAC,OAAO,EAAE,EAAE,CAAC,CACZ,KAAC,qBAAqB,CAAC,QAAQ,IAAC,KAAK,EAAE,EAAE,OAAO,EAAE,OAAO,EAAE,YACxD,QAAQ,GACsB,CAClC,GACiB,CACrB,CAAA;AACH,CAAC;AAED,OAAO,EAAE,iBAAiB,EAAE,CAAA;AAE5B;;;;;;;;GAQG;AACH,MAAM,UAAU,cAAc,CAAC,EAC7B,QAAQ,GAGT;IACC,MAAM,EAAE,OAAO,EAAE,GAAG,iBAAiB,EAAE,CAAA;IACvC,MAAM,UAAU,GAAG,gBAAgB,EAAE,CAAA;IACrC,MAAM,EAAE,KAAK,EAAE,KAAK,EAAE,GAAG,iBAAiB,CAAqB,OAAO,EAAE;QACtE,UAAU;QACV,eAAe,EAAE,IAAI;KACtB,CAAC,CAAA;IACF,OAAO,CACL,KAAC,iBAAiB,IAAC,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,YAC1C,QAAQ,GACS,CACrB,CAAA;AACH,CAAC","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 `BackendGate` — 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"]}
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 BackendGate). */
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":"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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 BackendGate). */\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"]}
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,3 @@
1
+ export declare function InviteUserBar({ projectId }: {
2
+ projectId: string;
3
+ }): import("react").JSX.Element;
@@ -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":"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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 { 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'\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 role = useProjectRole(projectId)\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 <CollectionViewProvider source={source}>\n <LoadedCollectionTable projectId={projectId} />\n </CollectionViewProvider>\n )\n}\n"]}
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
- /** Structured sign-in outcome — invalid credentials are a state, not a throw. */
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/** Structured sign-in outcome — invalid credentials are a state, not a throw. */\nexport type SignInResult =\n | { readonly ok: true; readonly session: AuthSession }\n | {\n readonly ok: false\n readonly reason: 'invalid-credentials' | 'account-exists' | 'unavailable'\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"]}
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
  }