@elytracms/studio 0.0.27 → 0.0.29

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 (138) hide show
  1. package/convex/assets.ts +19 -10
  2. package/convex/authority.ts +60 -0
  3. package/convex/cliTokens.ts +4 -4
  4. package/convex/cms.ts +12 -12
  5. package/convex/content.ts +10 -3
  6. package/convex/delivery.ts +22 -4
  7. package/convex/guard.ts +28 -11
  8. package/convex/members.ts +40 -7
  9. package/convex/redirects.ts +67 -0
  10. package/convex/references.ts +11 -11
  11. package/convex/schema.ts +30 -1
  12. package/convex/sync.ts +17 -10
  13. package/convex/users.ts +63 -0
  14. package/dist/components/app-views/default-modules.d.ts +48 -14
  15. package/dist/components/app-views/default-modules.js +64 -21
  16. package/dist/components/app-views/default-modules.js.map +1 -1
  17. package/dist/components/app-views/module.d.ts +32 -0
  18. package/dist/components/app-views/module.js +16 -0
  19. package/dist/components/app-views/module.js.map +1 -1
  20. package/dist/components/app-views/sidebar.d.ts +15 -1
  21. package/dist/components/app-views/sidebar.js +10 -0
  22. package/dist/components/app-views/sidebar.js.map +1 -1
  23. package/dist/components/cms/asset-picker.js +4 -4
  24. package/dist/components/cms/asset-picker.js.map +1 -1
  25. package/dist/components/cms/cell.js +16 -13
  26. package/dist/components/cms/cell.js.map +1 -1
  27. package/dist/components/cms/cms-store.d.ts +11 -26
  28. package/dist/components/cms/cms-store.js +25 -160
  29. package/dist/components/cms/cms-store.js.map +1 -1
  30. package/dist/components/cms/collection-table.d.ts +10 -0
  31. package/dist/components/cms/collection-table.js +72 -72
  32. package/dist/components/cms/collection-table.js.map +1 -1
  33. package/dist/components/cms/detail-editor.d.ts +14 -0
  34. package/dist/components/cms/detail-editor.js +224 -72
  35. package/dist/components/cms/detail-editor.js.map +1 -1
  36. package/dist/components/cms/document-editor-context.d.ts +26 -0
  37. package/dist/components/cms/document-editor-context.js.map +1 -1
  38. package/dist/components/cms/editable-cell.js +12 -7
  39. package/dist/components/cms/editable-cell.js.map +1 -1
  40. package/dist/components/cms/field-control.d.ts +2 -0
  41. package/dist/components/cms/field-control.js +40 -30
  42. package/dist/components/cms/field-control.js.map +1 -1
  43. package/dist/components/cms/filter-builder.d.ts +18 -0
  44. package/dist/components/cms/filter-builder.js +47 -6
  45. package/dist/components/cms/filter-builder.js.map +1 -1
  46. package/dist/components/cms/import-export.js +5 -3
  47. package/dist/components/cms/import-export.js.map +1 -1
  48. package/dist/components/cms/reference-confirm.js +8 -9
  49. package/dist/components/cms/reference-confirm.js.map +1 -1
  50. package/dist/components/cms/relation-picker.js +13 -10
  51. package/dist/components/cms/relation-picker.js.map +1 -1
  52. package/dist/components/cms/use-cell-edit.js +13 -7
  53. package/dist/components/cms/use-cell-edit.js.map +1 -1
  54. package/dist/components/cms/use-document-versions.d.ts +16 -0
  55. package/dist/components/cms/use-document-versions.js +31 -0
  56. package/dist/components/cms/use-document-versions.js.map +1 -0
  57. package/dist/components/cms/version-compare-text.d.ts +30 -0
  58. package/dist/components/cms/version-compare-text.js +113 -0
  59. package/dist/components/cms/version-compare-text.js.map +1 -0
  60. package/dist/components/cms/version-compare.d.ts +12 -0
  61. package/dist/components/cms/version-compare.js +271 -0
  62. package/dist/components/cms/version-compare.js.map +1 -0
  63. package/dist/components/cms/versions-pane.d.ts +3 -0
  64. package/dist/components/cms/versions-pane.js +47 -38
  65. package/dist/components/cms/versions-pane.js.map +1 -1
  66. package/dist/components/cms/view-bar.d.ts +21 -6
  67. package/dist/components/cms/view-bar.js +42 -57
  68. package/dist/components/cms/view-bar.js.map +1 -1
  69. package/dist/components/collection-view/cms-collection-view-source.d.ts +15 -0
  70. package/dist/components/collection-view/cms-collection-view-source.js +158 -0
  71. package/dist/components/collection-view/cms-collection-view-source.js.map +1 -0
  72. package/dist/components/collection-view/collection-view-context.d.ts +10 -0
  73. package/dist/components/collection-view/collection-view-context.js +26 -0
  74. package/dist/components/collection-view/collection-view-context.js.map +1 -0
  75. package/dist/components/current-user-menu.js +8 -1
  76. package/dist/components/current-user-menu.js.map +1 -1
  77. package/dist/components/redirects/redirects-collection-view-source.d.ts +24 -0
  78. package/dist/components/redirects/redirects-collection-view-source.js +137 -0
  79. package/dist/components/redirects/redirects-collection-view-source.js.map +1 -0
  80. package/dist/components/redirects/redirects-module.d.ts +21 -0
  81. package/dist/components/redirects/redirects-module.js +43 -0
  82. package/dist/components/redirects/redirects-module.js.map +1 -0
  83. package/dist/components/redirects/redirects-schema.d.ts +10 -0
  84. package/dist/components/redirects/redirects-schema.js +42 -0
  85. package/dist/components/redirects/redirects-schema.js.map +1 -0
  86. package/dist/components/redirects/redirects-store.d.ts +46 -0
  87. package/dist/components/redirects/redirects-store.js +127 -0
  88. package/dist/components/redirects/redirects-store.js.map +1 -0
  89. package/dist/components/references/references-panel.js +2 -2
  90. package/dist/components/references/references-panel.js.map +1 -1
  91. package/dist/components/section-nav.js +14 -5
  92. package/dist/components/section-nav.js.map +1 -1
  93. package/dist/components/users/user-search.d.ts +8 -0
  94. package/dist/components/users/user-search.js +13 -0
  95. package/dist/components/users/user-search.js.map +1 -0
  96. package/dist/components/users/users-collection-view-source.d.ts +28 -0
  97. package/dist/components/users/users-collection-view-source.js +157 -0
  98. package/dist/components/users/users-collection-view-source.js.map +1 -0
  99. package/dist/components/users/users-module.d.ts +23 -0
  100. package/dist/components/users/users-module.js +61 -0
  101. package/dist/components/users/users-module.js.map +1 -0
  102. package/dist/components/users/users-schema.d.ts +19 -0
  103. package/dist/components/users/users-schema.js +38 -0
  104. package/dist/components/users/users-schema.js.map +1 -0
  105. package/dist/components/users/users-store.d.ts +40 -0
  106. package/dist/components/users/users-store.js +87 -0
  107. package/dist/components/users/users-store.js.map +1 -0
  108. package/dist/lib/cms-views/index.d.ts +4 -5
  109. package/dist/lib/cms-views/index.js +4 -5
  110. package/dist/lib/cms-views/index.js.map +1 -1
  111. package/dist/lib/cms-views/view-model.d.ts +48 -1
  112. package/dist/lib/cms-views/view-model.js +77 -1
  113. package/dist/lib/cms-views/view-model.js.map +1 -1
  114. package/dist/lib/collection-view/collection-view-source.d.ts +251 -0
  115. package/dist/lib/collection-view/collection-view-source.js +10 -0
  116. package/dist/lib/collection-view/collection-view-source.js.map +1 -0
  117. package/dist/lib/operations/session-hydration.d.ts +7 -0
  118. package/dist/lib/operations/session-hydration.js +7 -1
  119. package/dist/lib/operations/session-hydration.js.map +1 -1
  120. package/dist/lib/persistence/convex-adapter.d.ts +5 -1
  121. package/dist/lib/persistence/convex-adapter.js +148 -1
  122. package/dist/lib/persistence/convex-adapter.js.map +1 -1
  123. package/dist/lib/workspace/index.d.ts +1 -8
  124. package/dist/lib/workspace/index.js +3 -9
  125. package/dist/lib/workspace/index.js.map +1 -1
  126. package/dist/router/match.d.ts +4 -0
  127. package/dist/router/match.js +8 -0
  128. package/dist/router/match.js.map +1 -1
  129. package/dist/ui/index.d.ts +2 -0
  130. package/dist/ui/index.js +2 -0
  131. package/dist/ui/index.js.map +1 -1
  132. package/dist/ui/popover.d.ts +13 -0
  133. package/dist/ui/popover.js +17 -0
  134. package/dist/ui/popover.js.map +1 -0
  135. package/dist/ui/row-item.d.ts +16 -0
  136. package/dist/ui/row-item.js +18 -0
  137. package/dist/ui/row-item.js.map +1 -0
  138. package/package.json +3 -2
