cortena-ui 1.4.2 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/LICENSE +7 -0
  3. package/README.md +235 -3
  4. package/dist/a2ui/views.js +2 -2
  5. package/dist/agent-chat/a2ui-block.d.ts +60 -0
  6. package/dist/agent-chat/a2ui-block.js +69 -0
  7. package/dist/agent-chat/a2ui-block.js.map +1 -0
  8. package/dist/agent-chat/agui-client.d.ts +40 -0
  9. package/dist/agent-chat/agui-client.js +251 -0
  10. package/dist/agent-chat/agui-client.js.map +1 -0
  11. package/dist/agent-chat/bridge.d.ts +109 -0
  12. package/dist/agent-chat/bridge.js +353 -0
  13. package/dist/agent-chat/bridge.js.map +1 -0
  14. package/dist/agent-chat/session.d.ts +79 -0
  15. package/dist/agent-chat/session.js +391 -0
  16. package/dist/agent-chat/session.js.map +1 -0
  17. package/dist/agent-chat/step-label.d.ts +99 -0
  18. package/dist/agent-chat/step-label.js +116 -0
  19. package/dist/agent-chat/step-label.js.map +1 -0
  20. package/dist/agent-chat/store.d.ts +102 -0
  21. package/dist/agent-chat/store.js +876 -0
  22. package/dist/agent-chat/store.js.map +1 -0
  23. package/dist/agent-chat/types.d.ts +277 -0
  24. package/dist/agent-chat/types.js +17 -0
  25. package/dist/agent-chat/types.js.map +1 -0
  26. package/dist/agent-chat.d.ts +11 -0
  27. package/dist/agent-chat.js +11 -0
  28. package/dist/components/admin-permissions/admin-permissions.d.ts +66 -0
  29. package/dist/components/admin-permissions/admin-permissions.js +101 -0
  30. package/dist/components/admin-permissions/admin-permissions.js.map +1 -0
  31. package/dist/components/admin-permissions/context.d.ts +70 -0
  32. package/dist/components/admin-permissions/context.js +258 -0
  33. package/dist/components/admin-permissions/context.js.map +1 -0
  34. package/dist/components/admin-permissions/index.d.ts +10 -0
  35. package/dist/components/admin-permissions/licence.d.ts +15 -0
  36. package/dist/components/admin-permissions/licence.js +78 -0
  37. package/dist/components/admin-permissions/licence.js.map +1 -0
  38. package/dist/components/admin-permissions/matrix.d.ts +20 -0
  39. package/dist/components/admin-permissions/matrix.js +191 -0
  40. package/dist/components/admin-permissions/matrix.js.map +1 -0
  41. package/dist/components/admin-permissions/members.d.ts +18 -0
  42. package/dist/components/admin-permissions/members.js +185 -0
  43. package/dist/components/admin-permissions/members.js.map +1 -0
  44. package/dist/components/admin-permissions/role-assignment.d.ts +35 -0
  45. package/dist/components/admin-permissions/role-assignment.js +174 -0
  46. package/dist/components/admin-permissions/role-assignment.js.map +1 -0
  47. package/dist/components/admin-permissions/roles.d.ts +25 -0
  48. package/dist/components/admin-permissions/roles.js +168 -0
  49. package/dist/components/admin-permissions/roles.js.map +1 -0
  50. package/dist/components/admin-permissions/types.d.ts +152 -0
  51. package/dist/components/admin-permissions/types.js +63 -0
  52. package/dist/components/admin-permissions/types.js.map +1 -0
  53. package/dist/components/agent-chat-popup.d.ts +29 -0
  54. package/dist/components/agent-chat-popup.js +188 -0
  55. package/dist/components/agent-chat-popup.js.map +1 -0
  56. package/dist/components/agent-chat.d.ts +163 -0
  57. package/dist/components/agent-chat.js +673 -0
  58. package/dist/components/agent-chat.js.map +1 -0
  59. package/dist/components/app-shell.d.ts +126 -0
  60. package/dist/components/app-shell.js +297 -0
  61. package/dist/components/app-shell.js.map +1 -0
  62. package/dist/components/badge.d.ts +1 -1
  63. package/dist/components/button-link.js +1 -1
  64. package/dist/components/button.d.ts +1 -1
  65. package/dist/components/checkbox.d.ts +1 -1
  66. package/dist/components/combobox.d.ts +1 -1
  67. package/dist/components/combobox.js +1 -1
  68. package/dist/components/consent-screen.d.ts +65 -0
  69. package/dist/components/consent-screen.js +123 -0
  70. package/dist/components/consent-screen.js.map +1 -0
  71. package/dist/components/data-table/data-table.d.ts +15 -1
  72. package/dist/components/data-table/data-table.js +18 -4
  73. package/dist/components/data-table/data-table.js.map +1 -1
  74. package/dist/components/data-table/index.d.ts +4 -4
  75. package/dist/components/data-table/parts.d.ts +27 -3
  76. package/dist/components/data-table/parts.js +175 -55
  77. package/dist/components/data-table/parts.js.map +1 -1
  78. package/dist/components/data-table/types.d.ts +61 -0
  79. package/dist/components/data-table/use-data-table.js +91 -6
  80. package/dist/components/data-table/use-data-table.js.map +1 -1
  81. package/dist/components/data-table/use-server-source.js +119 -28
  82. package/dist/components/data-table/use-server-source.js.map +1 -1
  83. package/dist/components/help-panel.d.ts +131 -0
  84. package/dist/components/help-panel.js +545 -0
  85. package/dist/components/help-panel.js.map +1 -0
  86. package/dist/components/login-screen.d.ts +127 -0
  87. package/dist/components/login-screen.js +339 -0
  88. package/dist/components/login-screen.js.map +1 -0
  89. package/dist/components/session-guard.d.ts +268 -0
  90. package/dist/components/session-guard.js +632 -0
  91. package/dist/components/session-guard.js.map +1 -0
  92. package/dist/components/toast.d.ts +1 -1
  93. package/dist/core.d.ts +5 -1
  94. package/dist/core.js +11 -7
  95. package/dist/data-table.d.ts +13 -4
  96. package/dist/data-table.js +10 -2
  97. package/dist/hooks/use-cortena-theme.js +49 -3
  98. package/dist/hooks/use-cortena-theme.js.map +1 -1
  99. package/dist/index.d.ts +17 -4
  100. package/dist/index.js +21 -8
  101. package/dist/markdown.d.ts +2 -1
  102. package/dist/markdown.js +2 -1
  103. package/package.json +18 -5
  104. package/src/agent-chat/a2ui-block.ts +118 -0
  105. package/src/agent-chat/agui-client.ts +405 -0
  106. package/src/agent-chat/bridge.ts +445 -0
  107. package/src/agent-chat/session.ts +549 -0
  108. package/src/agent-chat/step-label.ts +177 -0
  109. package/src/agent-chat/store.ts +1234 -0
  110. package/src/agent-chat/types.ts +308 -0
  111. package/src/components/admin-permissions/admin-permissions.tsx +130 -0
  112. package/src/components/admin-permissions/context.tsx +376 -0
  113. package/src/components/admin-permissions/index.tsx +32 -0
  114. package/src/components/admin-permissions/licence.tsx +84 -0
  115. package/src/components/admin-permissions/matrix.tsx +257 -0
  116. package/src/components/admin-permissions/members.tsx +204 -0
  117. package/src/components/admin-permissions/role-assignment.tsx +239 -0
  118. package/src/components/admin-permissions/roles.tsx +169 -0
  119. package/src/components/admin-permissions/types.ts +231 -0
  120. package/src/components/agent-chat-popup.tsx +289 -0
  121. package/src/components/agent-chat.tsx +1006 -0
  122. package/src/components/app-shell.tsx +502 -0
  123. package/src/components/consent-screen.tsx +239 -0
  124. package/src/components/data-table/data-table.tsx +36 -0
  125. package/src/components/data-table/index.tsx +6 -1
  126. package/src/components/data-table/parts.tsx +223 -47
  127. package/src/components/data-table/types.ts +68 -0
  128. package/src/components/data-table/use-data-table.ts +152 -4
  129. package/src/components/data-table/use-server-source.ts +150 -12
  130. package/src/components/help-panel.tsx +765 -0
  131. package/src/components/login-screen.tsx +479 -0
  132. package/src/components/session-guard.tsx +1071 -0
  133. package/src/entries/agent-chat.ts +137 -0
  134. package/src/entries/core.ts +8 -0
  135. package/src/entries/data-table.ts +41 -0
  136. package/src/entries/markdown.ts +25 -0
  137. package/src/hooks/use-cortena-theme.ts +63 -4
  138. package/src/index.ts +6 -0
