@elytracms/studio 0.0.76 → 0.0.77
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/cms.ts +11 -107
- package/convex/schema.ts +6 -5
- package/dist/components/cms/doc-state.d.ts +3 -2
- package/dist/components/cms/doc-state.js +3 -2
- package/dist/components/cms/doc-state.js.map +1 -1
- package/dist/lib/auth/route-guard.d.ts +24 -3
- package/dist/lib/auth/route-guard.js +25 -4
- package/dist/lib/auth/route-guard.js.map +1 -1
- package/dist/router/root.js +13 -3
- package/dist/router/root.js.map +1 -1
- package/dist/ui/status-dot.d.ts +11 -8
- package/dist/ui/status-dot.js +7 -5
- package/dist/ui/status-dot.js.map +1 -1
- package/package.json +2 -2
package/convex/cms.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { v } from 'convex/values'
|
|
2
2
|
import { documentsSemanticEqual, foldDocument, unfoldDocument } from '@elytracms/core/cms-core'
|
|
3
3
|
import type { CmsDocument } from '@elytracms/core/cms-core'
|
|
4
|
-
import {
|
|
5
|
-
import { internalMutation, mutation, query } from './functions'
|
|
4
|
+
import { mutation, query } from './functions'
|
|
6
5
|
import { requireCapability } from './guard'
|
|
7
6
|
import { assertWithinWriteLimits } from './write-limits'
|
|
8
7
|
|
|
@@ -330,109 +329,14 @@ export const unpublishDocument = mutation({
|
|
|
330
329
|
})
|
|
331
330
|
|
|
332
331
|
/**
|
|
333
|
-
*
|
|
334
|
-
*
|
|
335
|
-
*
|
|
336
|
-
*
|
|
337
|
-
*
|
|
338
|
-
*
|
|
339
|
-
*
|
|
340
|
-
* `
|
|
341
|
-
*
|
|
342
|
-
*
|
|
343
|
-
* INTERNAL (scheduler/CLI only, EC-150): the operator runs it once per deployment
|
|
344
|
-
* after `convex deploy` (e.g. `npx convex run cms:backfillPublishedAtColumn`); it is
|
|
345
|
-
* not client-callable. Returns a small audit summary (`{ scanned, stamped }`).
|
|
346
|
-
*/
|
|
347
|
-
export const backfillPublishedAtColumn = internalMutation({
|
|
348
|
-
args: {},
|
|
349
|
-
handler: async (ctx) => {
|
|
350
|
-
const docs = await ctx.db.query('cmsDocuments').collect()
|
|
351
|
-
let scanned = 0
|
|
352
|
-
let stamped = 0
|
|
353
|
-
for (const doc of docs) {
|
|
354
|
-
scanned += 1
|
|
355
|
-
// Skip the version read for the certain-skip cases (draft / already-stamped);
|
|
356
|
-
// otherwise resolve the pinned version and let the pure helper decide.
|
|
357
|
-
if (doc.publishedVersion === undefined || doc.publishedAt !== undefined) continue
|
|
358
|
-
const pinned = await ctx.db
|
|
359
|
-
.query('documentVersions')
|
|
360
|
-
.withIndex('by_doc_version', (q) =>
|
|
361
|
-
q
|
|
362
|
-
.eq('collection', doc.collection)
|
|
363
|
-
.eq('docId', doc.docId)
|
|
364
|
-
.eq('version', doc.publishedVersion as number),
|
|
365
|
-
)
|
|
366
|
-
.unique()
|
|
367
|
-
const publishedAt = backfillPublishedAt(
|
|
368
|
-
{ publishedVersion: doc.publishedVersion, publishedAt: doc.publishedAt },
|
|
369
|
-
pinned?.createdAt,
|
|
370
|
-
)
|
|
371
|
-
if (publishedAt === undefined) continue
|
|
372
|
-
await ctx.db.patch(doc._id, { publishedAt })
|
|
373
|
-
stamped += 1
|
|
374
|
-
}
|
|
375
|
-
return { scanned, stamped }
|
|
376
|
-
},
|
|
377
|
-
})
|
|
378
|
-
|
|
379
|
-
/**
|
|
380
|
-
* EC-449 one-time backfill — materialize `hasUnpublishedChanges` on documents
|
|
381
|
-
* published BEFORE the flag was recorded, so the amber "changed" dot is correct
|
|
382
|
-
* immediately after the repoint instead of understating until the next save. For
|
|
383
|
-
* each currently-published doc that has NO flag yet, runs the SAME semantic
|
|
384
|
-
* compare the write chokepoint runs going forward (`documentsSemanticEqual`,
|
|
385
|
-
* schema-less lens) of the stored draft against its pinned published snapshot —
|
|
386
|
-
* the full compare was chosen over the ratified default-`false` fallback because
|
|
387
|
-
* it costs one indexed version read + one compare per unstamped published doc
|
|
388
|
-
* (the same order of work as `cms:backfillPublishedAtColumn`). The decision is
|
|
389
|
-
* the pure, unit-tested `backfillHasUnpublishedChanges`
|
|
390
|
-
* (`@elytracms/core/persistence`): an already-flagged doc, an unpublished doc,
|
|
391
|
-
* or one whose pinned version row is missing is SKIPPED, so a re-run performs
|
|
392
|
-
* ZERO writes (idempotent). Touches ONLY `hasUnpublishedChanges` (never the
|
|
393
|
-
* pin/`document`/timestamps), so the write-chokepoint trigger fires no host
|
|
394
|
-
* revalidation (`publishedVersion` unchanged — see triggers.ts).
|
|
395
|
-
*
|
|
396
|
-
* INTERNAL (scheduler/CLI only, EC-150): the operator runs it once per deployment
|
|
397
|
-
* after `convex deploy` (e.g. `npx convex run cms:backfillHasUnpublishedChangesColumn`);
|
|
398
|
-
* it is not client-callable. Returns a small audit summary (`{ scanned, stamped }`).
|
|
332
|
+
* One-time data migrations (EC-450 principle): a migration mutation ships
|
|
333
|
+
* transiently — added when a schema/behavior change needs to backfill existing
|
|
334
|
+
* deployments, then REMOVED once every deployment has confirmed running it. It
|
|
335
|
+
* never lingers in the permanent `@elytracms/*` shipped surface (a brand-new
|
|
336
|
+
* consumer never needs it — nothing pre-dates the change it backfills). Pattern:
|
|
337
|
+
* an `internalMutation` here delegating its per-row decision to a pure, unit-tested
|
|
338
|
+
* core in `@elytracms/core/persistence`; the operator runs it once per deployment
|
|
339
|
+
* via `npx convex run cms:<name>` after `convex deploy`, confirms all deployments,
|
|
340
|
+
* then deletes both the mutation and its core in one removal pass (see EC-447/449
|
|
341
|
+
* → EC-450 for the worked example).
|
|
399
342
|
*/
|
|
400
|
-
export const backfillHasUnpublishedChangesColumn = internalMutation({
|
|
401
|
-
args: {},
|
|
402
|
-
handler: async (ctx) => {
|
|
403
|
-
const docs = await ctx.db.query('cmsDocuments').collect()
|
|
404
|
-
let scanned = 0
|
|
405
|
-
let stamped = 0
|
|
406
|
-
for (const doc of docs) {
|
|
407
|
-
scanned += 1
|
|
408
|
-
// Skip the version read for the certain-skip cases (draft / already-flagged);
|
|
409
|
-
// otherwise resolve the pinned snapshot, compare once, and let the pure
|
|
410
|
-
// helper decide.
|
|
411
|
-
if (doc.publishedVersion === undefined || doc.hasUnpublishedChanges !== undefined) continue
|
|
412
|
-
const pinned = await ctx.db
|
|
413
|
-
.query('documentVersions')
|
|
414
|
-
.withIndex('by_doc_version', (q) =>
|
|
415
|
-
q
|
|
416
|
-
.eq('collection', doc.collection)
|
|
417
|
-
.eq('docId', doc.docId)
|
|
418
|
-
.eq('version', doc.publishedVersion as number),
|
|
419
|
-
)
|
|
420
|
-
.unique()
|
|
421
|
-
const differs = pinned
|
|
422
|
-
? !documentsSemanticEqual(
|
|
423
|
-
undefined,
|
|
424
|
-
unfoldDocument(pinned.snapshot as CmsDocument),
|
|
425
|
-
unfoldDocument(doc.document as CmsDocument),
|
|
426
|
-
)
|
|
427
|
-
: undefined
|
|
428
|
-
const hasUnpublishedChanges = backfillHasUnpublishedChanges(
|
|
429
|
-
{ publishedVersion: doc.publishedVersion, hasUnpublishedChanges: doc.hasUnpublishedChanges },
|
|
430
|
-
differs,
|
|
431
|
-
)
|
|
432
|
-
if (hasUnpublishedChanges === undefined) continue
|
|
433
|
-
await ctx.db.patch(doc._id, { hasUnpublishedChanges })
|
|
434
|
-
stamped += 1
|
|
435
|
-
}
|
|
436
|
-
return { scanned, stamped }
|
|
437
|
-
},
|
|
438
|
-
})
|
package/convex/schema.ts
CHANGED
|
@@ -144,8 +144,9 @@ export default defineSchema({
|
|
|
144
144
|
* publish-action ISO timestamp captured AT publish (NOT a revision `createdAt`),
|
|
145
145
|
* written set-once on publish and cleared on unpublish. A distinct materialized
|
|
146
146
|
* value alongside `createdAt`/`updatedAt`, backing the `$publishedAt` list column.
|
|
147
|
-
* Optional (additive): existing rows stay valid
|
|
148
|
-
*
|
|
147
|
+
* Optional (additive): existing rows stay valid. Pre-EC-447 published docs on the
|
|
148
|
+
* three original deployments were close-reconstructed by a one-time backfill,
|
|
149
|
+
* since removed (EC-450 — ship-then-remove; a fresh deployment never needs it).
|
|
149
150
|
*/
|
|
150
151
|
publishedAt: v.optional(v.string()),
|
|
151
152
|
/**
|
|
@@ -155,9 +156,9 @@ export default defineSchema({
|
|
|
155
156
|
* against the pinned published snapshot; publish writes `false`; unpublish
|
|
156
157
|
* clears it. NOT derived from `updatedAt > publishedAt` (set-once EC-447
|
|
157
158
|
* semantics would false-positive on every republish). Optional (additive):
|
|
158
|
-
* existing rows stay valid
|
|
159
|
-
*
|
|
160
|
-
*
|
|
159
|
+
* existing rows stay valid. Pre-EC-449 published docs on the three original
|
|
160
|
+
* deployments were materialized by a one-time backfill, since removed
|
|
161
|
+
* (EC-450 — ship-then-remove; a fresh deployment never needs it).
|
|
161
162
|
*/
|
|
162
163
|
hasUnpublishedChanges: v.optional(v.boolean()),
|
|
163
164
|
createdAt: v.string(),
|
|
@@ -49,11 +49,12 @@ export declare function docStateLabel(state: DocState): string;
|
|
|
49
49
|
* The list `$state` cell indicator (EC-448) — the compact {@link StatusDot}
|
|
50
50
|
* replacing {@link DocStateBadge} in the collection table's FIXED narrow leading
|
|
51
51
|
* status column (checkbox → status-dot → title). Tone per {@link stateTone}; the
|
|
52
|
-
* localized state label rides the dot's `aria-label
|
|
52
|
+
* localized state label rides the dot's `aria-label` + hover pill (EC-452: no
|
|
53
|
+
* native `title` — it duplicated the pill's label on mouse hover).
|
|
53
54
|
*
|
|
54
55
|
* Rendered with the NON-focusable `StatusDot` variant (`interactive={false}`) so a
|
|
55
56
|
* long list adds no tab stop per row (EC-446 operator note); the label stays
|
|
56
|
-
* reachable via the accessible name +
|
|
57
|
+
* reachable via the accessible name + hover pill. A fixed-size dot (never a
|
|
57
58
|
* text-width badge) is what keeps the title column's left edge consistent across
|
|
58
59
|
* collections (EC-448 §4 — the alignment fix).
|
|
59
60
|
*/
|
|
@@ -57,11 +57,12 @@ export function docStateLabel(state) {
|
|
|
57
57
|
* The list `$state` cell indicator (EC-448) — the compact {@link StatusDot}
|
|
58
58
|
* replacing {@link DocStateBadge} in the collection table's FIXED narrow leading
|
|
59
59
|
* status column (checkbox → status-dot → title). Tone per {@link stateTone}; the
|
|
60
|
-
* localized state label rides the dot's `aria-label
|
|
60
|
+
* localized state label rides the dot's `aria-label` + hover pill (EC-452: no
|
|
61
|
+
* native `title` — it duplicated the pill's label on mouse hover).
|
|
61
62
|
*
|
|
62
63
|
* Rendered with the NON-focusable `StatusDot` variant (`interactive={false}`) so a
|
|
63
64
|
* long list adds no tab stop per row (EC-446 operator note); the label stays
|
|
64
|
-
* reachable via the accessible name +
|
|
65
|
+
* reachable via the accessible name + hover pill. A fixed-size dot (never a
|
|
65
66
|
* text-width badge) is what keeps the title column's left edge consistent across
|
|
66
67
|
* collections (EC-448 §4 — the alignment fix).
|
|
67
68
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"doc-state.js","sourceRoot":"","sources":["../../../src/components/cms/doc-state.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,cAAc,CAAA;AAC1C,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAA;AAEvD,OAAO,EAAE,SAAS,EAAE,MAAM,4BAA4B,CAAA;AAEtD;;;;;;;;;GASG;AACH,MAAM,UAAU,aAAa,CAAC,EAAE,SAAS,EAA0B;IACjE,OAAO,SAAS,CAAC,CAAC,CAAC,CACjB,MAAC,KAAK,IAAC,OAAO,EAAC,SAAS,aACtB,KAAC,GAAG,IAAC,SAAS,EAAC,QAAQ,GAAG,OAAE,SAAS,CAAC,wBAAwB,CAAC,IACzD,CACT,CAAC,CAAC,CAAC,CACF,MAAC,KAAK,IAAC,OAAO,EAAC,SAAS,aACtB,KAAC,MAAM,IAAC,SAAS,EAAC,QAAQ,GAAG,OAAE,SAAS,CAAC,oBAAoB,CAAC,IACxD,CACT,CAAA;AACH,CAAC;AAoBD;;;;;GAKG;AACH,MAAM,UAAU,UAAU,CAAC,SAAkB,EAAE,qBAA8B;IAC3E,IAAI,CAAC,SAAS;QAAE,OAAO,OAAO,CAAA;IAC9B,OAAO,qBAAqB,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,WAAW,CAAA;AACxD,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,SAAS,CAAC,KAAe;IACvC,QAAQ,KAAK,EAAE,CAAC;QACd,KAAK,WAAW;YACd,OAAO,SAAS,CAAA;QAClB,KAAK,SAAS;YACZ,OAAO,SAAS,CAAA;QAClB,KAAK,OAAO;YACV,OAAO,OAAO,CAAA;IAClB,CAAC;AACH,CAAC;AAED,kFAAkF;AAClF,MAAM,UAAU,aAAa,CAAC,KAAe;IAC3C,QAAQ,KAAK,EAAE,CAAC;QACd,KAAK,WAAW;YACd,OAAO,SAAS,CAAC,wBAAwB,CAAC,CAAA;QAC5C,KAAK,SAAS;YACZ,OAAO,SAAS,CAAC,sBAAsB,CAAC,CAAA;QAC1C,KAAK,OAAO;YACV,OAAO,SAAS,CAAC,oBAAoB,CAAC,CAAA;IAC1C,CAAC;AACH,CAAC;AAED
|
|
1
|
+
{"version":3,"file":"doc-state.js","sourceRoot":"","sources":["../../../src/components/cms/doc-state.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,cAAc,CAAA;AAC1C,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAA;AAEvD,OAAO,EAAE,SAAS,EAAE,MAAM,4BAA4B,CAAA;AAEtD;;;;;;;;;GASG;AACH,MAAM,UAAU,aAAa,CAAC,EAAE,SAAS,EAA0B;IACjE,OAAO,SAAS,CAAC,CAAC,CAAC,CACjB,MAAC,KAAK,IAAC,OAAO,EAAC,SAAS,aACtB,KAAC,GAAG,IAAC,SAAS,EAAC,QAAQ,GAAG,OAAE,SAAS,CAAC,wBAAwB,CAAC,IACzD,CACT,CAAC,CAAC,CAAC,CACF,MAAC,KAAK,IAAC,OAAO,EAAC,SAAS,aACtB,KAAC,MAAM,IAAC,SAAS,EAAC,QAAQ,GAAG,OAAE,SAAS,CAAC,oBAAoB,CAAC,IACxD,CACT,CAAA;AACH,CAAC;AAoBD;;;;;GAKG;AACH,MAAM,UAAU,UAAU,CAAC,SAAkB,EAAE,qBAA8B;IAC3E,IAAI,CAAC,SAAS;QAAE,OAAO,OAAO,CAAA;IAC9B,OAAO,qBAAqB,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,WAAW,CAAA;AACxD,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,SAAS,CAAC,KAAe;IACvC,QAAQ,KAAK,EAAE,CAAC;QACd,KAAK,WAAW;YACd,OAAO,SAAS,CAAA;QAClB,KAAK,SAAS;YACZ,OAAO,SAAS,CAAA;QAClB,KAAK,OAAO;YACV,OAAO,OAAO,CAAA;IAClB,CAAC;AACH,CAAC;AAED,kFAAkF;AAClF,MAAM,UAAU,aAAa,CAAC,KAAe;IAC3C,QAAQ,KAAK,EAAE,CAAC;QACd,KAAK,WAAW;YACd,OAAO,SAAS,CAAC,wBAAwB,CAAC,CAAA;QAC5C,KAAK,SAAS;YACZ,OAAO,SAAS,CAAC,sBAAsB,CAAC,CAAA;QAC1C,KAAK,OAAO;YACV,OAAO,SAAS,CAAC,oBAAoB,CAAC,CAAA;IAC1C,CAAC;AACH,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,WAAW,CAAC,EAAE,KAAK,EAAuB;IACxD,OAAO,KAAC,SAAS,IAAC,IAAI,EAAE,SAAS,CAAC,KAAK,CAAC,EAAE,KAAK,EAAE,aAAa,CAAC,KAAK,CAAC,EAAE,WAAW,EAAE,KAAK,GAAI,CAAA;AAC/F,CAAC","sourcesContent":["import { Eye, EyeOff } from 'lucide-react'\nimport { Badge, StatusDot } from '@elytracms/studio/ui'\nimport type { StatusTone } from '@elytracms/studio/ui'\nimport { translate } from '@elytracms/studio/lib/i18n'\n\n/**\n * Draft vs published indicator (brief §8.4, EC-077). Published-ness is the\n * version pointer (EC-224) — the caller passes `cms.isPublished(ref)`, not the\n * flat `state` enum. Copy is localized (EC-241/EC-346) through the message\n * catalog — never hardcoded English.\n *\n * Still used by the detail-editor header (a single prominent badge). The dense\n * LIST `$state` column swapped to the compact {@link DocStateDot} (EC-448); this\n * badge is untouched for its non-list consumers.\n */\nexport function DocStateBadge({ published }: { published: boolean }) {\n return published ? (\n <Badge variant=\"success\">\n <Eye className=\"size-3\" /> {translate('cms.docState.published')}\n </Badge>\n ) : (\n <Badge variant=\"warning\">\n <EyeOff className=\"size-3\" /> {translate('cms.docState.draft')}\n </Badge>\n )\n}\n\n/**\n * The publication-state vocabulary a document's status expresses (EC-448 — owner\n * ratified 2026-07-17). Three states:\n *\n * - `published` — live, no pending changes.\n * - `changed` — published, but the working draft has unpublished changes (\"Changed\").\n * - `draft` — never published / currently unpublished.\n *\n * Since EC-449 the list feeds all THREE: `changed` comes from the cheap per-row\n * `hasUnpublishedChanges` signal (materialized at the write chokepoint via the\n * editor's own snapshot-vs-draft semantic compare — never re-derived per render,\n * and never the `updatedAt > publishedAt` heuristic, which false-positives on\n * every republish). {@link docStateOf} is the one derivation. This is the exact\n * `DocStateBadge` published/draft vocabulary, plus the ratified `changed` middle\n * state — nothing re-derived.\n */\nexport type DocState = 'published' | 'changed' | 'draft'\n\n/**\n * Derive a document's {@link DocState} from the two cheap per-row signals\n * (EC-449, the ratified three-way): not published → `draft`; published with\n * unpublished draft changes → `changed` (amber); else `published` (green).\n * Pure — the single derivation the list's status cell feeds `DocStateDot` with.\n */\nexport function docStateOf(published: boolean, hasUnpublishedChanges: boolean): DocState {\n if (!published) return 'draft'\n return hasUnpublishedChanges ? 'changed' : 'published'\n}\n\n/**\n * Map a doc-state to the shared {@link StatusTone} (EC-448) — pure, so it unit-tests\n * for every state without a DOM. The ratified mapping (owner, 2026-07-17): published\n * → `success` (green), changed → `warning` (amber), draft → `muted` (quiet, present\n * but low-key).\n */\nexport function stateTone(state: DocState): StatusTone {\n switch (state) {\n case 'published':\n return 'success'\n case 'changed':\n return 'warning'\n case 'draft':\n return 'muted'\n }\n}\n\n/** The localized label for a doc-state (EC-448) — the dot's hover/`aria` text. */\nexport function docStateLabel(state: DocState): string {\n switch (state) {\n case 'published':\n return translate('cms.docState.published')\n case 'changed':\n return translate('cms.docState.changed')\n case 'draft':\n return translate('cms.docState.draft')\n }\n}\n\n/**\n * The list `$state` cell indicator (EC-448) — the compact {@link StatusDot}\n * replacing {@link DocStateBadge} in the collection table's FIXED narrow leading\n * status column (checkbox → status-dot → title). Tone per {@link stateTone}; the\n * localized state label rides the dot's `aria-label` + hover pill (EC-452: no\n * native `title` — it duplicated the pill's label on mouse hover).\n *\n * Rendered with the NON-focusable `StatusDot` variant (`interactive={false}`) so a\n * long list adds no tab stop per row (EC-446 operator note); the label stays\n * reachable via the accessible name + hover pill. A fixed-size dot (never a\n * text-width badge) is what keeps the title column's left edge consistent across\n * collections (EC-448 §4 — the alignment fix).\n */\nexport function DocStateDot({ state }: { state: DocState }) {\n return <StatusDot tone={stateTone(state)} label={docStateLabel(state)} interactive={false} />\n}\n"]}
|
|
@@ -22,12 +22,33 @@ export type RouteGuardDecision =
|
|
|
22
22
|
readonly kind: 'redirect-to-sign-in';
|
|
23
23
|
readonly redirect: string;
|
|
24
24
|
}
|
|
25
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* Authenticated user on the sign-in screen → send them to the validated
|
|
27
|
+
* still-pending `?redirect` target, falling back home (EC-451). Carrying
|
|
28
|
+
* the target IN the decision is what makes the post-sign-in navigation
|
|
29
|
+
* order-independent: whichever navigation wins the race — `SignInView`'s
|
|
30
|
+
* own `postSignInTarget` navigate or this gate bounce (dev auto-login, a
|
|
31
|
+
* fast real sign-in) — both compute the same validated target.
|
|
32
|
+
*/
|
|
26
33
|
| {
|
|
27
34
|
readonly kind: 'redirect-home';
|
|
35
|
+
readonly target: string;
|
|
28
36
|
};
|
|
29
|
-
export declare function resolveRouteGuard(pathname: string, status: AuthStatus): RouteGuardDecision;
|
|
30
|
-
/**
|
|
37
|
+
export declare function resolveRouteGuard(pathname: string, status: AuthStatus, redirect?: string): RouteGuardDecision;
|
|
38
|
+
/**
|
|
39
|
+
* Where to navigate after a successful sign-in. The ONE validator for the
|
|
40
|
+
* `?redirect` search param — an attacker-suppliable input — shared by
|
|
41
|
+
* `SignInView`'s post-sign-in navigate and the `AuthGate`'s signed-in bounce
|
|
42
|
+
* (EC-451). Rejects everything but a same-origin relative path:
|
|
43
|
+
*
|
|
44
|
+
* - no leading `/` → absolute URLs (`https://evil.example`), `javascript:` …;
|
|
45
|
+
* - `//…` → protocol-relative external URLs;
|
|
46
|
+
* - any `\` → browsers normalize `\`→`/` when resolving a URL, so
|
|
47
|
+
* `/\evil.example` would become `//evil.example` (EC-304 B-3 precedent);
|
|
48
|
+
* - any tab/CR/LF → the WHATWG URL parser strips them, so `/\t\/evil.example`
|
|
49
|
+
* would collapse to `//evil.example`;
|
|
50
|
+
* - the sign-in screen itself → circular.
|
|
51
|
+
*/
|
|
31
52
|
export declare function postSignInTarget(redirect: string | undefined): string;
|
|
32
53
|
/** Map a session onto the operations-layer {@link Principal}. */
|
|
33
54
|
export declare function sessionPrincipal(session: AuthSession): Principal;
|
|
@@ -13,7 +13,7 @@ const PUBLIC_PATHS = new Set([SIGN_IN_PATH, '/styleguide']);
|
|
|
13
13
|
export function isPublicPath(pathname) {
|
|
14
14
|
return PUBLIC_PATHS.has(normalize(pathname));
|
|
15
15
|
}
|
|
16
|
-
export function resolveRouteGuard(pathname, status) {
|
|
16
|
+
export function resolveRouteGuard(pathname, status, redirect) {
|
|
17
17
|
const path = normalize(pathname);
|
|
18
18
|
if (status === 'loading') {
|
|
19
19
|
// Public paths render immediately; protected ones wait for the session.
|
|
@@ -26,15 +26,36 @@ export function resolveRouteGuard(pathname, status) {
|
|
|
26
26
|
}
|
|
27
27
|
// signed-in
|
|
28
28
|
if (path === SIGN_IN_PATH)
|
|
29
|
-
return { kind: 'redirect-home' };
|
|
29
|
+
return { kind: 'redirect-home', target: postSignInTarget(redirect) };
|
|
30
30
|
return { kind: 'allow' };
|
|
31
31
|
}
|
|
32
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* Where to navigate after a successful sign-in. The ONE validator for the
|
|
34
|
+
* `?redirect` search param — an attacker-suppliable input — shared by
|
|
35
|
+
* `SignInView`'s post-sign-in navigate and the `AuthGate`'s signed-in bounce
|
|
36
|
+
* (EC-451). Rejects everything but a same-origin relative path:
|
|
37
|
+
*
|
|
38
|
+
* - no leading `/` → absolute URLs (`https://evil.example`), `javascript:` …;
|
|
39
|
+
* - `//…` → protocol-relative external URLs;
|
|
40
|
+
* - any `\` → browsers normalize `\`→`/` when resolving a URL, so
|
|
41
|
+
* `/\evil.example` would become `//evil.example` (EC-304 B-3 precedent);
|
|
42
|
+
* - any tab/CR/LF → the WHATWG URL parser strips them, so `/\t\/evil.example`
|
|
43
|
+
* would collapse to `//evil.example`;
|
|
44
|
+
* - the sign-in screen itself → circular.
|
|
45
|
+
*/
|
|
33
46
|
export function postSignInTarget(redirect) {
|
|
34
|
-
if (!redirect || !
|
|
47
|
+
if (!redirect || !isSameOriginPath(redirect))
|
|
35
48
|
return '/';
|
|
36
49
|
return normalize(redirect) === SIGN_IN_PATH ? '/' : redirect;
|
|
37
50
|
}
|
|
51
|
+
/** Same-origin relative-path check for {@link postSignInTarget}. */
|
|
52
|
+
function isSameOriginPath(target) {
|
|
53
|
+
if (!target.startsWith('/') || target.startsWith('//'))
|
|
54
|
+
return false;
|
|
55
|
+
// No legitimate studio path contains a backslash or a strippable control
|
|
56
|
+
// character — reject outright instead of normalizing (fallback is home).
|
|
57
|
+
return !/[\\\t\r\n]/.test(target);
|
|
58
|
+
}
|
|
38
59
|
/** Map a session onto the operations-layer {@link Principal}. */
|
|
39
60
|
export function sessionPrincipal(session) {
|
|
40
61
|
return {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"route-guard.js","sourceRoot":"","sources":["../../../src/lib/auth/route-guard.ts"],"names":[],"mappings":"AAGA;;;;GAIG;AAEH,MAAM,CAAC,MAAM,YAAY,GAAG,UAAU,CAAA;AAEtC;;;;GAIG;AACH,MAAM,YAAY,GAAwB,IAAI,GAAG,CAAC,CAAC,YAAY,EAAE,aAAa,CAAC,CAAC,CAAA;AAEhF,MAAM,UAAU,YAAY,CAAC,QAAgB;IAC3C,OAAO,YAAY,CAAC,GAAG,CAAC,SAAS,CAAC,QAAQ,CAAC,CAAC,CAAA;AAC9C,CAAC;
|
|
1
|
+
{"version":3,"file":"route-guard.js","sourceRoot":"","sources":["../../../src/lib/auth/route-guard.ts"],"names":[],"mappings":"AAGA;;;;GAIG;AAEH,MAAM,CAAC,MAAM,YAAY,GAAG,UAAU,CAAA;AAEtC;;;;GAIG;AACH,MAAM,YAAY,GAAwB,IAAI,GAAG,CAAC,CAAC,YAAY,EAAE,aAAa,CAAC,CAAC,CAAA;AAEhF,MAAM,UAAU,YAAY,CAAC,QAAgB;IAC3C,OAAO,YAAY,CAAC,GAAG,CAAC,SAAS,CAAC,QAAQ,CAAC,CAAC,CAAA;AAC9C,CAAC;AAqBD,MAAM,UAAU,iBAAiB,CAC/B,QAAgB,EAChB,MAAkB,EAClB,QAAiB;IAEjB,MAAM,IAAI,GAAG,SAAS,CAAC,QAAQ,CAAC,CAAA;IAChC,IAAI,MAAM,KAAK,SAAS,EAAE,CAAC;QACzB,wEAAwE;QACxE,OAAO,YAAY,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,CAAA;IACrE,CAAC;IACD,IAAI,MAAM,KAAK,YAAY,EAAE,CAAC;QAC5B,IAAI,YAAY,CAAC,IAAI,CAAC;YAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,CAAA;QAChD,OAAO,EAAE,IAAI,EAAE,qBAAqB,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAA;IACxD,CAAC;IACD,YAAY;IACZ,IAAI,IAAI,KAAK,YAAY;QAAE,OAAO,EAAE,IAAI,EAAE,eAAe,EAAE,MAAM,EAAE,gBAAgB,CAAC,QAAQ,CAAC,EAAE,CAAA;IAC/F,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,CAAA;AAC1B,CAAC;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,gBAAgB,CAAC,QAA4B;IAC3D,IAAI,CAAC,QAAQ,IAAI,CAAC,gBAAgB,CAAC,QAAQ,CAAC;QAAE,OAAO,GAAG,CAAA;IACxD,OAAO,SAAS,CAAC,QAAQ,CAAC,KAAK,YAAY,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,QAAQ,CAAA;AAC9D,CAAC;AAED,oEAAoE;AACpE,SAAS,gBAAgB,CAAC,MAAc;IACtC,IAAI,CAAC,MAAM,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,MAAM,CAAC,UAAU,CAAC,IAAI,CAAC;QAAE,OAAO,KAAK,CAAA;IACpE,yEAAyE;IACzE,yEAAyE;IACzE,OAAO,CAAC,YAAY,CAAC,IAAI,CAAC,MAAM,CAAC,CAAA;AACnC,CAAC;AAED,iEAAiE;AACjE,MAAM,UAAU,gBAAgB,CAAC,OAAoB;IACnD,OAAO;QACL,EAAE,EAAE,OAAO,CAAC,IAAI,CAAC,EAAE;QACnB,GAAG,CAAC,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,OAAO,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACzD,GAAG,CAAC,OAAO,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,OAAO,CAAC,IAAI,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KACzE,CAAA;AACH,CAAC;AAED,SAAS,SAAS,CAAC,QAAgB;IACjC,IAAI,QAAQ,CAAC,MAAM,GAAG,CAAC,IAAI,QAAQ,CAAC,QAAQ,CAAC,GAAG,CAAC;QAAE,OAAO,QAAQ,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAA;IAC/E,OAAO,QAAQ,CAAA;AACjB,CAAC","sourcesContent":["import type { Principal } from '@elytracms/core/operations'\nimport type { AuthSession } from './auth-adapter'\n\n/**\n * Pure route-guard + principal-mapping logic (EC-150), kept free of React and\n * router types so it is unit-testable and deterministic. The `__root` layout\n * applies {@link resolveRouteGuard} on every location change.\n */\n\nexport const SIGN_IN_PATH = '/sign-in'\n\n/**\n * Paths reachable without a session: the sign-in screen itself and the\n * component styleguide (a static internal reference, not a project surface).\n * Everything else — project surfaces, the launcher — requires a session.\n */\nconst PUBLIC_PATHS: ReadonlySet<string> = new Set([SIGN_IN_PATH, '/styleguide'])\n\nexport function isPublicPath(pathname: string): boolean {\n return PUBLIC_PATHS.has(normalize(pathname))\n}\n\nexport type AuthStatus = 'loading' | 'signed-in' | 'signed-out'\n\nexport type RouteGuardDecision =\n /** Render the route. */\n | { readonly kind: 'allow' }\n /** Session still resolving — render the deterministic checking state. */\n | { readonly kind: 'pending' }\n /** Unauthenticated on a protected path → sign-in, preserving the target. */\n | { readonly kind: 'redirect-to-sign-in'; readonly redirect: string }\n /**\n * Authenticated user on the sign-in screen → send them to the validated\n * still-pending `?redirect` target, falling back home (EC-451). Carrying\n * the target IN the decision is what makes the post-sign-in navigation\n * order-independent: whichever navigation wins the race — `SignInView`'s\n * own `postSignInTarget` navigate or this gate bounce (dev auto-login, a\n * fast real sign-in) — both compute the same validated target.\n */\n | { readonly kind: 'redirect-home'; readonly target: string }\n\nexport function resolveRouteGuard(\n pathname: string,\n status: AuthStatus,\n redirect?: string,\n): RouteGuardDecision {\n const path = normalize(pathname)\n if (status === 'loading') {\n // Public paths render immediately; protected ones wait for the session.\n return isPublicPath(path) ? { kind: 'allow' } : { kind: 'pending' }\n }\n if (status === 'signed-out') {\n if (isPublicPath(path)) return { kind: 'allow' }\n return { kind: 'redirect-to-sign-in', redirect: path }\n }\n // signed-in\n if (path === SIGN_IN_PATH) return { kind: 'redirect-home', target: postSignInTarget(redirect) }\n return { kind: 'allow' }\n}\n\n/**\n * Where to navigate after a successful sign-in. The ONE validator for the\n * `?redirect` search param — an attacker-suppliable input — shared by\n * `SignInView`'s post-sign-in navigate and the `AuthGate`'s signed-in bounce\n * (EC-451). Rejects everything but a same-origin relative path:\n *\n * - no leading `/` → absolute URLs (`https://evil.example`), `javascript:` …;\n * - `//…` → protocol-relative external URLs;\n * - any `\\` → browsers normalize `\\`→`/` when resolving a URL, so\n * `/\\evil.example` would become `//evil.example` (EC-304 B-3 precedent);\n * - any tab/CR/LF → the WHATWG URL parser strips them, so `/\\t\\/evil.example`\n * would collapse to `//evil.example`;\n * - the sign-in screen itself → circular.\n */\nexport function postSignInTarget(redirect: string | undefined): string {\n if (!redirect || !isSameOriginPath(redirect)) return '/'\n return normalize(redirect) === SIGN_IN_PATH ? '/' : redirect\n}\n\n/** Same-origin relative-path check for {@link postSignInTarget}. */\nfunction isSameOriginPath(target: string): boolean {\n if (!target.startsWith('/') || target.startsWith('//')) return false\n // No legitimate studio path contains a backslash or a strippable control\n // character — reject outright instead of normalizing (fallback is home).\n return !/[\\\\\\t\\r\\n]/.test(target)\n}\n\n/** Map a session onto the operations-layer {@link Principal}. */\nexport function sessionPrincipal(session: AuthSession): Principal {\n return {\n id: session.user.id,\n ...(session.user.name ? { name: session.user.name } : {}),\n ...(session.user.globalFlags ? { roles: session.user.globalFlags } : {}),\n }\n}\n\nfunction normalize(pathname: string): string {\n if (pathname.length > 1 && pathname.endsWith('/')) return pathname.slice(0, -1)\n return pathname\n}\n"]}
|
package/dist/router/root.js
CHANGED
|
@@ -53,8 +53,18 @@ export function createStudioRootRoute(options) {
|
|
|
53
53
|
*/
|
|
54
54
|
function AuthGate({ children }) {
|
|
55
55
|
const { status } = useAuth();
|
|
56
|
-
const
|
|
57
|
-
|
|
56
|
+
const location = useLocation();
|
|
57
|
+
// EC-451: feed the still-pending `?redirect` search param into the decision.
|
|
58
|
+
// Dev auto-login (or any fast real sign-in — SSO return, password-manager
|
|
59
|
+
// autofill) can flip the session to signed-in while the location is still
|
|
60
|
+
// `/sign-in?redirect=…`, BEFORE `SignInView`'s own post-sign-in navigation
|
|
61
|
+
// runs; the gate's bounce then fires first and must consume the target
|
|
62
|
+
// instead of dropping it on an unconditional `/`. The target is validated
|
|
63
|
+
// by `postSignInTarget` inside `resolveRouteGuard` (same-origin relative
|
|
64
|
+
// paths only — redirect is an attacker-suppliable search param).
|
|
65
|
+
const rawRedirect = location.search.redirect;
|
|
66
|
+
const redirect = typeof rawRedirect === 'string' ? rawRedirect : undefined;
|
|
67
|
+
const decision = resolveRouteGuard(location.pathname, status, redirect);
|
|
58
68
|
if (decision.kind === 'pending')
|
|
59
69
|
return _jsx(SessionLoading, {});
|
|
60
70
|
if (decision.kind === 'redirect-to-sign-in') {
|
|
@@ -64,7 +74,7 @@ function AuthGate({ children }) {
|
|
|
64
74
|
return _jsx(NavigateOnce, { to: SIGN_IN_PATH, search: { redirect: decision.redirect }, replace: true });
|
|
65
75
|
}
|
|
66
76
|
if (decision.kind === 'redirect-home')
|
|
67
|
-
return _jsx(NavigateOnce, { to:
|
|
77
|
+
return _jsx(NavigateOnce, { to: decision.target, replace: true });
|
|
68
78
|
return children;
|
|
69
79
|
}
|
|
70
80
|
/** Deterministic session-restore state (also the SSR output for protected paths). */
|
package/dist/router/root.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"root.js","sourceRoot":"","sources":["../../src/router/root.tsx"],"names":[],"mappings":"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","sourcesContent":["import type { ReactNode } from 'react'\nimport {\n createRootRoute,\n HeadContent,\n Outlet,\n Scripts,\n useLocation,\n} from '@tanstack/react-router'\nimport { NavigateOnce } from './navigate-once'\nimport { AuthProvider, useAuth } from '@elytracms/studio/components/auth-context'\nimport { DeploymentSetupGate } from '@elytracms/studio/components/deployment-setup-gate'\nimport { SessionExpiredOverlay } from '@elytracms/studio/components/session-expired-overlay'\nimport { StudioProvider } from '@elytracms/studio/components/studio-context'\nimport { ThemeProvider } from '@elytracms/studio/components/theme-context'\nimport { Text } from '@elytracms/studio/ui'\nimport { SIGN_IN_PATH, resolveRouteGuard } from '@elytracms/studio/lib/auth'\nimport { useT } from '@elytracms/studio/lib/i18n'\nimport { setStudioBaseTitle } from '@elytracms/studio/lib/tab-title'\nimport { activeSlugForLocation, setActiveProjectSlug } from '@elytracms/studio/lib/workspace'\nimport { THEME_INIT_SCRIPT } from '@elytracms/studio/lib/theme'\n\n/**\n * Studio root-route boot factory (EC-375; consolidated from the copy-pasted\n * consumer `__root.tsx` by EC-369 finding #7). It assembles the ONE canonical\n * provider/gate stack — ThemeProvider → DeploymentSetupGate → AuthProvider(keyed)\n * → AuthGate → StudioProvider → Outlet — plus the `RootDocument` shell\n * (html/head with the pre-hydration theme script + the chrome stylesheet).\n *\n * Its whole purpose is to make the NEXT required-gate addition automatic on\n * package upgrade: when EC-258 added `DeploymentSetupGate`, every hand-copied\n * `__root.tsx` had to be manually re-synced, and Konditorei's silently missed\n * it for five releases (EC-367). Owning the composition here means a consumer's\n * `__root.tsx` shrinks to a thin shell and picks up new gates for free.\n *\n * The consumer re-exports the returned route as `Route` (the generated route\n * tree imports `Route` from `./routes/__root`):\n *\n * ```tsx\n * import { createStudioRootRoute } from '@elytracms/studio/router/root'\n * import appCss from '../styles/app.css?url'\n * export const Route = createStudioRootRoute({ appCssUrl: appCss })\n * ```\n */\nexport interface StudioRootRouteOptions {\n /**\n * URL of the studio chrome stylesheet, from the consumer's Vite `?url` import\n * (`import appCss from '../styles/app.css?url'`). The one option every consumer\n * must pass — the sheet is project-owned (it carries the delivery theme).\n */\n appCssUrl: string\n /** Document `<title>`. Default: `'Elytra'`. */\n title?: string\n /** `<html lang>`. Default: `'en'`. */\n htmlLang?: string\n /**\n * Extra `<head>` meta entries appended after the defaults (charset, viewport,\n * title). Same shape TanStack Router's `head().meta` takes.\n */\n meta?: Array<Record<string, string>>\n /** Extra `<head>` link entries appended after the app stylesheet. */\n links?: Array<Record<string, string>>\n /**\n * Escape hatch: wrap additional project-specific providers around the routed\n * subtree. Rendered INSIDE `StudioProvider` and around `<Outlet />`, so nested\n * providers see the full studio/auth/theme context and remount with the active\n * project. Return the passed `children` wrapped in your providers.\n */\n providers?: (children: ReactNode) => ReactNode\n}\n\nexport function createStudioRootRoute(options: StudioRootRouteOptions) {\n const { appCssUrl, title = 'Elytra', htmlLang = 'en', meta = [], links = [], providers } = options\n\n // EC-402: register the base title for the shell's per-view tab-title effect\n // (`StudioTabTitle` composes `deepest crumb · view · project · <this title>`\n // and restores exactly this value when leaving a project view). Module-holder\n // seam — same pattern as `setActiveProjectSlug` — so the shell never has to\n // capture `document.title` and race the router's own head effect.\n setStudioBaseTitle(title)\n\n function RootComponent() {\n const location = useLocation()\n // EC-179: the active project selects its Convex backend. Resolve it from the\n // URL (or the sign-in redirect target) and bind it *before* the auth and\n // persistence adapters are selected, so the initial sign-in authenticates\n // against the project the user is heading for. Keying the auth/studio subtree\n // by the active project remounts it on a project switch, so the adapters\n // re-select against the new backend and the session is restored per-backend.\n const activeSlug = activeSlugForLocation(\n location.pathname,\n (location.search as { redirect?: string }).redirect,\n )\n setActiveProjectSlug(activeSlug)\n\n return (\n <RootDocument htmlLang={htmlLang}>\n <ThemeProvider>\n {/* EC-258: one deployment-setup gate runs before auth — a misconfigured or\n unreachable backend, an invalid workspace config, or any failing readiness\n precondition (missing auth signing key, required collections, …) is a\n single visible boot screen with copy-pasteable fixes, never a silent\n in-memory fallback or a misleading downstream error. All checks passing\n renders children as before. */}\n <DeploymentSetupGate>\n <AuthProvider key={activeSlug ?? '__launcher__'}>\n <AuthGate>\n <StudioProvider>\n {providers ? providers(<Outlet />) : <Outlet />}\n {/* EC-403: the blocking in-place re-auth overlay — mounted over\n every route, INSIDE StudioProvider (it invalidates the studio\n query cache on re-auth) and inside AuthProvider (it reads the\n sessionExpired state). Renders null unless the session died. */}\n <SessionExpiredOverlay />\n </StudioProvider>\n </AuthGate>\n </AuthProvider>\n </DeploymentSetupGate>\n </ThemeProvider>\n </RootDocument>\n )\n }\n\n return createRootRoute({\n head: () => ({\n meta: [\n { charSet: 'utf-8' },\n { name: 'viewport', content: 'width=device-width, initial-scale=1' },\n { title },\n ...meta,\n ],\n links: [{ rel: 'stylesheet', href: appCssUrl }, ...links],\n }),\n component: RootComponent,\n })\n}\n\n/**\n * Route guard (EC-150): one chokepoint in the root layout. Unauthenticated\n * visitors on protected paths are redirected to the sign-in screen (with the\n * intended target preserved); signed-in users never see the sign-in screen.\n * The decision logic is the pure, unit-tested `resolveRouteGuard`.\n */\nfunction AuthGate({ children }: { children: ReactNode }) {\n const { status } = useAuth()\n const { pathname } = useLocation()\n const decision = resolveRouteGuard(pathname, status)\n\n if (decision.kind === 'pending') return <SessionLoading />\n if (decision.kind === 'redirect-to-sign-in') {\n // EC-441: `NavigateOnce`, not `<Navigate>` — the gate stays mounted while\n // the sign-in transition is pending, and a per-render re-navigate spirals\n // into the router's update-depth rejection.\n return <NavigateOnce to={SIGN_IN_PATH} search={{ redirect: decision.redirect }} replace />\n }\n if (decision.kind === 'redirect-home') return <NavigateOnce to=\"/\" replace />\n return children\n}\n\n/** Deterministic session-restore state (also the SSR output for protected paths). */\nfunction SessionLoading() {\n const t = useT()\n return (\n <div className=\"grid min-h-dvh place-items-center bg-background\">\n <Text size=\"sm\" tone=\"muted\">\n {t('auth.boot.checkingSession')}\n </Text>\n </div>\n )\n}\n\nfunction RootDocument({ children, htmlLang }: { children: ReactNode; htmlLang: string }) {\n // Dark-first SSR baseline; THEME_INIT_SCRIPT flips the class pre-hydration\n // from the stored preference, and ThemeProvider keeps it reactive thereafter.\n // suppressHydrationWarning: the script intentionally mutates the class before\n // React hydrates, so a className mismatch on <html> is expected, not a bug.\n // The app stylesheet is emitted via head().links and rendered by <HeadContent />.\n return (\n <html lang={htmlLang} className=\"dark\" suppressHydrationWarning>\n <head>\n <HeadContent />\n <script dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} />\n </head>\n <body className=\"min-h-screen bg-background font-sans text-foreground antialiased\">\n {children}\n <Scripts />\n </body>\n </html>\n )\n}\n"]}
|
|
1
|
+
{"version":3,"file":"root.js","sourceRoot":"","sources":["../../src/router/root.tsx"],"names":[],"mappings":"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","sourcesContent":["import type { ReactNode } from 'react'\nimport {\n createRootRoute,\n HeadContent,\n Outlet,\n Scripts,\n useLocation,\n} from '@tanstack/react-router'\nimport { NavigateOnce } from './navigate-once'\nimport { AuthProvider, useAuth } from '@elytracms/studio/components/auth-context'\nimport { DeploymentSetupGate } from '@elytracms/studio/components/deployment-setup-gate'\nimport { SessionExpiredOverlay } from '@elytracms/studio/components/session-expired-overlay'\nimport { StudioProvider } from '@elytracms/studio/components/studio-context'\nimport { ThemeProvider } from '@elytracms/studio/components/theme-context'\nimport { Text } from '@elytracms/studio/ui'\nimport { SIGN_IN_PATH, resolveRouteGuard } from '@elytracms/studio/lib/auth'\nimport { useT } from '@elytracms/studio/lib/i18n'\nimport { setStudioBaseTitle } from '@elytracms/studio/lib/tab-title'\nimport { activeSlugForLocation, setActiveProjectSlug } from '@elytracms/studio/lib/workspace'\nimport { THEME_INIT_SCRIPT } from '@elytracms/studio/lib/theme'\n\n/**\n * Studio root-route boot factory (EC-375; consolidated from the copy-pasted\n * consumer `__root.tsx` by EC-369 finding #7). It assembles the ONE canonical\n * provider/gate stack — ThemeProvider → DeploymentSetupGate → AuthProvider(keyed)\n * → AuthGate → StudioProvider → Outlet — plus the `RootDocument` shell\n * (html/head with the pre-hydration theme script + the chrome stylesheet).\n *\n * Its whole purpose is to make the NEXT required-gate addition automatic on\n * package upgrade: when EC-258 added `DeploymentSetupGate`, every hand-copied\n * `__root.tsx` had to be manually re-synced, and Konditorei's silently missed\n * it for five releases (EC-367). Owning the composition here means a consumer's\n * `__root.tsx` shrinks to a thin shell and picks up new gates for free.\n *\n * The consumer re-exports the returned route as `Route` (the generated route\n * tree imports `Route` from `./routes/__root`):\n *\n * ```tsx\n * import { createStudioRootRoute } from '@elytracms/studio/router/root'\n * import appCss from '../styles/app.css?url'\n * export const Route = createStudioRootRoute({ appCssUrl: appCss })\n * ```\n */\nexport interface StudioRootRouteOptions {\n /**\n * URL of the studio chrome stylesheet, from the consumer's Vite `?url` import\n * (`import appCss from '../styles/app.css?url'`). The one option every consumer\n * must pass — the sheet is project-owned (it carries the delivery theme).\n */\n appCssUrl: string\n /** Document `<title>`. Default: `'Elytra'`. */\n title?: string\n /** `<html lang>`. Default: `'en'`. */\n htmlLang?: string\n /**\n * Extra `<head>` meta entries appended after the defaults (charset, viewport,\n * title). Same shape TanStack Router's `head().meta` takes.\n */\n meta?: Array<Record<string, string>>\n /** Extra `<head>` link entries appended after the app stylesheet. */\n links?: Array<Record<string, string>>\n /**\n * Escape hatch: wrap additional project-specific providers around the routed\n * subtree. Rendered INSIDE `StudioProvider` and around `<Outlet />`, so nested\n * providers see the full studio/auth/theme context and remount with the active\n * project. Return the passed `children` wrapped in your providers.\n */\n providers?: (children: ReactNode) => ReactNode\n}\n\nexport function createStudioRootRoute(options: StudioRootRouteOptions) {\n const { appCssUrl, title = 'Elytra', htmlLang = 'en', meta = [], links = [], providers } = options\n\n // EC-402: register the base title for the shell's per-view tab-title effect\n // (`StudioTabTitle` composes `deepest crumb · view · project · <this title>`\n // and restores exactly this value when leaving a project view). Module-holder\n // seam — same pattern as `setActiveProjectSlug` — so the shell never has to\n // capture `document.title` and race the router's own head effect.\n setStudioBaseTitle(title)\n\n function RootComponent() {\n const location = useLocation()\n // EC-179: the active project selects its Convex backend. Resolve it from the\n // URL (or the sign-in redirect target) and bind it *before* the auth and\n // persistence adapters are selected, so the initial sign-in authenticates\n // against the project the user is heading for. Keying the auth/studio subtree\n // by the active project remounts it on a project switch, so the adapters\n // re-select against the new backend and the session is restored per-backend.\n const activeSlug = activeSlugForLocation(\n location.pathname,\n (location.search as { redirect?: string }).redirect,\n )\n setActiveProjectSlug(activeSlug)\n\n return (\n <RootDocument htmlLang={htmlLang}>\n <ThemeProvider>\n {/* EC-258: one deployment-setup gate runs before auth — a misconfigured or\n unreachable backend, an invalid workspace config, or any failing readiness\n precondition (missing auth signing key, required collections, …) is a\n single visible boot screen with copy-pasteable fixes, never a silent\n in-memory fallback or a misleading downstream error. All checks passing\n renders children as before. */}\n <DeploymentSetupGate>\n <AuthProvider key={activeSlug ?? '__launcher__'}>\n <AuthGate>\n <StudioProvider>\n {providers ? providers(<Outlet />) : <Outlet />}\n {/* EC-403: the blocking in-place re-auth overlay — mounted over\n every route, INSIDE StudioProvider (it invalidates the studio\n query cache on re-auth) and inside AuthProvider (it reads the\n sessionExpired state). Renders null unless the session died. */}\n <SessionExpiredOverlay />\n </StudioProvider>\n </AuthGate>\n </AuthProvider>\n </DeploymentSetupGate>\n </ThemeProvider>\n </RootDocument>\n )\n }\n\n return createRootRoute({\n head: () => ({\n meta: [\n { charSet: 'utf-8' },\n { name: 'viewport', content: 'width=device-width, initial-scale=1' },\n { title },\n ...meta,\n ],\n links: [{ rel: 'stylesheet', href: appCssUrl }, ...links],\n }),\n component: RootComponent,\n })\n}\n\n/**\n * Route guard (EC-150): one chokepoint in the root layout. Unauthenticated\n * visitors on protected paths are redirected to the sign-in screen (with the\n * intended target preserved); signed-in users never see the sign-in screen.\n * The decision logic is the pure, unit-tested `resolveRouteGuard`.\n */\nfunction AuthGate({ children }: { children: ReactNode }) {\n const { status } = useAuth()\n const location = useLocation()\n // EC-451: feed the still-pending `?redirect` search param into the decision.\n // Dev auto-login (or any fast real sign-in — SSO return, password-manager\n // autofill) can flip the session to signed-in while the location is still\n // `/sign-in?redirect=…`, BEFORE `SignInView`'s own post-sign-in navigation\n // runs; the gate's bounce then fires first and must consume the target\n // instead of dropping it on an unconditional `/`. The target is validated\n // by `postSignInTarget` inside `resolveRouteGuard` (same-origin relative\n // paths only — redirect is an attacker-suppliable search param).\n const rawRedirect = (location.search as { redirect?: unknown }).redirect\n const redirect = typeof rawRedirect === 'string' ? rawRedirect : undefined\n const decision = resolveRouteGuard(location.pathname, status, redirect)\n\n if (decision.kind === 'pending') return <SessionLoading />\n if (decision.kind === 'redirect-to-sign-in') {\n // EC-441: `NavigateOnce`, not `<Navigate>` — the gate stays mounted while\n // the sign-in transition is pending, and a per-render re-navigate spirals\n // into the router's update-depth rejection.\n return <NavigateOnce to={SIGN_IN_PATH} search={{ redirect: decision.redirect }} replace />\n }\n if (decision.kind === 'redirect-home') return <NavigateOnce to={decision.target} replace />\n return children\n}\n\n/** Deterministic session-restore state (also the SSR output for protected paths). */\nfunction SessionLoading() {\n const t = useT()\n return (\n <div className=\"grid min-h-dvh place-items-center bg-background\">\n <Text size=\"sm\" tone=\"muted\">\n {t('auth.boot.checkingSession')}\n </Text>\n </div>\n )\n}\n\nfunction RootDocument({ children, htmlLang }: { children: ReactNode; htmlLang: string }) {\n // Dark-first SSR baseline; THEME_INIT_SCRIPT flips the class pre-hydration\n // from the stored preference, and ThemeProvider keeps it reactive thereafter.\n // suppressHydrationWarning: the script intentionally mutates the class before\n // React hydrates, so a className mismatch on <html> is expected, not a bug.\n // The app stylesheet is emitted via head().links and rendered by <HeadContent />.\n return (\n <html lang={htmlLang} className=\"dark\" suppressHydrationWarning>\n <head>\n <HeadContent />\n <script dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} />\n </head>\n <body className=\"min-h-screen bg-background font-sans text-foreground antialiased\">\n {children}\n <Scripts />\n </body>\n </html>\n )\n}\n"]}
|
package/dist/ui/status-dot.d.ts
CHANGED
|
@@ -18,8 +18,9 @@ export interface StatusDotProps extends Omit<HTMLAttributes<HTMLSpanElement>, 'c
|
|
|
18
18
|
tone: StatusTone;
|
|
19
19
|
/**
|
|
20
20
|
* The accessible + hover/focus-revealed text (e.g. "Published"). Exposed as the
|
|
21
|
-
* `aria-label`
|
|
22
|
-
* keyboard user
|
|
21
|
+
* `aria-label` (screen readers) and shown as a small pill on hover/focus so a
|
|
22
|
+
* mouse OR keyboard user can read it — no native `title` (EC-452: it duplicated
|
|
23
|
+
* the pill, showing the label twice on mouse hover).
|
|
23
24
|
*/
|
|
24
25
|
label: string;
|
|
25
26
|
/**
|
|
@@ -27,8 +28,8 @@ export interface StatusDotProps extends Omit<HTMLAttributes<HTMLSpanElement>, 'c
|
|
|
27
28
|
* EC-446 focusable behavior (the versions pane — a few rows — so a keyboard user can
|
|
28
29
|
* focus a dot to reveal its pill). Pass `false` for a DENSE surface (the list
|
|
29
30
|
* `$state` column, EC-448): a long list must not add a tab stop per row. The label
|
|
30
|
-
* stays reachable via `aria-label`
|
|
31
|
-
*
|
|
31
|
+
* stays reachable via `aria-label` (and the hover pill on mouse) — just not via
|
|
32
|
+
* focus. The focus-ring styling is likewise dropped when non-interactive.
|
|
32
33
|
*/
|
|
33
34
|
interactive?: boolean;
|
|
34
35
|
}
|
|
@@ -39,10 +40,12 @@ export interface StatusDotProps extends Omit<HTMLAttributes<HTMLSpanElement>, 'c
|
|
|
39
40
|
* list `$state` column). Pure-presentational and domain-agnostic: it knows a
|
|
40
41
|
* `tone` and a `label`, nothing about versions or documents.
|
|
41
42
|
*
|
|
42
|
-
* Accessibility: the wrapper is `role="img"` and carries the label as
|
|
43
|
-
* `aria-label` (screen readers)
|
|
44
|
-
*
|
|
45
|
-
*
|
|
43
|
+
* Accessibility: the wrapper is `role="img"` and carries the label as its
|
|
44
|
+
* `aria-label` (screen readers) — no native `title` (EC-452: pairing one with
|
|
45
|
+
* the CSS pill below made the label show twice on mouse hover; the pill is the
|
|
46
|
+
* one hover surface now). When `interactive` (the default), it is also
|
|
47
|
+
* focusable (`tabIndex=0`) and the same CSS pill reveals the label on
|
|
48
|
+
* `:focus-visible`, so the meaning is reachable without a mouse; a
|
|
46
49
|
* non-interactive dot (EC-448, dense lists) drops the tab stop + focus ring but
|
|
47
50
|
* keeps the name + hover pill. The dot glyph and the pill are `aria-hidden` — the
|
|
48
51
|
* wrapper's `aria-label` is the single announced name.
|
package/dist/ui/status-dot.js
CHANGED
|
@@ -24,15 +24,17 @@ export function statusDotToneClass(tone) {
|
|
|
24
24
|
* list `$state` column). Pure-presentational and domain-agnostic: it knows a
|
|
25
25
|
* `tone` and a `label`, nothing about versions or documents.
|
|
26
26
|
*
|
|
27
|
-
* Accessibility: the wrapper is `role="img"` and carries the label as
|
|
28
|
-
* `aria-label` (screen readers)
|
|
29
|
-
*
|
|
30
|
-
*
|
|
27
|
+
* Accessibility: the wrapper is `role="img"` and carries the label as its
|
|
28
|
+
* `aria-label` (screen readers) — no native `title` (EC-452: pairing one with
|
|
29
|
+
* the CSS pill below made the label show twice on mouse hover; the pill is the
|
|
30
|
+
* one hover surface now). When `interactive` (the default), it is also
|
|
31
|
+
* focusable (`tabIndex=0`) and the same CSS pill reveals the label on
|
|
32
|
+
* `:focus-visible`, so the meaning is reachable without a mouse; a
|
|
31
33
|
* non-interactive dot (EC-448, dense lists) drops the tab stop + focus ring but
|
|
32
34
|
* keeps the name + hover pill. The dot glyph and the pill are `aria-hidden` — the
|
|
33
35
|
* wrapper's `aria-label` is the single announced name.
|
|
34
36
|
*/
|
|
35
37
|
export function StatusDot({ tone, label, className, interactive = true, ...props }) {
|
|
36
|
-
return (_jsxs("span", { role: "img", ...(interactive ? { tabIndex: 0 } : {}), "aria-label": label,
|
|
38
|
+
return (_jsxs("span", { role: "img", ...(interactive ? { tabIndex: 0 } : {}), "aria-label": label, className: cn('group/status-dot relative inline-flex items-center justify-center rounded-full outline-none', interactive && 'focus-visible:ring-2 focus-visible:ring-ring/60', className), ...props, children: [_jsx("span", { "aria-hidden": true, className: cn('size-2 shrink-0 rounded-full', statusDotToneClass(tone)) }), _jsx("span", { "aria-hidden": true, className: cn('pointer-events-none absolute left-1/2 top-full z-50 mt-1.5 -translate-x-1/2 whitespace-nowrap', 'rounded-md border border-border bg-popover px-2 py-1 text-xs font-medium text-popover-foreground shadow-md', 'opacity-0 transition-opacity duration-100', 'group-hover/status-dot:opacity-100 group-focus-visible/status-dot:opacity-100'), children: label })] }));
|
|
37
39
|
}
|
|
38
40
|
//# sourceMappingURL=status-dot.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"status-dot.js","sourceRoot":"","sources":["../../src/ui/status-dot.tsx"],"names":[],"mappings":";AACA,OAAO,EAAE,EAAE,EAAE,MAAM,cAAc,CAAA;AAWjC;;;;GAIG;AACH,MAAM,UAAU,kBAAkB,CAAC,IAAgB;IACjD,QAAQ,IAAI,EAAE,CAAC;QACb,KAAK,SAAS;YACZ,OAAO,YAAY,CAAA;QACrB,KAAK,SAAS;YACZ,OAAO,YAAY,CAAA;QACrB,KAAK,OAAO;YACV,OAAO,wBAAwB,CAAA;QACjC,KAAK,SAAS;YACZ,OAAO,qBAAqB,CAAA;IAChC,CAAC;AACH,CAAC;
|
|
1
|
+
{"version":3,"file":"status-dot.js","sourceRoot":"","sources":["../../src/ui/status-dot.tsx"],"names":[],"mappings":";AACA,OAAO,EAAE,EAAE,EAAE,MAAM,cAAc,CAAA;AAWjC;;;;GAIG;AACH,MAAM,UAAU,kBAAkB,CAAC,IAAgB;IACjD,QAAQ,IAAI,EAAE,CAAC;QACb,KAAK,SAAS;YACZ,OAAO,YAAY,CAAA;QACrB,KAAK,SAAS;YACZ,OAAO,YAAY,CAAA;QACrB,KAAK,OAAO;YACV,OAAO,wBAAwB,CAAA;QACjC,KAAK,SAAS;YACZ,OAAO,qBAAqB,CAAA;IAChC,CAAC;AACH,CAAC;AAuBD;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,UAAU,SAAS,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,SAAS,EAAE,WAAW,GAAG,IAAI,EAAE,GAAG,KAAK,EAAkB;IAChG,OAAO,CACL,gBACE,IAAI,EAAC,KAAK,KACN,CAAC,WAAW,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,gBAC5B,KAAK,EACjB,SAAS,EAAE,EAAE,CACX,6FAA6F,EAC7F,WAAW,IAAI,iDAAiD,EAChE,SAAS,CACV,KACG,KAAK,aAET,oCAAkB,SAAS,EAAE,EAAE,CAAC,8BAA8B,EAAE,kBAAkB,CAAC,IAAI,CAAC,CAAC,GAAI,EAC7F,oCAEE,SAAS,EAAE,EAAE,CACX,+FAA+F,EAC/F,4GAA4G,EAC5G,2CAA2C,EAC3C,+EAA+E,CAChF,YAEA,KAAK,GACD,IACF,CACR,CAAA;AACH,CAAC","sourcesContent":["import type { HTMLAttributes } from 'react'\nimport { cn } from '../lib/utils'\n\n/**\n * The status vocabulary a {@link StatusDot} can express. Kept deliberately\n * generic (not domain-specific) so the same primitive marks a published version\n * (`success`), a draft/pending state (`warning`), or a quiet/absent state\n * (`muted`/`neutral`) — the versions pane (EC-446) is its first consumer, the\n * list `$state` column (EC-445) its next.\n */\nexport type StatusTone = 'success' | 'warning' | 'muted' | 'neutral'\n\n/**\n * The dot's fill class per tone — a pure mapping onto the shared token palette\n * (the same `--success`/`--warning`/`--muted-foreground` tokens {@link Badge}\n * uses), extracted so it is unit-testable without a DOM.\n */\nexport function statusDotToneClass(tone: StatusTone): string {\n switch (tone) {\n case 'success':\n return 'bg-success'\n case 'warning':\n return 'bg-warning'\n case 'muted':\n return 'bg-muted-foreground/40'\n case 'neutral':\n return 'bg-muted-foreground'\n }\n}\n\nexport interface StatusDotProps extends Omit<HTMLAttributes<HTMLSpanElement>, 'children' | 'aria-label'> {\n /** Which status the dot expresses — maps onto the shared token palette. */\n tone: StatusTone\n /**\n * The accessible + hover/focus-revealed text (e.g. \"Published\"). Exposed as the\n * `aria-label` (screen readers) and shown as a small pill on hover/focus so a\n * mouse OR keyboard user can read it — no native `title` (EC-452: it duplicated\n * the pill, showing the label twice on mouse hover).\n */\n label: string\n /**\n * Whether the dot is a keyboard tab stop (EC-448). **Default `true`** preserves the\n * EC-446 focusable behavior (the versions pane — a few rows — so a keyboard user can\n * focus a dot to reveal its pill). Pass `false` for a DENSE surface (the list\n * `$state` column, EC-448): a long list must not add a tab stop per row. The label\n * stays reachable via `aria-label` (and the hover pill on mouse) — just not via\n * focus. The focus-ring styling is likewise dropped when non-interactive.\n */\n interactive?: boolean\n}\n\n/**\n * EC-446 — a compact, reusable status indicator: a small colored dot whose\n * meaning is revealed on hover/focus rather than spent as a full-width pill. The\n * primary space win over {@link Badge} for dense rows (the versions history, the\n * list `$state` column). Pure-presentational and domain-agnostic: it knows a\n * `tone` and a `label`, nothing about versions or documents.\n *\n * Accessibility: the wrapper is `role=\"img\"` and carries the label as its\n * `aria-label` (screen readers) — no native `title` (EC-452: pairing one with\n * the CSS pill below made the label show twice on mouse hover; the pill is the\n * one hover surface now). When `interactive` (the default), it is also\n * focusable (`tabIndex=0`) and the same CSS pill reveals the label on\n * `:focus-visible`, so the meaning is reachable without a mouse; a\n * non-interactive dot (EC-448, dense lists) drops the tab stop + focus ring but\n * keeps the name + hover pill. The dot glyph and the pill are `aria-hidden` — the\n * wrapper's `aria-label` is the single announced name.\n */\nexport function StatusDot({ tone, label, className, interactive = true, ...props }: StatusDotProps) {\n return (\n <span\n role=\"img\"\n {...(interactive ? { tabIndex: 0 } : {})}\n aria-label={label}\n className={cn(\n 'group/status-dot relative inline-flex items-center justify-center rounded-full outline-none',\n interactive && 'focus-visible:ring-2 focus-visible:ring-ring/60',\n className,\n )}\n {...props}\n >\n <span aria-hidden className={cn('size-2 shrink-0 rounded-full', statusDotToneClass(tone))} />\n <span\n aria-hidden\n className={cn(\n 'pointer-events-none absolute left-1/2 top-full z-50 mt-1.5 -translate-x-1/2 whitespace-nowrap',\n 'rounded-md border border-border bg-popover px-2 py-1 text-xs font-medium text-popover-foreground shadow-md',\n 'opacity-0 transition-opacity duration-100',\n 'group-hover/status-dot:opacity-100 group-focus-visible/status-dot:opacity-100',\n )}\n >\n {label}\n </span>\n </span>\n )\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elytracms/studio",
|
|
3
3
|
"author": "finaldream <hi@finaldream.de>",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.77",
|
|
5
5
|
"private": false,
|
|
6
6
|
"description": "The Elytra studio engine (UI + lib + routes + data-only Convex schema) as an importable package. Extraction staged in EXTRACTION.md (EC-223); Stages 1-2 = UI primitives + router-free lib leaves.",
|
|
7
7
|
"license": "MIT",
|
|
@@ -150,7 +150,7 @@
|
|
|
150
150
|
"lucide-react": "^1.23.0",
|
|
151
151
|
"tailwind-merge": "^3.6.0",
|
|
152
152
|
"zod": "^4.4.3",
|
|
153
|
-
"@elytracms/core": "^0.0.
|
|
153
|
+
"@elytracms/core": "^0.0.77"
|
|
154
154
|
},
|
|
155
155
|
"peerDependencies": {
|
|
156
156
|
"@tanstack/react-router": "^1.170.0",
|