@elytracms/studio 0.0.27 → 0.0.28
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 +33 -14
- package/dist/components/app-views/default-modules.js +59 -20
- 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 +18 -0
- package/dist/components/redirects/redirects-collection-view-source.js +114 -0
- package/dist/components/redirects/redirects-collection-view-source.js.map +1 -0
- package/dist/components/redirects/redirects-module.d.ts +18 -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 +124 -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
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { useCallback, useMemo, useState } from 'react';
|
|
2
|
+
import { studioPath } from '@elytracms/studio/router';
|
|
3
|
+
import { defaultViewState } from '@elytracms/studio/lib/cms-views';
|
|
4
|
+
import { useAuth } from '../auth-context';
|
|
5
|
+
import { useUsers } from './users-store';
|
|
6
|
+
import { usersCollectionDef } from './users-schema';
|
|
7
|
+
/** The users rail is not localized — one implicit locale keeps the lens honest. */
|
|
8
|
+
const USERS_LOCALE = 'en';
|
|
9
|
+
const USERS_LOCALE_CONFIG = { default: USERS_LOCALE, locales: [USERS_LOCALE] };
|
|
10
|
+
/** The built-in field names — everything else in `values` is a custom `meta` field. */
|
|
11
|
+
const IDENTITY_FIELDS = new Set(['name', 'email', 'role']);
|
|
12
|
+
/** Project the auth-identity + membership join into the lens's `values` shape. */
|
|
13
|
+
export function userToDocument(record) {
|
|
14
|
+
return {
|
|
15
|
+
collection: usersCollectionDef.id,
|
|
16
|
+
id: record.id,
|
|
17
|
+
values: {
|
|
18
|
+
// Display title falls through empties (name → email → id) so a nameless,
|
|
19
|
+
// blank-email member never renders as a blank row; `email` keeps its real value.
|
|
20
|
+
name: record.name || record.email || record.id,
|
|
21
|
+
email: record.email,
|
|
22
|
+
role: record.roles[0] ?? 'viewer',
|
|
23
|
+
...(record.meta ?? {}),
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Principal-based field access for the users rail (EC-240, AD-18). `role` is
|
|
29
|
+
* authority: **read-only when you are editing YOURSELF** — you don't change your
|
|
30
|
+
* own role (the server integrity floor also guards it) — and **editable otherwise**
|
|
31
|
+
* (the rail is admin-only, so a non-self editor is an admin acting on someone else).
|
|
32
|
+
* Identity fields (name/email) follow their schema `form.readOnly`; everything else
|
|
33
|
+
* is editable. Pure, so the self/other rule is unit-testable without the auth hook.
|
|
34
|
+
*/
|
|
35
|
+
export function userFieldAccess(field, record, currentUserId) {
|
|
36
|
+
if (field.name === 'role') {
|
|
37
|
+
return record?.id === currentUserId ? 'read' : 'edit';
|
|
38
|
+
}
|
|
39
|
+
return field.form?.readOnly ? 'read' : 'edit';
|
|
40
|
+
}
|
|
41
|
+
/** Map an edited lens document back to the write contract (role + custom `meta`). */
|
|
42
|
+
export function documentToSaveInput(doc) {
|
|
43
|
+
const values = doc.values ?? {};
|
|
44
|
+
const meta = {};
|
|
45
|
+
for (const [key, value] of Object.entries(values)) {
|
|
46
|
+
if (!IDENTITY_FIELDS.has(key))
|
|
47
|
+
meta[key] = value;
|
|
48
|
+
}
|
|
49
|
+
const role = typeof values.role === 'string' ? values.role : 'viewer';
|
|
50
|
+
return { id: doc.id, roles: [role], ...(Object.keys(meta).length > 0 ? { meta } : {}) };
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Assemble a {@link CollectionViewSource} for the **users rail** (EC-240, AD-19) —
|
|
54
|
+
* the reuse proof that the portable list/edit surfaces serve a non-`cmsDocuments`
|
|
55
|
+
* rail. Mirrors {@link useCmsCollectionViewSource}, over the users store instead of
|
|
56
|
+
* the CMS store, with the users capability profile: admin-only, no
|
|
57
|
+
* publish/versions/create/import (identities are minted by sign-up and their bytes
|
|
58
|
+
* are auth-owned), remove allowed (revoke membership). Identity fields are
|
|
59
|
+
* read-only; an admin edits a member's role (and future custom `meta` fields).
|
|
60
|
+
*
|
|
61
|
+
* Must be mounted under a `UsersProvider` (for {@link useUsers}).
|
|
62
|
+
*/
|
|
63
|
+
export function useUsersCollectionViewSource(projectId) {
|
|
64
|
+
const users = useUsers();
|
|
65
|
+
// The signed-in principal — so `role` locks to read-only when an admin opens
|
|
66
|
+
// their OWN record (self ≠ other; the server floor enforces it regardless).
|
|
67
|
+
const auth = useAuth();
|
|
68
|
+
const currentUserId = auth.session?.user?.id;
|
|
69
|
+
const [viewState, setViewStateRaw] = useState(() => defaultViewState(usersCollectionDef));
|
|
70
|
+
const [lastError, setLastError] = useState(null);
|
|
71
|
+
const documents = useCallback(() => users.users.map(userToDocument), [users.users]);
|
|
72
|
+
const get = useCallback((ref) => {
|
|
73
|
+
const record = users.users.find((u) => u.id === ref.id);
|
|
74
|
+
return record ? userToDocument(record) : undefined;
|
|
75
|
+
}, [users.users]);
|
|
76
|
+
const titleOf = useCallback((ref) => {
|
|
77
|
+
const record = users.users.find((u) => u.id === ref.id);
|
|
78
|
+
return record?.name || record?.email || ref.id;
|
|
79
|
+
}, [users.users]);
|
|
80
|
+
const setViewState = useCallback((update) => {
|
|
81
|
+
setViewStateRaw((prev) => (typeof update === 'function' ? update(prev) : update));
|
|
82
|
+
}, []);
|
|
83
|
+
const save = useCallback((doc) => {
|
|
84
|
+
void users.save(documentToSaveInput(doc)).then((result) => {
|
|
85
|
+
if (!result.ok)
|
|
86
|
+
setLastError({ code: 'unknown', message: result.message });
|
|
87
|
+
});
|
|
88
|
+
}, [users]);
|
|
89
|
+
const remove = useCallback((ref) => {
|
|
90
|
+
void users.remove(ref.id).then((result) => {
|
|
91
|
+
if (!result.ok)
|
|
92
|
+
setLastError({ code: 'unknown', message: result.message });
|
|
93
|
+
});
|
|
94
|
+
}, [users]);
|
|
95
|
+
return useMemo(() => {
|
|
96
|
+
const capabilities = {
|
|
97
|
+
publish: false,
|
|
98
|
+
versions: false,
|
|
99
|
+
locales: false,
|
|
100
|
+
inlineEdit: false,
|
|
101
|
+
create: false, // identities are minted by sign-up, never here
|
|
102
|
+
remove: true, // revoke membership (does not delete the auth identity)
|
|
103
|
+
bulk: false,
|
|
104
|
+
liveUrl: false,
|
|
105
|
+
hierarchy: false,
|
|
106
|
+
importExport: false, // credential records are never dumped to raw JSON
|
|
107
|
+
adminOnly: true,
|
|
108
|
+
};
|
|
109
|
+
const source = {
|
|
110
|
+
collectionId: usersCollectionDef.id,
|
|
111
|
+
schema: usersCollectionDef,
|
|
112
|
+
capabilities,
|
|
113
|
+
projectId,
|
|
114
|
+
localeConfig: USERS_LOCALE_CONFIG,
|
|
115
|
+
activeLocale: USERS_LOCALE,
|
|
116
|
+
documents,
|
|
117
|
+
get,
|
|
118
|
+
titleOf,
|
|
119
|
+
// Users carry no relation fields in v1, so there are no cross-collection
|
|
120
|
+
// candidates to resolve; the lens is the only collection in scope.
|
|
121
|
+
referenceCandidates: () => [],
|
|
122
|
+
collections: [usersCollectionDef],
|
|
123
|
+
// The users rail now has a per-member detail surface (EC-240): rows link to
|
|
124
|
+
// it, and the edit shell's back-crumb / post-delete nav return to the list.
|
|
125
|
+
detailPath: (id) => studioPath.user(id),
|
|
126
|
+
listPath: studioPath.users(),
|
|
127
|
+
isFieldReadOnly: (field) => field.form?.readOnly ?? false,
|
|
128
|
+
// Principal-based tri-state: `role` is read-only for self, editable for an
|
|
129
|
+
// admin acting on another; identity fields follow their schema readOnly.
|
|
130
|
+
fieldAccess: (field, record) => userFieldAccess(field, record, currentUserId),
|
|
131
|
+
validate: () => [],
|
|
132
|
+
lastError,
|
|
133
|
+
clearLastError: () => setLastError(null),
|
|
134
|
+
viewState: () => viewState,
|
|
135
|
+
setViewState,
|
|
136
|
+
// create is gated off (capabilities.create === false); never reached.
|
|
137
|
+
create: () => {
|
|
138
|
+
throw new Error('users are created by sign-up, not the users rail');
|
|
139
|
+
},
|
|
140
|
+
save,
|
|
141
|
+
remove,
|
|
142
|
+
};
|
|
143
|
+
return source;
|
|
144
|
+
}, [
|
|
145
|
+
projectId,
|
|
146
|
+
currentUserId,
|
|
147
|
+
documents,
|
|
148
|
+
get,
|
|
149
|
+
titleOf,
|
|
150
|
+
setViewState,
|
|
151
|
+
save,
|
|
152
|
+
remove,
|
|
153
|
+
viewState,
|
|
154
|
+
lastError,
|
|
155
|
+
]);
|
|
156
|
+
}
|
|
157
|
+
//# sourceMappingURL=users-collection-view-source.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"users-collection-view-source.js","sourceRoot":"","sources":["../../../src/components/users/users-collection-view-source.ts"],"names":[],"mappings":"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","sourcesContent":["import { useCallback, useMemo, useState } from 'react'\nimport type { CmsDocument, DocumentRef, FieldDef, Locale, LocaleConfig } from '@elytracms/core/cms-core'\nimport type { ProjectRole, SaveUserInput, UserRecord } from '@elytracms/core/persistence'\nimport type { OperationError } from '@elytracms/core/operations'\nimport { studioPath } from '@elytracms/studio/router'\nimport { defaultViewState } from '@elytracms/studio/lib/cms-views'\nimport type { TableViewState } from '@elytracms/studio/lib/cms-views'\nimport type {\n CollectionViewCapabilities,\n CollectionViewSource,\n FieldAccess,\n} from '@elytracms/studio/lib/collection-view/collection-view-source'\nimport { useAuth } from '../auth-context'\nimport { useUsers } from './users-store'\nimport { usersCollectionDef } from './users-schema'\n\n/** The users rail is not localized — one implicit locale keeps the lens honest. */\nconst USERS_LOCALE: Locale = 'en'\nconst USERS_LOCALE_CONFIG: LocaleConfig = { default: USERS_LOCALE, locales: [USERS_LOCALE] }\n\n/** The built-in field names — everything else in `values` is a custom `meta` field. */\nconst IDENTITY_FIELDS = new Set(['name', 'email', 'role'])\n\n/** Project the auth-identity + membership join into the lens's `values` shape. */\nexport function userToDocument(record: UserRecord): CmsDocument {\n return {\n collection: usersCollectionDef.id,\n id: record.id,\n values: {\n // Display title falls through empties (name → email → id) so a nameless,\n // blank-email member never renders as a blank row; `email` keeps its real value.\n name: record.name || record.email || record.id,\n email: record.email,\n role: record.roles[0] ?? 'viewer',\n ...(record.meta ?? {}),\n },\n }\n}\n\n/**\n * Principal-based field access for the users rail (EC-240, AD-18). `role` is\n * authority: **read-only when you are editing YOURSELF** — you don't change your\n * own role (the server integrity floor also guards it) — and **editable otherwise**\n * (the rail is admin-only, so a non-self editor is an admin acting on someone else).\n * Identity fields (name/email) follow their schema `form.readOnly`; everything else\n * is editable. Pure, so the self/other rule is unit-testable without the auth hook.\n */\nexport function userFieldAccess(\n field: FieldDef,\n record: CmsDocument | undefined,\n currentUserId: string | undefined,\n): FieldAccess {\n if (field.name === 'role') {\n return record?.id === currentUserId ? 'read' : 'edit'\n }\n return field.form?.readOnly ? 'read' : 'edit'\n}\n\n/** Map an edited lens document back to the write contract (role + custom `meta`). */\nexport function documentToSaveInput(doc: CmsDocument): SaveUserInput {\n const values = doc.values ?? {}\n const meta: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(values)) {\n if (!IDENTITY_FIELDS.has(key)) meta[key] = value\n }\n const role = typeof values.role === 'string' ? (values.role as ProjectRole) : 'viewer'\n return { id: doc.id, roles: [role], ...(Object.keys(meta).length > 0 ? { meta } : {}) }\n}\n\n/**\n * Assemble a {@link CollectionViewSource} for the **users rail** (EC-240, AD-19) —\n * the reuse proof that the portable list/edit surfaces serve a non-`cmsDocuments`\n * rail. Mirrors {@link useCmsCollectionViewSource}, over the users store instead of\n * the CMS store, with the users capability profile: admin-only, no\n * publish/versions/create/import (identities are minted by sign-up and their bytes\n * are auth-owned), remove allowed (revoke membership). Identity fields are\n * read-only; an admin edits a member's role (and future custom `meta` fields).\n *\n * Must be mounted under a `UsersProvider` (for {@link useUsers}).\n */\nexport function useUsersCollectionViewSource(projectId: string): CollectionViewSource {\n const users = useUsers()\n // The signed-in principal — so `role` locks to read-only when an admin opens\n // their OWN record (self ≠ other; the server floor enforces it regardless).\n const auth = useAuth()\n const currentUserId = auth.session?.user?.id\n const [viewState, setViewStateRaw] = useState<TableViewState>(() =>\n defaultViewState(usersCollectionDef),\n )\n const [lastError, setLastError] = useState<OperationError | null>(null)\n\n const documents = useCallback(() => users.users.map(userToDocument), [users.users])\n const get = useCallback(\n (ref: DocumentRef): CmsDocument | undefined => {\n const record = users.users.find((u) => u.id === ref.id)\n return record ? userToDocument(record) : undefined\n },\n [users.users],\n )\n const titleOf = useCallback(\n (ref: DocumentRef): string => {\n const record = users.users.find((u) => u.id === ref.id)\n return record?.name || record?.email || ref.id\n },\n [users.users],\n )\n const setViewState = useCallback<CollectionViewSource['setViewState']>((update) => {\n setViewStateRaw((prev) => (typeof update === 'function' ? update(prev) : update))\n }, [])\n\n const save = useCallback(\n (doc: CmsDocument) => {\n void users.save(documentToSaveInput(doc)).then((result) => {\n if (!result.ok) setLastError({ code: 'unknown', message: result.message })\n })\n },\n [users],\n )\n const remove = useCallback(\n (ref: DocumentRef) => {\n void users.remove(ref.id).then((result) => {\n if (!result.ok) setLastError({ code: 'unknown', message: result.message })\n })\n },\n [users],\n )\n\n return useMemo(() => {\n const capabilities: CollectionViewCapabilities = {\n publish: false,\n versions: false,\n locales: false,\n inlineEdit: false,\n create: false, // identities are minted by sign-up, never here\n remove: true, // revoke membership (does not delete the auth identity)\n bulk: false,\n liveUrl: false,\n hierarchy: false,\n importExport: false, // credential records are never dumped to raw JSON\n adminOnly: true,\n }\n const source: CollectionViewSource = {\n collectionId: usersCollectionDef.id,\n schema: usersCollectionDef,\n capabilities,\n projectId,\n localeConfig: USERS_LOCALE_CONFIG,\n activeLocale: USERS_LOCALE,\n documents,\n get,\n titleOf,\n // Users carry no relation fields in v1, so there are no cross-collection\n // candidates to resolve; the lens is the only collection in scope.\n referenceCandidates: () => [],\n collections: [usersCollectionDef],\n // The users rail now has a per-member detail surface (EC-240): rows link to\n // it, and the edit shell's back-crumb / post-delete nav return to the list.\n detailPath: (id: string) => studioPath.user(id),\n listPath: studioPath.users(),\n isFieldReadOnly: (field: FieldDef) => field.form?.readOnly ?? false,\n // Principal-based tri-state: `role` is read-only for self, editable for an\n // admin acting on another; identity fields follow their schema readOnly.\n fieldAccess: (field: FieldDef, record?: CmsDocument): FieldAccess =>\n userFieldAccess(field, record, currentUserId),\n validate: () => [],\n lastError,\n clearLastError: () => setLastError(null),\n viewState: () => viewState,\n setViewState,\n // create is gated off (capabilities.create === false); never reached.\n create: () => {\n throw new Error('users are created by sign-up, not the users rail')\n },\n save,\n remove,\n }\n return source\n }, [\n projectId,\n currentUserId,\n documents,\n get,\n titleOf,\n setViewState,\n save,\n remove,\n viewState,\n lastError,\n ])\n}\n"]}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The studio Users module (EC-240). An admin-only section for managing who has
|
|
3
|
+
* access to this deployment and their role. AD-19: it is the **reuse proof** for
|
|
4
|
+
* the portable interaction surfaces — it renders the SAME `LoadedCollectionTable`
|
|
5
|
+
* the CMS uses for the roster, and the SAME `DetailEditor` body (via
|
|
6
|
+
* {@link PortableDetailEditor}) for a member's detail, both over a users
|
|
7
|
+
* {@link useUsersCollectionViewSource} instead of a CMS source. No bespoke roster,
|
|
8
|
+
* no bespoke editor: filtering, columns, the count, and the edit form all come
|
|
9
|
+
* from the shared surfaces. The users capability profile (admin-only, no publish/
|
|
10
|
+
* versions/create/import) drives which regions appear.
|
|
11
|
+
*
|
|
12
|
+
* `userId === null` is the roster; a `userId` opens that member's detail (routed
|
|
13
|
+
* via `studioPath.user`). Both render under one `UsersProvider` + one source.
|
|
14
|
+
*
|
|
15
|
+
* Gating is layered: the operations chokepoint AND the Convex handlers enforce
|
|
16
|
+
* admin-only server-side (a non-admin `users.list` is denied). This UI gate is UX
|
|
17
|
+
* politeness — a non-admin sees a clear "restricted" state instead of a raw
|
|
18
|
+
* authorization error, and we skip firing a query we know will be denied.
|
|
19
|
+
*/
|
|
20
|
+
export declare function UsersModule({ projectId, userId, }: {
|
|
21
|
+
projectId: string;
|
|
22
|
+
userId?: string | null;
|
|
23
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { AlertTriangle, RotateCcw, ShieldAlert } from 'lucide-react';
|
|
3
|
+
import { Button, Heading, Row, Stack, Text } from '@elytracms/studio/ui';
|
|
4
|
+
import { useProjectRole } from '../auth-context';
|
|
5
|
+
import { UsersProvider, useUsers } from './users-store';
|
|
6
|
+
import { useUsersCollectionViewSource } from './users-collection-view-source';
|
|
7
|
+
import { CollectionViewProvider } from '../collection-view/collection-view-context';
|
|
8
|
+
import { LoadedCollectionTable } from '../cms/collection-table';
|
|
9
|
+
import { PortableDetailEditor } from '../cms/detail-editor';
|
|
10
|
+
/**
|
|
11
|
+
* The studio Users module (EC-240). An admin-only section for managing who has
|
|
12
|
+
* access to this deployment and their role. AD-19: it is the **reuse proof** for
|
|
13
|
+
* the portable interaction surfaces — it renders the SAME `LoadedCollectionTable`
|
|
14
|
+
* the CMS uses for the roster, and the SAME `DetailEditor` body (via
|
|
15
|
+
* {@link PortableDetailEditor}) for a member's detail, both over a users
|
|
16
|
+
* {@link useUsersCollectionViewSource} instead of a CMS source. No bespoke roster,
|
|
17
|
+
* no bespoke editor: filtering, columns, the count, and the edit form all come
|
|
18
|
+
* from the shared surfaces. The users capability profile (admin-only, no publish/
|
|
19
|
+
* versions/create/import) drives which regions appear.
|
|
20
|
+
*
|
|
21
|
+
* `userId === null` is the roster; a `userId` opens that member's detail (routed
|
|
22
|
+
* via `studioPath.user`). Both render under one `UsersProvider` + one source.
|
|
23
|
+
*
|
|
24
|
+
* Gating is layered: the operations chokepoint AND the Convex handlers enforce
|
|
25
|
+
* admin-only server-side (a non-admin `users.list` is denied). This UI gate is UX
|
|
26
|
+
* politeness — a non-admin sees a clear "restricted" state instead of a raw
|
|
27
|
+
* authorization error, and we skip firing a query we know will be denied.
|
|
28
|
+
*/
|
|
29
|
+
export function UsersModule({ projectId, userId = null, }) {
|
|
30
|
+
const role = useProjectRole(projectId);
|
|
31
|
+
// `useProjectRole` conflates "loading" and "not a member" as null; in the studio
|
|
32
|
+
// a member always reaches here, so null resolves to the real role momentarily.
|
|
33
|
+
if (role === null) {
|
|
34
|
+
return (_jsx(Stack, { className: "h-full place-items-center p-8", align: "center", justify: "center", children: _jsx(Text, { size: "sm", tone: "muted", children: "Checking access\u2026" }) }));
|
|
35
|
+
}
|
|
36
|
+
if (role !== 'admin')
|
|
37
|
+
return _jsx(RestrictedState, {});
|
|
38
|
+
return (_jsx(UsersProvider, { projectId: projectId, children: _jsx(UsersSurface, { projectId: projectId, userId: userId }) }));
|
|
39
|
+
}
|
|
40
|
+
/** The non-admin gate: honest about why the surface is empty. */
|
|
41
|
+
function RestrictedState() {
|
|
42
|
+
return (_jsxs(Stack, { gap: "2", align: "center", justify: "center", className: "h-full p-8 text-center", children: [_jsx(ShieldAlert, { className: "size-6 text-muted-foreground" }), _jsx(Heading, { level: 2, size: "h3", children: "Admins only" }), _jsx(Text, { size: "sm", tone: "muted", className: "max-w-sm", children: "Managing users and roles requires the admin role on this deployment. Ask an admin to grant you access." })] }));
|
|
43
|
+
}
|
|
44
|
+
/** The loaded surface: the portable list (or a member's detail) over the users source. */
|
|
45
|
+
function UsersSurface({ projectId, userId }) {
|
|
46
|
+
const store = useUsers();
|
|
47
|
+
const source = useUsersCollectionViewSource(projectId);
|
|
48
|
+
if (store.status === 'error') {
|
|
49
|
+
return (_jsxs(Stack, { gap: "2", className: "p-6", "data-testid": "users-error", children: [_jsxs(Row, { gap: "2", children: [_jsx(AlertTriangle, { className: "size-4 text-destructive" }), _jsx(Text, { weight: "semibold", children: "Users failed to load" })] }), _jsx(Text, { size: "sm", tone: "muted", children: store.errorMessage ?? 'The persistence backend is unreachable.' }), _jsx(Row, { children: _jsxs(Button, { variant: "outline", size: "sm", onClick: () => void store.refresh(), children: [_jsx(RotateCcw, {}), " Retry"] }) })] }));
|
|
50
|
+
}
|
|
51
|
+
if (store.status === 'loading') {
|
|
52
|
+
return (_jsx(Stack, { className: "h-full place-items-center p-8", align: "center", justify: "center", children: _jsx(Text, { size: "sm", tone: "muted", children: "Loading users\u2026" }) }));
|
|
53
|
+
}
|
|
54
|
+
// A member's detail opens in the portable edit shell (AD-19); the list otherwise.
|
|
55
|
+
// The shell mounts its own CollectionViewProvider, so only the list wraps here.
|
|
56
|
+
if (userId !== null) {
|
|
57
|
+
return _jsx(PortableDetailEditor, { source: source, projectId: projectId, documentId: userId });
|
|
58
|
+
}
|
|
59
|
+
return (_jsx(CollectionViewProvider, { source: source, children: _jsx(LoadedCollectionTable, { projectId: projectId }) }));
|
|
60
|
+
}
|
|
61
|
+
//# sourceMappingURL=users-module.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"users-module.js","sourceRoot":"","sources":["../../../src/components/users/users-module.tsx"],"names":[],"mappings":"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","sourcesContent":["import { AlertTriangle, RotateCcw, ShieldAlert } from 'lucide-react'\nimport { Button, Heading, Row, Stack, Text } from '@elytracms/studio/ui'\nimport { useProjectRole } from '../auth-context'\nimport { UsersProvider, useUsers } from './users-store'\nimport { useUsersCollectionViewSource } from './users-collection-view-source'\nimport { CollectionViewProvider } from '../collection-view/collection-view-context'\nimport { LoadedCollectionTable } from '../cms/collection-table'\nimport { PortableDetailEditor } from '../cms/detail-editor'\n\n/**\n * The studio Users module (EC-240). An admin-only section for managing who has\n * access to this deployment and their role. AD-19: it is the **reuse proof** for\n * the portable interaction surfaces — it renders the SAME `LoadedCollectionTable`\n * the CMS uses for the roster, and the SAME `DetailEditor` body (via\n * {@link PortableDetailEditor}) for a member's detail, both over a users\n * {@link useUsersCollectionViewSource} instead of a CMS source. No bespoke roster,\n * no bespoke editor: filtering, columns, the count, and the edit form all come\n * from the shared surfaces. The users capability profile (admin-only, no publish/\n * versions/create/import) drives which regions appear.\n *\n * `userId === null` is the roster; a `userId` opens that member's detail (routed\n * via `studioPath.user`). Both render under one `UsersProvider` + one source.\n *\n * Gating is layered: the operations chokepoint AND the Convex handlers enforce\n * admin-only server-side (a non-admin `users.list` is denied). This UI gate is UX\n * politeness — a non-admin sees a clear \"restricted\" state instead of a raw\n * authorization error, and we skip firing a query we know will be denied.\n */\nexport function UsersModule({\n projectId,\n userId = null,\n}: {\n projectId: string\n userId?: string | null\n}) {\n const role = useProjectRole(projectId)\n\n // `useProjectRole` conflates \"loading\" and \"not a member\" as null; in the studio\n // a member always reaches here, so null resolves to the real role momentarily.\n if (role === null) {\n return (\n <Stack className=\"h-full place-items-center p-8\" align=\"center\" justify=\"center\">\n <Text size=\"sm\" tone=\"muted\">\n Checking access…\n </Text>\n </Stack>\n )\n }\n if (role !== 'admin') return <RestrictedState />\n\n return (\n <UsersProvider projectId={projectId}>\n <UsersSurface projectId={projectId} userId={userId} />\n </UsersProvider>\n )\n}\n\n/** The non-admin gate: honest about why the surface is empty. */\nfunction RestrictedState() {\n return (\n <Stack gap=\"2\" align=\"center\" justify=\"center\" className=\"h-full p-8 text-center\">\n <ShieldAlert className=\"size-6 text-muted-foreground\" />\n <Heading level={2} size=\"h3\">\n Admins only\n </Heading>\n <Text size=\"sm\" tone=\"muted\" className=\"max-w-sm\">\n Managing users and roles requires the admin role on this deployment. Ask an admin to grant\n you access.\n </Text>\n </Stack>\n )\n}\n\n/** The loaded surface: the portable list (or a member's detail) over the users source. */\nfunction UsersSurface({ projectId, userId }: { projectId: string; userId: string | null }) {\n const store = useUsers()\n const source = useUsersCollectionViewSource(projectId)\n\n if (store.status === 'error') {\n return (\n <Stack gap=\"2\" className=\"p-6\" data-testid=\"users-error\">\n <Row gap=\"2\">\n <AlertTriangle className=\"size-4 text-destructive\" />\n <Text weight=\"semibold\">Users failed to load</Text>\n </Row>\n <Text size=\"sm\" tone=\"muted\">\n {store.errorMessage ?? 'The persistence backend is unreachable.'}\n </Text>\n <Row>\n <Button variant=\"outline\" size=\"sm\" onClick={() => void store.refresh()}>\n <RotateCcw /> Retry\n </Button>\n </Row>\n </Stack>\n )\n }\n\n if (store.status === 'loading') {\n return (\n <Stack className=\"h-full place-items-center p-8\" align=\"center\" justify=\"center\">\n <Text size=\"sm\" tone=\"muted\">\n Loading users…\n </Text>\n </Stack>\n )\n }\n\n // A member's detail opens in the portable edit shell (AD-19); the list otherwise.\n // The shell mounts its own CollectionViewProvider, so only the list wraps here.\n if (userId !== null) {\n return <PortableDetailEditor source={source} projectId={projectId} documentId={userId} />\n }\n\n return (\n <CollectionViewProvider source={source}>\n <LoadedCollectionTable projectId={projectId} />\n </CollectionViewProvider>\n )\n}\n"]}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { CollectionDef } from '@elytracms/core/cms-core';
|
|
2
|
+
/**
|
|
3
|
+
* The built-in **users** field lens (EC-240, AD-19). Users are a separate rail —
|
|
4
|
+
* NOT a `cmsDocuments` collection (`collectionKindSchema` is `document|asset`) —
|
|
5
|
+
* so this `CollectionDef` exists only to drive the portable list/edit surfaces
|
|
6
|
+
* over the users {@link CollectionViewSource}. The rail's *user-ness* comes from
|
|
7
|
+
* the source's capabilities (admin-only, no publish/versions/create), never the
|
|
8
|
+
* schema `kind`.
|
|
9
|
+
*
|
|
10
|
+
* Identity fields (`name`, `email`) are **auth-owned** and read-only here — an
|
|
11
|
+
* admin manages a member's *role* (and, later, project-declared custom fields
|
|
12
|
+
* over the `meta` bag, mirroring assets); the person edits their own name/email
|
|
13
|
+
* via account settings. A v1 default; a project may extend the field set later.
|
|
14
|
+
*/
|
|
15
|
+
export declare const USER_ROLE_OPTIONS: {
|
|
16
|
+
value: import("@elytracms/core/persistence").ProjectRole;
|
|
17
|
+
label: string;
|
|
18
|
+
}[];
|
|
19
|
+
export declare const usersCollectionDef: CollectionDef;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { collectionDefSchema } from '@elytracms/core/cms-core';
|
|
2
|
+
import { roleOptions } from '@elytracms/core/persistence';
|
|
3
|
+
/**
|
|
4
|
+
* The built-in **users** field lens (EC-240, AD-19). Users are a separate rail —
|
|
5
|
+
* NOT a `cmsDocuments` collection (`collectionKindSchema` is `document|asset`) —
|
|
6
|
+
* so this `CollectionDef` exists only to drive the portable list/edit surfaces
|
|
7
|
+
* over the users {@link CollectionViewSource}. The rail's *user-ness* comes from
|
|
8
|
+
* the source's capabilities (admin-only, no publish/versions/create), never the
|
|
9
|
+
* schema `kind`.
|
|
10
|
+
*
|
|
11
|
+
* Identity fields (`name`, `email`) are **auth-owned** and read-only here — an
|
|
12
|
+
* admin manages a member's *role* (and, later, project-declared custom fields
|
|
13
|
+
* over the `meta` bag, mirroring assets); the person edits their own name/email
|
|
14
|
+
* via account settings. A v1 default; a project may extend the field set later.
|
|
15
|
+
*/
|
|
16
|
+
// Derived from the ONE role policy (EC-240c) — no hardcoded duplicate of the role
|
|
17
|
+
// set. Reflects the shipped default today; a project's custom policy flows through.
|
|
18
|
+
export const USER_ROLE_OPTIONS = roleOptions();
|
|
19
|
+
export const usersCollectionDef = collectionDefSchema.parse({
|
|
20
|
+
id: 'user',
|
|
21
|
+
// Users aren't a CMS `kind`; 'document' just satisfies the lens schema. The
|
|
22
|
+
// source's capabilities (adminOnly, no publish/versions/create) carry the semantics.
|
|
23
|
+
kind: 'document',
|
|
24
|
+
titleField: 'name',
|
|
25
|
+
form: { label: 'Users', labelSingular: 'user' },
|
|
26
|
+
fields: [
|
|
27
|
+
{ name: 'name', type: 'text', form: { label: 'Name', readOnly: true }, filterable: true },
|
|
28
|
+
{ name: 'email', type: 'text', form: { label: 'Email', readOnly: true }, filterable: true },
|
|
29
|
+
{
|
|
30
|
+
name: 'role',
|
|
31
|
+
type: 'select',
|
|
32
|
+
options: [...USER_ROLE_OPTIONS],
|
|
33
|
+
filterable: true,
|
|
34
|
+
form: { label: 'Role' },
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
});
|
|
38
|
+
//# sourceMappingURL=users-schema.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"users-schema.js","sourceRoot":"","sources":["../../../src/components/users/users-schema.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAA;AAE9D,OAAO,EAAE,WAAW,EAAE,MAAM,6BAA6B,CAAA;AAEzD;;;;;;;;;;;;GAYG;AACH,kFAAkF;AAClF,oFAAoF;AACpF,MAAM,CAAC,MAAM,iBAAiB,GAAG,WAAW,EAAE,CAAA;AAE9C,MAAM,CAAC,MAAM,kBAAkB,GAAkB,mBAAmB,CAAC,KAAK,CAAC;IACzE,EAAE,EAAE,MAAM;IACV,4EAA4E;IAC5E,qFAAqF;IACrF,IAAI,EAAE,UAAU;IAChB,UAAU,EAAE,MAAM;IAClB,IAAI,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,aAAa,EAAE,MAAM,EAAE;IAC/C,MAAM,EAAE;QACN,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,EAAE,UAAU,EAAE,IAAI,EAAE;QACzF,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,IAAI,EAAE,EAAE,UAAU,EAAE,IAAI,EAAE;QAC3F;YACE,IAAI,EAAE,MAAM;YACZ,IAAI,EAAE,QAAQ;YACd,OAAO,EAAE,CAAC,GAAG,iBAAiB,CAAC;YAC/B,UAAU,EAAE,IAAI;YAChB,IAAI,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE;SACxB;KACF;CACF,CAAC,CAAA","sourcesContent":["import { collectionDefSchema } from '@elytracms/core/cms-core'\nimport type { CollectionDef } from '@elytracms/core/cms-core'\nimport { roleOptions } from '@elytracms/core/persistence'\n\n/**\n * The built-in **users** field lens (EC-240, AD-19). Users are a separate rail —\n * NOT a `cmsDocuments` collection (`collectionKindSchema` is `document|asset`) —\n * so this `CollectionDef` exists only to drive the portable list/edit surfaces\n * over the users {@link CollectionViewSource}. The rail's *user-ness* comes from\n * the source's capabilities (admin-only, no publish/versions/create), never the\n * schema `kind`.\n *\n * Identity fields (`name`, `email`) are **auth-owned** and read-only here — an\n * admin manages a member's *role* (and, later, project-declared custom fields\n * over the `meta` bag, mirroring assets); the person edits their own name/email\n * via account settings. A v1 default; a project may extend the field set later.\n */\n// Derived from the ONE role policy (EC-240c) — no hardcoded duplicate of the role\n// set. Reflects the shipped default today; a project's custom policy flows through.\nexport const USER_ROLE_OPTIONS = roleOptions()\n\nexport const usersCollectionDef: CollectionDef = collectionDefSchema.parse({\n id: 'user',\n // Users aren't a CMS `kind`; 'document' just satisfies the lens schema. The\n // source's capabilities (adminOnly, no publish/versions/create) carry the semantics.\n kind: 'document',\n titleField: 'name',\n form: { label: 'Users', labelSingular: 'user' },\n fields: [\n { name: 'name', type: 'text', form: { label: 'Name', readOnly: true }, filterable: true },\n { name: 'email', type: 'text', form: { label: 'Email', readOnly: true }, filterable: true },\n {\n name: 'role',\n type: 'select',\n options: [...USER_ROLE_OPTIONS],\n filterable: true,\n form: { label: 'Role' },\n },\n ],\n})\n"]}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import type { SaveUserInput, UserRecord } from '@elytracms/core/persistence';
|
|
3
|
+
/**
|
|
4
|
+
* Studio users store (EC-240, 240b). The users rail — role + custom fields
|
|
5
|
+
* joined to auth identity — read + written through the shared operations client
|
|
6
|
+
* (`users.*` commands: validate → authorize → mutate → event), the same seam the
|
|
7
|
+
* assets store uses. Admin-only: every `users.*` operation is gated at the
|
|
8
|
+
* chokepoint AND server-side, so a non-admin session sees an authorization error
|
|
9
|
+
* rather than data. Mirrors `assets-store.tsx`, minus the blob/upload machinery
|
|
10
|
+
* (users have no bytes).
|
|
11
|
+
*/
|
|
12
|
+
export interface UsersStore {
|
|
13
|
+
/** The active project (deployment) whose members this rail manages. */
|
|
14
|
+
projectId: string;
|
|
15
|
+
status: 'loading' | 'ready' | 'error';
|
|
16
|
+
errorMessage?: string;
|
|
17
|
+
users: readonly UserRecord[];
|
|
18
|
+
/** Grant/modify a user's role(s) + custom fields (admin-only). */
|
|
19
|
+
save(input: SaveUserInput): Promise<{
|
|
20
|
+
ok: true;
|
|
21
|
+
user: UserRecord;
|
|
22
|
+
} | {
|
|
23
|
+
ok: false;
|
|
24
|
+
message: string;
|
|
25
|
+
}>;
|
|
26
|
+
/** Revoke a user's studio membership (does not delete the auth identity). */
|
|
27
|
+
remove(userId: string): Promise<{
|
|
28
|
+
ok: true;
|
|
29
|
+
} | {
|
|
30
|
+
ok: false;
|
|
31
|
+
message: string;
|
|
32
|
+
}>;
|
|
33
|
+
/** Re-list from the backend (the public escape hatch). */
|
|
34
|
+
refresh(): Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
export declare function UsersProvider({ projectId, children, }: {
|
|
37
|
+
projectId: string;
|
|
38
|
+
children: ReactNode;
|
|
39
|
+
}): import("react").JSX.Element;
|
|
40
|
+
export declare function useUsers(): UsersStore;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { createContext, useCallback, useContext, useMemo } from 'react';
|
|
3
|
+
import { getStudioOperations } from '@elytracms/studio/lib/operations';
|
|
4
|
+
import { useOperationCache, useOperationQuery } from '@elytracms/studio/lib/query';
|
|
5
|
+
const UsersContext = createContext(null);
|
|
6
|
+
const EMPTY_USERS = [];
|
|
7
|
+
/** Cache key for a project's users rail (shared by the query and its patches). */
|
|
8
|
+
function usersQueryKey(projectId) {
|
|
9
|
+
return ['users', projectId];
|
|
10
|
+
}
|
|
11
|
+
/** Load a project's studio users (pure over the operations client). A list
|
|
12
|
+
* failure throws so the query surfaces it as an error status. */
|
|
13
|
+
async function loadUsers(operations, projectId) {
|
|
14
|
+
const result = await operations.users.list({ projectId });
|
|
15
|
+
if (!result.ok)
|
|
16
|
+
throw new Error(result.error.message);
|
|
17
|
+
return [...result.value];
|
|
18
|
+
}
|
|
19
|
+
export function UsersProvider({ projectId, children, }) {
|
|
20
|
+
// The users list, read through the one operations-backed query cache
|
|
21
|
+
// (EC-308/AD-17). This provider is the sole writer of its project's users, so
|
|
22
|
+
// its own mutations patch the cache in place rather than re-listing.
|
|
23
|
+
const query = useOperationQuery({
|
|
24
|
+
queryKey: usersQueryKey(projectId),
|
|
25
|
+
loader: (operations) => loadUsers(operations, projectId),
|
|
26
|
+
});
|
|
27
|
+
const cache = useOperationCache();
|
|
28
|
+
const refetch = query.refetch;
|
|
29
|
+
const users = query.data ?? EMPTY_USERS;
|
|
30
|
+
const status = query.isPending
|
|
31
|
+
? 'loading'
|
|
32
|
+
: query.isError
|
|
33
|
+
? 'error'
|
|
34
|
+
: 'ready';
|
|
35
|
+
const errorMessage = query.isError && query.error instanceof Error ? query.error.message : undefined;
|
|
36
|
+
const refresh = useCallback(async () => {
|
|
37
|
+
await refetch();
|
|
38
|
+
}, [refetch]);
|
|
39
|
+
const save = useCallback(async (input) => {
|
|
40
|
+
// Writes go through the `users.save` command via the shared operations
|
|
41
|
+
// client (EC-141) — validate → authorize (admin) → mutate → event.
|
|
42
|
+
const { operations } = await getStudioOperations();
|
|
43
|
+
const result = await operations.users.save({
|
|
44
|
+
projectId,
|
|
45
|
+
userId: input.id,
|
|
46
|
+
// 240a bridge: the contract's `roles: string[]` is permissive (240c-ready);
|
|
47
|
+
// the operation input constrains it to the current enum + re-validates at
|
|
48
|
+
// runtime. The cast crosses that permissive→strict seam.
|
|
49
|
+
roles: input.roles,
|
|
50
|
+
...(input.meta !== undefined ? { meta: input.meta } : {}),
|
|
51
|
+
});
|
|
52
|
+
if (!result.ok)
|
|
53
|
+
return { ok: false, message: result.error.message };
|
|
54
|
+
// Patch the one updated record into the cache rather than re-listing.
|
|
55
|
+
const saved = result.value;
|
|
56
|
+
cache.patch(usersQueryKey(projectId), (prev) => {
|
|
57
|
+
const rest = (prev ?? []).filter((u) => u.id !== saved.id);
|
|
58
|
+
return [...rest, saved].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
59
|
+
});
|
|
60
|
+
return { ok: true, user: saved };
|
|
61
|
+
}, [projectId, cache]);
|
|
62
|
+
const remove = useCallback(async (userId) => {
|
|
63
|
+
const { operations } = await getStudioOperations();
|
|
64
|
+
const result = await operations.users.remove({ projectId, userId });
|
|
65
|
+
if (!result.ok)
|
|
66
|
+
return { ok: false, message: result.error.message };
|
|
67
|
+
cache.patch(usersQueryKey(projectId), (prev) => (prev ?? []).filter((u) => u.id !== userId));
|
|
68
|
+
return { ok: true };
|
|
69
|
+
}, [projectId, cache]);
|
|
70
|
+
const store = useMemo(() => ({
|
|
71
|
+
projectId,
|
|
72
|
+
status,
|
|
73
|
+
...(errorMessage !== undefined ? { errorMessage } : {}),
|
|
74
|
+
users,
|
|
75
|
+
save,
|
|
76
|
+
remove,
|
|
77
|
+
refresh,
|
|
78
|
+
}), [projectId, status, errorMessage, users, save, remove, refresh]);
|
|
79
|
+
return _jsx(UsersContext.Provider, { value: store, children: children });
|
|
80
|
+
}
|
|
81
|
+
export function useUsers() {
|
|
82
|
+
const store = useContext(UsersContext);
|
|
83
|
+
if (!store)
|
|
84
|
+
throw new Error('useUsers must be used within a <UsersProvider>.');
|
|
85
|
+
return store;
|
|
86
|
+
}
|
|
87
|
+
//# sourceMappingURL=users-store.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"users-store.js","sourceRoot":"","sources":["../../../src/components/users/users-store.tsx"],"names":[],"mappings":"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","sourcesContent":["import { createContext, useCallback, useContext, useMemo } from 'react'\nimport type { ReactNode } from 'react'\nimport type { ProjectRole, SaveUserInput, UserRecord } from '@elytracms/core/persistence'\nimport { getStudioOperations } from '@elytracms/studio/lib/operations'\nimport type { OperationsClient } from '@elytracms/studio/lib/operations'\nimport { useOperationCache, useOperationQuery } from '@elytracms/studio/lib/query'\n\n/**\n * Studio users store (EC-240, 240b). The users rail — role + custom fields\n * joined to auth identity — read + written through the shared operations client\n * (`users.*` commands: validate → authorize → mutate → event), the same seam the\n * assets store uses. Admin-only: every `users.*` operation is gated at the\n * chokepoint AND server-side, so a non-admin session sees an authorization error\n * rather than data. Mirrors `assets-store.tsx`, minus the blob/upload machinery\n * (users have no bytes).\n */\n\nexport interface UsersStore {\n /** The active project (deployment) whose members this rail manages. */\n projectId: string\n status: 'loading' | 'ready' | 'error'\n errorMessage?: string\n users: readonly UserRecord[]\n /** Grant/modify a user's role(s) + custom fields (admin-only). */\n save(input: SaveUserInput): Promise<{ ok: true; user: UserRecord } | { ok: false; message: string }>\n /** Revoke a user's studio membership (does not delete the auth identity). */\n remove(userId: string): Promise<{ ok: true } | { ok: false; message: string }>\n /** Re-list from the backend (the public escape hatch). */\n refresh(): Promise<void>\n}\n\nconst UsersContext = createContext<UsersStore | null>(null)\n\nconst EMPTY_USERS: readonly UserRecord[] = []\n\n/** Cache key for a project's users rail (shared by the query and its patches). */\nfunction usersQueryKey(projectId: string): readonly [string, string] {\n return ['users', projectId]\n}\n\n/** Load a project's studio users (pure over the operations client). A list\n * failure throws so the query surfaces it as an error status. */\nasync function loadUsers(operations: OperationsClient, projectId: string): Promise<UserRecord[]> {\n const result = await operations.users.list({ projectId })\n if (!result.ok) throw new Error(result.error.message)\n return [...result.value]\n}\n\nexport function UsersProvider({\n projectId,\n children,\n}: {\n projectId: string\n children: ReactNode\n}) {\n // The users list, read through the one operations-backed query cache\n // (EC-308/AD-17). This provider is the sole writer of its project's users, so\n // its own mutations patch the cache in place rather than re-listing.\n const query = useOperationQuery<UserRecord[]>({\n queryKey: usersQueryKey(projectId),\n loader: (operations) => loadUsers(operations, projectId),\n })\n const cache = useOperationCache()\n const refetch = query.refetch\n const users = query.data ?? EMPTY_USERS\n const status: UsersStore['status'] = query.isPending\n ? 'loading'\n : query.isError\n ? 'error'\n : 'ready'\n const errorMessage =\n query.isError && query.error instanceof Error ? query.error.message : undefined\n\n const refresh = useCallback(async () => {\n await refetch()\n }, [refetch])\n\n const save = useCallback<UsersStore['save']>(\n async (input) => {\n // Writes go through the `users.save` command via the shared operations\n // client (EC-141) — validate → authorize (admin) → mutate → event.\n const { operations } = await getStudioOperations()\n const result = await operations.users.save({\n projectId,\n userId: input.id,\n // 240a bridge: the contract's `roles: string[]` is permissive (240c-ready);\n // the operation input constrains it to the current enum + re-validates at\n // runtime. The cast crosses that permissive→strict seam.\n roles: input.roles as ProjectRole[],\n ...(input.meta !== undefined ? { meta: input.meta } : {}),\n })\n if (!result.ok) return { ok: false, message: result.error.message }\n // Patch the one updated record into the cache rather than re-listing.\n const saved = result.value\n cache.patch<UserRecord[]>(usersQueryKey(projectId), (prev) => {\n const rest = (prev ?? []).filter((u) => u.id !== saved.id)\n return [...rest, saved].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))\n })\n return { ok: true, user: saved }\n },\n [projectId, cache],\n )\n\n const remove = useCallback<UsersStore['remove']>(\n async (userId) => {\n const { operations } = await getStudioOperations()\n const result = await operations.users.remove({ projectId, userId })\n if (!result.ok) return { ok: false, message: result.error.message }\n cache.patch<UserRecord[]>(usersQueryKey(projectId), (prev) =>\n (prev ?? []).filter((u) => u.id !== userId),\n )\n return { ok: true }\n },\n [projectId, cache],\n )\n\n const store = useMemo<UsersStore>(\n () => ({\n projectId,\n status,\n ...(errorMessage !== undefined ? { errorMessage } : {}),\n users,\n save,\n remove,\n refresh,\n }),\n [projectId, status, errorMessage, users, save, remove, refresh],\n )\n\n return <UsersContext.Provider value={store}>{children}</UsersContext.Provider>\n}\n\nexport function useUsers(): UsersStore {\n const store = useContext(UsersContext)\n if (!store) throw new Error('useUsers must be used within a <UsersProvider>.')\n return store\n}\n"]}
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* (
|
|
5
|
-
*
|
|
2
|
+
* The table-view model for the CMS collection table: the pure filter/sort/column
|
|
3
|
+
* model (`view-model`) and row selection (`selection`). Views are config-as-code
|
|
4
|
+
* (EC-246b) — the DB saved-view store is retired; `view-model.configViewsFor`
|
|
5
|
+
* resolves the named views a collection declares in `cms/`.
|
|
6
6
|
*/
|
|
7
7
|
export * from './view-model';
|
|
8
8
|
export * from './selection';
|
|
9
|
-
export * from './views-store';
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* (
|
|
5
|
-
*
|
|
2
|
+
* The table-view model for the CMS collection table: the pure filter/sort/column
|
|
3
|
+
* model (`view-model`) and row selection (`selection`). Views are config-as-code
|
|
4
|
+
* (EC-246b) — the DB saved-view store is retired; `view-model.configViewsFor`
|
|
5
|
+
* resolves the named views a collection declares in `cms/`.
|
|
6
6
|
*/
|
|
7
7
|
export * from './view-model';
|
|
8
8
|
export * from './selection';
|
|
9
|
-
export * from './views-store';
|
|
10
9
|
//# sourceMappingURL=index.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/lib/cms-views/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,cAAc,cAAc,CAAA;AAC5B,cAAc,aAAa,CAAA
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/lib/cms-views/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,cAAc,cAAc,CAAA;AAC5B,cAAc,aAAa,CAAA","sourcesContent":["/**\n * The table-view model for the CMS collection table: the pure filter/sort/column\n * model (`view-model`) and row selection (`selection`). Views are config-as-code\n * (EC-246b) — the DB saved-view store is retired; `view-model.configViewsFor`\n * resolves the named views a collection declares in `cms/`.\n */\nexport * from './view-model'\nexport * from './selection'\n"]}
|
|
@@ -142,8 +142,55 @@ export interface AppliedViewState {
|
|
|
142
142
|
export declare function applyViewState(collection: CollectionDef, docs: readonly CmsDocument[], localeConfig: LocaleConfig, state: TableViewState, isPublished?: IsPublished): AppliedViewState;
|
|
143
143
|
/** The id of the unnamed built-in "All documents" view. */
|
|
144
144
|
export declare const ALL_VIEW_ID = "all";
|
|
145
|
-
/** The default (unnamed) view: no filters, no sort, every field visible. */
|
|
146
145
|
export declare function defaultViewState(collection: CollectionDef): TableViewState;
|
|
146
|
+
/**
|
|
147
|
+
* A config-declared view (EC-246b) — the flat authoring shape a collection
|
|
148
|
+
* declares in `cms/` config: `{ name, filters?, sort?, columns?, hidden? }`.
|
|
149
|
+
* Filters/sort/columns sit at the top level (no nested `state` wrapper). The
|
|
150
|
+
* studio validates + interprets these; `cms-core` stores them opaquely (it
|
|
151
|
+
* can't reference the filter vocabulary without a layering cycle).
|
|
152
|
+
*/
|
|
153
|
+
export declare const configViewSchema: z.ZodObject<{
|
|
154
|
+
id: z.ZodOptional<z.ZodString>;
|
|
155
|
+
name: z.ZodString;
|
|
156
|
+
filters: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
157
|
+
field: z.ZodString;
|
|
158
|
+
op: z.ZodEnum<{
|
|
159
|
+
in: "in";
|
|
160
|
+
eq: "eq";
|
|
161
|
+
gt: "gt";
|
|
162
|
+
gte: "gte";
|
|
163
|
+
lt: "lt";
|
|
164
|
+
lte: "lte";
|
|
165
|
+
}>;
|
|
166
|
+
value: z.ZodUnion<readonly [z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>, z.ZodArray<z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>>]>;
|
|
167
|
+
}, z.core.$strip>>>;
|
|
168
|
+
sort: z.ZodOptional<z.ZodNullable<z.ZodObject<{
|
|
169
|
+
field: z.ZodString;
|
|
170
|
+
dir: z.ZodEnum<{
|
|
171
|
+
desc: "desc";
|
|
172
|
+
asc: "asc";
|
|
173
|
+
}>;
|
|
174
|
+
}, z.core.$strip>>>;
|
|
175
|
+
columns: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
176
|
+
hidden: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
177
|
+
}, z.core.$strip>;
|
|
178
|
+
export type ConfigView = z.infer<typeof configViewSchema>;
|
|
179
|
+
/** A resolved view: identity + the table state it restores (matches `SavedView`). */
|
|
180
|
+
export interface ResolvedView {
|
|
181
|
+
id: string;
|
|
182
|
+
name: string;
|
|
183
|
+
state: TableViewState;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Resolve a collection's config-declared `views` into full {@link ResolvedView}s
|
|
187
|
+
* (EC-246b). Each is validated with {@link configViewSchema}; malformed entries
|
|
188
|
+
* and duplicate ids are dropped (defensive). Columns fall back to the collection
|
|
189
|
+
* default when a view doesn't pin them, so a filter-only view still renders every
|
|
190
|
+
* column. Config views are shared and change only in
|
|
191
|
+
* code — the runtime never mutates them.
|
|
192
|
+
*/
|
|
193
|
+
export declare function configViewsFor(collection: CollectionDef): ResolvedView[];
|
|
147
194
|
/** Deep-cloned canonical form (stable key order via the Zod shape). */
|
|
148
195
|
export declare function normalizeViewState(state: TableViewState): TableViewState;
|
|
149
196
|
/** Structural equality on normalized states — drives the "unsaved changes" affordance. */
|