@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.
- package/convex/assets.ts +19 -10
- package/convex/authority.ts +60 -0
- package/convex/cliTokens.ts +4 -4
- package/convex/cms.ts +12 -12
- package/convex/content.ts +10 -3
- package/convex/delivery.ts +22 -4
- package/convex/guard.ts +28 -11
- package/convex/members.ts +40 -7
- package/convex/redirects.ts +67 -0
- package/convex/references.ts +11 -11
- package/convex/schema.ts +30 -1
- package/convex/sync.ts +17 -10
- package/convex/users.ts +63 -0
- package/dist/components/app-views/default-modules.d.ts +48 -14
- package/dist/components/app-views/default-modules.js +64 -21
- package/dist/components/app-views/default-modules.js.map +1 -1
- package/dist/components/app-views/module.d.ts +32 -0
- package/dist/components/app-views/module.js +16 -0
- package/dist/components/app-views/module.js.map +1 -1
- package/dist/components/app-views/sidebar.d.ts +15 -1
- package/dist/components/app-views/sidebar.js +10 -0
- package/dist/components/app-views/sidebar.js.map +1 -1
- package/dist/components/cms/asset-picker.js +4 -4
- package/dist/components/cms/asset-picker.js.map +1 -1
- package/dist/components/cms/cell.js +16 -13
- package/dist/components/cms/cell.js.map +1 -1
- package/dist/components/cms/cms-store.d.ts +11 -26
- package/dist/components/cms/cms-store.js +25 -160
- package/dist/components/cms/cms-store.js.map +1 -1
- package/dist/components/cms/collection-table.d.ts +10 -0
- package/dist/components/cms/collection-table.js +72 -72
- package/dist/components/cms/collection-table.js.map +1 -1
- package/dist/components/cms/detail-editor.d.ts +14 -0
- package/dist/components/cms/detail-editor.js +224 -72
- package/dist/components/cms/detail-editor.js.map +1 -1
- package/dist/components/cms/document-editor-context.d.ts +26 -0
- package/dist/components/cms/document-editor-context.js.map +1 -1
- package/dist/components/cms/editable-cell.js +12 -7
- package/dist/components/cms/editable-cell.js.map +1 -1
- package/dist/components/cms/field-control.d.ts +2 -0
- package/dist/components/cms/field-control.js +40 -30
- package/dist/components/cms/field-control.js.map +1 -1
- package/dist/components/cms/filter-builder.d.ts +18 -0
- package/dist/components/cms/filter-builder.js +47 -6
- package/dist/components/cms/filter-builder.js.map +1 -1
- package/dist/components/cms/import-export.js +5 -3
- package/dist/components/cms/import-export.js.map +1 -1
- package/dist/components/cms/reference-confirm.js +8 -9
- package/dist/components/cms/reference-confirm.js.map +1 -1
- package/dist/components/cms/relation-picker.js +13 -10
- package/dist/components/cms/relation-picker.js.map +1 -1
- package/dist/components/cms/use-cell-edit.js +13 -7
- package/dist/components/cms/use-cell-edit.js.map +1 -1
- package/dist/components/cms/use-document-versions.d.ts +16 -0
- package/dist/components/cms/use-document-versions.js +31 -0
- package/dist/components/cms/use-document-versions.js.map +1 -0
- package/dist/components/cms/version-compare-text.d.ts +30 -0
- package/dist/components/cms/version-compare-text.js +113 -0
- package/dist/components/cms/version-compare-text.js.map +1 -0
- package/dist/components/cms/version-compare.d.ts +12 -0
- package/dist/components/cms/version-compare.js +271 -0
- package/dist/components/cms/version-compare.js.map +1 -0
- package/dist/components/cms/versions-pane.d.ts +3 -0
- package/dist/components/cms/versions-pane.js +47 -38
- package/dist/components/cms/versions-pane.js.map +1 -1
- package/dist/components/cms/view-bar.d.ts +21 -6
- package/dist/components/cms/view-bar.js +42 -57
- package/dist/components/cms/view-bar.js.map +1 -1
- package/dist/components/collection-view/cms-collection-view-source.d.ts +15 -0
- package/dist/components/collection-view/cms-collection-view-source.js +158 -0
- package/dist/components/collection-view/cms-collection-view-source.js.map +1 -0
- package/dist/components/collection-view/collection-view-context.d.ts +10 -0
- package/dist/components/collection-view/collection-view-context.js +26 -0
- package/dist/components/collection-view/collection-view-context.js.map +1 -0
- package/dist/components/current-user-menu.js +8 -1
- package/dist/components/current-user-menu.js.map +1 -1
- package/dist/components/redirects/redirects-collection-view-source.d.ts +24 -0
- package/dist/components/redirects/redirects-collection-view-source.js +137 -0
- package/dist/components/redirects/redirects-collection-view-source.js.map +1 -0
- package/dist/components/redirects/redirects-module.d.ts +21 -0
- package/dist/components/redirects/redirects-module.js +43 -0
- package/dist/components/redirects/redirects-module.js.map +1 -0
- package/dist/components/redirects/redirects-schema.d.ts +10 -0
- package/dist/components/redirects/redirects-schema.js +42 -0
- package/dist/components/redirects/redirects-schema.js.map +1 -0
- package/dist/components/redirects/redirects-store.d.ts +46 -0
- package/dist/components/redirects/redirects-store.js +127 -0
- package/dist/components/redirects/redirects-store.js.map +1 -0
- package/dist/components/references/references-panel.js +2 -2
- package/dist/components/references/references-panel.js.map +1 -1
- package/dist/components/section-nav.js +14 -5
- package/dist/components/section-nav.js.map +1 -1
- package/dist/components/users/user-search.d.ts +8 -0
- package/dist/components/users/user-search.js +13 -0
- package/dist/components/users/user-search.js.map +1 -0
- package/dist/components/users/users-collection-view-source.d.ts +28 -0
- package/dist/components/users/users-collection-view-source.js +157 -0
- package/dist/components/users/users-collection-view-source.js.map +1 -0
- package/dist/components/users/users-module.d.ts +23 -0
- package/dist/components/users/users-module.js +61 -0
- package/dist/components/users/users-module.js.map +1 -0
- package/dist/components/users/users-schema.d.ts +19 -0
- package/dist/components/users/users-schema.js +38 -0
- package/dist/components/users/users-schema.js.map +1 -0
- package/dist/components/users/users-store.d.ts +40 -0
- package/dist/components/users/users-store.js +87 -0
- package/dist/components/users/users-store.js.map +1 -0
- package/dist/lib/cms-views/index.d.ts +4 -5
- package/dist/lib/cms-views/index.js +4 -5
- package/dist/lib/cms-views/index.js.map +1 -1
- package/dist/lib/cms-views/view-model.d.ts +48 -1
- package/dist/lib/cms-views/view-model.js +77 -1
- package/dist/lib/cms-views/view-model.js.map +1 -1
- package/dist/lib/collection-view/collection-view-source.d.ts +251 -0
- package/dist/lib/collection-view/collection-view-source.js +10 -0
- package/dist/lib/collection-view/collection-view-source.js.map +1 -0
- package/dist/lib/operations/session-hydration.d.ts +7 -0
- package/dist/lib/operations/session-hydration.js +7 -1
- package/dist/lib/operations/session-hydration.js.map +1 -1
- package/dist/lib/persistence/convex-adapter.d.ts +5 -1
- package/dist/lib/persistence/convex-adapter.js +148 -1
- package/dist/lib/persistence/convex-adapter.js.map +1 -1
- package/dist/lib/workspace/index.d.ts +1 -8
- package/dist/lib/workspace/index.js +3 -9
- package/dist/lib/workspace/index.js.map +1 -1
- package/dist/router/match.d.ts +4 -0
- package/dist/router/match.js +8 -0
- package/dist/router/match.js.map +1 -1
- package/dist/ui/index.d.ts +2 -0
- package/dist/ui/index.js +2 -0
- package/dist/ui/index.js.map +1 -1
- package/dist/ui/popover.d.ts +13 -0
- package/dist/ui/popover.js +17 -0
- package/dist/ui/popover.js.map +1 -0
- package/dist/ui/row-item.d.ts +16 -0
- package/dist/ui/row-item.js +18 -0
- package/dist/ui/row-item.js.map +1 -0
- package/package.json +3 -2
package/convex/references.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { v } from 'convex/values'
|
|
2
2
|
import { mutation, query } from './functions'
|
|
3
|
-
import {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
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 (
|
|
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 "${
|
|
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.
|
|
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
|
-
/**
|
|
67
|
-
|
|
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
|
-
/**
|
|
106
|
-
readonly
|
|
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 {
|
|
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
|
-
/**
|
|
94
|
-
|
|
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: '
|
|
97
|
-
label: config.label ?? '
|
|
98
|
-
description: '
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
/**
|
|
216
|
-
|
|
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. */
|