@@ -1,6 +1,6 @@
1
1
  import { v } from 'convex/values'
2
2
  import { mutation, query } from './functions'
3
- import { requireRole } from './guard'
3
+ import { requireCapability } from './guard'
4
4
  import type { MutationCtx } from './functions'
5
5
 
6
6
  /**
@@ -61,7 +61,7 @@ export const replaceForSource = mutation({
61
61
  references: v.array(outboundReferenceValidator),
62
62
  },
63
63
  handler: async (ctx, args) => {
64
- await requireRole(ctx, 'editor') // EC-150
64
+ await requireCapability(ctx, 'content.write') // EC-150
65
65
  await deleteForSource(ctx, args.variant, args.sourceType, args.sourceId)
66
66
  for (const reference of args.references) {
67
67
  await ctx.db.insert('referenceEntries', {
@@ -81,7 +81,7 @@ export const replaceForSourceTypes = mutation({
81
81
  entries: v.array(entryValidator),
82
82
  },
83
83
  handler: async (ctx, args) => {
84
- await requireRole(ctx, 'editor') // EC-150
84
+ await requireCapability(ctx, 'content.write') // EC-150
85
85
  for (const sourceType of args.sourceTypes) {
86
86
  await deleteForSource(ctx, args.variant, sourceType)
87
87
  }
@@ -101,7 +101,7 @@ export const removeForSource = mutation({
101
101
  variant: v.optional(variantValidator),
102
102
  },
103
103
  handler: async (ctx, args) => {
104
- await requireRole(ctx, 'editor') // EC-150
104
+ await requireCapability(ctx, 'content.write') // EC-150
105
105
  const variants = args.variant ? [args.variant] : (['draft', 'published'] as const)
106
106
  for (const variant of variants) {
107
107
  await deleteForSource(ctx, variant, args.sourceType, args.sourceId)
@@ -112,7 +112,7 @@ export const removeForSource = mutation({
112
112
  export const clearProject = mutation({
113
113
  args: {},
114
114
  handler: async (ctx) => {
115
- await requireRole(ctx, 'editor') // EC-150
115
+ await requireCapability(ctx, 'content.write') // EC-150
116
116
  const existing = await ctx.db.query('referenceEntries').collect()
117
117
  for (const doc of existing) await ctx.db.delete(doc._id)
118
118
  // EC-269: a cleared index is no longer "built" — drop the marker so the next
@@ -126,7 +126,7 @@ export const clearProject = mutation({
126
126
  export const buildMarker = query({
127
127
  args: {},
128
128
  handler: async (ctx) => {
129
- await requireRole(ctx, 'viewer') // EC-150
129
+ await requireCapability(ctx, 'content.read') // EC-150
130
130
  const marker = await ctx.db.query('referenceIndexMarker').first()
131
131
  return marker ? { entryCount: marker.entryCount } : null
132
132
  },
@@ -135,7 +135,7 @@ export const buildMarker = query({
135
135
  export const setBuildMarker = mutation({
136
136
  args: { entryCount: v.number() },
137
137
  handler: async (ctx, args) => {
138
- await requireRole(ctx, 'editor') // EC-150
138
+ await requireCapability(ctx, 'content.write') // EC-150
139
139
  const existing = await ctx.db.query('referenceIndexMarker').first()
140
140
  if (existing) {
141
141
  await ctx.db.patch(existing._id, { entryCount: args.entryCount })
@@ -152,7 +152,7 @@ export const outbound = query({
152
152
  variant: v.optional(variantValidator),
153
153
  },
154
154
  handler: async (ctx, args) => {
155
- await requireRole(ctx, 'viewer') // EC-150
155
+ await requireCapability(ctx, 'content.read') // EC-150
156
156
  const variants = args.variant ? [args.variant] : (['draft', 'published'] as const)
157
157
  const out = []
158
158
  for (const variant of variants) {
@@ -176,7 +176,7 @@ export const inbound = query({
176
176
  variant: v.optional(variantValidator),
177
177
  },
178
178
  handler: async (ctx, args) => {
179
- await requireRole(ctx, 'viewer') // EC-150
179
+ await requireCapability(ctx, 'content.read') // EC-150
180
180
  return ctx.db
181
181
  .query('referenceEntries')
182
182
  .withIndex('by_target', (q) => {
@@ -193,7 +193,7 @@ export const inboundMany = query({
193
193
  variant: v.optional(variantValidator),
194
194
  },
195
195
  handler: async (ctx, args) => {
196
- await requireRole(ctx, 'viewer') // EC-150
196
+ await requireCapability(ctx, 'content.read') // EC-150
197
197
  // EC-308: inbound entries for many targets in ONE round-trip — loop the
198
198
  // `by_target` index server-side instead of the client issuing one `inbound`
199
199
  // query per target (the bulk delete/unpublish pre-flight fan-out). One result
@@ -216,7 +216,7 @@ export const inboundMany = query({
216
216
  export const list = query({
217
217
  args: { variant: v.optional(variantValidator) },
218
218
  handler: async (ctx, args) => {
219
- await requireRole(ctx, 'viewer') // EC-150
219
+ await requireCapability(ctx, 'content.read') // EC-150
220
220
  // EC-302: `by_source` leads with `variant`, so a variant filter is an index
221
221
  // prefix — push it into the query instead of collecting the whole table and
222
222
  // filtering in JS. Callers treat the result as an unordered set, so the shift
package/convex/schema.ts CHANGED
@@ -54,7 +54,20 @@ export default defineSchema({
54
54
  */
