@columenlabs/lumen-ui 0.1.65 → 0.1.67
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/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/info-tip.d.ts +20 -0
- package/dist/info-tip.js +31 -0
- package/dist/people/client.d.ts +55 -0
- package/dist/people/client.js +39 -0
- package/dist/people/email-settings.d.ts +7 -0
- package/dist/people/email-settings.js +153 -0
- package/dist/people/index.d.ts +7 -0
- package/dist/people/index.js +9 -0
- package/dist/people/invite-accept.d.ts +9 -0
- package/dist/people/invite-accept.js +38 -0
- package/dist/people/invite-drawer.d.ts +13 -0
- package/dist/people/invite-drawer.js +116 -0
- package/dist/people/lumen-people.d.ts +18 -0
- package/dist/people/lumen-people.js +248 -0
- package/dist/people/person-drawer.d.ts +8 -0
- package/dist/people/person-drawer.js +109 -0
- package/dist/people/sections.d.ts +10 -0
- package/dist/people/sections.js +332 -0
- package/dist/people/shared.d.ts +51 -0
- package/dist/people/shared.js +126 -0
- package/package.json +6 -2
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// The People block's other pages: Invites (pending and expired, Resend and Revoke), Permissions
|
|
3
|
+
// (each scope's levels and who holds what) and the Audit log.
|
|
4
|
+
import { levelLabel, levelOf, levelsOf, } from '@columenlabs/lumen-ui-types/people';
|
|
5
|
+
import { useQuery } from '@tanstack/react-query';
|
|
6
|
+
import { AppWindowIcon, CircleDotIcon, ClockIcon, GitForkIcon, ListOrderedIcon, MailIcon, RotateCwIcon, ShieldCheckIcon, TagIcon, TextIcon, UserRoundIcon, XIcon, } from 'lucide-react';
|
|
7
|
+
import { useMemo, useState } from 'react';
|
|
8
|
+
import { LumenLayout } from '../lumen-layout.js';
|
|
9
|
+
import { LumenSelector } from '../lumen-selector.js';
|
|
10
|
+
import { useLumenLoad } from '../lumen-source.js';
|
|
11
|
+
import { Tile, TileRow } from '../tile.js';
|
|
12
|
+
import { Button } from '../ui/button.js';
|
|
13
|
+
import { CopyLink } from './invite-drawer.js';
|
|
14
|
+
import { LevelPicker, STATUS_LABEL, fromNow, peopleView, statusTone, topScopes, usePeopleAction, usePeopleQuery, useVisitView, when, withGrant, } from './shared.js';
|
|
15
|
+
const INVITES_VIEW = peopleView('Invites', {
|
|
16
|
+
columns: ['email', 'access', 'invited_by', 'sent', 'status', 'expires', 'actions'],
|
|
17
|
+
chips: [{ key: 'status' }],
|
|
18
|
+
filters: { fields: ['status', 'invited_by'], default: [] },
|
|
19
|
+
group: { fields: ['status', 'invited_by'], default: null },
|
|
20
|
+
pagination: { on: true, size: 50 },
|
|
21
|
+
total: false,
|
|
22
|
+
cards: [{ id: 'invites', kind: 'table', w: 6, title: null }],
|
|
23
|
+
});
|
|
24
|
+
export function InvitesSection({ frame }) {
|
|
25
|
+
const { adapter, view } = frame;
|
|
26
|
+
const load = useLumenLoad(usePeopleQuery(adapter));
|
|
27
|
+
const act = usePeopleAction(adapter);
|
|
28
|
+
const layout = useVisitView(INVITES_VIEW);
|
|
29
|
+
const [links, setLinks] = useState({});
|
|
30
|
+
const rows = view.people.filter((p) => p.status === 'invited' || p.status === 'expired');
|
|
31
|
+
const expired = rows.filter((p) => p.status === 'expired');
|
|
32
|
+
const next = rows
|
|
33
|
+
.filter((p) => p.status === 'invited' && p.expiresAt)
|
|
34
|
+
.sort((a, b) => (a.expiresAt ?? 0) - (b.expiresAt ?? 0))[0];
|
|
35
|
+
const tops = topScopes(view.scopes);
|
|
36
|
+
const resend = async (p) => {
|
|
37
|
+
const r = await act(() => adapter.resend(p.id), `New invite for ${p.email}`);
|
|
38
|
+
const link = r?.result.link;
|
|
39
|
+
if (link && !r?.result.mail?.ok)
|
|
40
|
+
setLinks((l) => ({ ...l, [p.id]: link }));
|
|
41
|
+
};
|
|
42
|
+
const domain = {
|
|
43
|
+
id: 'invites',
|
|
44
|
+
rowKey: (p) => p.id,
|
|
45
|
+
text: (p) => [p.email, p.name],
|
|
46
|
+
fields: [
|
|
47
|
+
{ key: 'email', label: 'Email', icon: MailIcon, value: (p) => p.email, format: 'text' },
|
|
48
|
+
{
|
|
49
|
+
key: 'access',
|
|
50
|
+
label: 'Access',
|
|
51
|
+
icon: AppWindowIcon,
|
|
52
|
+
value: (p) => p.role === 'admin'
|
|
53
|
+
? 'Admin'
|
|
54
|
+
: tops
|
|
55
|
+
.filter((s) => levelOf(p, s, view.scopes) !== levelsOf(s)[0]?.id)
|
|
56
|
+
.map((s) => `${s.name} ${levelLabel(s, levelOf(p, s, view.scopes))}`)
|
|
57
|
+
.join(', ') || 'None',
|
|
58
|
+
format: 'text',
|
|
59
|
+
maxWidth: '16rem',
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
key: 'invited_by',
|
|
63
|
+
label: 'Invited by',
|
|
64
|
+
icon: UserRoundIcon,
|
|
65
|
+
value: (p) => p.invitedBy ?? '—',
|
|
66
|
+
format: 'text',
|
|
67
|
+
filterable: true,
|
|
68
|
+
groupable: true,
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
key: 'sent',
|
|
72
|
+
label: 'Sent',
|
|
73
|
+
icon: ClockIcon,
|
|
74
|
+
value: (p) => when(p.invitedAt),
|
|
75
|
+
format: 'text',
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
key: 'status',
|
|
79
|
+
label: 'Status',
|
|
80
|
+
icon: CircleDotIcon,
|
|
81
|
+
value: (p) => STATUS_LABEL[p.status],
|
|
82
|
+
tone: statusTone,
|
|
83
|
+
format: 'text',
|
|
84
|
+
filterable: true,
|
|
85
|
+
groupable: true,
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
key: 'expires',
|
|
89
|
+
label: 'Expires',
|
|
90
|
+
icon: ClockIcon,
|
|
91
|
+
value: (p) => p.status === 'expired' ? `expired ${when(p.expiresAt)}` : fromNow(p.expiresAt),
|
|
92
|
+
format: 'text',
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
key: 'actions',
|
|
96
|
+
label: 'Actions',
|
|
97
|
+
icon: RotateCwIcon,
|
|
98
|
+
value: () => '',
|
|
99
|
+
render: (p) => (_jsxs("span", { className: "flex gap-1", onClick: (e) => e.stopPropagation(), onKeyDown: (e) => e.stopPropagation(), children: [_jsxs(Button, { size: "xs", variant: "outline", onClick: () => resend(p), children: [_jsx(RotateCwIcon, {}), " Resend"] }), _jsxs(Button, { size: "xs", variant: "ghost", onClick: () => act(() => adapter.revoke(p.id), `Revoked the invite for ${p.email}`), children: [_jsx(XIcon, {}), " Revoke"] })] })),
|
|
100
|
+
format: 'text',
|
|
101
|
+
maxWidth: false,
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
};
|
|
105
|
+
const shownLinks = Object.entries(links).filter(([id]) => rows.some((p) => p.id === id));
|
|
106
|
+
return (_jsx(LumenLayout, { crumbs: frame.crumbs, actions: _jsxs(_Fragment, { children: [expired.length > 0 && (_jsxs(Button, { size: "sm", variant: "outline", onClick: async () => {
|
|
107
|
+
for (const p of expired)
|
|
108
|
+
await resend(p);
|
|
109
|
+
}, children: [_jsx(RotateCwIcon, {}), " Resend all expired"] })), frame.invite] }), intro: _jsxs("div", { className: "flex flex-col gap-3", children: [frame.tabs, _jsxs(TileRow, { fill: true, "aria-label": "Invites at a glance", children: [_jsx(Tile, { label: "Pending", value: rows.length - expired.length, caption: next ? `next expires ${fromNow(next.expiresAt)}` : 'none waiting' }), _jsx(Tile, { label: "Expired", value: expired.length, caption: "refused at sign-in" }), _jsx(Tile, { label: "Link life", value: `${view.inviteDays} d`, caption: "Resend restarts it" })] }), frame.mailNote, shownLinks.map(([id, link]) => (_jsxs("div", { className: "flex flex-col gap-1 text-sm", children: [_jsxs("span", { children: ["The email to ", _jsx("span", { className: "font-medium", children: id }), " did not go. Send the link another way:"] }), _jsx(CopyLink, { link: link })] }, id)))] }), domain: domain, ...layout, rows: rows, load: load, state: rows.length ? undefined : (_jsx("p", { className: "p-6 text-center text-muted-foreground text-sm", children: "No invites waiting." })), rowTitle: (p) => p.email }));
|
|
110
|
+
}
|
|
111
|
+
const PERMISSIONS_VIEW = peopleView('Permissions', {
|
|
112
|
+
columns: ['name', 'resource', 'level', 'from'],
|
|
113
|
+
chips: [{ key: 'from' }],
|
|
114
|
+
filters: { fields: ['from', 'level'], default: [] },
|
|
115
|
+
group: { fields: ['from', 'level'], default: null },
|
|
116
|
+
pagination: { on: true, size: 50 },
|
|
117
|
+
total: false,
|
|
118
|
+
cards: [{ id: 'holdings', kind: 'table', w: 6, title: 'Who has what' }],
|
|
119
|
+
});
|
|
120
|
+
export function PermissionsSection({ frame }) {
|
|
121
|
+
const { adapter, view } = frame;
|
|
122
|
+
const load = useLumenLoad(usePeopleQuery(adapter));
|
|
123
|
+
const act = usePeopleAction(adapter);
|
|
124
|
+
const layout = useVisitView(PERMISSIONS_VIEW);
|
|
125
|
+
const [scopeId, setScopeId] = useState(view.scopes[0]?.id ?? '');
|
|
126
|
+
const scope = view.scopes.find((s) => s.id === scopeId) ?? view.scopes[0];
|
|
127
|
+
const parent = scope?.parent ? view.scopes.find((s) => s.id === scope.parent) : undefined;
|
|
128
|
+
const rows = useMemo(() => {
|
|
129
|
+
if (!scope)
|
|
130
|
+
return [];
|
|
131
|
+
const low = levelsOf(scope)[0]?.id;
|
|
132
|
+
const out = [];
|
|
133
|
+
for (const p of view.people) {
|
|
134
|
+
const from = p.status === 'disabled'
|
|
135
|
+
? 'Disabled'
|
|
136
|
+
: p.status === 'expired'
|
|
137
|
+
? 'Invite expired'
|
|
138
|
+
: p.role === 'admin'
|
|
139
|
+
? 'Admin role'
|
|
140
|
+
: 'Own grant';
|
|
141
|
+
if (scope.parent) {
|
|
142
|
+
for (const g of p.grants.filter((g) => g.scope === scope.id && g.resource))
|
|
143
|
+
out.push({ key: `${p.id}\u0001${g.resource}`, person: p, resource: g.resource, from });
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const has = p.grants.some((g) => g.scope === scope.id && !g.resource);
|
|
147
|
+
const level = levelOf(p, scope, view.scopes);
|
|
148
|
+
if (level === low && !has && p.role !== 'admin')
|
|
149
|
+
continue;
|
|
150
|
+
out.push({ key: p.id, person: p, from });
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
}, [scope, view]);
|
|
154
|
+
if (!scope)
|
|
155
|
+
return _jsx(LumenLayout, { crumbs: frame.crumbs, intro: frame.tabs, cards: [] });
|
|
156
|
+
const domain = {
|
|
157
|
+
id: 'permissions',
|
|
158
|
+
rowKey: (r) => r.key,
|
|
159
|
+
text: (r) => [r.person.email, r.person.name],
|
|
160
|
+
fields: [
|
|
161
|
+
{
|
|
162
|
+
key: 'name',
|
|
163
|
+
label: 'Person',
|
|
164
|
+
icon: UserRoundIcon,
|
|
165
|
+
value: (r) => r.person.name || r.person.email,
|
|
166
|
+
format: 'text',
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
key: 'resource',
|
|
170
|
+
label: scope.resourceLabel ?? 'Resource',
|
|
171
|
+
icon: TagIcon,
|
|
172
|
+
value: (r) => r.resource ?? '—',
|
|
173
|
+
format: 'text',
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
key: 'level',
|
|
177
|
+
label: 'Level',
|
|
178
|
+
icon: ShieldCheckIcon,
|
|
179
|
+
value: (r) => levelLabel(scope, levelOf(r.person, scope, view.scopes, r.resource)),
|
|
180
|
+
render: (r) => (_jsx(LevelPicker, { person: r.person, scope: scope, scopes: view.scopes, resource: r.resource, onPick: (l) => {
|
|
181
|
+
const before = r.person.grants;
|
|
182
|
+
void act(() => adapter.setAccess(r.person.id, withGrant(before, scope, l, r.resource)), `${scope.name}: ${levelLabel(scope, l)} for ${r.person.name || r.person.email}`, () => adapter.setAccess(r.person.id, before));
|
|
183
|
+
} })),
|
|
184
|
+
format: 'text',
|
|
185
|
+
filterable: true,
|
|
186
|
+
groupable: true,
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
key: 'from',
|
|
190
|
+
label: 'Comes from',
|
|
191
|
+
icon: GitForkIcon,
|
|
192
|
+
value: (r) => r.from,
|
|
193
|
+
tone: (r) => r.from === 'Admin role' ? 'sky' : r.from === 'Own grant' ? undefined : 'muted',
|
|
194
|
+
format: 'text',
|
|
195
|
+
filterable: true,
|
|
196
|
+
groupable: true,
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
};
|
|
200
|
+
const levels = levelsOf(scope);
|
|
201
|
+
return (_jsx(LumenLayout, { crumbs: frame.crumbs, actions: _jsx(LumenSelector, { label: "Scope", size: "sm", value: scope.id, options: view.scopes.map((s) => ({
|
|
202
|
+
value: s.id,
|
|
203
|
+
label: s.name,
|
|
204
|
+
group: s.parent ? 'Inside an app' : 'Apps',
|
|
205
|
+
detail: s.parent ? view.scopes.find((x) => x.id === s.parent)?.name : undefined,
|
|
206
|
+
})), onChange: setScopeId }), intro: frame.tabs, domain: domain, ...layout, rows: rows, load: load, state: rows.length ? undefined : (_jsx("p", { className: "p-6 text-center text-muted-foreground text-sm", children: scope.parent
|
|
207
|
+
? `No one holds a grant on a ${(scope.resourceLabel ?? 'resource').toLowerCase()} yet.`
|
|
208
|
+
: 'No one has access yet.' })), rowTitle: (r) => r.person.name || r.person.email, lead: [
|
|
209
|
+
{
|
|
210
|
+
id: 'levels',
|
|
211
|
+
w: 3,
|
|
212
|
+
title: `Levels for ${scope.name}`,
|
|
213
|
+
icon: ListOrderedIcon,
|
|
214
|
+
link: false,
|
|
215
|
+
children: (_jsxs("ol", { className: "flex flex-col gap-2 text-sm", children: [levels.map((l, i) => (_jsxs("li", { className: "flex gap-3", children: [_jsx("span", { className: "num w-4 shrink-0 text-muted-foreground", children: i + 1 }), _jsx("span", { className: "w-20 shrink-0 font-medium", children: l.label }), _jsx("span", { className: "min-w-0 flex-1 text-muted-foreground", children: l.description ?? '' })] }, l.id))), parent && (_jsxs("li", { className: "pt-1 text-muted-foreground text-xs", children: ["No grant of its own: the level on ", parent.name, ", by rank."] }))] })),
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
id: 'roles',
|
|
219
|
+
w: 3,
|
|
220
|
+
title: 'Roles',
|
|
221
|
+
icon: ShieldCheckIcon,
|
|
222
|
+
link: false,
|
|
223
|
+
children: (_jsxs("dl", { className: "grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm", children: [_jsx("dt", { className: "font-medium", children: "Admin" }), _jsx("dd", { className: "text-muted-foreground", children: "The top level on every scope; manages People. One is always kept." }), _jsx("dt", { className: "font-medium", children: "Member" }), _jsx("dd", { className: "text-muted-foreground", children: "Only the levels set per scope. The default for invites." })] })),
|
|
224
|
+
},
|
|
225
|
+
] }));
|
|
226
|
+
}
|
|
227
|
+
const AUDIT_VIEW = peopleView('Audit log', {
|
|
228
|
+
columns: ['time', 'actor', 'kind', 'detail', 'target', 'via'],
|
|
229
|
+
chips: [{ key: 'kind' }],
|
|
230
|
+
filters: { fields: ['actor', 'kind', 'target', 'via', 'outcome'], default: [] },
|
|
231
|
+
group: { fields: ['day', 'kind', 'actor'], default: 'day' },
|
|
232
|
+
pagination: { on: true, size: 100 },
|
|
233
|
+
total: false,
|
|
234
|
+
cards: [{ id: 'audit', kind: 'table', w: 6, title: null }],
|
|
235
|
+
});
|
|
236
|
+
const WINDOWS = [
|
|
237
|
+
{ value: '1', label: 'Last 24 hours' },
|
|
238
|
+
{ value: '7', label: 'Last 7 days' },
|
|
239
|
+
{ value: '30', label: 'Last 30 days' },
|
|
240
|
+
{ value: '365', label: 'Last year' },
|
|
241
|
+
];
|
|
242
|
+
export function AuditSection({ frame }) {
|
|
243
|
+
const { adapter } = frame;
|
|
244
|
+
const [days, setDays] = useState('30');
|
|
245
|
+
const since = useMemo(() => Date.now() - Number(days) * 86_400_000, [days]);
|
|
246
|
+
const q = useQuery({
|
|
247
|
+
queryKey: ['lumen-people', adapter.key, 'audit', 'log', days],
|
|
248
|
+
queryFn: () => adapter.audit?.({ since, limit: 1000 }) ?? Promise.resolve({ events: [] }),
|
|
249
|
+
});
|
|
250
|
+
const load = useLumenLoad(q);
|
|
251
|
+
const layout = useVisitView(AUDIT_VIEW);
|
|
252
|
+
const domain = {
|
|
253
|
+
id: 'audit',
|
|
254
|
+
rowKey: (e) => e.id,
|
|
255
|
+
text: (e) => [e.actor, e.target, e.detail],
|
|
256
|
+
fields: [
|
|
257
|
+
{
|
|
258
|
+
key: 'day',
|
|
259
|
+
label: 'Day',
|
|
260
|
+
icon: ClockIcon,
|
|
261
|
+
value: (e) => new Date(e.at).toLocaleDateString(undefined, {
|
|
262
|
+
weekday: 'short',
|
|
263
|
+
day: 'numeric',
|
|
264
|
+
month: 'short',
|
|
265
|
+
}),
|
|
266
|
+
format: 'text',
|
|
267
|
+
groupable: true,
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
key: 'time',
|
|
271
|
+
label: 'When',
|
|
272
|
+
icon: ClockIcon,
|
|
273
|
+
value: (e) => new Date(e.at).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }),
|
|
274
|
+
format: 'text',
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
key: 'actor',
|
|
278
|
+
label: 'Who',
|
|
279
|
+
icon: UserRoundIcon,
|
|
280
|
+
value: (e) => e.actor,
|
|
281
|
+
format: 'text',
|
|
282
|
+
filterable: true,
|
|
283
|
+
groupable: true,
|
|
284
|
+
},
|
|
285
|
+
{
|
|
286
|
+
key: 'kind',
|
|
287
|
+
label: 'Kind',
|
|
288
|
+
icon: TagIcon,
|
|
289
|
+
value: (e) => e.kind,
|
|
290
|
+
format: 'text',
|
|
291
|
+
filterable: true,
|
|
292
|
+
groupable: true,
|
|
293
|
+
},
|
|
294
|
+
{
|
|
295
|
+
key: 'detail',
|
|
296
|
+
label: 'What changed',
|
|
297
|
+
icon: TextIcon,
|
|
298
|
+
value: (e) => (e.refused ? `${e.detail} refused: ${e.refused}` : e.detail),
|
|
299
|
+
tint: (e) => (e.refused ? 'color-mix(in oklch, var(--loss) 8%, transparent)' : undefined),
|
|
300
|
+
format: 'text',
|
|
301
|
+
maxWidth: '28rem',
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
key: 'target',
|
|
305
|
+
label: 'Target',
|
|
306
|
+
icon: UserRoundIcon,
|
|
307
|
+
value: (e) => e.target,
|
|
308
|
+
format: 'text',
|
|
309
|
+
filterable: true,
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
key: 'via',
|
|
313
|
+
label: 'From',
|
|
314
|
+
icon: AppWindowIcon,
|
|
315
|
+
value: (e) => e.via,
|
|
316
|
+
format: 'text',
|
|
317
|
+
filterable: true,
|
|
318
|
+
groupable: true,
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
key: 'outcome',
|
|
322
|
+
label: 'Outcome',
|
|
323
|
+
icon: CircleDotIcon,
|
|
324
|
+
value: (e) => (e.refused ? 'Refused' : 'Done'),
|
|
325
|
+
format: 'text',
|
|
326
|
+
filterable: true,
|
|
327
|
+
groupable: true,
|
|
328
|
+
},
|
|
329
|
+
],
|
|
330
|
+
};
|
|
331
|
+
return (_jsx(LumenLayout, { crumbs: frame.crumbs, actions: _jsx(LumenSelector, { label: "Window", size: "sm", value: days, options: WINDOWS, onChange: setDays }), intro: frame.tabs, domain: domain, ...layout, rows: q.data?.events ?? [], load: load, rowTitle: (e) => e.detail }));
|
|
332
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { type Grant, type PeopleView, type Person, type PersonStatus, type Scope } from '@columenlabs/lumen-ui-types/people';
|
|
2
|
+
import { type CustomViewConfig } from '@columenlabs/lumen-ui-types/views';
|
|
3
|
+
import { type ReactNode } from 'react';
|
|
4
|
+
import type { Crumb } from '../page.js';
|
|
5
|
+
import type { ViewChipTone } from '../view-model.js';
|
|
6
|
+
import type { PeopleAdapter, WithSync } from './client.js';
|
|
7
|
+
export declare const STATUS_LABEL: Record<PersonStatus, string>;
|
|
8
|
+
export declare const statusTone: (p: Pick<Person, 'status'>) => ViewChipTone | undefined;
|
|
9
|
+
/** `today 09:12`, `yesterday 18:42`, `24 Oct`, `24 Oct 2025`; a dash for never. */
|
|
10
|
+
export declare function when(ts: number | null | undefined, now?: number): string;
|
|
11
|
+
/** `in 5 d`, `in 3 h`, `2 d ago`: how far a time is from now. */
|
|
12
|
+
export declare function fromNow(ts: number | null | undefined, now?: number): string;
|
|
13
|
+
/** The top scopes (apps), in order; constructs inside one have a `parent`. */
|
|
14
|
+
export declare const topScopes: (scopes: readonly Scope[]) => Scope[];
|
|
15
|
+
/** Grants with one scope's (and resource's) level set; the lowest level drops the grant. */
|
|
16
|
+
export declare function withGrant(grants: readonly Grant[], scope: Scope, level: string, resource?: string): Grant[];
|
|
17
|
+
/** Why a person's role or status cannot change (the last or built-in admin), or null. */
|
|
18
|
+
export declare const adminGuard: (people: readonly Person[], p: Person, fixed: readonly string[]) => string | null;
|
|
19
|
+
/** Runs a change, says how it went, and reloads the People data. A sign-in sync that failed is
|
|
20
|
+
* said too: the change stands, but the provider still has the old list. */
|
|
21
|
+
export declare function usePeopleAction(adapter: PeopleAdapter): <R extends WithSync | undefined>(run: () => Promise<R>, done: string, undo?: () => Promise<unknown>) => Promise<R | null>;
|
|
22
|
+
/** One person's level on one scope (or one resource of it): a chip that changes it at once. Admins
|
|
23
|
+
* and people without access show what they hold, fixed, with why on hover. */
|
|
24
|
+
export declare function LevelPicker({ person, scope, scopes, resource, onPick, disabled, }: {
|
|
25
|
+
person: Person;
|
|
26
|
+
scope: Scope;
|
|
27
|
+
scopes: readonly Scope[];
|
|
28
|
+
resource?: string;
|
|
29
|
+
onPick: (level: string) => void;
|
|
30
|
+
disabled?: boolean;
|
|
31
|
+
}): import("react").JSX.Element;
|
|
32
|
+
export declare const levelText: (scope: Scope, p: Person, scopes: readonly Scope[], resource?: string) => string;
|
|
33
|
+
/** A page's default view, parsed so every setting has its default. */
|
|
34
|
+
export declare const peopleView: (name: string, config: Record<string, unknown>) => CustomViewConfig;
|
|
35
|
+
/** A view config the person changes for the visit, with Reset back to the code's. */
|
|
36
|
+
export declare function useVisitView(view: CustomViewConfig): {
|
|
37
|
+
config: CustomViewConfig;
|
|
38
|
+
onConfigChange: import("react").Dispatch<import("react").SetStateAction<CustomViewConfig>>;
|
|
39
|
+
onReset: () => void;
|
|
40
|
+
};
|
|
41
|
+
export declare const scopeKey: (s: Scope) => string;
|
|
42
|
+
export declare function usePeopleQuery(adapter: PeopleAdapter): import("@tanstack/react-query").UseQueryResult<PeopleView, Error>;
|
|
43
|
+
/** What every section's page shares: the data, its crumbs, the tabs and the Invite button. */
|
|
44
|
+
export interface PeopleFrame {
|
|
45
|
+
adapter: PeopleAdapter;
|
|
46
|
+
view: PeopleView;
|
|
47
|
+
crumbs: Crumb[];
|
|
48
|
+
tabs: ReactNode;
|
|
49
|
+
mailNote: ReactNode;
|
|
50
|
+
invite: ReactNode;
|
|
51
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
// What the People screens share: times in words, a person's status, the level picker and how a
|
|
3
|
+
// change reports back.
|
|
4
|
+
import { checkAdminFloor, levelLabel, levelOf, levelsOf, } from '@columenlabs/lumen-ui-types/people';
|
|
5
|
+
import { customViewConfigSchema } from '@columenlabs/lumen-ui-types/views';
|
|
6
|
+
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
7
|
+
import { useCallback, useState } from 'react';
|
|
8
|
+
import { errorText } from '../integrations.js';
|
|
9
|
+
import { LumenSelector } from '../lumen-selector.js';
|
|
10
|
+
import { lumenToast } from '../lumen-toast.js';
|
|
11
|
+
export const STATUS_LABEL = {
|
|
12
|
+
active: 'Active',
|
|
13
|
+
invited: 'Invited',
|
|
14
|
+
expired: 'Expired',
|
|
15
|
+
disabled: 'Disabled',
|
|
16
|
+
};
|
|
17
|
+
export const statusTone = (p) => p.status === 'active'
|
|
18
|
+
? 'gain'
|
|
19
|
+
: p.status === 'invited'
|
|
20
|
+
? 'info'
|
|
21
|
+
: p.status === 'expired'
|
|
22
|
+
? 'amber'
|
|
23
|
+
: 'muted';
|
|
24
|
+
const DAY = 86_400_000;
|
|
25
|
+
const sameDay = (a, b) => a.toDateString() === b.toDateString();
|
|
26
|
+
/** `today 09:12`, `yesterday 18:42`, `24 Oct`, `24 Oct 2025`; a dash for never. */
|
|
27
|
+
export function when(ts, now = Date.now()) {
|
|
28
|
+
if (!ts)
|
|
29
|
+
return '—';
|
|
30
|
+
const d = new Date(ts);
|
|
31
|
+
const n = new Date(now);
|
|
32
|
+
const time = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
|
33
|
+
if (sameDay(d, n))
|
|
34
|
+
return `today ${time}`;
|
|
35
|
+
if (sameDay(d, new Date(now - DAY)))
|
|
36
|
+
return `yesterday ${time}`;
|
|
37
|
+
return d.toLocaleDateString(undefined, {
|
|
38
|
+
day: 'numeric',
|
|
39
|
+
month: 'short',
|
|
40
|
+
...(d.getFullYear() === n.getFullYear() ? {} : { year: 'numeric' }),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
/** `in 5 d`, `in 3 h`, `2 d ago`: how far a time is from now. */
|
|
44
|
+
export function fromNow(ts, now = Date.now()) {
|
|
45
|
+
if (!ts)
|
|
46
|
+
return '—';
|
|
47
|
+
const ms = ts - now;
|
|
48
|
+
const abs = Math.abs(ms);
|
|
49
|
+
const n = abs >= DAY
|
|
50
|
+
? `${Math.round(abs / DAY)} d`
|
|
51
|
+
: abs >= 3_600_000
|
|
52
|
+
? `${Math.round(abs / 3_600_000)} h`
|
|
53
|
+
: `${Math.max(1, Math.round(abs / 60_000))} min`;
|
|
54
|
+
return ms >= 0 ? `in ${n}` : `${n} ago`;
|
|
55
|
+
}
|
|
56
|
+
/** The top scopes (apps), in order; constructs inside one have a `parent`. */
|
|
57
|
+
export const topScopes = (scopes) => scopes.filter((s) => !s.parent);
|
|
58
|
+
/** Grants with one scope's (and resource's) level set; the lowest level drops the grant. */
|
|
59
|
+
export function withGrant(grants, scope, level, resource) {
|
|
60
|
+
const rest = grants.filter((g) => !(g.scope === scope.id && (g.resource ?? '') === (resource ?? '')));
|
|
61
|
+
return level === levelsOf(scope)[0]?.id
|
|
62
|
+
? rest
|
|
63
|
+
: [...rest, { scope: scope.id, level, ...(resource ? { resource } : {}) }];
|
|
64
|
+
}
|
|
65
|
+
/** Why a person's role or status cannot change (the last or built-in admin), or null. */
|
|
66
|
+
export const adminGuard = (people, p, fixed) => checkAdminFloor(people, { id: p.id, role: 'member' }, fixed);
|
|
67
|
+
/** Runs a change, says how it went, and reloads the People data. A sign-in sync that failed is
|
|
68
|
+
* said too: the change stands, but the provider still has the old list. */
|
|
69
|
+
export function usePeopleAction(adapter) {
|
|
70
|
+
const qc = useQueryClient();
|
|
71
|
+
return useCallback(async (run, done, undo) => {
|
|
72
|
+
try {
|
|
73
|
+
const r = await run();
|
|
74
|
+
const sync = r?.access && !r.access.ok ? ` Sign-in sync: ${r.access.detail}` : '';
|
|
75
|
+
if (sync)
|
|
76
|
+
lumenToast.warning(`${done}.${sync}`);
|
|
77
|
+
else if (undo)
|
|
78
|
+
lumenToast.undo(done, () => {
|
|
79
|
+
void undo()
|
|
80
|
+
.then(() => qc.invalidateQueries({ queryKey: ['lumen-people', adapter.key] }))
|
|
81
|
+
.catch((e) => lumenToast.error(errorText(e)));
|
|
82
|
+
});
|
|
83
|
+
else
|
|
84
|
+
lumenToast.success(done);
|
|
85
|
+
return r;
|
|
86
|
+
}
|
|
87
|
+
catch (e) {
|
|
88
|
+
lumenToast.error(errorText(e));
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
await qc.invalidateQueries({ queryKey: ['lumen-people', adapter.key] });
|
|
93
|
+
}
|
|
94
|
+
}, [adapter, qc]);
|
|
95
|
+
}
|
|
96
|
+
/** One person's level on one scope (or one resource of it): a chip that changes it at once. Admins
|
|
97
|
+
* and people without access show what they hold, fixed, with why on hover. */
|
|
98
|
+
export function LevelPicker({ person, scope, scopes, resource, onPick, disabled, }) {
|
|
99
|
+
const fixed = person.role === 'admin'
|
|
100
|
+
? 'Admins hold the top level everywhere'
|
|
101
|
+
: person.status === 'disabled'
|
|
102
|
+
? 'Disabled: no access'
|
|
103
|
+
: person.status === 'expired'
|
|
104
|
+
? 'Invite expired: resend it first'
|
|
105
|
+
: null;
|
|
106
|
+
const own = person.grants.some((g) => g.scope === scope.id && (g.resource ?? '') === (resource ?? ''));
|
|
107
|
+
const parent = scope.parent ? scopes.find((s) => s.id === scope.parent) : undefined;
|
|
108
|
+
const value = levelOf(person, scope, scopes, resource);
|
|
109
|
+
return (_jsx("span", { title: fixed ?? undefined, onClick: (e) => e.stopPropagation(), onKeyDown: (e) => e.stopPropagation(), children: _jsx(LumenSelector, { trigger: "chip", size: "sm", label: `${scope.name} for ${person.name || person.email}`, disabled: disabled || !!fixed, value: value, options: levelsOf(scope).map((l) => ({
|
|
110
|
+
value: l.id,
|
|
111
|
+
label: !own && parent && l.id === value ? `${l.label} (from ${parent.name})` : l.label,
|
|
112
|
+
description: l.description,
|
|
113
|
+
})), onChange: (level) => level !== value && onPick(level) }) }));
|
|
114
|
+
}
|
|
115
|
+
export const levelText = (scope, p, scopes, resource) => levelLabel(scope, levelOf(p, scope, scopes, resource));
|
|
116
|
+
/** A page's default view, parsed so every setting has its default. */
|
|
117
|
+
export const peopleView = (name, config) => ({ ...customViewConfigSchema.parse(config), name });
|
|
118
|
+
/** A view config the person changes for the visit, with Reset back to the code's. */
|
|
119
|
+
export function useVisitView(view) {
|
|
120
|
+
const [config, setConfig] = useState(view);
|
|
121
|
+
return { config, onConfigChange: setConfig, onReset: () => setConfig(view) };
|
|
122
|
+
}
|
|
123
|
+
export const scopeKey = (s) => `scope_${s.id.replace(/[^a-z0-9]/gi, '_').toLowerCase()}`;
|
|
124
|
+
export function usePeopleQuery(adapter) {
|
|
125
|
+
return useQuery({ queryKey: ['lumen-people', adapter.key], queryFn: () => adapter.view() });
|
|
126
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@columenlabs/lumen-ui",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.67",
|
|
4
4
|
"description": "Lumen: shared React components (frame, views, widgets, charts, drawers).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
"types": "./dist/ui/*.d.ts",
|
|
16
16
|
"default": "./dist/ui/*.js"
|
|
17
17
|
},
|
|
18
|
+
"./people": {
|
|
19
|
+
"types": "./dist/people/index.d.ts",
|
|
20
|
+
"default": "./dist/people/index.js"
|
|
21
|
+
},
|
|
18
22
|
"./theme.css": "./dist/theme.css"
|
|
19
23
|
},
|
|
20
24
|
"files": [
|
|
@@ -41,7 +45,7 @@
|
|
|
41
45
|
"@codemirror/lang-javascript": "6.2.5",
|
|
42
46
|
"@codemirror/lang-json": "6.0.2",
|
|
43
47
|
"@codemirror/lang-sql": "6.10.0",
|
|
44
|
-
"@columenlabs/lumen-ui-types": "0.1.
|
|
48
|
+
"@columenlabs/lumen-ui-types": "0.1.67",
|
|
45
49
|
"@dnd-kit/core": "6.3.1",
|
|
46
50
|
"@dnd-kit/sortable": "10.0.0",
|
|
47
51
|
"@dnd-kit/utilities": "3.2.2",
|