@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
package/dist/index.d.ts
CHANGED
|
@@ -15,6 +15,7 @@ export { LumenEChart, type EChartOption, type ChartTokens, axisStyle, baseOption
|
|
|
15
15
|
export { LumenGeoMap } from './lumen-geo-map.js';
|
|
16
16
|
export { LumenWidget, type CardControls, type CardLink, LumenSavedWidget } from './lumen-widget.js';
|
|
17
17
|
export { LumenConfirm, type LumenConfirmProps } from './confirm.js';
|
|
18
|
+
export { InfoLabel, InfoTip } from './info-tip.js';
|
|
18
19
|
export { type LumenAuthPoint, LumenAuthHeading, LumenAuthNote, LumenAuthProblem, LumenAuthStage, type LumenAuthStageProps, LumenCodeField, LumenEmailField, LumenPasswordField, LumenStepBar, } from './lumen-auth.js';
|
|
19
20
|
export { LumenBackupCodes, type LumenCodeMethod, type LumenPasskey, type LumenResult, LumenSecondStep, type LumenSecondStepProps, LumenSignInMethods, type LumenSignInMethodsProps, } from './lumen-security.js';
|
|
20
21
|
export { LumenDateInput, type LumenDateInputProps, stepDate, } from './lumen-date-input.js';
|
package/dist/index.js
CHANGED
|
@@ -22,6 +22,7 @@ export { LumenEChart, axisStyle, baseOption, useChartTokens, } from './lumen-ech
|
|
|
22
22
|
export { LumenGeoMap } from './lumen-geo-map.js';
|
|
23
23
|
export { LumenWidget, LumenSavedWidget } from './lumen-widget.js';
|
|
24
24
|
export { LumenConfirm } from './confirm.js';
|
|
25
|
+
export { InfoLabel, InfoTip } from './info-tip.js';
|
|
25
26
|
export { LumenAuthHeading, LumenAuthNote, LumenAuthProblem, LumenAuthStage, LumenCodeField, LumenEmailField, LumenPasswordField, LumenStepBar, } from './lumen-auth.js';
|
|
26
27
|
export { LumenBackupCodes, LumenSecondStep, LumenSignInMethods, } from './lumen-security.js';
|
|
27
28
|
export { LumenDateInput, stepDate, } from './lumen-date-input.js';
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* The details of a label, behind an (i) beside it: shown on hover or focus, and on tap on a
|
|
4
|
+
* phone. The kit's one way to say more than a label: keep visible copy to a few words and put
|
|
5
|
+
* the rest here, never in a grey line under a field or a section title.
|
|
6
|
+
*/
|
|
7
|
+
export declare function InfoTip({ children, label, side, className, }: {
|
|
8
|
+
/** The details: a sentence or two, a short list. */
|
|
9
|
+
children: ReactNode;
|
|
10
|
+
/** The button's accessible name ("About Edit"). */
|
|
11
|
+
label?: string;
|
|
12
|
+
side?: 'top' | 'right' | 'bottom' | 'left';
|
|
13
|
+
className?: string;
|
|
14
|
+
}): import("react").JSX.Element;
|
|
15
|
+
/** A label with its (i): `Link works for (i)`. */
|
|
16
|
+
export declare function InfoLabel({ children, info, className, }: {
|
|
17
|
+
children: ReactNode;
|
|
18
|
+
info?: ReactNode;
|
|
19
|
+
className?: string;
|
|
20
|
+
}): import("react").JSX.Element;
|
package/dist/info-tip.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { InfoIcon } from 'lucide-react';
|
|
3
|
+
import { useRef, useState } from 'react';
|
|
4
|
+
import { cn } from './lib/utils.js';
|
|
5
|
+
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover.js';
|
|
6
|
+
/**
|
|
7
|
+
* The details of a label, behind an (i) beside it: shown on hover or focus, and on tap on a
|
|
8
|
+
* phone. The kit's one way to say more than a label: keep visible copy to a few words and put
|
|
9
|
+
* the rest here, never in a grey line under a field or a section title.
|
|
10
|
+
*/
|
|
11
|
+
export function InfoTip({ children, label = 'More about this', side = 'top', className, }) {
|
|
12
|
+
const [open, setOpen] = useState(false);
|
|
13
|
+
// Hover opens it after a beat and leaving closes it after one, so the pointer can cross to it.
|
|
14
|
+
const timer = useRef(null);
|
|
15
|
+
const later = (next, ms) => {
|
|
16
|
+
if (timer.current)
|
|
17
|
+
clearTimeout(timer.current);
|
|
18
|
+
timer.current = setTimeout(() => setOpen(next), ms);
|
|
19
|
+
};
|
|
20
|
+
const hover = {
|
|
21
|
+
onPointerEnter: (e) => e.pointerType === 'mouse' && later(true, 150),
|
|
22
|
+
onPointerLeave: (e) => e.pointerType === 'mouse' && later(false, 200),
|
|
23
|
+
};
|
|
24
|
+
return (_jsxs(Popover, { open: open, onOpenChange: setOpen, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsx("button", { type: "button", "aria-label": label, className: cn('inline-flex size-4 shrink-0 items-center justify-center rounded-full align-middle text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring', className), onFocus: () => later(true, 0), onBlur: () => later(false, 100), ...hover, children: _jsx(InfoIcon, { className: "size-3.5" }) }) }), _jsx(PopoverContent, { side: side, className: "w-72 text-muted-foreground text-xs leading-relaxed",
|
|
25
|
+
// Focus stays on the (i): the tip is read, not worked in.
|
|
26
|
+
onOpenAutoFocus: (e) => e.preventDefault(), onCloseAutoFocus: (e) => e.preventDefault(), ...hover, children: children })] }));
|
|
27
|
+
}
|
|
28
|
+
/** A label with its (i): `Link works for (i)`. */
|
|
29
|
+
export function InfoLabel({ children, info, className, }) {
|
|
30
|
+
return (_jsxs("span", { className: cn('inline-flex items-center gap-1', className), children: [children, info ? (_jsx(InfoTip, { label: typeof children === 'string' ? `About ${children}` : undefined, children: info })) : null] }));
|
|
31
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { AuditEvent, AuditQuery, BulkOp, BulkResult, EmailCheck, EmailDraft, EmailSettingsView, Grant, InviteInput, InviteLink, InviteResult, PeopleView, Person, RemovePlan, Role, SyncStatus } from '@columenlabs/lumen-ui-types/people';
|
|
2
|
+
/** What a change did to the sign-in provider's allow list, when the app keeps one in step. */
|
|
3
|
+
export interface WithSync {
|
|
4
|
+
access?: SyncStatus | null;
|
|
5
|
+
}
|
|
6
|
+
export interface PeopleAdapter {
|
|
7
|
+
/** Names the cache its reads keep (two adapters on one page need two keys). */
|
|
8
|
+
key: string;
|
|
9
|
+
view(): Promise<PeopleView>;
|
|
10
|
+
invite(input: InviteInput): Promise<{
|
|
11
|
+
results: InviteResult[];
|
|
12
|
+
} & WithSync>;
|
|
13
|
+
update(id: string, change: {
|
|
14
|
+
name?: string;
|
|
15
|
+
role?: Role;
|
|
16
|
+
status?: 'active' | 'disabled';
|
|
17
|
+
}): Promise<{
|
|
18
|
+
person: Person;
|
|
19
|
+
} & WithSync>;
|
|
20
|
+
setAccess(id: string, grants: Grant[]): Promise<{
|
|
21
|
+
person: Person;
|
|
22
|
+
} & WithSync>;
|
|
23
|
+
resend(id: string): Promise<{
|
|
24
|
+
result: InviteResult;
|
|
25
|
+
} & WithSync>;
|
|
26
|
+
revoke(id: string): Promise<WithSync>;
|
|
27
|
+
removePlan(id: string): Promise<RemovePlan>;
|
|
28
|
+
remove(id: string): Promise<WithSync>;
|
|
29
|
+
/** One change over many people; without it the bulk bar runs them one by one. */
|
|
30
|
+
bulk?(ids: string[], op: BulkOp): Promise<BulkResult & WithSync>;
|
|
31
|
+
/** The audit log; without it the Audit page and a person's Recent activity hide. */
|
|
32
|
+
audit?(q?: AuditQuery): Promise<{
|
|
33
|
+
events: AuditEvent[];
|
|
34
|
+
}>;
|
|
35
|
+
/** Rewrites the sign-in provider's allow list now. */
|
|
36
|
+
sync?(): Promise<SyncStatus>;
|
|
37
|
+
/** The resources of a construct scope (a scope with a `parent`), to grant one. */
|
|
38
|
+
resources?(scope: string): Promise<{
|
|
39
|
+
id: string;
|
|
40
|
+
name: string;
|
|
41
|
+
}[]>;
|
|
42
|
+
/** Email through a Resend-style API (the Email settings section); left out, it hides. */
|
|
43
|
+
email?: {
|
|
44
|
+
view(): Promise<EmailSettingsView>;
|
|
45
|
+
check(draft: EmailDraft): Promise<EmailCheck>;
|
|
46
|
+
test(draft: EmailDraft): Promise<EmailCheck>;
|
|
47
|
+
save(draft: EmailDraft): Promise<EmailSettingsView>;
|
|
48
|
+
clear(): Promise<unknown>;
|
|
49
|
+
};
|
|
50
|
+
/** The invitee's side: what a link is for, and accepting it. */
|
|
51
|
+
invitation?(token: string): Promise<InviteLink>;
|
|
52
|
+
accept?(token: string): Promise<Person>;
|
|
53
|
+
}
|
|
54
|
+
/** The adapter over `peopleRoutes` mounted at `base` (`/api/people`). */
|
|
55
|
+
export declare function peopleClient(base: string): PeopleAdapter;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { apiJson } from '../api-fetch.js';
|
|
2
|
+
/** The adapter over `peopleRoutes` mounted at `base` (`/api/people`). */
|
|
3
|
+
export function peopleClient(base) {
|
|
4
|
+
const root = base.replace(/\/+$/, '');
|
|
5
|
+
const at = (path) => `${root}${path}`;
|
|
6
|
+
const id = (p) => encodeURIComponent(p);
|
|
7
|
+
const send = (method, path, body = {}) => apiJson(at(path), { method, body: JSON.stringify(body) }, 'people');
|
|
8
|
+
const get = (path) => apiJson(at(path), {}, 'people');
|
|
9
|
+
return {
|
|
10
|
+
key: root,
|
|
11
|
+
view: () => get('/'),
|
|
12
|
+
invite: (input) => send('POST', '/invites', input),
|
|
13
|
+
update: (p, change) => send('PATCH', `/${id(p)}`, change),
|
|
14
|
+
setAccess: (p, grants) => send('PUT', `/${id(p)}/access`, { grants }),
|
|
15
|
+
resend: (p) => send('POST', `/${id(p)}/resend`),
|
|
16
|
+
revoke: (p) => send('POST', `/${id(p)}/revoke`),
|
|
17
|
+
removePlan: (p) => get(`/${id(p)}/remove-plan`),
|
|
18
|
+
remove: (p) => send('DELETE', `/${id(p)}`),
|
|
19
|
+
bulk: (ids, op) => send('POST', '/bulk', { ids, op }),
|
|
20
|
+
audit: (q = {}) => {
|
|
21
|
+
const params = new URLSearchParams();
|
|
22
|
+
for (const [k, v] of Object.entries(q))
|
|
23
|
+
if (v !== undefined)
|
|
24
|
+
params.set(k, String(v));
|
|
25
|
+
const qs = params.toString();
|
|
26
|
+
return get(`/audit${qs ? `?${qs}` : ''}`);
|
|
27
|
+
},
|
|
28
|
+
sync: () => send('POST', '/sync'),
|
|
29
|
+
email: {
|
|
30
|
+
view: () => get('/email'),
|
|
31
|
+
check: (draft) => send('POST', '/email/check', draft),
|
|
32
|
+
test: (draft) => send('POST', '/email/test', draft),
|
|
33
|
+
save: (draft) => send('PUT', '/email', draft),
|
|
34
|
+
clear: () => send('DELETE', '/email'),
|
|
35
|
+
},
|
|
36
|
+
invitation: (token) => get(`/invite/${id(token)}`),
|
|
37
|
+
accept: (token) => send('POST', `/invite/${id(token)}/accept`),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { PeopleAdapter } from './client.js';
|
|
2
|
+
/** The Email section's body. In LumenSettings its badge sits in the header and Save in the footer;
|
|
3
|
+
* `inline` draws both in the body instead. */
|
|
4
|
+
export declare function LumenEmailSettings({ adapter, inline, }: {
|
|
5
|
+
adapter: PeopleAdapter;
|
|
6
|
+
inline?: boolean;
|
|
7
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
3
|
+
import { CableIcon, CheckIcon, CircleHelpIcon, GlobeIcon, MailIcon, PlugZapIcon, XIcon, } from 'lucide-react';
|
|
4
|
+
import { useEffect, useId, useState } from 'react';
|
|
5
|
+
import { SaveButton } from '../form-issues.js';
|
|
6
|
+
import { InfoLabel } from '../info-tip.js';
|
|
7
|
+
import { errorText } from '../integrations.js';
|
|
8
|
+
import { LumenChoice } from '../lumen-choice.js';
|
|
9
|
+
import { lumenToast } from '../lumen-toast.js';
|
|
10
|
+
import { SettingsActions, SettingsFooter } from '../settings.js';
|
|
11
|
+
import { Badge } from '../ui/badge.js';
|
|
12
|
+
import { Button } from '../ui/button.js';
|
|
13
|
+
import { Input } from '../ui/input.js';
|
|
14
|
+
import { Spinner } from '../ui/spinner.js';
|
|
15
|
+
import { when } from './shared.js';
|
|
16
|
+
const blank = (v) => {
|
|
17
|
+
const route = v.current?.route ?? v.routes[0]?.id ?? '';
|
|
18
|
+
return {
|
|
19
|
+
route,
|
|
20
|
+
url: v.current?.url ?? v.routes.find((r) => r.id === route)?.url ?? '',
|
|
21
|
+
apiKey: '',
|
|
22
|
+
from: v.current?.from ?? '',
|
|
23
|
+
fromName: v.current?.fromName ?? '',
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
const same = (a, b) => a.route === b.route &&
|
|
27
|
+
a.url === b.url &&
|
|
28
|
+
(a.apiKey ?? '') === (b.apiKey ?? '') &&
|
|
29
|
+
a.from === b.from &&
|
|
30
|
+
a.fromName === b.fromName;
|
|
31
|
+
/** The Email section's body. In LumenSettings its badge sits in the header and Save in the footer;
|
|
32
|
+
* `inline` draws both in the body instead. */
|
|
33
|
+
export function LumenEmailSettings({ adapter, inline = false, }) {
|
|
34
|
+
const api = adapter.email;
|
|
35
|
+
const q = useQuery({
|
|
36
|
+
queryKey: ['lumen-people', adapter.key, 'email'],
|
|
37
|
+
queryFn: () => api?.view() ?? Promise.reject(new Error('Email settings are not set up here.')),
|
|
38
|
+
});
|
|
39
|
+
if (!q.data)
|
|
40
|
+
return (_jsx("p", { className: "text-muted-foreground text-sm", children: q.isError ? errorText(q.error) : 'Loading…' }));
|
|
41
|
+
return _jsx(EmailForm, { adapter: adapter, view: q.data, inline: inline });
|
|
42
|
+
}
|
|
43
|
+
function EmailForm({ adapter, view, inline, }) {
|
|
44
|
+
const api = adapter.email;
|
|
45
|
+
const qc = useQueryClient();
|
|
46
|
+
const ids = useId();
|
|
47
|
+
const [draft, setDraft] = useState(() => blank(view));
|
|
48
|
+
const [replacing, setReplacing] = useState(!view.current);
|
|
49
|
+
const [result, setResult] = useState(null);
|
|
50
|
+
const [busy, setBusy] = useState(null);
|
|
51
|
+
useEffect(() => setDraft(blank(view)), [view]);
|
|
52
|
+
if (!api)
|
|
53
|
+
return null;
|
|
54
|
+
const route = view.routes.find((r) => r.id === draft.route);
|
|
55
|
+
const changed = !view.current || !same(draft, { ...blank(view), apiKey: '' });
|
|
56
|
+
const passed = !!result?.check.ok && same(result.for, draft);
|
|
57
|
+
const set = (patch) => setDraft((d) => ({ ...d, ...patch }));
|
|
58
|
+
const run = async (kind) => {
|
|
59
|
+
setBusy(kind);
|
|
60
|
+
try {
|
|
61
|
+
const check = await (kind === 'check' ? api.check(draft) : api.test(draft));
|
|
62
|
+
setResult({ for: draft, check });
|
|
63
|
+
}
|
|
64
|
+
catch (e) {
|
|
65
|
+
lumenToast.error(errorText(e));
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
setBusy(null);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
const save = async () => {
|
|
72
|
+
setBusy('save');
|
|
73
|
+
try {
|
|
74
|
+
const next = await api.save(draft);
|
|
75
|
+
qc.setQueryData(['lumen-people', adapter.key, 'email'], next);
|
|
76
|
+
await qc.invalidateQueries({ queryKey: ['lumen-people', adapter.key] });
|
|
77
|
+
setReplacing(false);
|
|
78
|
+
setResult(null);
|
|
79
|
+
lumenToast.success('Email saved. Invites now go out by email.');
|
|
80
|
+
}
|
|
81
|
+
catch (e) {
|
|
82
|
+
lumenToast.error(errorText(e));
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
setBusy(null);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
const issues = [];
|
|
89
|
+
if (!draft.url.trim())
|
|
90
|
+
issues.push({ where: 'Server address', message: 'Enter the address', target: `${ids}-url` });
|
|
91
|
+
if (replacing && !draft.apiKey?.trim())
|
|
92
|
+
issues.push({ where: 'API key', message: 'Enter the key', target: `${ids}-key` });
|
|
93
|
+
if (!draft.from.trim())
|
|
94
|
+
issues.push({
|
|
95
|
+
where: 'Address',
|
|
96
|
+
message: 'Enter who mail is sent from',
|
|
97
|
+
target: `${ids}-from`,
|
|
98
|
+
});
|
|
99
|
+
const ready = issues.length === 0;
|
|
100
|
+
if (ready && changed && !passed)
|
|
101
|
+
issues.push({ where: 'Test it', message: 'Check the connection or send a test first' });
|
|
102
|
+
const badge = passed ? (_jsx(Badge, { variant: "secondary", children: "Ready to save" })) : view.current ? (view.lastTest && !view.lastTest.ok ? (_jsx(Badge, { variant: "destructive", children: "Last test failed" })) : (_jsx(Badge, { variant: "secondary", className: "text-gain", children: "Working" }))) : (_jsx(Badge, { variant: "outline", children: "Not set up" }));
|
|
103
|
+
const footer = (_jsxs("div", { className: "flex w-full items-center gap-2", children: [_jsx("span", { className: "min-w-0 flex-1 truncate text-muted-foreground text-xs", children: passed
|
|
104
|
+
? 'Passed'
|
|
105
|
+
: view.current
|
|
106
|
+
? `Saved by ${view.current.updatedBy ?? '—'} ${when(view.current.updatedAt)}`
|
|
107
|
+
: 'Test to save' }), changed && (_jsx(Button, { size: "sm", variant: "ghost", onClick: () => {
|
|
108
|
+
setDraft(blank(view));
|
|
109
|
+
setReplacing(!view.current);
|
|
110
|
+
setResult(null);
|
|
111
|
+
}, children: "Revert" })), _jsx(SaveButton, { issues: issues, busy: busy === 'save', disabled: !changed, onSave: save })] }));
|
|
112
|
+
return (_jsxs("div", { className: "flex flex-col", children: [inline ? (_jsx("div", { className: "flex justify-end pb-2", children: badge })) : (_jsx(SettingsActions, { children: badge })), _jsx(Step, { n: 1, title: "Reach the server through", children: _jsx(LumenChoice, { label: "Reach the server through", value: draft.route, options: view.routes.map((r) => ({
|
|
113
|
+
value: r.id,
|
|
114
|
+
icon: r.id === 'public' ? GlobeIcon : CableIcon,
|
|
115
|
+
title: (_jsxs("span", { className: "flex w-full items-center gap-2", children: [r.label, r.state && (_jsx("span", { className: "ml-auto font-normal text-gain text-xs", children: r.state }))] })),
|
|
116
|
+
})), onValueChange: (id) => {
|
|
117
|
+
const next = view.routes.find((r) => r.id === id);
|
|
118
|
+
set({ route: id, ...(next?.url && id !== draft.route ? { url: next.url } : {}) });
|
|
119
|
+
} }) }), _jsxs(Step, { n: 2, title: "Email server", children: [_jsx(Labelled, { id: `${ids}-url`, label: _jsx(InfoLabel, { info: route?.urlInfo, children: "Server address" }), children: _jsx(Input, { id: `${ids}-url`, className: "font-code text-xs", value: draft.url, placeholder: route?.url ?? 'https://api.resend.com', onChange: (e) => set({ url: e.target.value }) }) }), _jsx(Labelled, { id: `${ids}-key`, label: "API key", children: replacing ? (_jsxs("div", { className: "flex gap-2", children: [_jsx(Input, { id: `${ids}-key`, type: "password", autoComplete: "off", className: "font-code text-xs", value: draft.apiKey ?? '', onChange: (e) => set({ apiKey: e.target.value }) }), view.current && (_jsx(Button, { size: "sm", variant: "ghost", onClick: () => {
|
|
120
|
+
setReplacing(false);
|
|
121
|
+
set({ apiKey: '' });
|
|
122
|
+
}, children: "Keep" }))] })) : (_jsxs("div", { className: "flex h-9 items-center gap-3 rounded-md border px-3 text-sm", children: [_jsx("span", { className: "font-code text-muted-foreground text-xs", children: view.current?.keyHint }), _jsx("span", { className: "ml-auto text-gain text-xs", children: "sealed" }), _jsx(Button, { size: "xs", variant: "link", onClick: () => setReplacing(true), children: "Replace" })] })) })] }), _jsx(Step, { n: 3, title: "Sent from", children: _jsxs("div", { className: "flex flex-col gap-3 sm:flex-row", children: [_jsx(Labelled, { id: `${ids}-name`, label: "Name", className: "sm:flex-1", children: _jsx(Input, { id: `${ids}-name`, value: draft.fromName, onChange: (e) => set({ fromName: e.target.value }) }) }), _jsx(Labelled, { id: `${ids}-from`, label: "Address", className: "sm:flex-[1.4]", children: _jsx(Input, { id: `${ids}-from`, type: "email", value: draft.from, placeholder: "hello@example.com", onChange: (e) => set({ from: e.target.value }) }) })] }) }), _jsxs(Step, { n: 4, title: "Test it", children: [_jsxs("div", { className: "flex flex-wrap gap-2", children: [_jsxs(Button, { size: "sm", variant: "outline", disabled: !ready || !!busy, onClick: () => run('check'), children: [busy === 'check' ? _jsx(Spinner, {}) : _jsx(PlugZapIcon, {}), " Check connection"] }), _jsxs(Button, { size: "sm", variant: "outline", disabled: !ready || !!busy, onClick: () => run('test'), children: [busy === 'test' ? _jsx(Spinner, {}) : _jsx(MailIcon, {}), " Send test to ", view.me] })] }), result && same(result.for, draft) && _jsx(Steps, { check: result.check }), !result && view.lastTest && (_jsxs("p", { className: "text-muted-foreground text-xs", children: ["Last ", view.lastTest.kind === 'test' ? 'test' : 'check', " ", when(view.lastTest.at), ":", ' ', view.lastTest.ok ? 'passed' : 'failed', "."] }))] }), inline ? (_jsx("div", { className: "border-t pt-3", children: footer })) : (_jsx(SettingsFooter, { children: footer }))] }));
|
|
123
|
+
}
|
|
124
|
+
function Step({ n, title, children }) {
|
|
125
|
+
return (_jsxs("section", { className: "flex gap-4 border-t py-5 first-of-type:border-t-0", "aria-label": title, children: [_jsx("span", { className: "num flex size-6 shrink-0 items-center justify-center rounded-full bg-muted font-semibold text-xs", children: n }), _jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-3", children: [_jsx("h3", { className: "font-medium text-sm", children: title }), children] })] }));
|
|
126
|
+
}
|
|
127
|
+
function Labelled({ id, label, className, children, }) {
|
|
128
|
+
return (_jsxs("div", { className: `flex min-w-0 flex-col gap-1.5 ${className ?? ''}`, children: [_jsx("label", { htmlFor: id, className: "text-muted-foreground text-xs", children: label }), children] }));
|
|
129
|
+
}
|
|
130
|
+
function Steps({ check }) {
|
|
131
|
+
return (_jsx("ul", { className: "flex flex-col gap-2 rounded-lg border bg-muted/50 px-3.5 py-3 text-sm", children: check.steps.map((s) => (_jsxs("li", { className: "flex items-center gap-2.5", children: [s.ok === true ? (_jsx(CheckIcon, { className: "size-4 shrink-0 text-gain" })) : s.ok === false ? (_jsx(XIcon, { className: "size-4 shrink-0 text-loss" })) : (_jsx(CircleHelpIcon, { className: "size-4 shrink-0 text-muted-foreground" })), _jsx("span", { className: "min-w-0 flex-1", children: s.text }), s.ms !== null && _jsxs("span", { className: "num text-muted-foreground text-xs", children: [s.ms, " ms"] })] }, s.id))) }));
|
|
132
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { type PeopleAdapter, peopleClient, type WithSync } from './client.js';
|
|
2
|
+
export { InviteDrawer } from './invite-drawer.js';
|
|
3
|
+
export { LumenInviteAccept } from './invite-accept.js';
|
|
4
|
+
export { LumenPeople, type LumenPeopleProps, type PeopleSection } from './lumen-people.js';
|
|
5
|
+
export { PersonDrawer } from './person-drawer.js';
|
|
6
|
+
export { LevelPicker, type PeopleFrame } from './shared.js';
|
|
7
|
+
export { LumenEmailSettings } from './email-settings.js';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// `@columenlabs/lumen-ui/people`: the People screens. The server side is `@columenlabs/lumen-people`;
|
|
2
|
+
// the shapes and rules both share are `@columenlabs/lumen-ui-types/people`.
|
|
3
|
+
export { peopleClient } from './client.js';
|
|
4
|
+
export { InviteDrawer } from './invite-drawer.js';
|
|
5
|
+
export { LumenInviteAccept } from './invite-accept.js';
|
|
6
|
+
export { LumenPeople } from './lumen-people.js';
|
|
7
|
+
export { PersonDrawer } from './person-drawer.js';
|
|
8
|
+
export { LevelPicker } from './shared.js';
|
|
9
|
+
export { LumenEmailSettings } from './email-settings.js';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { PeopleAdapter } from './client.js';
|
|
2
|
+
export declare function LumenInviteAccept({ adapter, token, onAccepted, onSwitchAccount, }: {
|
|
3
|
+
adapter: PeopleAdapter;
|
|
4
|
+
token: string;
|
|
5
|
+
/** After Accept: open the app. */
|
|
6
|
+
onAccepted: () => void;
|
|
7
|
+
/** Signs out so they can sign in with the invited email. */
|
|
8
|
+
onSwitchAccount?: () => void;
|
|
9
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// What an invite link opens: who invited them and to what, then Accept. An expired link, a link for
|
|
3
|
+
// another email or one that no longer works each say so, with the way forward.
|
|
4
|
+
import { useMutation, useQuery } from '@tanstack/react-query';
|
|
5
|
+
import { CheckIcon, LogOutIcon, MailWarningIcon, UserPlusIcon } from 'lucide-react';
|
|
6
|
+
import { ApiError } from '../api-fetch.js';
|
|
7
|
+
import { errorText } from '../integrations.js';
|
|
8
|
+
import { Badge } from '../ui/badge.js';
|
|
9
|
+
import { Button } from '../ui/button.js';
|
|
10
|
+
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle, } from '../ui/empty.js';
|
|
11
|
+
import { Spinner } from '../ui/spinner.js';
|
|
12
|
+
import { when } from './shared.js';
|
|
13
|
+
export function LumenInviteAccept({ adapter, token, onAccepted, onSwitchAccount, }) {
|
|
14
|
+
const q = useQuery({
|
|
15
|
+
queryKey: ['lumen-people', adapter.key, 'invite', token],
|
|
16
|
+
queryFn: () => adapter.invitation?.(token) ?? Promise.reject(new Error('Invites are not set up here.')),
|
|
17
|
+
retry: false,
|
|
18
|
+
});
|
|
19
|
+
const accept = useMutation({
|
|
20
|
+
mutationFn: () => adapter.accept?.(token) ?? Promise.reject(new Error('Invites are not set up here.')),
|
|
21
|
+
onSuccess: onAccepted,
|
|
22
|
+
});
|
|
23
|
+
const inv = q.data;
|
|
24
|
+
if (q.isPending)
|
|
25
|
+
return _jsx(Screen, { icon: _jsx(Spinner, {}), title: "Opening your invite\u2026" });
|
|
26
|
+
if (!inv)
|
|
27
|
+
return (_jsx(Screen, { icon: _jsx(MailWarningIcon, {}), title: "This invite link does not work", text: q.error instanceof ApiError && q.error.status === 404
|
|
28
|
+
? 'It was used already, replaced by a newer invite, or revoked. Ask the person who invited you for a new one.'
|
|
29
|
+
: errorText(q.error) }));
|
|
30
|
+
if (inv.status === 'expired')
|
|
31
|
+
return (_jsx(Screen, { icon: _jsx(MailWarningIcon, {}), title: `This invite expired ${when(inv.expiresAt)}`, text: `Ask ${inv.invitedBy ?? 'the person who invited you'} to resend it. A new link works again from the day it is sent.` }));
|
|
32
|
+
if (inv.signedInAs.toLowerCase() !== inv.email)
|
|
33
|
+
return (_jsx(Screen, { icon: _jsx(MailWarningIcon, {}), title: "This invite is for another email", text: `It is for ${inv.email}. You are signed in as ${inv.signedInAs}.`, action: onSwitchAccount && (_jsxs(Button, { size: "sm", onClick: onSwitchAccount, children: [_jsx(LogOutIcon, {}), " Switch account"] })) }));
|
|
34
|
+
return (_jsx(Screen, { icon: _jsx(UserPlusIcon, {}), title: `Join ${inv.appName}`, text: `${inv.invitedBy ?? 'An admin'} invited ${inv.email}. You’ll get:`, action: _jsxs("div", { className: "flex flex-col items-center gap-3", children: [_jsx("div", { className: "flex flex-wrap justify-center gap-1.5", children: inv.access.map((a) => (_jsx(Badge, { variant: "secondary", children: a }, a))) }), accept.isError && _jsx("p", { className: "text-destructive text-sm", children: errorText(accept.error) }), _jsxs(Button, { disabled: accept.isPending, onClick: () => accept.mutate(), children: [accept.isPending ? _jsx(Spinner, {}) : _jsx(CheckIcon, {}), " Accept and open"] }), inv.expiresAt && (_jsxs("span", { className: "text-muted-foreground text-xs", children: ["The link works until ", when(inv.expiresAt), "."] }))] }) }));
|
|
35
|
+
}
|
|
36
|
+
function Screen({ icon, title, text, action, }) {
|
|
37
|
+
return (_jsx("div", { className: "flex min-h-svh items-center justify-center p-4", children: _jsxs(Empty, { className: "max-w-md border", children: [_jsxs(EmptyHeader, { children: [_jsx(EmptyMedia, { variant: "icon", children: icon }), _jsx(EmptyTitle, { children: title }), text && _jsx(EmptyDescription, { children: text })] }), action && _jsx(EmptyContent, { children: action })] }) }));
|
|
38
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type PeopleView } from '@columenlabs/lumen-ui-types/people';
|
|
2
|
+
import type { PeopleAdapter } from './client.js';
|
|
3
|
+
export declare function InviteDrawer({ adapter, view, open, onClose, onSetUpMail, }: {
|
|
4
|
+
adapter: PeopleAdapter;
|
|
5
|
+
view: PeopleView;
|
|
6
|
+
open: boolean;
|
|
7
|
+
onClose: () => void;
|
|
8
|
+
/** Opens the app's email settings; shown when no email is set up. */
|
|
9
|
+
onSetUpMail?: () => void;
|
|
10
|
+
}): import("react").JSX.Element;
|
|
11
|
+
export declare function CopyLink({ link }: {
|
|
12
|
+
link: string;
|
|
13
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Invite people: many emails at once, a role, access per scope, how long the link works and a note.
|
|
3
|
+
// Each result says whether the email went; when it did not, the link is there to copy.
|
|
4
|
+
import { emailsIn, levelsOf, } from '@columenlabs/lumen-ui-types/people';
|
|
5
|
+
import { CheckIcon, CopyIcon, MailIcon, TriangleAlertIcon, UserPlusIcon } from 'lucide-react';
|
|
6
|
+
import { useId, useMemo, useState } from 'react';
|
|
7
|
+
import { DrawerSection } from '../drawer-section.js';
|
|
8
|
+
import { SaveButton } from '../form-issues.js';
|
|
9
|
+
import { InfoLabel } from '../info-tip.js';
|
|
10
|
+
import { LumenDrawer } from '../lumen-drawer.js';
|
|
11
|
+
import { LumenSelector } from '../lumen-selector.js';
|
|
12
|
+
import { lumenToast } from '../lumen-toast.js';
|
|
13
|
+
import { NoteLine } from '../note-line.js';
|
|
14
|
+
import { Badge } from '../ui/badge.js';
|
|
15
|
+
import { Button } from '../ui/button.js';
|
|
16
|
+
import { Textarea } from '../ui/textarea.js';
|
|
17
|
+
import { topScopes, usePeopleAction, when, withGrant } from './shared.js';
|
|
18
|
+
const DAYS = [1, 3, 7, 14, 30];
|
|
19
|
+
const MAX = 50;
|
|
20
|
+
export function InviteDrawer({ adapter, view, open, onClose, onSetUpMail, }) {
|
|
21
|
+
const act = usePeopleAction(adapter);
|
|
22
|
+
const ids = useId();
|
|
23
|
+
const [text, setText] = useState('');
|
|
24
|
+
const [role, setRole] = useState('member');
|
|
25
|
+
const [grants, setGrants] = useState([]);
|
|
26
|
+
const [days, setDays] = useState(view.inviteDays);
|
|
27
|
+
const [note, setNote] = useState('');
|
|
28
|
+
const [busy, setBusy] = useState(false);
|
|
29
|
+
const [results, setResults] = useState(null);
|
|
30
|
+
const emails = useMemo(() => emailsIn(text), [text]);
|
|
31
|
+
const known = new Map(view.people.map((p) => [p.id, p]));
|
|
32
|
+
const fresh = emails.filter((e) => {
|
|
33
|
+
const p = known.get(e);
|
|
34
|
+
return !p || p.status === 'invited' || p.status === 'expired';
|
|
35
|
+
});
|
|
36
|
+
const issues = [];
|
|
37
|
+
if (!emails.length)
|
|
38
|
+
issues.push({ where: 'Emails', message: 'Add an email address', target: `${ids}-emails` });
|
|
39
|
+
else if (!fresh.length)
|
|
40
|
+
issues.push({
|
|
41
|
+
where: 'Emails',
|
|
42
|
+
message: 'Everyone here is already a member',
|
|
43
|
+
target: `${ids}-emails`,
|
|
44
|
+
});
|
|
45
|
+
if (emails.length > MAX)
|
|
46
|
+
issues.push({ where: 'Emails', message: `At most ${MAX} at once`, target: `${ids}-emails` });
|
|
47
|
+
const reset = () => {
|
|
48
|
+
setText('');
|
|
49
|
+
setRole('member');
|
|
50
|
+
setGrants([]);
|
|
51
|
+
setDays(view.inviteDays);
|
|
52
|
+
setNote('');
|
|
53
|
+
setResults(null);
|
|
54
|
+
};
|
|
55
|
+
const close = () => {
|
|
56
|
+
onClose();
|
|
57
|
+
reset();
|
|
58
|
+
};
|
|
59
|
+
const send = async () => {
|
|
60
|
+
setBusy(true);
|
|
61
|
+
const r = await act(() => adapter.invite({
|
|
62
|
+
emails,
|
|
63
|
+
role,
|
|
64
|
+
grants: role === 'admin' ? [] : grants,
|
|
65
|
+
days,
|
|
66
|
+
note: note.trim() || undefined,
|
|
67
|
+
}), emails.length === 1 ? `Invited ${emails[0]}` : `Invited ${fresh.length} people`);
|
|
68
|
+
setBusy(false);
|
|
69
|
+
if (r)
|
|
70
|
+
setResults(r.results);
|
|
71
|
+
};
|
|
72
|
+
return (_jsx(LumenDrawer, { open: open, onClose: close, title: "Invite people", footerStart: view.mail.configured
|
|
73
|
+
? view.mail.from
|
|
74
|
+
? `From ${view.mail.from}`
|
|
75
|
+
: 'By email'
|
|
76
|
+
: 'Links only', footer: results ? (_jsxs(_Fragment, { children: [_jsxs(Button, { size: "sm", variant: "outline", onClick: reset, children: [_jsx(UserPlusIcon, {}), " Invite more"] }), _jsx(Button, { size: "sm", onClick: close, children: "Done" })] })) : (_jsxs(_Fragment, { children: [_jsx(Button, { size: "sm", variant: "ghost", onClick: close, children: "Cancel" }), _jsx(SaveButton, { issues: issues, busy: busy, icon: view.mail.configured ? MailIcon : UserPlusIcon, label: view.mail.configured
|
|
77
|
+
? `Send ${fresh.length > 1 ? `${fresh.length} invites` : 'invite'}`
|
|
78
|
+
: `Make ${fresh.length > 1 ? `${fresh.length} links` : 'link'}`, onSave: send })] })), children: results ? (_jsx(Results, { results: results })) : (_jsxs(_Fragment, { children: [!view.mail.configured && (_jsx(NoteLine, { icon: TriangleAlertIcon, title: "Email is not set up.", note: "Each invite gives you a link to send yourself.", action: onSetUpMail && (_jsx(Button, { size: "sm", variant: "outline", onClick: onSetUpMail, children: "Set up email" })) })), _jsx(DrawerSection, { title: "Who", count: emails.length || null, defaultOpen: true, children: _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx("label", { htmlFor: `${ids}-emails`, className: "font-medium text-sm", children: "Emails" }), _jsx(Textarea, { id: `${ids}-emails`, rows: 3, value: text, placeholder: "jo@example.com, ravi@example.org", onChange: (e) => setText(e.target.value) }), emails.length > 0 && (_jsx("div", { className: "flex flex-wrap gap-1.5", children: emails.map((e) => {
|
|
79
|
+
const p = known.get(e);
|
|
80
|
+
const member = p && p.status !== 'invited' && p.status !== 'expired';
|
|
81
|
+
return (_jsx(Badge, { variant: member ? 'outline' : 'secondary', className: member ? 'text-muted-foreground line-through' : undefined, title: member
|
|
82
|
+
? 'Already a member: skipped'
|
|
83
|
+
: p
|
|
84
|
+
? 'Invited before: sends a new link'
|
|
85
|
+
: undefined, children: e }, e));
|
|
86
|
+
}) }))] }) }), _jsx(DrawerSection, { title: "Access", defaultOpen: true, children: _jsxs("ul", { className: "flex flex-col gap-2 text-sm", children: [_jsxs("li", { className: "flex items-center justify-between gap-3", children: [_jsx(InfoLabel, { info: "Admins hold the top level everywhere and manage People.", children: "Role" }), _jsx(LumenSelector, { trigger: "chip", size: "sm", label: "Role", value: role, options: [
|
|
87
|
+
{ value: 'member', label: 'Member' },
|
|
88
|
+
{ value: 'admin', label: 'Admin' },
|
|
89
|
+
], onChange: setRole })] }), role === 'member' &&
|
|
90
|
+
topScopes(view.scopes).map((s) => {
|
|
91
|
+
const levels = levelsOf(s);
|
|
92
|
+
const value = grants.find((g) => g.scope === s.id && !g.resource)?.level ?? levels[0]?.id;
|
|
93
|
+
return (_jsxs("li", { className: "flex items-center justify-between gap-3", children: [s.name, _jsx(LumenSelector, { trigger: "chip", size: "sm", label: s.name, value: value, options: levels.map((l) => ({
|
|
94
|
+
value: l.id,
|
|
95
|
+
label: l.label,
|
|
96
|
+
description: l.description,
|
|
97
|
+
})), onChange: (l) => setGrants((g) => withGrant(g, s, l)) })] }, s.id));
|
|
98
|
+
})] }) }), _jsx(DrawerSection, { title: "Link and note", defaultOpen: true, children: _jsxs("div", { className: "flex flex-col gap-3 text-sm", children: [_jsxs("div", { className: "flex items-center justify-between gap-3", children: [_jsx(InfoLabel, { info: "Past this the link stops working and they are refused at sign-in until you resend.", children: "Link works for" }), _jsx(LumenSelector, { trigger: "chip", size: "sm", label: "Link works for", value: String(days), options: [...new Set([...DAYS, view.inviteDays])]
|
|
99
|
+
.sort((a, b) => a - b)
|
|
100
|
+
.map((d) => ({
|
|
101
|
+
value: String(d),
|
|
102
|
+
label: d === 1 ? '1 day' : `${d} days`,
|
|
103
|
+
detail: `until ${when(Date.now() + d * 86_400_000)}`,
|
|
104
|
+
})), onChange: (d) => setDays(Number(d)) })] }), view.mail.configured && (_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx("label", { htmlFor: `${ids}-note`, className: "font-medium", children: "A note in the email" }), _jsx(Textarea, { id: `${ids}-note`, rows: 2, maxLength: 500, value: note, placeholder: "Optional", onChange: (e) => setNote(e.target.value) })] }))] }) })] })) }));
|
|
105
|
+
}
|
|
106
|
+
/** One line per email: sent, link only (with Copy link), or skipped and why. */
|
|
107
|
+
function Results({ results }) {
|
|
108
|
+
return (_jsx("ul", { className: "flex flex-col gap-3 py-3 text-sm", children: results.map((r) => (_jsxs("li", { className: "flex flex-col gap-1.5", children: [_jsxs("span", { className: "flex items-start gap-2", children: [r.ok && r.mail?.ok ? (_jsx(CheckIcon, { className: "mt-0.5 size-4 shrink-0 text-gain" })) : (_jsx(TriangleAlertIcon, { className: `mt-0.5 size-4 shrink-0 ${r.ok ? 'text-warning' : 'text-muted-foreground'}` })), _jsxs("span", { className: "min-w-0", children: [_jsx("span", { className: "font-medium", children: r.email }), ' ', _jsx("span", { className: "text-muted-foreground", children: !r.ok
|
|
109
|
+
? `skipped: ${r.detail}`
|
|
110
|
+
: r.mail?.ok
|
|
111
|
+
? `was emailed. The link works until ${when(r.person?.expiresAt)}.`
|
|
112
|
+
: `is invited, but the email did not go${r.mail?.detail ? ` (${r.mail.detail})` : ''}.` })] })] }), r.ok && r.link && !r.mail?.ok && _jsx(CopyLink, { link: r.link })] }, r.email))) }));
|
|
113
|
+
}
|
|
114
|
+
export function CopyLink({ link }) {
|
|
115
|
+
return (_jsxs("span", { className: "ml-6 flex items-center gap-2", children: [_jsx("code", { className: "min-w-0 flex-1 truncate rounded bg-muted px-2 py-1 text-xs", children: link }), _jsxs(Button, { size: "sm", variant: "outline", onClick: () => navigator.clipboard.writeText(link).then(() => lumenToast.success('Link copied. It is single use and tied to this email.'), () => lumenToast.error('Could not copy: select the link and copy it.')), children: [_jsx(CopyIcon, {}), " Copy link"] })] }));
|
|
116
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type Person } from '@columenlabs/lumen-ui-types/people';
|
|
2
|
+
import type { Crumb } from '../page.js';
|
|
3
|
+
import type { ViewField } from '../view-model.js';
|
|
4
|
+
import type { PeopleAdapter } from './client.js';
|
|
5
|
+
export type PeopleSection = 'people' | 'invites' | 'permissions' | 'audit';
|
|
6
|
+
export interface LumenPeopleProps {
|
|
7
|
+
adapter: PeopleAdapter;
|
|
8
|
+
/** The crumbs before the section's own (`[{ label: 'Admin' }]`). */
|
|
9
|
+
crumbs?: Crumb[];
|
|
10
|
+
/** The open section; left out, the block keeps it and shows its own tabs. */
|
|
11
|
+
section?: PeopleSection;
|
|
12
|
+
onSection?: (section: PeopleSection) => void;
|
|
13
|
+
/** Opens the app's email settings, offered while no email is set up. */
|
|
14
|
+
onSetUpMail?: () => void;
|
|
15
|
+
/** The app's own columns per person (their accounts, their storage). */
|
|
16
|
+
fields?: ViewField<Person>[];
|
|
17
|
+
}
|
|
18
|
+
export declare function LumenPeople({ adapter, crumbs, section: given, onSection, onSetUpMail, fields, }: LumenPeopleProps): import("react").JSX.Element;
|