@columenlabs/lumen-ui 0.1.64 → 0.1.66
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 +132 -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,248 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// LumenPeople: who may sign in, what each person may open, invites and the audit log, as one block
|
|
3
|
+
// an app drops in with an adapter (`peopleClient(url)` over `@columenlabs/lumen-people`'s routes).
|
|
4
|
+
import { levelLabel, levelOf, levelsOf, } from '@columenlabs/lumen-ui-types/people';
|
|
5
|
+
import { AppWindowIcon, BanIcon, CircleDotIcon, ClockIcon, HistoryIcon, KeyRoundIcon, MailIcon, ShieldCheckIcon, Trash2Icon, TriangleAlertIcon, UserPlusIcon, UserRoundIcon, UsersIcon, XIcon, } from 'lucide-react';
|
|
6
|
+
import { useMemo, useState } from 'react';
|
|
7
|
+
import { LumenConfirm } from '../confirm.js';
|
|
8
|
+
import { LumenBoundary } from '../lumen-boundary.js';
|
|
9
|
+
import { LumenLayout } from '../lumen-layout.js';
|
|
10
|
+
import { LumenPageState } from '../lumen-page-state.js';
|
|
11
|
+
import { LumenSelector } from '../lumen-selector.js';
|
|
12
|
+
import { useLumenLoad } from '../lumen-source.js';
|
|
13
|
+
import { LumenTabs } from '../lumen-tabs.js';
|
|
14
|
+
import { NoteLine } from '../note-line.js';
|
|
15
|
+
import { Tile, TileRow } from '../tile.js';
|
|
16
|
+
import { Button } from '../ui/button.js';
|
|
17
|
+
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle, } from '../ui/empty.js';
|
|
18
|
+
import { InviteDrawer } from './invite-drawer.js';
|
|
19
|
+
import { PersonDrawer } from './person-drawer.js';
|
|
20
|
+
import { AuditSection, InvitesSection, PermissionsSection } from './sections.js';
|
|
21
|
+
import { LevelPicker, STATUS_LABEL, fromNow, peopleView, scopeKey, statusTone, topScopes, usePeopleAction, usePeopleQuery, useVisitView, when, withGrant, } from './shared.js';
|
|
22
|
+
export function LumenPeople({ adapter, crumbs = [], section: given, onSection, onSetUpMail, fields = [], }) {
|
|
23
|
+
const [own, setOwn] = useState('people');
|
|
24
|
+
const section = given ?? own;
|
|
25
|
+
const go = (s) => (onSection ? onSection(s) : setOwn(s));
|
|
26
|
+
const q = usePeopleQuery(adapter);
|
|
27
|
+
const [inviting, setInviting] = useState(false);
|
|
28
|
+
const titles = {
|
|
29
|
+
people: 'People',
|
|
30
|
+
invites: 'Invites',
|
|
31
|
+
permissions: 'Permissions',
|
|
32
|
+
audit: 'Audit log',
|
|
33
|
+
};
|
|
34
|
+
if (!q.data)
|
|
35
|
+
return (_jsx(LumenPageState, { crumbs: [...crumbs, { label: titles[section] }], title: titles[section], icon: UsersIcon, query: q }));
|
|
36
|
+
const view = q.data;
|
|
37
|
+
const pending = view.people.filter((p) => p.status === 'invited' || p.status === 'expired');
|
|
38
|
+
const tabs = [
|
|
39
|
+
{ value: 'people', label: 'People', icon: UsersIcon },
|
|
40
|
+
{
|
|
41
|
+
value: 'invites',
|
|
42
|
+
label: 'Invites',
|
|
43
|
+
icon: MailIcon,
|
|
44
|
+
after: pending.length ? (_jsx("span", { className: "num text-muted-foreground", children: pending.length })) : undefined,
|
|
45
|
+
},
|
|
46
|
+
{ value: 'permissions', label: 'Permissions', icon: KeyRoundIcon },
|
|
47
|
+
{ value: 'audit', label: 'Audit log', icon: HistoryIcon, hidden: !adapter.audit },
|
|
48
|
+
];
|
|
49
|
+
const frame = {
|
|
50
|
+
adapter,
|
|
51
|
+
view,
|
|
52
|
+
crumbs: [...crumbs, { label: titles[section] }],
|
|
53
|
+
tabs: onSection ? null : _jsx(LumenTabs, { tabs: tabs, value: section, onChange: go }),
|
|
54
|
+
mailNote: view.mail.configured ? null : (_jsx(NoteLine, { icon: TriangleAlertIcon, title: "Email is not set up.", note: "You can still invite: each invite gives you a link to send yourself.", action: onSetUpMail && (_jsx(Button, { size: "sm", variant: "outline", onClick: onSetUpMail, children: "Set up email" })) })),
|
|
55
|
+
invite: (_jsxs(Button, { size: "sm", onClick: () => setInviting(true), children: [_jsx(UserPlusIcon, {}), " Invite people"] })),
|
|
56
|
+
};
|
|
57
|
+
return (_jsxs(_Fragment, { children: [section === 'people' && (_jsx(PeopleList, { frame: frame, fields: fields, onInvite: () => setInviting(true) })), section === 'invites' && _jsx(InvitesSection, { frame: frame }), section === 'permissions' && _jsx(PermissionsSection, { frame: frame }), section === 'audit' && _jsx(AuditSection, { frame: frame }), _jsx(LumenBoundary, { where: "people-invite", what: "panel", children: _jsx(InviteDrawer, { adapter: adapter, view: view, open: inviting, onClose: () => setInviting(false), onSetUpMail: onSetUpMail }) })] }));
|
|
58
|
+
}
|
|
59
|
+
const LIST_VIEW = (scopes, extra) => peopleView('People', {
|
|
60
|
+
columns: [
|
|
61
|
+
'name',
|
|
62
|
+
'email',
|
|
63
|
+
'role',
|
|
64
|
+
'status',
|
|
65
|
+
...topScopes(scopes).map(scopeKey),
|
|
66
|
+
...extra,
|
|
67
|
+
'last',
|
|
68
|
+
],
|
|
69
|
+
chips: [{ key: 'status' }, { key: 'role' }],
|
|
70
|
+
filters: { fields: ['role', 'status'], default: [] },
|
|
71
|
+
group: { fields: ['role', 'status'], default: null },
|
|
72
|
+
pagination: { on: true, size: 50 },
|
|
73
|
+
total: false,
|
|
74
|
+
cards: [{ id: 'people', kind: 'table', w: 6, title: null }],
|
|
75
|
+
});
|
|
76
|
+
function PeopleList({ frame, fields, onInvite, }) {
|
|
77
|
+
const { adapter, view } = frame;
|
|
78
|
+
const q = usePeopleQuery(adapter);
|
|
79
|
+
const load = useLumenLoad(q);
|
|
80
|
+
const act = usePeopleAction(adapter);
|
|
81
|
+
const layout = useVisitView(useMemo(() => LIST_VIEW(view.scopes, fields.map((f) => f.key)), [view.scopes, fields]));
|
|
82
|
+
const [openId, setOpenId] = useState(null);
|
|
83
|
+
const [picked, setPicked] = useState(new Set());
|
|
84
|
+
const people = view.people;
|
|
85
|
+
const open = people.find((p) => p.id === openId) ?? null;
|
|
86
|
+
const setLevel = (p, s, level) => {
|
|
87
|
+
const before = p.grants;
|
|
88
|
+
void act(() => adapter.setAccess(p.id, withGrant(before, s, level)), `${s.name}: ${levelLabel(s, level)} for ${p.name || p.email}`, () => adapter.setAccess(p.id, before));
|
|
89
|
+
};
|
|
90
|
+
const domain = {
|
|
91
|
+
id: 'people',
|
|
92
|
+
rowKey: (p) => p.id,
|
|
93
|
+
text: (p) => [p.email, p.name],
|
|
94
|
+
fields: [
|
|
95
|
+
{
|
|
96
|
+
key: 'name',
|
|
97
|
+
label: 'Name',
|
|
98
|
+
icon: UserRoundIcon,
|
|
99
|
+
value: (p) => p.name || p.email,
|
|
100
|
+
format: 'text',
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
key: 'email',
|
|
104
|
+
label: 'Email',
|
|
105
|
+
icon: MailIcon,
|
|
106
|
+
value: (p) => p.email,
|
|
107
|
+
format: 'text',
|
|
108
|
+
maxWidth: '16rem',
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
key: 'role',
|
|
112
|
+
label: 'Role',
|
|
113
|
+
icon: ShieldCheckIcon,
|
|
114
|
+
value: (p) => (p.role === 'admin' ? 'Admin' : 'Member'),
|
|
115
|
+
tone: (p) => (p.role === 'admin' ? 'sky' : undefined),
|
|
116
|
+
format: 'text',
|
|
117
|
+
filterable: true,
|
|
118
|
+
groupable: true,
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
key: 'status',
|
|
122
|
+
label: 'Status',
|
|
123
|
+
icon: CircleDotIcon,
|
|
124
|
+
value: (p) => STATUS_LABEL[p.status],
|
|
125
|
+
tone: statusTone,
|
|
126
|
+
format: 'text',
|
|
127
|
+
filterable: true,
|
|
128
|
+
groupable: true,
|
|
129
|
+
},
|
|
130
|
+
...topScopes(view.scopes).map((s) => ({
|
|
131
|
+
key: scopeKey(s),
|
|
132
|
+
label: s.name,
|
|
133
|
+
icon: AppWindowIcon,
|
|
134
|
+
value: (p) => levelLabel(s, levelOf(p, s, view.scopes)),
|
|
135
|
+
render: (p) => (_jsx(LevelPicker, { person: p, scope: s, scopes: view.scopes, onPick: (l) => setLevel(p, s, l) })),
|
|
136
|
+
format: 'text',
|
|
137
|
+
filterable: true,
|
|
138
|
+
groupable: true,
|
|
139
|
+
})),
|
|
140
|
+
...fields,
|
|
141
|
+
{
|
|
142
|
+
key: 'invited_by',
|
|
143
|
+
label: 'Invited by',
|
|
144
|
+
icon: UserPlusIcon,
|
|
145
|
+
value: (p) => p.invitedBy ?? '—',
|
|
146
|
+
format: 'text',
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
key: 'expires',
|
|
150
|
+
label: 'Invite expires',
|
|
151
|
+
icon: ClockIcon,
|
|
152
|
+
value: (p) => (p.status === 'invited' ? fromNow(p.expiresAt) : '—'),
|
|
153
|
+
format: 'text',
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
key: 'last',
|
|
157
|
+
label: 'Last seen',
|
|
158
|
+
icon: ClockIcon,
|
|
159
|
+
value: (p) => when(p.lastSeen),
|
|
160
|
+
format: 'text',
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
};
|
|
164
|
+
const admins = people.filter((p) => p.role === 'admin' && p.status === 'active');
|
|
165
|
+
const expired = people.filter((p) => p.status === 'expired').length;
|
|
166
|
+
const invited = people.filter((p) => p.status === 'invited').length;
|
|
167
|
+
const active = people.filter((p) => p.status === 'active').length;
|
|
168
|
+
const disabled = people.filter((p) => p.status === 'disabled').length;
|
|
169
|
+
const onlyMe = people.length <= 1;
|
|
170
|
+
return (_jsxs(_Fragment, { children: [_jsx(LumenLayout, { crumbs: frame.crumbs, actions: frame.invite, intro: _jsxs("div", { className: "flex flex-col gap-3", children: [frame.tabs, _jsxs(TileRow, { fill: true, "aria-label": "People at a glance", children: [_jsx(Tile, { label: "People", value: people.length, caption: `${active} active${disabled ? ` · ${disabled} disabled` : ''}` }), _jsx(Tile, { label: "Admins", value: admins.length, caption: "at least 1 always kept" }), _jsx(Tile, { label: "Invites pending", value: invited, caption: expired ? `${expired} expired` : 'none expired' }), view.sync ? (_jsx(Tile, { label: "Sign-in sync", value: view.sync.ok ? 'Synced' : 'Out of step', caption: view.sync.at ? when(view.sync.at) : view.sync.detail, valueClassName: view.sync.ok ? undefined : 'text-loss' })) : (_jsx(Tile, { label: "Email", value: view.mail.configured ? 'Working' : 'Not set up', caption: view.mail.from ?? 'links only' }))] }), frame.mailNote, picked.size > 0 && (_jsx(BulkBar, { frame: frame, ids: [...picked], onClear: () => setPicked(new Set()) }))] }), domain: domain, ...layout, rows: people, load: load, state: onlyMe ? _jsx(FirstRun, { mail: view.mail.configured, onInvite: onInvite }) : undefined, selection: { keys: picked, onChange: setPicked }, rowTitle: (p) => p.name || p.email, onOpen: (p) => setOpenId(p.id) }), _jsx(LumenBoundary, { where: "people-person", what: "panel", children: _jsx(PersonDrawer, { adapter: adapter, view: view, person: open, onClose: () => setOpenId(null) }) })] }));
|
|
171
|
+
}
|
|
172
|
+
/** One change over the ticked people. Apply clears the selection; Undo puts the change back. */
|
|
173
|
+
function BulkBar({ frame, ids, onClear, }) {
|
|
174
|
+
const { adapter, view } = frame;
|
|
175
|
+
const act = usePeopleAction(adapter);
|
|
176
|
+
const [removing, setRemoving] = useState(false);
|
|
177
|
+
const chosen = view.people.filter((p) => ids.includes(p.id));
|
|
178
|
+
const invites = chosen.filter((p) => p.status === 'invited' || p.status === 'expired');
|
|
179
|
+
const n = chosen.length;
|
|
180
|
+
const many = (k) => (k === 1 ? '1 person' : `${k} people`);
|
|
181
|
+
/** A role, status, resend or remove over many: the adapter's bulk call, else one by one. */
|
|
182
|
+
const each = async (op, list) => {
|
|
183
|
+
if (adapter.bulk) {
|
|
184
|
+
const r = await adapter.bulk(list.map((p) => p.id), op);
|
|
185
|
+
if (r.failed.length)
|
|
186
|
+
throw new Error(`${r.failed.length} not changed: ${r.failed[0]?.error}`);
|
|
187
|
+
return r;
|
|
188
|
+
}
|
|
189
|
+
for (const p of list) {
|
|
190
|
+
if (op.op === 'role')
|
|
191
|
+
await adapter.update(p.id, { role: op.role });
|
|
192
|
+
else if (op.op === 'status')
|
|
193
|
+
await adapter.update(p.id, { status: op.status });
|
|
194
|
+
else if (op.op === 'resend')
|
|
195
|
+
await adapter.resend(p.id);
|
|
196
|
+
else if (op.op === 'remove')
|
|
197
|
+
await adapter.remove(p.id);
|
|
198
|
+
}
|
|
199
|
+
return {};
|
|
200
|
+
};
|
|
201
|
+
const levelOptions = topScopes(view.scopes).flatMap((s) => levelsOf(s).map((l) => ({
|
|
202
|
+
value: `${s.id}\u0001${l.id}`,
|
|
203
|
+
label: l.label,
|
|
204
|
+
group: s.name,
|
|
205
|
+
description: l.description,
|
|
206
|
+
})));
|
|
207
|
+
const setLevel = (pick) => {
|
|
208
|
+
const [sid, level] = pick.split('\u0001');
|
|
209
|
+
const s = view.scopes.find((x) => x.id === sid);
|
|
210
|
+
if (!s || !level)
|
|
211
|
+
return;
|
|
212
|
+
const members = chosen.filter((p) => p.role !== 'admin');
|
|
213
|
+
const before = new Map(members.map((p) => [p.id, p.grants]));
|
|
214
|
+
onClear();
|
|
215
|
+
void act(async () => {
|
|
216
|
+
for (const p of members)
|
|
217
|
+
await adapter.setAccess(p.id, withGrant(p.grants, s, level));
|
|
218
|
+
return undefined;
|
|
219
|
+
}, `${s.name}: ${levelLabel(s, level)} for ${many(members.length)}`, async () => {
|
|
220
|
+
for (const [id, g] of before)
|
|
221
|
+
await adapter.setAccess(id, g);
|
|
222
|
+
});
|
|
223
|
+
};
|
|
224
|
+
const setRole = (role) => {
|
|
225
|
+
const list = chosen.filter((p) => p.role !== role);
|
|
226
|
+
onClear();
|
|
227
|
+
void act(() => each({ op: 'role', role }, list), `${many(list.length)} made ${role === 'admin' ? 'admin' : 'member'}`, () => each({ op: 'role', role: role === 'admin' ? 'member' : 'admin' }, list));
|
|
228
|
+
};
|
|
229
|
+
const disable = () => {
|
|
230
|
+
const list = chosen.filter((p) => p.status === 'active');
|
|
231
|
+
onClear();
|
|
232
|
+
void act(() => each({ op: 'status', status: 'disabled' }, list), `${many(list.length)} disabled`, () => each({ op: 'status', status: 'active' }, list));
|
|
233
|
+
};
|
|
234
|
+
return (_jsxs("div", { role: "toolbar", "aria-label": "Change the ticked people", className: "flex flex-wrap items-center gap-2 rounded-lg border bg-muted px-3 py-2 text-sm", children: [_jsxs("span", { className: "num font-medium", children: [n, " selected"] }), _jsx(LumenSelector, { trigger: "chip", size: "sm", label: "Set level on\u2026", placeholder: "Set level on\u2026", value: null, options: levelOptions, onChange: setLevel }), _jsxs(Button, { size: "sm", variant: "outline", onClick: () => setRole('admin'), children: [_jsx(ShieldCheckIcon, {}), " Make admin"] }), invites.length > 0 && (_jsxs(Button, { size: "sm", variant: "outline", onClick: () => {
|
|
235
|
+
onClear();
|
|
236
|
+
void act(() => each({ op: 'resend' }, invites), `New invites sent to ${many(invites.length)}`);
|
|
237
|
+
}, children: [_jsx(MailIcon, {}), " Resend invites"] })), _jsxs(Button, { size: "sm", variant: "outline", onClick: disable, children: [_jsx(BanIcon, {}), " Disable"] }), _jsxs(Button, { size: "sm", variant: "outline", className: "text-destructive", onClick: () => setRemoving(true), children: [_jsx(Trash2Icon, {}), " Remove"] }), _jsx(Button, { size: "icon-sm", variant: "ghost", "aria-label": "Clear the selection", className: "ml-auto", onClick: onClear, children: _jsx(XIcon, {}) }), _jsx(LumenConfirm, { open: removing, onOpenChange: setRemoving, title: `Remove ${many(n)}?`, description: "Each part of the app deletes their data first, then they leave People. This cannot be undone.", typeToConfirm: "REMOVE", action: "Remove for good", busyAction: "Removing\u2026", onConfirm: async () => {
|
|
238
|
+
const r = await act(() => each({ op: 'remove' }, chosen), `Removed ${many(n)}`);
|
|
239
|
+
if (!r)
|
|
240
|
+
throw new Error('Some people were not removed.');
|
|
241
|
+
setRemoving(false);
|
|
242
|
+
onClear();
|
|
243
|
+
} })] }));
|
|
244
|
+
}
|
|
245
|
+
/** Only the admin so far: what to do first. */
|
|
246
|
+
function FirstRun({ mail, onInvite }) {
|
|
247
|
+
return (_jsxs(Empty, { children: [_jsxs(EmptyHeader, { children: [_jsx(EmptyMedia, { variant: "icon", children: _jsx(UsersIcon, {}) }), _jsx(EmptyTitle, { children: "It\u2019s just you so far" }), _jsxs(EmptyDescription, { children: ["Invite people and pick what each one can open. You can change it any time.", mail ? '' : ' Without email set up, each invite gives you a link to send.'] })] }), _jsx(EmptyContent, { children: _jsxs(Button, { size: "sm", onClick: onInvite, children: [_jsx(UserPlusIcon, {}), " Invite people"] }) })] }));
|
|
248
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type PeopleView, type Person } from '@columenlabs/lumen-ui-types/people';
|
|
2
|
+
import type { PeopleAdapter } from './client.js';
|
|
3
|
+
export declare function PersonDrawer({ adapter, view, person, onClose, }: {
|
|
4
|
+
adapter: PeopleAdapter;
|
|
5
|
+
view: PeopleView;
|
|
6
|
+
person: Person | null;
|
|
7
|
+
onClose: () => void;
|
|
8
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// One person: their access per scope, role, sign-in, recent activity and the danger zone.
|
|
3
|
+
import { levelLabel, levelOf, levelsOf, } from '@columenlabs/lumen-ui-types/people';
|
|
4
|
+
import { useQuery } from '@tanstack/react-query';
|
|
5
|
+
import { AppWindowIcon, BanIcon, HistoryIcon, KeyRoundIcon, MailIcon, ShieldCheckIcon, Trash2Icon, TriangleAlertIcon, XIcon, } from 'lucide-react';
|
|
6
|
+
import { useState } from 'react';
|
|
7
|
+
import { LumenConfirm } from '../confirm.js';
|
|
8
|
+
import { DrawerSection } from '../drawer-section.js';
|
|
9
|
+
import { InfoLabel, InfoTip } from '../info-tip.js';
|
|
10
|
+
import { errorText } from '../integrations.js';
|
|
11
|
+
import { LumenDrawer } from '../lumen-drawer.js';
|
|
12
|
+
import { LumenSelector } from '../lumen-selector.js';
|
|
13
|
+
import { NoteLine } from '../note-line.js';
|
|
14
|
+
import { Badge } from '../ui/badge.js';
|
|
15
|
+
import { Button } from '../ui/button.js';
|
|
16
|
+
import { LevelPicker, STATUS_LABEL, adminGuard, fromNow, topScopes, usePeopleAction, when, withGrant, } from './shared.js';
|
|
17
|
+
const ROLES = [
|
|
18
|
+
{ value: 'member', label: 'Member', description: 'Only the levels set per scope' },
|
|
19
|
+
{ value: 'admin', label: 'Admin', description: 'Top level everywhere, and manages People' },
|
|
20
|
+
];
|
|
21
|
+
export function PersonDrawer({ adapter, view, person, onClose, }) {
|
|
22
|
+
const act = usePeopleAction(adapter);
|
|
23
|
+
const p = person;
|
|
24
|
+
const guard = p ? adminGuard(view.people, p, view.fixedAdmins) : null;
|
|
25
|
+
const pending = p?.status === 'invited' || p?.status === 'expired';
|
|
26
|
+
const who = p ? p.name || p.email : '';
|
|
27
|
+
const setLevel = (scopeId, level, resource) => {
|
|
28
|
+
if (!p)
|
|
29
|
+
return;
|
|
30
|
+
const scope = view.scopes.find((s) => s.id === scopeId);
|
|
31
|
+
if (!scope)
|
|
32
|
+
return;
|
|
33
|
+
const before = p.grants;
|
|
34
|
+
const next = withGrant(before, scope, level, resource);
|
|
35
|
+
void act(() => adapter.setAccess(p.id, next), `${scope.name}: ${levelLabel(scope, level)} for ${who}`, () => adapter.setAccess(p.id, before));
|
|
36
|
+
};
|
|
37
|
+
return (_jsx(LumenDrawer, { open: !!p, onClose: onClose, title: who, description: p ? `${p.email} · ${STATUS_LABEL[p.status]} · invited ${when(p.invitedAt)}` : undefined, footer: p && pending ? (_jsxs(_Fragment, { children: [_jsxs(Button, { size: "sm", variant: "outline", onClick: () => act(() => adapter.revoke(p.id), `Revoked the invite for ${p.email}`).then((r) => r && onClose()), children: [_jsx(XIcon, {}), " Revoke"] }), _jsxs(Button, { size: "sm", onClick: () => act(() => adapter.resend(p.id), `New invite sent to ${p.email}`), children: [_jsx(MailIcon, {}), " Resend invite"] })] })) : undefined, children: p && (_jsxs(_Fragment, { children: [_jsx(AccessSection, { adapter: adapter, view: view, person: p, onLevel: setLevel }), _jsxs(DrawerSection, { icon: ShieldCheckIcon, title: "Role", defaultOpen: true, children: [_jsxs("div", { className: "flex items-center justify-between gap-3 py-1 text-sm", children: [_jsx(InfoLabel, { info: "Admins hold the top level on every scope and manage People. One active admin is always kept.", children: "Role" }), _jsx(LumenSelector, { trigger: "chip", size: "sm", label: `Role of ${who}`, disabled: !!guard && p.role === 'admin', value: p.role, options: ROLES, onChange: (role) => role !== p.role &&
|
|
38
|
+
void act(() => adapter.update(p.id, { role }), `${who} is now ${role === 'admin' ? 'an admin' : 'a member'}`, () => adapter.update(p.id, { role: p.role })) })] }), guard && p.role === 'admin' && _jsx(NoteLine, { icon: ShieldCheckIcon, title: guard })] }), _jsx(DrawerSection, { icon: KeyRoundIcon, title: "Sign-in", defaultOpen: true, children: _jsxs("dl", { className: "grid grid-cols-2 gap-x-3 gap-y-1.5 text-sm", children: [_jsx("dt", { className: "text-muted-foreground", children: "Last seen" }), _jsx("dd", { children: when(p.lastSeen) }), _jsx("dt", { className: "text-muted-foreground", children: "Invited by" }), _jsx("dd", { className: "truncate", children: p.invitedBy ?? '—' }), pending && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-muted-foreground", children: "Invite" }), _jsx("dd", { children: p.status === 'expired'
|
|
39
|
+
? `expired ${when(p.expiresAt)}`
|
|
40
|
+
: `expires ${fromNow(p.expiresAt)}` })] })), view.sync && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-muted-foreground", children: "Sign-in provider" }), _jsxs("dd", { children: [view.sync.ok ? 'Synced' : 'Out of step', " \u00B7 ", when(view.sync.at)] })] }))] }) }), adapter.audit && _jsx(ActivitySection, { adapter: adapter, person: p }), _jsx(DangerSection, { adapter: adapter, person: p, guard: guard, onGone: onClose })] })) }));
|
|
41
|
+
}
|
|
42
|
+
/** Each app's level, then the person's grants on constructs inside it. */
|
|
43
|
+
function AccessSection({ adapter, view, person: p, onLevel, }) {
|
|
44
|
+
const tops = topScopes(view.scopes);
|
|
45
|
+
const low = (id) => levelsOf(view.scopes.find((s) => s.id === id))[0]?.id;
|
|
46
|
+
const open = tops.filter((s) => levelOf(p, s, view.scopes) !== low(s.id)).length;
|
|
47
|
+
return (_jsx(DrawerSection, { icon: AppWindowIcon, title: "Access", hint: `${open} of ${tops.length}`, defaultOpen: true, children: _jsx("ul", { className: "flex flex-col gap-2 text-sm", children: tops.map((s) => {
|
|
48
|
+
const kids = view.scopes.filter((c) => c.parent === s.id);
|
|
49
|
+
return (_jsxs("li", { className: "flex flex-col gap-1.5", children: [_jsxs("div", { className: "flex items-center justify-between gap-3", children: [s.name, _jsx(LevelPicker, { person: p, scope: s, scopes: view.scopes, onPick: (l) => onLevel(s.id, l) })] }), kids.map((k) => (_jsx(ResourceGrants, { adapter: adapter, view: view, person: p, scopeId: k.id, onLevel: onLevel }, k.id)))] }, s.id));
|
|
50
|
+
}) }) }));
|
|
51
|
+
}
|
|
52
|
+
/** The person's grants on one construct (Collections inside Recipes), and + Grant on another. */
|
|
53
|
+
function ResourceGrants({ adapter, view, person: p, scopeId, onLevel, }) {
|
|
54
|
+
const scope = view.scopes.find((s) => s.id === scopeId);
|
|
55
|
+
const [adding, setAdding] = useState(false);
|
|
56
|
+
const list = useQuery({
|
|
57
|
+
queryKey: ['lumen-people', adapter.key, 'resources', scopeId],
|
|
58
|
+
queryFn: () => adapter.resources?.(scopeId) ?? Promise.resolve([]),
|
|
59
|
+
enabled: !!adapter.resources && adding,
|
|
60
|
+
});
|
|
61
|
+
if (!scope)
|
|
62
|
+
return null;
|
|
63
|
+
const held = p.grants.filter((g) => g.scope === scopeId && g.resource);
|
|
64
|
+
const name = (id) => list.data?.find((r) => r.id === id)?.name ?? id;
|
|
65
|
+
const noun = scope.resourceLabel ?? scope.name;
|
|
66
|
+
return (_jsxs("div", { className: "ml-3 flex flex-col gap-1.5 border-l pl-3", children: [held.map((g) => (_jsxs("div", { className: "flex items-center justify-between gap-3", children: [_jsx("span", { className: "truncate text-muted-foreground", children: name(g.resource ?? '') }), _jsx(LevelPicker, { person: p, scope: scope, scopes: view.scopes, resource: g.resource, onPick: (l) => onLevel(scopeId, l, g.resource) })] }, g.resource))), adapter.resources && p.role !== 'admin' && (_jsx(LumenSelector, { trigger: "chip", size: "sm", label: `Grant on a ${noun.toLowerCase()}`, placeholder: `+ Grant on a ${noun.toLowerCase()}`, value: null, open: adding, onOpenChange: setAdding, empty: list.isPending ? 'Loading…' : `No ${noun.toLowerCase()} to add`, options: (list.data ?? [])
|
|
67
|
+
.filter((r) => !held.some((g) => g.resource === r.id))
|
|
68
|
+
.map((r) => ({ value: r.id, label: r.name })), onChange: (r) => onLevel(scopeId, levelsOf(scope)[1]?.id ?? 'view', r) }))] }));
|
|
69
|
+
}
|
|
70
|
+
function ActivitySection({ adapter, person }) {
|
|
71
|
+
const q = useQuery({
|
|
72
|
+
queryKey: ['lumen-people', adapter.key, 'audit', person.id],
|
|
73
|
+
queryFn: () => adapter.audit?.({ target: person.id, limit: 5 }) ?? Promise.resolve(null),
|
|
74
|
+
});
|
|
75
|
+
const events = q.data?.events ?? [];
|
|
76
|
+
return (_jsx(DrawerSection, { icon: HistoryIcon, title: "Recent activity", count: events.length || null, children: q.isError ? (_jsx("p", { className: "text-destructive text-sm", children: errorText(q.error) })) : events.length ? (_jsx("ul", { className: "flex flex-col gap-1.5 text-sm", children: events.map((e) => (_jsxs("li", { className: "flex gap-3", children: [_jsx("span", { className: "num w-24 shrink-0 text-muted-foreground", children: when(e.at) }), _jsxs("span", { className: "min-w-0 flex-1 truncate", title: e.detail, children: [e.actor, " \u00B7 ", e.detail] }), e.refused && _jsx(Badge, { variant: "destructive", children: "Refused" })] }, e.id))) })) : (_jsx("p", { className: "text-muted-foreground text-sm", children: q.isPending ? 'Loading…' : 'Nothing yet.' })) }));
|
|
77
|
+
}
|
|
78
|
+
/** Disable or Enable, and Remove with what each part of the app does with their data first. */
|
|
79
|
+
function DangerSection({ adapter, person: p, guard, onGone, }) {
|
|
80
|
+
const act = usePeopleAction(adapter);
|
|
81
|
+
const [plan, setPlan] = useState(null);
|
|
82
|
+
const [planError, setPlanError] = useState(null);
|
|
83
|
+
const [confirming, setConfirming] = useState(false);
|
|
84
|
+
const who = p.name || p.email;
|
|
85
|
+
const locked = !!guard && p.role === 'admin';
|
|
86
|
+
const openRemove = async () => {
|
|
87
|
+
setPlan(null);
|
|
88
|
+
setPlanError(null);
|
|
89
|
+
setConfirming(true);
|
|
90
|
+
try {
|
|
91
|
+
setPlan(await adapter.removePlan(p.id));
|
|
92
|
+
}
|
|
93
|
+
catch (e) {
|
|
94
|
+
setPlanError(errorText(e));
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
return (_jsxs(DrawerSection, { icon: TriangleAlertIcon, title: "Danger zone", children: [locked ? (_jsx(NoteLine, { icon: ShieldCheckIcon, title: guard })) : (_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [p.status !== 'invited' && p.status !== 'expired' && (_jsxs(Button, { size: "sm", variant: "outline", onClick: () => {
|
|
98
|
+
const status = p.status === 'disabled' ? 'active' : 'disabled';
|
|
99
|
+
void act(() => adapter.update(p.id, { status }), status === 'disabled' ? `${who} is disabled` : `${who} is enabled`, () => adapter.update(p.id, {
|
|
100
|
+
status: p.status === 'disabled' ? 'disabled' : 'active',
|
|
101
|
+
}));
|
|
102
|
+
}, children: [_jsx(BanIcon, {}), " ", p.status === 'disabled' ? 'Enable' : 'Disable'] })), _jsxs(Button, { size: "sm", variant: "outline", className: "text-destructive", onClick: openRemove, children: [_jsx(Trash2Icon, {}), " Remove\u2026"] }), _jsx(InfoTip, { label: "About Disable and Remove", children: "Disable keeps their data and blocks sign-in. Remove asks each part of the app to delete their data first, then takes them out of People." })] })), _jsx(LumenConfirm, { open: confirming, onOpenChange: setConfirming, size: "default", title: `Remove ${who}?`, description: planError ? (planError) : plan ? (_jsxs("span", { className: "flex flex-col gap-2", children: [_jsx("span", { children: "This cannot be undone." }), _jsx("span", { className: "flex flex-col gap-1", children: plan.steps.map((s) => (_jsxs("span", { className: "flex justify-between gap-3", children: [_jsx("span", { children: s.label }), _jsx("span", { className: "text-muted-foreground", children: s.outcome })] }, s.label))) })] })) : ('Checking what removing them does…'), typeToConfirm: p.email, action: "Remove for good", busyAction: "Removing\u2026", onConfirm: async () => {
|
|
103
|
+
const r = await act(() => adapter.remove(p.id), `Removed ${who}`);
|
|
104
|
+
if (!r)
|
|
105
|
+
throw new Error('Nothing was removed.');
|
|
106
|
+
setConfirming(false);
|
|
107
|
+
onGone();
|
|
108
|
+
} })] }));
|
|
109
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type PeopleFrame } from './shared.js';
|
|
2
|
+
export declare function InvitesSection({ frame }: {
|
|
3
|
+
frame: PeopleFrame;
|
|
4
|
+
}): import("react").JSX.Element;
|
|
5
|
+
export declare function PermissionsSection({ frame }: {
|
|
6
|
+
frame: PeopleFrame;
|
|
7
|
+
}): import("react").JSX.Element;
|
|
8
|
+
export declare function AuditSection({ frame }: {
|
|
9
|
+
frame: PeopleFrame;
|
|
10
|
+
}): import("react").JSX.Element;
|