55
55
  members: defineTable({
56
56
  userId: v.string(),
57
- role: v.union(v.literal('admin'), v.literal('editor'), v.literal('viewer')),
57
+ // A config-driven role string (EC-240c, schema-as-lens): the store is agnostic
58
+ // about WHICH roles exist so a project's custom role set round-trips. Validity
59
+ // against the active policy is enforced on write (operations + guard.ts), not
60
+ // by this column — the same "DB is data, structure is code" split as documents.
61
+ role: v.string(),
62
+ /**
63
+ * Studio-user custom fields (EC-240, 240a): an open bag whose lens is the
64
+ * project-declared `kind:'user'` field set. Single-tenant means a `members`
65
+ * row is 1:1 with a studio user, so this is the natural home for their custom
66
+ * data — the row WE own, alongside the auth-owned `users` identity. Mirrors
67
+ * the asset `meta` bag; the users rail joins it to identity for the Users
68
+ * module. Optional (absent for members created before EC-240).
69
+ */
70
+ meta: v.optional(v.any()),
58
71
  createdAt: v.string(),
59
72
  updatedAt: v.string(),
60
73
  }).index('by_user', ['userId']),
@@ -137,6 +150,22 @@ export default defineSchema({
137
150
  updatedAt: v.string(),
138
151
  }).index('by_asset', ['assetId']),