@@ -0,0 +1,257 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { Button } from "@/components/button";
5
+ import { Checkbox } from "@/components/checkbox";
6
+ import { EmptyState } from "@/components/empty-state";
7
+ import { ErrorBanner } from "@/components/error-banner";
8
+ import { LoadingSkeleton } from "@/components/loading-skeleton";
9
+ import { SectionCard } from "@/components/section-card";
10
+ import { Spinner } from "@/components/spinner";
11
+ import { useAdminPermissions } from "./context";
12
+ import { adminErrorMessage, type AdminPermissionMatrix } from "./types";
13
+
14
+ function grantsEqual(a: Record<string, string[]>, b: Record<string, string[]>): boolean {
15
+ const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
16
+ for (const key of keys) {
17
+ const left = [...(a[key] ?? [])].sort();
18
+ const right = [...(b[key] ?? [])].sort();
19
+ if (left.length !== right.length) return false;
20
+ if (left.some((value, i) => value !== right[i])) return false;
21
+ }
22
+ return true;
23
+ }
24
+
25
+ /**
26
+ * `/admin/permissions` — the extension's own roles × actions matrix, the one
27
+ * document `may()` reads (§13.2, P-14).
28
+ *
29
+ * A grid of checkboxes, and nothing clever: rows are the roles the extension
30
+ * declares, columns are the actions it declares, and a tick is a grant. The
31
+ * screen holds a draft rather than writing per click — a permission matrix is
32
+ * edited as a whole thought, and a half-applied one is a security hole with a
33
+ * progress spinner over it — so the edits accumulate, Save PUTs the document
34
+ * and Discard throws the draft away.
35
+ *
36
+ * The roles and the actions are both read-only here: they are the extension's
37
+ * declared vocabulary, changed in its source, not in an admin screen.
38
+ */
39
+ export function AdminMatrix() {
40
+ const { matrix, matrixState, saveMatrix } = useAdminPermissions();
41
+ const [draft, setDraft] = React.useState<Record<string, string[]> | null>(null);
42
+ const [saving, setSaving] = React.useState(false);
43
+ const [error, setError] = React.useState<string | null>(null);
44
+
45
+ // Re-seed when a different document arrives: a reload, the optimistic copy
46
+ // this screen just wrote, or the rollback that put the server's back.
47
+ // Adjusting state during render, not an effect.
48
+ //
49
+ // The error is deliberately NOT cleared here. A failed save rolls the
50
+ // optimistic copy back, which lands as exactly this re-seed, in the same
51
+ // batch as the message — clearing it would wipe the message that explains
52
+ // why the grid just moved. Save and Discard clear it instead.
53
+ const [seeded, setSeeded] = React.useState<AdminPermissionMatrix | null>(matrix);
54
+ if (matrix !== seeded) {
55
+ setSeeded(matrix);
56
+ setDraft(null);
57
+ }
58
+
59
+ if (matrixState.error) {
60
+ return <ErrorBanner message={matrixState.error.message} onRetry={matrixState.reload} />;
61
+ }
62
+ if (!matrix) {
63
+ return <LoadingSkeleton preset="table" data-slot="admin-matrix-loading" />;
64
+ }
65
+
66
+ const grants = draft ?? matrix.grants;
67
+ const dirty = draft !== null && !grantsEqual(draft, matrix.grants);
68
+
69
+ const toggle = (role: string, action: string, checked: boolean) => {
70
+ setDraft((prev) => {
71
+ const base = prev ?? matrix.grants;
72
+ const current = base[role] ?? [];
73
+ const next = checked
74
+ ? current.includes(action)
75
+ ? current
76
+ : [...current, action]
77
+ : current.filter((a) => a !== action);
78
+ return { ...base, [role]: next };
79
+ });
80
+ };
81
+
82
+ const save = async () => {
83
+ if (!draft) return;
84
+ setSaving(true);
85
+ setError(null);
86
+ try {
87
+ // Only what the grid showed. A key whose grants are all off is dropped
88
+ // rather than written back as an empty array, so revoking an unknown
89
+ // role really removes it from the document.
90
+ const grants = Object.fromEntries(
91
+ Object.entries(draft).filter(([, actions]) => actions.length > 0),
92
+ );
93
+ await saveMatrix({ roles: matrix.roles, actions: matrix.actions, grants });
94
+ setDraft(null);
95
+ } catch (err) {
96
+ // `saveMatrix` has already put the stored document back, so the grid is
97
+ // showing the server's grants again rather than an edit that never
98
+ // landed. The envelope's own message says why (§15.6).
99
+ setError(adminErrorMessage(err));
100
+ } finally {
101
+ setSaving(false);
102
+ }
103
+ };
104
+
105
+ /**
106
+ * Grants naming a role or an action the extension no longer declares.
107
+ *
108
+ * They exist routinely: a release renames `document.write` or retires
109
+ * `reviewer`, and the stored document keeps the old key. Rendering only the
110
+ * declared vocabulary hid them — and Save then PUT the whole `grants` object
111
+ * back, so every invisible grant was rewritten, unchanged, by an
112
+ * administrator who had just been looking at the screen and believed they
113
+ * had seen the whole policy. `may()` reads the document, not this grid, so
114
+ * a hidden `reviewer: ["admin.manage"]` is live permission nobody can see or
115
+ * revoke.
116
+ *
117
+ * The rule `role-assignment.tsx` already follows for a retired role: show
118
+ * it, mark it, and let it be taken away.
119
+ */
120
+ const grantedRoles = Object.keys(grants).filter((role) => (grants[role] ?? []).length > 0);
121
+ const unknownRoles = grantedRoles.filter((role) => !matrix.roles.includes(role)).sort();
122
+ const grantedActions = [...new Set(Object.values(grants).flat())];
123
+ const unknownActions = grantedActions.filter((a) => !matrix.actions.includes(a)).sort();
124
+ const roleRows = [...matrix.roles, ...unknownRoles];
125
+ const actionColumns = [...matrix.actions, ...unknownActions];
126
+ const hasUnknown = unknownRoles.length > 0 || unknownActions.length > 0;
127
+
128
+ const empty = roleRows.length === 0 || actionColumns.length === 0;
129
+
130
+ return (
131
+ <div data-slot="admin-matrix" className="flex flex-col gap-3">
132
+ {error ? <ErrorBanner message={error} data-slot="admin-matrix-error" /> : null}
133
+ <SectionCard
134
+ title="Permissions"
135
+ description="This extension's own matrix, in its own database. One policy function reads it, and every route and every MCP tool goes through that function."
136
+ actions={
137
+ empty ? null : (
138
+ <>
139
+ <Button
140
+ variant="outline"
141
+ size="sm"
142
+ disabled={!dirty || saving}
143
+ onClick={() => {
144
+ setDraft(null);
145
+ setError(null);
146
+ }}
147
+ >
148
+ Discard
149
+ </Button>
150
+ <Button size="sm" disabled={!dirty || saving} onClick={save}>
151
+ {saving ? <Spinner tone="inverse" /> : null}
152
+ Save
153
+ </Button>
154
+ </>
155
+ )
156
+ }
157
+ flush={!empty}
158
+ >
159
+ {empty ? (
160
+ <EmptyState
161
+ size="compact"
162
+ title="Nothing declared yet"
163
+ description="The matrix is the roles and actions this extension declares. Until it declares both, there is nothing to grant."
164
+ />
165
+ ) : (
166
+ <div className="overflow-x-auto">
167
+ <table
168
+ data-slot="admin-matrix-grid"
169
+ className="w-full border-collapse text-[length:var(--ds-text-caption-lg)]"
170
+ >
171
+ <caption className="sr-only">Roles and the actions each one grants</caption>
172
+ <thead>
173
+ <tr className="bg-[var(--ds-muted)] text-[color:var(--ds-muted-foreground)]">
174
+ <th
175
+ scope="col"
176
+ className="sticky left-0 z-10 bg-[var(--ds-muted)] px-4 py-2.5 text-left font-medium"
177
+ >
178
+ Role
179
+ </th>
180
+ {actionColumns.map((action) => (
181
+ <th
182
+ key={action}
183
+ scope="col"
184
+ data-action={action}
185
+ data-unknown={unknownActions.includes(action) ? "" : undefined}
186
+ title={
187
+ unknownActions.includes(action)
188
+ ? "This extension no longer declares this action"
189
+ : undefined
190
+ }
191
+ className="whitespace-nowrap px-3 py-2.5 text-center font-medium"
192
+ >
193
+ {action}
194
+ {unknownActions.includes(action) ? (
195
+ <span className="ml-1 text-[color:var(--ds-warning)]">(unknown)</span>
196
+ ) : null}
197
+ </th>
198
+ ))}
199
+ </tr>
200
+ </thead>
201
+ <tbody>
202
+ {roleRows.map((role) => (
203
+ <tr
204
+ key={role}
205
+ data-slot="admin-matrix-row"
206
+ data-role={role}
207
+ data-unknown={unknownRoles.includes(role) ? "" : undefined}
208
+ className="border-t border-[var(--ds-border-subtle)]"
209
+ >
210
+ <th
211
+ scope="row"
212
+ className="sticky left-0 z-10 bg-[var(--ds-card)] px-4 py-2 text-left font-medium text-[color:var(--ds-foreground)]"
213
+ >
214
+ {role}
215
+ {unknownRoles.includes(role) ? (
216
+ <span
217
+ data-slot="admin-matrix-unknown"
218
+ className="ml-1 font-normal text-[length:var(--ds-text-caption)] text-[color:var(--ds-warning)]"
219
+ >
220
+ unknown role
221
+ </span>
222
+ ) : null}
223
+ </th>
224
+ {actionColumns.map((action) => {
225
+ const checked = (grants[role] ?? []).includes(action);
226
+ return (
227
+ <td key={action} className="px-3 py-2 text-center">
228
+ <Checkbox
229
+ checked={checked}
230
+ data-role={role}
231
+ data-action={action}
232
+ aria-label={`${role} may ${action}`}
233
+ onCheckedChange={(next) => toggle(role, action, Boolean(next))}
234
+ />
235
+ </td>
236
+ );
237
+ })}
238
+ </tr>
239
+ ))}
240
+ </tbody>
241
+ </table>
242
+ {hasUnknown ? (
243
+ <p
244
+ data-slot="admin-matrix-unknown-note"
245
+ className="px-4 py-2.5 text-[length:var(--ds-text-caption)] text-[color:var(--ds-muted-foreground)]"
246
+ >
247
+ Rows and columns marked unknown are grants on a role or an action this
248
+ extension no longer declares. They are still live — untick them and save to
249
+ remove them.
250
+ </p>
251
+ ) : null}
252
+ </div>
253
+ )}
254
+ </SectionCard>
255
+ </div>
256
+ );
257
+ }
@@ -0,0 +1,204 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { Badge } from "@/components/badge";
5
+ import { Button } from "@/components/button";
6
+ import { Chip } from "@/components/chip";
7
+ import { DataTable, createColumnHelper } from "@/components/data-table";
8
+ import { ErrorBanner } from "@/components/error-banner";
9
+ import { directRolesOf, effectiveRolesWithOverride, useAdminPermissions } from "./context";
10
+ import { EffectiveRoles, RoleAssignmentDialog } from "./role-assignment";
11
+ import { groupsOf, sourceGroupOf, type AdminMemberRow, type AdminPrincipal } from "./types";
12
+
13
+ const helper = /* @__PURE__ */ createColumnHelper<AdminMemberRow>();
14
+
15
+ function statusVariant(status: string): "default" | "success" | "warning" | "destructive" {
16
+ const s = status.toLowerCase();
17
+ if (s === "active") return "success";
18
+ if (s === "invited" || s === "pending") return "warning";
19
+ if (s === "suspended" || s === "disabled" || s === "removed") return "destructive";
20
+ return "default";
21
+ }
22
+
23
+ function formatLastActive(value: string | null | undefined): string {
24
+ if (!value) return "Never";
25
+ const date = new Date(value);
26
+ if (Number.isNaN(date.getTime())) return value;
27
+ return date.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
28
+ }
29
+
30
+ /**
31
+ * `/admin/members` — the join described in §13.3, on the DataTable **server**
32
+ * source: an org's membership is unbounded, so it is paged, searched and
33
+ * sorted by the extension's own endpoint.
34
+ *
35
+ * The left half of every row is cortena-auth's and read-only: the person,
36
+ * their status, and the groups they belong to. The right half is the
37
+ * extension's: the direct assignment, and the effective roles `may()` resolves
38
+ * from it plus every group's roles, each labelled with its source. Only the
39
+ * direct assignment is editable, and the groups column disappears entirely
40
+ * when the org has no groups.
41
+ */
42
+ export function AdminMembers() {
43
+ const { api, hasGroups, groupsUnknown, groupsState, revision, memberRoles, reconcileMembers } =
44
+ useAdminPermissions();
45
+ const [editing, setEditing] = React.useState<AdminPrincipal | null>(null);
46
+ const [editingRoles, setEditingRoles] = React.useState<readonly string[]>([]);
47
+
48
+ const columns = React.useMemo(
49
+ () =>
50
+ helper.columns([
51
+ helper.accessor("name", {
52
+ header: "Member",
53
+ cell: ({ row }) => (
54
+ <div className="flex flex-col">
55
+ <span className="font-medium text-[color:var(--ds-foreground)]">
56
+ {row.original.name}
57
+ </span>
58
+ <span className="text-[length:var(--ds-text-caption)] text-[color:var(--ds-muted-foreground)]">
59
+ {row.original.email}
60
+ </span>
61
+ </div>
62
+ ),
63
+ meta: { label: "Member" },
64
+ }),
65
+ helper.accessor("status", {
66
+ header: "Status",
67
+ cell: ({ row }) => (
68
+ <Badge variant={statusVariant(row.original.status)}>{row.original.status}</Badge>
69
+ ),
70
+ // Deliberately not a faceted filter: a server source loads one page,
71
+ // so the facets would have to be a fixed list, and membership status
72
+ // is cortena-auth's vocabulary rather than this component's to
73
+ // invent. The search box already reaches it.
74
+ meta: { label: "Status" },
75
+ }),
76
+ ...(hasGroups || groupsUnknown
77
+ ? [
78
+ helper.display({
79
+ id: "groups",
80
+ header: "Groups",
81
+ cell: ({ row }) => {
82
+ const groups = groupsOf(row.original);
83
+ if (groups.length === 0) {
84
+ return (
85
+ <span className="text-[length:var(--ds-text-caption)] text-[color:var(--ds-text-tertiary)]">
86
+ None
87
+ </span>
88
+ );
89
+ }
90
+ return (
91
+ <div className="flex flex-wrap items-center gap-1">
92
+ {groups.map((group) => (
93
+ <Chip key={group.id} size="sm" data-group={group.id}>
94
+ {group.name}
95
+ </Chip>
96
+ ))}
97
+ </div>
98
+ );
99
+ },
100
+ meta: {
101
+ label: "Groups",
102
+ exportValue: (row: AdminMemberRow) =>
103
+ groupsOf(row)
104
+ .map((g) => g.name)
105
+ .join(", "),
106
+ },
107
+ enableSorting: false,
108
+ }),
109
+ ]
110
+ : []),
111
+ helper.display({
112
+ id: "effectiveRoles",
113
+ header: "Effective roles",
114
+ cell: ({ row }) => (
115
+ <EffectiveRoles roles={effectiveRolesWithOverride(row.original, memberRoles)} />
116
+ ),
117
+ meta: {
118
+ label: "Effective roles",
119
+ exportValue: (row: AdminMemberRow) =>
120
+ effectiveRolesWithOverride(row, memberRoles)
121
+ .map((entry) => {
122
+ const group = sourceGroupOf(entry.source);
123
+ return group ? `${entry.role} (via ${group.name})` : entry.role;
124
+ })
125
+ .join(", "),
126
+ },
127
+ enableSorting: false,
128
+ }),
129
+ helper.accessor("lastActiveAt", {
130
+ header: "Last active",
131
+ cell: ({ row }) => formatLastActive(row.original.lastActiveAt),
132
+ meta: { label: "Last active" },
133
+ }),
134
+ helper.display({
135
+ id: "actions",
136
+ header: () => <span className="sr-only">Actions</span>,
137
+ cell: ({ row }) => (
138
+ <div className="flex justify-end">
139
+ <Button
140
+ variant="outline"
141
+ size="sm"
142
+ aria-label={`Edit roles for ${row.original.name}`}
143
+ onClick={() => {
144
+ setEditingRoles(directRolesOf(row.original, memberRoles));
145
+ setEditing({
146
+ kind: "user",
147
+ id: row.original.userId,
148
+ name: row.original.name,
149
+ });
150
+ }}
151
+ >
152
+ Edit roles
153
+ </Button>
154
+ </div>
155
+ ),
156
+ meta: { align: "end", exportable: false },
157
+ enableSorting: false,
158
+ enableHiding: false,
159
+ }),
160
+ ]),
161
+ [hasGroups, groupsUnknown, memberRoles],
162
+ );
163
+
164
+ return (
165
+ <div data-slot="admin-members" className="flex flex-col gap-3">
166
+ {/* A failed `groups.list()` renders exactly like an org with no groups:
167
+ every inherited role silently disappears and the effective-roles
168
+ column reads as a fact about the member. Say so instead. */}
169
+ {groupsState.error ? (
170
+ <ErrorBanner
171
+ data-slot="admin-members-groups-error"
172
+ message={`Group membership could not be loaded, so inherited roles may be missing. ${groupsState.error.message}`}
173
+ onRetry={groupsState.reload}
174
+ />
175
+ ) : null}
176
+ <DataTable
177
+ columns={columns}
178
+ dataSource={{
179
+ kind: "server",
180
+ fetch: async (query, signal) => {
181
+ const result = await api.members.list(query, signal);
182
+ // The server has spoken: any optimistic entry it now agrees with
183
+ // has done its job and is retired.
184
+ reconcileMembers(result.rows);
185
+ return result;
186
+ },
187
+ // The revision is part of the cache scope, so a successful write —
188
+ // to this member or to a group they belong to — invalidates the page
189
+ // rather than leaving a stale resolution on screen.
190
+ queryKey: ["cortena-admin-members", revision],
191
+ }}
192
+ getRowId={(row) => row.userId}
193
+ searchPlaceholder="Search members…"
194
+ emptyMessage="No members"
195
+ exportFileName="members"
196
+ />
197
+ <RoleAssignmentDialog
198
+ principal={editing}
199
+ current={editingRoles}
200
+ onOpenChange={(open) => !open && setEditing(null)}
201
+ />
202
+ </div>
203
+ );
204
+ }