139
152
 
153
+ /**
154
+ * Editorial redirects (EC-303) — 301/302 forwards an editor owns, stored as
155
+ * DATA (not config) so they change dynamically without a deploy. The delivery
156
+ * snapshot projects enabled entries onto the routing `RedirectRecord` the
157
+ * catch-all resolves; static/infra redirects stay native (next.config/vercel).
158
+ */
159
+ redirects: defineTable({
160
+ redirectId: v.string(),
161
+ from: v.string(),
162
+ to: v.string(),
163
+ permanent: v.boolean(),
164
+ enabled: v.boolean(),
165
+ createdAt: v.string(),
166
+ updatedAt: v.string(),
167
+ }).index('by_redirect', ['redirectId']),
168
+
140
169
  /**
141
170
  * Reference index entries (EC-155, vision AD-6) — derived data, recomputed
142
171
  * replace-all-for-source on every write. Indexed for efficient lookup in both
package/convex/sync.ts CHANGED
@@ -3,9 +3,13 @@ import type { GenericActionCtx, GenericDataModel } from 'convex/server'
3
3
  import { v } from 'convex/values'
4
4
  import { verifyCliToken } from '@elytracms/core/operations'
5
5
  import type { VerifiedCliToken } from '@elytracms/core/operations'
6
- import type { CliTokenRepository, PersistenceAdapter } from '@elytracms/core/persistence'
6
+ import { roleGrantsCapability } from '@elytracms/core/persistence'
7
+ import type {
8
+ CliTokenRepository,
9
+ OperationCapabilityId,
10
+ PersistenceAdapter,
11
+ } from '@elytracms/core/persistence'
7
12
  import { httpAction, internalQuery } from './functions'
8
- import type { ProjectRole } from './guard'
9
13
 
10
14
  /**
11
15
  * EC-157 — the builder-sync HTTP endpoint. Serves the contract fixed in
@@ -123,11 +127,14 @@ function parseSyncRoute(method: string, pathname: string): SyncRoute | undefined
123
127
 
124
128
  // --- Token auth (EC-150) -------------------------------------------------------
125
129
 
126
- const ROLE_RANK: Record<ProjectRole, number> = { viewer: 0, editor: 1, admin: 2 }
127
-
128
- /** What each endpoint requires; the snapshot read is `viewer`-level (EC-150). */
129
- const REQUIRED_ROLE: Record<SyncEndpoint, ProjectRole> = {
130
- snapshot: 'viewer',
130
+ /**
131
+ * The capability each endpoint requires; the snapshot read needs `content.read`
132
+ * (EC-240c). Capability grants come from the ONE canonical role policy
133
+ * (`roleGrantsCapability`), so a token's role is checked the same way handlers
134
+ * check members — no role literal at the boundary.
135
+ */
136
+ const REQUIRED_CAPABILITY: Record<SyncEndpoint, OperationCapabilityId> = {
137
+ snapshot: 'content.read',
131
138
  }
132
139
 
133
140
  /**
@@ -158,7 +165,7 @@ function cliTokenRepository(ctx: ActionCtx): CliTokenRepository {
158
165
 
159
166
  type AuthResult = { ok: true; verified: VerifiedCliToken } | { ok: false; response: Response }
160
167
 
161
- /** Resolve and verify the Bearer CLI token, then check it grants the required role. */
168
+ /** Resolve and verify the Bearer CLI token, then check it grants the required capability. */
162
169
  async function authorize(
163
170
  ctx: ActionCtx,
164
171
  request: Request,
@@ -186,13 +193,13 @@ async function authorize(
186
193
  response: errorResponse(401, 'unauthorized', 'Unknown, malformed, or revoked CLI token.'),
187
194
  }
188
195
  }
189
- if (ROLE_RANK[verified.role] < ROLE_RANK[REQUIRED_ROLE[route.endpoint]]) {
196
+ if (!roleGrantsCapability(verified.role, REQUIRED_CAPABILITY[route.endpoint])) {
190
197
  return {
191
198
  ok: false,
192
199
  response: errorResponse(
193
200
  403,
194
201
  'forbidden',
195
- `This endpoint requires the "${REQUIRED_ROLE[route.endpoint]}" role (token grants "${verified.role}").`,
202
+ `This endpoint requires the "${REQUIRED_CAPABILITY[route.endpoint]}" capability (token role "${verified.role}" does not grant it).`,
196
203
  ),
197
204
  }
198
205
  }
package/convex/users.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  import { getAuthUserId } from '@convex-dev/auth/server'
2
+ import { v } from 'convex/values'
2
3
  import { query } from './functions'
4
+ import type { QueryCtx } from './functions'
5
+ import { requireCapability } from './guard'
3
6
 
4
7
  /**
5
8
  * Current-user lookup (EC-150). The Convex `AuthAdapter` implementation calls
@@ -21,3 +24,63 @@ export const me = query({
21
24
  }
22
25
  },
23
26
  })
27
+
28
+ /**
29
+ * The studio users rail (EC-240, 240a). A user is a **projection** joining a
30
+ * `members` row (role + the custom-field `meta` bag WE own) to the auth-owned
31
+ * `users` identity (email / name / image). The `ConvexUserRepository` maps these
32
+ * onto `@elytracms/core/persistence` `UserRecord`s.
33
+ *
34
+ * Admin-only, server-side: the list exposes every member's email + role — an
35
+ * administration concern (like `cliTokens:list`), not a content read. UI gating
36
+ * alone is never trusted.
37
+ */
38
+
39
+ type MemberDoc = {
40
+ userId: string
41
+ // A config-driven role string (EC-240c) — the store is policy-agnostic; validity
42
+ // is enforced on write, not by this shape.
43
+ role: string
44
+ meta?: unknown
45
+ createdAt: string
46
+ updatedAt: string
47
+ }
48
+
49
+ /** Join one member row to its auth identity, producing the rail's wire shape. */
50
+ async function joinUser(ctx: QueryCtx, member: MemberDoc) {
51
+ const authId = ctx.db.normalizeId('users', member.userId)
52
+ const user = authId ? await ctx.db.get(authId) : null
53
+ return {
54
+ id: member.userId,
55
+ email: user?.email ?? '',
56
+ ...(user?.name ? { name: user.name } : {}),
57
+ ...(user?.image ? { avatar: user.image } : {}),
58
+ role: member.role,
59
+ ...(member.meta !== undefined ? { meta: member.meta } : {}),
60
+ createdAt: member.createdAt,
61
+ updatedAt: member.updatedAt,
62
+ }
63
+ }
64
+
65
+ export const list = query({
66
+ args: {},
67
+ handler: async (ctx) => {
68
+ await requireCapability(ctx, 'project.administer')
69
+ const members = await ctx.db.query('members').collect()
70
+ const rows = await Promise.all(members.map((m) => joinUser(ctx, m)))
71
+ return rows.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
72
+ },
73
+ })
74
+
75
+ export const get = query({
76
+ args: { userId: v.string() },
77
+ handler: async (ctx, { userId }) => {
78
+ await requireCapability(ctx, 'project.administer')
79
+ const member = await ctx.db
80
+ .query('members')
81
+ .withIndex('by_user', (q) => q.eq('userId', userId))
82
+ .first()
83
+ if (!member) return null
84
+ return joinUser(ctx, member)
85
+ },
86
+ })
@@ -1,7 +1,8 @@
1
1
  import type { ReactNode } from 'react';
2
2
  import type { LucideIcon } from 'lucide-react';
3
+ import type { CollectionFieldOverride, CollectionFormMeta } from '@elytracms/core/cms-core';
3
4
  import type { SectionId } from '@elytracms/core/studio-core';
4
- import { type AppViewContext, type AppViewDeepLink, type AppViewModule, type SidebarViewNode } from './module';
5
+ import { type AppViewContext, type AppViewDeepLink, type AppViewModule, type NavPermission, type SidebarViewNode } from './module';
5
6
  /**
6
7
  * EC-237/239 — the studio's BUILT-IN views + the consumer-facing `nav` namespace,
7
8
  * an instance of the EC-236 customization seam.
@@ -36,11 +37,34 @@ export interface CmsViewConfig {
36
37
  children?: SidebarViewNode[];
37
38
  /** The availability section this view rides on (EC-221); defaults to `cms`. */
38
39
  availabilitySection?: SectionId;
40
+ /** OPTIONAL, default-open nav-permission predicate (EC-240 / AD-18). See {@link BuiltinViewConfig.visible}. */
41
+ visible?: NavPermission;
39
42
  }
40
- /** Common label/icon overrides for a built-in view (`nav.routes({ label: 'URLs' })`). */
43
+ /** Common label/icon overrides for a built-in view (`nav.redirects({ label: 'URLs' })`). */
41
44
  export interface BuiltinViewConfig {
42
45
  label?: string;
43
46
  icon?: LucideIcon;
47
+ /**
48
+ * OPTIONAL, default-open nav-permission predicate (EC-240 / AD-18): hide this
49
+ * rail item for principals it returns `false` for; absent → always visible. The
50
+ * policy (which roles) lives in the consuming project, never the engine. E.g.
51
+ * `nav.users({ visible: ({ role }) => role === 'admin' })`.
52
+ */
53
+ visible?: NavPermission;
54
+ }
55
+ /**
56
+ * Config for the **redirects** rail (EC-311) — a built-in view whose engine-owned
57
+ * schema is customizable. Beyond the shared label/icon/visible, a mount may rename
58
+ * the rail (`form`) and **override built-in fields by equal name** (`fields`):
59
+ * naming, permissions (`form.readOnly`), rendering (`form.control` → `fieldRenderers`)
60
+ * and validation. The canonical `from`/`to`/`permanent`/`enabled` names+types are
61
+ * contract-locked; a type change or a net-new field is a loud config error.
62
+ */
63
+ export interface RedirectsViewConfig extends BuiltinViewConfig {
64
+ /** Rename the rail (e.g. `{ label: 'URLs' }`). */
65
+ form?: Partial<CollectionFormMeta>;
66
+ /** Override built-in redirect fields, matched by `name`. */
67
+ fields?: readonly CollectionFieldOverride[];
44
68
  }
45
69
  /** Config for a fully custom view (`nav.view({...})`) — the net-new surface escape hatch. */
46
70
  export interface CustomViewConfig {
@@ -52,6 +76,8 @@ export interface CustomViewConfig {
52
76
  render: (ctx: AppViewContext) => ReactNode;
53
77
  parsePath?: (segments: string[]) => AppViewDeepLink | null;
54
78
  availabilitySection?: SectionId;
79
+ /** OPTIONAL, default-open nav-permission predicate (EC-240 / AD-18). See {@link BuiltinViewConfig.visible}. */
80
+ visible?: NavPermission;
55
81
  }
56
82
  /**
57
83
  * Build a CMS view — the default `cms` view, or a focused instance. Every instance
@@ -63,10 +89,24 @@ export interface CustomViewConfig {
63
89
  export declare function cmsView(config?: CmsViewConfig): AppViewModule;
64
90
  /** The components registry browser view. */
65
91
  export declare function componentsView(config?: BuiltinViewConfig): AppViewModule;
66
- /** The resolved routes & redirects view (read-only; routing is config-as-code). */
67
- export declare function routesView(config?: BuiltinViewConfig): AppViewModule;
92
+ /**
93
+ * The Redirects view (EC-303) — editorial redirects as DATA, an editor-owned CRUD
94
+ * surface (create/edit/delete) over the portable list/edit surfaces. Routes are no
95
+ * longer shown in the admin (they're code; native `next.config`/`vercel` own static
96
+ * forwards, evaluated before these). Rides on the CMS availability (a content/
97
+ * routing concern). `[]` is the list; `[redirectId]` opens that redirect's detail.
98
+ */
99
+ export declare function redirectsView(config?: RedirectsViewConfig): AppViewModule;
68
100
  /** The asset library view. */
69
101
  export declare function assetsView(config?: BuiltinViewConfig): AppViewModule;
102
+ /**
103
+ * The studio Users view (EC-240) — an admin-only roster of who has access to this
104
+ * deployment and their role. A first-class section (its own rail entry), modeled
105
+ * like the Assets library; the surface itself gates to admins and renders a
106
+ * "restricted" state otherwise. Rides on the `settings` availability (an
107
+ * admin/config concern, so it appears/hides with a project's settings surface).
108
+ */
109
+ export declare function usersView(config?: BuiltinViewConfig): AppViewModule;
70
110
  /** The extensions marketplace view. */
71
111
  export declare function extensionsView(config?: BuiltinViewConfig): AppViewModule;
72
112
  /** The project settings view. */
@@ -83,14 +123,6 @@ export declare function customView(config: CustomViewConfig): AppViewModule;
83
123
  * when a consumer's rail omits one.
84
124
  */
85
125
  export declare const DEFAULT_VIEWS: readonly AppViewModule[];
86
- /**
87
- * The `nav` namespace — the single typed builder for `studio.config.tsx`'s section
88
- * rail. Each call returns a {@link SidebarViewNode} (or a group / separator) that
89
- * carries its view DIRECTLY: `nav.cms({ collection })` is a focused CMS view,
90
- * `nav.view({ render })` a net-new surface, `nav.group(label, [...])` a header with
91
- * sub-views, `nav.separator()` a divider. No string ids, no `moduleId` — the rail
92
- * is the views.
93
- */
94
126
  export declare const nav: {
95
127
  /**
96
128
  * The CMS view. `nav.cms()` / `nav.cms({ collections })` is a PARENT that nests its
@@ -102,12 +134,14 @@ export declare const nav: {
102
134
  readonly components: (config?: BuiltinViewConfig) => SidebarViewNode;
103
135
  /** The asset library. */
104
136
  readonly assets: (config?: BuiltinViewConfig) => SidebarViewNode;
105
- /** The resolved routes & redirects. */
106
- readonly routes: (config?: BuiltinViewConfig) => SidebarViewNode;
137
+ /** Editorial redirects (EC-303); built-in fields customizable by name (EC-311). */
138
+ readonly redirects: (config?: RedirectsViewConfig) => SidebarViewNode;
107
139
  /** Project settings. */
108
140
  readonly settings: (config?: BuiltinViewConfig) => SidebarViewNode;
109
141
  /** The extensions marketplace. */
110
142
  readonly extensions: (config?: BuiltinViewConfig) => SidebarViewNode;
143
+ /** The admin users roster (EC-240). */
144
+ readonly users: (config?: BuiltinViewConfig) => SidebarViewNode;
111
145
  /** A fully custom view — pass your own `render` (and optional `parsePath`). */
112
146
  readonly view: (config: CustomViewConfig) => SidebarViewNode;
113
147
  /** A labelled group of views (a header with sub-entries). */
@@ -1,16 +1,18 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect } from 'react';
3
- import { Blocks, Database, FileCog, Image, LayoutGrid, Puzzle, Settings as SettingsIcon, Signpost, } from 'lucide-react';
4
- import { isSingleton, resolveSingletonDocument } from '@elytracms/core/cms-core';
3
+ import { Blocks, Database, FileCog, Image, LayoutGrid, Puzzle, Settings as SettingsIcon, Signpost, Users, } from 'lucide-react';
4
+ import { extendCollectionDef, isSingleton, resolveSingletonDocument } from '@elytracms/core/cms-core';
5
5
  import { Navigate, STUDIO_PROJECT_ROUTE, studioPath } from '@elytracms/studio/router';
6
6
  import { Text } from '@elytracms/studio/ui';
7
7
  import { getInstantiatedKit } from '@elytracms/studio/lib/starters/kit-session';
8
- import { cmsDeepLink, componentsDeepLink, groupNode, separatorNode, viewNode, } from './module';
8
+ import { cmsDeepLink, componentsDeepLink, usersDeepLink, redirectsDeepLink, groupNode, separatorNode, viewNode, } from './module';
9
9
  import { SectionSurface } from '../section-surface';
10
10
  import { SectionStateView } from '../section-state';
11
11
  import { SettingsForm } from '../settings-form';
12
12
  import { AssetLibrary } from '../assets/asset-library';
13
- import { RoutesShell } from '../routes/routes-shell';
13
+ import { UsersModule } from '../users/users-module';
14
+ import { RedirectsModule } from '../redirects/redirects-module';
15
+ import { redirectsCollectionDef } from '../redirects/redirects-schema';
14
16
  import { ExtensionsView } from '../extensions/extensions-view';
15
17
  import { RegistryBrowser } from '../components/registry-browser';
16
18
  import { ComponentsIndex } from '../components/components-index';
@@ -90,16 +92,25 @@ export function componentsView(config = {}) {
90
92
  render: (ctx) => (_jsx(ComponentsScreen, { projectId: ctx.projectId, componentId: ctx.deepLink.componentId ?? null, state: ctx.search.state })),
91
93
  };
92
94
  }
93
- /** The resolved routes & redirects view (read-only; routing is config-as-code). */
94
- export function routesView(config = {}) {
95
+ /**
96
+ * The Redirects view (EC-303) — editorial redirects as DATA, an editor-owned CRUD
97
+ * surface (create/edit/delete) over the portable list/edit surfaces. Routes are no
98
+ * longer shown in the admin (they're code; native `next.config`/`vercel` own static
99
+ * forwards, evaluated before these). Rides on the CMS availability (a content/
100
+ * routing concern). `[]` is the list; `[redirectId]` opens that redirect's detail.
101
+ */
102
+ export function redirectsView(config = {}) {
103
+ // EC-311: merge any field/label overrides over the engine-owned def ONCE, at config
104
+ // build (config-as-code — a locked violation throws here, at `defineStudio` mount).
105
+ const schema = extendCollectionDef(redirectsCollectionDef, config, { additiveFields: 'reject' });
95
106
  return {
96
- id: 'routes',
97
- label: config.label ?? 'Routes',
98
- description: 'View the resolved URL routes & redirects (defined in elytra.config.ts).',
107
+ id: 'redirects',
108
+ label: config.label ?? schema.form?.label ?? 'Redirects',
109
+ description: 'Create and manage URL redirects (301/302) for this site.',
99
110
  icon: config.icon ?? Signpost,
100
- // Routes/redirects are CMS routing concerns, so the surface rides on the CMS availability.
101
111
  availabilitySection: 'cms',
102
- render: (ctx) => ctx.search.state && ctx.search.state !== 'working' ? (_jsx(SectionStateView, { state: ctx.search.state, title: "Routes" })) : (_jsx(RoutesShell, { project: ctx.project })),
112
+ parsePath: redirectsDeepLink,
113
+ render: (ctx) => ctx.search.state && ctx.search.state !== 'working' ? (_jsx(SectionStateView, { state: ctx.search.state, title: "Redirects" })) : (_jsx(RedirectsModule, { projectId: ctx.projectId, redirectId: ctx.deepLink.redirectId ?? null, schema: schema })),
103
114
  };
104
115
  }
105
116
  /** The asset library view. */
@@ -113,6 +124,26 @@ export function assetsView(config = {}) {
113
124
  render: (ctx) => (_jsx(SectionSurface, { project: ctx.project, section: "assets", forcedState: ctx.search.state, children: _jsx(AssetLibrary, { projectId: ctx.projectId, focusAssetId: ctx.search.asset }) })),
114
125
  };
115
126
  }
127
+ /**
128
+ * The studio Users view (EC-240) — an admin-only roster of who has access to this
129
+ * deployment and their role. A first-class section (its own rail entry), modeled
130
+ * like the Assets library; the surface itself gates to admins and renders a
131
+ * "restricted" state otherwise. Rides on the `settings` availability (an
132
+ * admin/config concern, so it appears/hides with a project's settings surface).
133
+ */
134
+ export function usersView(config = {}) {
135
+ return {
136
+ id: 'users',
137
+ label: config.label ?? 'Users',
138
+ description: 'Manage who has access to this deployment and their role.',
139
+ icon: config.icon ?? Users,
140
+ availabilitySection: 'users',
141
+ // EC-240: `[]` is the roster; `[userId]` opens that member in the portable edit
142
+ // shell (AD-19 reuse — the same `DetailEditor` body over the users source).
143
+ parsePath: usersDeepLink,
144
+ render: (ctx) => ctx.search.state && ctx.search.state !== 'working' ? (_jsx(SectionStateView, { state: ctx.search.state, title: "Users" })) : (_jsx(UsersModule, { projectId: ctx.projectId, userId: ctx.deepLink.userId ?? null })),
145
+ };
146
+ }
116
147
  /** The extensions marketplace view. */
117
148
  export function extensionsView(config = {}) {
118
149
  return {
@@ -176,9 +207,10 @@ export function customView(config) {
176
207
  export const DEFAULT_VIEWS = [
177
208
  componentsView(),
178
209
  cmsView(),
179
- routesView(),
210
+ redirectsView(),
180
211
  assetsView(),
181
212
  extensionsView(),
213
+ usersView(),
182
214
  settingsView(),
183
215
  builderView(),
184
216
  ];
@@ -190,6 +222,14 @@ export const DEFAULT_VIEWS = [
190
222
  * sub-views, `nav.separator()` a divider. No string ids, no `moduleId` — the rail
191
223
  * is the views.
192
224
  */
225
+ /**
226
+ * Forward the OPTIONAL, default-open nav-permission predicate (EC-240 / AD-18)
227
+ * from a `nav.*` config onto the built view, so gating is settable on EVERY nav
228
+ * item — not just Users. Absent → the view is unchanged (always visible).
229
+ */
230
+ function withNavPermission(view, config) {
231
+ return config.visible ? { ...view, visible: config.visible } : view;
232
+ }
193
233
  export const nav = {
194
234
  /**
195
235
  * The CMS view. `nav.cms()` / `nav.cms({ collections })` is a PARENT that nests its
@@ -197,11 +237,12 @@ export const nav = {
197
237
  * is a single-collection LEAF; `nav.cms({ children })` supplies explicit children.
198
238
  */
199
239
  cms: (config = {}) => {
240
+ const view = withNavPermission(cmsView(config), config);
200
241
  // A single-collection lens is a leaf — it opens that collection directly.
201
242
  if (config.collection)
202
- return viewNode(cmsView(config));
243
+ return viewNode(view);
203
244
  // Otherwise a parent that expands its collections as nested rail children (EC-293).
204
- const node = { kind: 'view', view: cmsView(config) };
245
+ const node = { kind: 'view', view };
205
246
  if (config.children)
206
247
  node.children = config.children;
207
248
  else
@@ -209,17 +250,19 @@ export const nav = {
209
250
  return node;
210
251
  },
211
252
  /** The component registry browser. */
212
- components: (config = {}) => viewNode(componentsView(config)),
253
+ components: (config = {}) => viewNode(withNavPermission(componentsView(config), config)),
213
254
  /** The asset library. */
214
- assets: (config = {}) => viewNode(assetsView(config)),
215
- /** The resolved routes & redirects. */
216
- routes: (config = {}) => viewNode(routesView(config)),
255
+ assets: (config = {}) => viewNode(withNavPermission(assetsView(config), config)),
256
+ /** Editorial redirects (EC-303); built-in fields customizable by name (EC-311). */
257
+ redirects: (config = {}) => viewNode(withNavPermission(redirectsView(config), config)),
217
258
  /** Project settings. */
218
- settings: (config = {}) => viewNode(settingsView(config)),
259
+ settings: (config = {}) => viewNode(withNavPermission(settingsView(config), config)),
219
260
  /** The extensions marketplace. */
220
- extensions: (config = {}) => viewNode(extensionsView(config)),
261
+ extensions: (config = {}) => viewNode(withNavPermission(extensionsView(config), config)),
262
+ /** The admin users roster (EC-240). */
263
+ users: (config = {}) => viewNode(withNavPermission(usersView(config), config)),
221
264
  /** A fully custom view — pass your own `render` (and optional `parsePath`). */
222
- view: (config) => viewNode(customView(config)),
265
+ view: (config) => viewNode(withNavPermission(customView(config), config)),
223
266
  /** A labelled group of views (a header with sub-entries). */
224
267
  group: (label, children) => groupNode(label, children),
225
268
  /** A divider between rail sections. */