@protobase/ui 0.3.4 → 0.4.1
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/account/account-pages.js +20 -0
- package/dist/account/account-section.js +31 -0
- package/dist/account/account-security-page.js +59 -0
- package/dist/account/backup-codes.js +44 -0
- package/dist/account/passkeys-section.js +141 -0
- package/dist/account/password-dialog.js +58 -0
- package/dist/account/sign-in-policy-form.js +108 -0
- package/dist/account/sign-in-policy-labels.js +76 -0
- package/dist/account/sign-in-policy-page.js +72 -0
- package/dist/account/staff-sign-in-log.js +62 -0
- package/dist/account/totp-qr.js +37 -0
- package/dist/account/two-factor-section.js +123 -0
- package/dist/account/two-factor-setup.js +188 -0
- package/dist/activity-chart/activity-chart.js +1 -1
- package/dist/activity-chart/activity-tooltip.js +1 -1
- package/dist/app/app.d.ts +3 -1
- package/dist/app/app.js +50 -18
- package/dist/app/assistant-shell.js +1 -1
- package/dist/app/create/create-page.js +1 -1
- package/dist/app/delete/use-delete-flow.js +1 -1
- package/dist/app/error-banner.js +1 -1
- package/dist/app/list-page.js +1 -1
- package/dist/app/list-search.js +1 -1
- package/dist/app/meta-gate.js +1 -1
- package/dist/app/pages/blocks/modal-form.js +3 -3
- package/dist/app/pages/record-table.js +1 -1
- package/dist/app/record-page.js +5 -5
- package/dist/app/runtime/runtime-status.js +25 -0
- package/dist/app/runtime/runtime-update.js +77 -0
- package/dist/app/runtime/use-runtime-status.js +59 -0
- package/dist/app/sensitive-field.js +1 -1
- package/dist/app/staff-banner.js +67 -0
- package/dist/app/use-record-editor.js +1 -1
- package/dist/app-shell/breadcrumb.js +1 -1
- package/dist/app-shell/global-search.js +1 -1
- package/dist/app-shell/profile-menu.js +1 -1
- package/dist/app-shell/sidebar-group.js +1 -1
- package/dist/app-shell/top-bar.js +1 -1
- package/dist/assistant-dock/assistant-dock.js +1 -1
- package/dist/auth/auth-gate.js +42 -4
- package/dist/auth/auth-messages.js +48 -0
- package/dist/auth/auth-provider.js +40 -14
- package/dist/auth/code-input.js +17 -0
- package/dist/auth/email-code-form.js +81 -0
- package/dist/auth/email-code-page.js +40 -0
- package/dist/auth/first-run-page.js +1 -1
- package/dist/auth/forgot-password-page.js +1 -1
- package/dist/auth/reset-password-page.js +1 -1
- package/dist/auth/setup-required-form.js +43 -0
- package/dist/auth/setup-required-page.js +51 -0
- package/dist/auth/sign-in-form.js +53 -20
- package/dist/auth/sign-in-page.js +14 -13
- package/dist/auth/staff-link.js +30 -0
- package/dist/auth/staff-sign-in-form.js +93 -0
- package/dist/auth/staff-sign-in-page.js +41 -0
- package/dist/auth/two-factor-form.js +137 -0
- package/dist/auth/two-factor-page.js +39 -0
- package/dist/auth/user-to-shell.js +5 -2
- package/dist/chat/composer.js +1 -1
- package/dist/data/api-provider.d.ts +1 -1
- package/dist/data/api-provider.js +1 -1
- package/dist/data/meta-data.d.ts +4 -0
- package/dist/data/use-aggregates.js +1 -1
- package/dist/data/use-list.js +1 -1
- package/dist/data/use-meta.js +2 -1
- package/dist/data/use-update-record.js +1 -1
- package/dist/data-grid/cells.js +3 -3
- package/dist/data-grid/grid-header.js +2 -2
- package/dist/data-grid/grid-row.js +1 -1
- package/dist/data-grid/header-menu.js +1 -1
- package/dist/data-grid/row-menu.js +1 -1
- package/dist/diff-view/diff-view.js +1 -1
- package/dist/filter-panel/active-filter-chips.js +1 -1
- package/dist/filter-panel/facet-group.js +1 -1
- package/dist/filter-panel/filter-menu.js +1 -1
- package/dist/inline-child-table/inline-child-table.js +1 -1
- package/dist/inline-child-table/line-menu.js +1 -1
- package/dist/inline-child-table/line-row.js +2 -2
- package/dist/inline-child-table/totals-footer.js +1 -1
- package/dist/lib/use-visual-viewport.js +27 -0
- package/dist/live/use-filter-config.js +1 -1
- package/dist/primitives/checkbox.js +1 -1
- package/dist/primitives/sheet.js +1 -1
- package/dist/primitives/spinner.js +1 -1
- package/dist/record-view/field.js +1 -1
- package/dist/record-view/record-sidebar.js +1 -1
- package/dist/side-panel/side-panel.d.ts +5 -1
- package/dist/side-panel/side-panel.js +49 -35
- package/dist/step-list/step-list.js +1 -1
- package/dist/toasts/toast.js +1 -1
- package/package.json +10 -8
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { KeyRound, ShieldCheck } from "lucide-react";
|
|
2
|
+
//#region src/account/account-pages.ts
|
|
3
|
+
const accountSecurityPath = "/-/account";
|
|
4
|
+
const signInPolicyPath = "/-/sign-in-policy";
|
|
5
|
+
/** The app's own pages for the signed-in person, at the end of the profile menu: their sign-in, and for admins the policy. */
|
|
6
|
+
const accountMenuItems = (basePath, path, admin) => [{
|
|
7
|
+
id: "account-security",
|
|
8
|
+
label: "Sign-in & security",
|
|
9
|
+
icon: KeyRound,
|
|
10
|
+
href: `${basePath}${accountSecurityPath}`,
|
|
11
|
+
active: path === accountSecurityPath
|
|
12
|
+
}, ...admin ? [{
|
|
13
|
+
id: "sign-in-policy",
|
|
14
|
+
label: "Sign-in policy",
|
|
15
|
+
icon: ShieldCheck,
|
|
16
|
+
href: `${basePath}${signInPolicyPath}`,
|
|
17
|
+
active: path === signInPolicyPath
|
|
18
|
+
}] : []];
|
|
19
|
+
//#endregion
|
|
20
|
+
export { accountMenuItems, accountSecurityPath, signInPolicyPath };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
|
+
//#region src/account/account-section.tsx
|
|
3
|
+
/** One part of the account page: a title with its status, what it is, and its controls. */
|
|
4
|
+
const AccountSection = ({ title, status, description, children }) => /* @__PURE__ */ jsxs("section", {
|
|
5
|
+
"aria-label": title,
|
|
6
|
+
className: "rounded-lg border bg-background p-4 sm:p-5",
|
|
7
|
+
children: [
|
|
8
|
+
/* @__PURE__ */ jsxs("div", {
|
|
9
|
+
className: "flex flex-wrap items-center gap-2",
|
|
10
|
+
children: [/* @__PURE__ */ jsx("h2", {
|
|
11
|
+
className: "text-[15px] font-semibold",
|
|
12
|
+
children: title
|
|
13
|
+
}), status]
|
|
14
|
+
}),
|
|
15
|
+
/* @__PURE__ */ jsx("p", {
|
|
16
|
+
className: "mt-1 text-[13px] text-muted-foreground",
|
|
17
|
+
children: description
|
|
18
|
+
}),
|
|
19
|
+
children && /* @__PURE__ */ jsx("div", {
|
|
20
|
+
className: "mt-4",
|
|
21
|
+
children
|
|
22
|
+
})
|
|
23
|
+
]
|
|
24
|
+
});
|
|
25
|
+
const SectionError = ({ message }) => message ? /* @__PURE__ */ jsx("p", {
|
|
26
|
+
role: "alert",
|
|
27
|
+
className: "mb-3 text-xs font-medium text-danger-text",
|
|
28
|
+
children: message
|
|
29
|
+
}) : null;
|
|
30
|
+
//#endregion
|
|
31
|
+
export { AccountSection, SectionError };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Spinner } from "../primitives/spinner.js";
|
|
2
|
+
import { PageHeader } from "../app-shell/page-header.js";
|
|
3
|
+
import { authMessage } from "../auth/auth-messages.js";
|
|
4
|
+
import { useAuth } from "../auth/auth-provider.js";
|
|
5
|
+
import { PasskeysSection } from "./passkeys-section.js";
|
|
6
|
+
import { TwoFactorSection } from "./two-factor-section.js";
|
|
7
|
+
import { useCallback, useEffect, useState } from "react";
|
|
8
|
+
import { AuthError } from "@protobase/client";
|
|
9
|
+
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
10
|
+
//#region src/account/account-security-page.tsx
|
|
11
|
+
/** The signed-in person's passkeys and two-factor authentication, within what the sign-in policy allows. */
|
|
12
|
+
const AccountSecurityPage = ({ email }) => {
|
|
13
|
+
const { session } = useAuth();
|
|
14
|
+
const { account } = session;
|
|
15
|
+
const [loaded, setLoaded] = useState();
|
|
16
|
+
const [error, setError] = useState();
|
|
17
|
+
const reload = useCallback(() => {
|
|
18
|
+
Promise.all([account.signInMethods(), account.passkeys()]).then(([methods, passkeys]) => setLoaded({
|
|
19
|
+
methods,
|
|
20
|
+
passkeys
|
|
21
|
+
}), (failure) => setError(failure instanceof AuthError ? authMessage(failure) : "Could not read your sign-in methods"));
|
|
22
|
+
}, [account]);
|
|
23
|
+
useEffect(reload, [reload]);
|
|
24
|
+
const thenReload = (change) => change.then((result) => (reload(), result));
|
|
25
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
26
|
+
className: "mx-auto flex w-full max-w-3xl flex-col gap-4 px-4 py-6 md:px-6",
|
|
27
|
+
children: [/* @__PURE__ */ jsx(PageHeader, {
|
|
28
|
+
title: "Sign-in & security",
|
|
29
|
+
subtitle: email
|
|
30
|
+
}), loaded ? /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(PasskeysSection, {
|
|
31
|
+
rule: loaded.methods.policy.passkey,
|
|
32
|
+
passkeys: loaded.passkeys,
|
|
33
|
+
onAdd: () => thenReload(account.addPasskey()),
|
|
34
|
+
onRename: (id, name) => thenReload(account.renamePasskey(id, name)),
|
|
35
|
+
onRemove: (id) => thenReload(account.removePasskey(id))
|
|
36
|
+
}), /* @__PURE__ */ jsx(TwoFactorSection, {
|
|
37
|
+
rule: loaded.methods.policy.twoFactor,
|
|
38
|
+
enabled: loaded.methods.account.twoFactor,
|
|
39
|
+
authenticatorApp: loaded.methods.account.authenticatorApp,
|
|
40
|
+
mail: loaded.methods.mail,
|
|
41
|
+
needsPassword: loaded.methods.account.password,
|
|
42
|
+
onStartApp: (password) => account.startAuthenticatorApp(password),
|
|
43
|
+
onConfirmApp: (code) => account.confirmAuthenticatorApp(code),
|
|
44
|
+
onEmailedCodes: (password) => account.turnOnEmailedCodes(password),
|
|
45
|
+
onTurnOff: (password) => account.turnOffTwoFactor(password),
|
|
46
|
+
onNewBackupCodes: (password) => account.newBackupCodes(password),
|
|
47
|
+
onChanged: reload
|
|
48
|
+
})] }) : error ? /* @__PURE__ */ jsx("p", {
|
|
49
|
+
role: "alert",
|
|
50
|
+
className: "text-[13px] text-danger-text",
|
|
51
|
+
children: error
|
|
52
|
+
}) : /* @__PURE__ */ jsxs("div", {
|
|
53
|
+
className: "flex items-center gap-2 text-[13px] text-muted-foreground",
|
|
54
|
+
children: [/* @__PURE__ */ jsx(Spinner, {}), " Loading"]
|
|
55
|
+
})]
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
//#endregion
|
|
59
|
+
export { AccountSecurityPage };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Button } from "../primitives/button.js";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { Check, Copy, Download } from "lucide-react";
|
|
4
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
5
|
+
//#region src/account/backup-codes.tsx
|
|
6
|
+
/** The backup codes, shown once, with ways to keep them: each works once instead of a code from the app. */
|
|
7
|
+
const BackupCodes = ({ codes }) => {
|
|
8
|
+
const [copied, setCopied] = useState(false);
|
|
9
|
+
const text = `${codes.join("\n")}\n`;
|
|
10
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
11
|
+
className: "flex flex-col gap-3",
|
|
12
|
+
children: [/* @__PURE__ */ jsx("ul", {
|
|
13
|
+
"aria-label": "Backup codes",
|
|
14
|
+
className: "grid grid-cols-2 gap-x-4 gap-y-1 rounded-md border bg-surface p-3 font-mono text-[13px]",
|
|
15
|
+
children: codes.map((code) => /* @__PURE__ */ jsx("li", { children: code }, code))
|
|
16
|
+
}), /* @__PURE__ */ jsxs("div", {
|
|
17
|
+
className: "flex flex-wrap gap-2",
|
|
18
|
+
children: [/* @__PURE__ */ jsxs(Button, {
|
|
19
|
+
size: "sm",
|
|
20
|
+
onClick: () => {
|
|
21
|
+
navigator.clipboard.writeText(text).then(() => {
|
|
22
|
+
setCopied(true);
|
|
23
|
+
setTimeout(() => setCopied(false), 2e3);
|
|
24
|
+
});
|
|
25
|
+
},
|
|
26
|
+
children: [copied ? /* @__PURE__ */ jsx(Check, { className: "size-3.5 text-success" }) : /* @__PURE__ */ jsx(Copy, { className: "size-3.5" }), copied ? "Copied" : "Copy"]
|
|
27
|
+
}), /* @__PURE__ */ jsxs(Button, {
|
|
28
|
+
size: "sm",
|
|
29
|
+
onClick: () => saveText(text, "backup-codes.txt"),
|
|
30
|
+
children: [/* @__PURE__ */ jsx(Download, { className: "size-3.5" }), "Download"]
|
|
31
|
+
})]
|
|
32
|
+
})]
|
|
33
|
+
});
|
|
34
|
+
};
|
|
35
|
+
const saveText = (text, name) => {
|
|
36
|
+
const url = URL.createObjectURL(new Blob([text], { type: "text/plain" }));
|
|
37
|
+
Object.assign(document.createElement("a"), {
|
|
38
|
+
href: url,
|
|
39
|
+
download: name
|
|
40
|
+
}).click();
|
|
41
|
+
URL.revokeObjectURL(url);
|
|
42
|
+
};
|
|
43
|
+
//#endregion
|
|
44
|
+
export { BackupCodes };
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { Badge } from "../primitives/badge.js";
|
|
2
|
+
import { Input } from "../primitives/input.js";
|
|
3
|
+
import { authMessage } from "../auth/auth-messages.js";
|
|
4
|
+
import { formatDate } from "../format/date.js";
|
|
5
|
+
import { Button } from "../primitives/button.js";
|
|
6
|
+
import { Dialog } from "../primitives/dialog.js";
|
|
7
|
+
import { AccountSection, SectionError } from "./account-section.js";
|
|
8
|
+
import { useState } from "react";
|
|
9
|
+
import { KeyRound, Plus } from "lucide-react";
|
|
10
|
+
import { AuthError } from "@protobase/client";
|
|
11
|
+
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
12
|
+
//#region src/account/passkeys-section.tsx
|
|
13
|
+
const descriptions = {
|
|
14
|
+
allowed: "Sign in with Face ID, Touch ID, Windows Hello, your phone or a security key instead of a password.",
|
|
15
|
+
required: "This app requires a passkey for every account. Sign in with Face ID, Touch ID, Windows Hello, your phone or a security key.",
|
|
16
|
+
forbidden: "Your admin turned passkeys off for this app, so they cannot be used to sign in."
|
|
17
|
+
};
|
|
18
|
+
const PasskeysSection = ({ rule, passkeys, onAdd, onRename, onRemove }) => {
|
|
19
|
+
const [busy, setBusy] = useState(false);
|
|
20
|
+
const [error, setError] = useState();
|
|
21
|
+
const [editing, setEditing] = useState();
|
|
22
|
+
const [name, setName] = useState("");
|
|
23
|
+
const keepLast = rule === "required" && passkeys.length <= 1;
|
|
24
|
+
const attempt = async (action) => {
|
|
25
|
+
setBusy(true);
|
|
26
|
+
setError(void 0);
|
|
27
|
+
try {
|
|
28
|
+
await action();
|
|
29
|
+
setEditing(void 0);
|
|
30
|
+
} catch (failure) {
|
|
31
|
+
if (!(failure instanceof AuthError)) throw failure;
|
|
32
|
+
setError(authMessage(failure));
|
|
33
|
+
}
|
|
34
|
+
setBusy(false);
|
|
35
|
+
};
|
|
36
|
+
return /* @__PURE__ */ jsxs(AccountSection, {
|
|
37
|
+
title: "Passkeys",
|
|
38
|
+
status: rule === "required" && /* @__PURE__ */ jsx(Badge, {
|
|
39
|
+
tone: "blue",
|
|
40
|
+
children: "Required"
|
|
41
|
+
}),
|
|
42
|
+
description: descriptions[rule],
|
|
43
|
+
children: [
|
|
44
|
+
/* @__PURE__ */ jsx(SectionError, { message: editing ? void 0 : error }),
|
|
45
|
+
passkeys.length > 0 && /* @__PURE__ */ jsx("ul", {
|
|
46
|
+
className: "mb-3 divide-y rounded-md border",
|
|
47
|
+
children: passkeys.map((passkey) => /* @__PURE__ */ jsxs("li", {
|
|
48
|
+
className: "flex flex-wrap items-center gap-3 px-3 py-2.5",
|
|
49
|
+
children: [
|
|
50
|
+
/* @__PURE__ */ jsx(KeyRound, { className: "size-4 shrink-0 text-muted-foreground" }),
|
|
51
|
+
/* @__PURE__ */ jsxs("span", {
|
|
52
|
+
className: "min-w-0 flex-1",
|
|
53
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
54
|
+
className: "block truncate text-[13px] font-medium",
|
|
55
|
+
children: passkey.name ?? "Passkey"
|
|
56
|
+
}), /* @__PURE__ */ jsxs("span", {
|
|
57
|
+
className: "block text-xs text-muted-foreground",
|
|
58
|
+
children: [
|
|
59
|
+
passkey.createdAt ? `Added ${formatDate(Date.parse(passkey.createdAt))}` : "Added earlier",
|
|
60
|
+
" · ",
|
|
61
|
+
passkey.backedUp ? "Synced across your devices" : "On one device"
|
|
62
|
+
]
|
|
63
|
+
})]
|
|
64
|
+
}),
|
|
65
|
+
/* @__PURE__ */ jsxs("span", {
|
|
66
|
+
className: "flex gap-1",
|
|
67
|
+
children: [/* @__PURE__ */ jsx(Button, {
|
|
68
|
+
size: "sm",
|
|
69
|
+
variant: "ghost",
|
|
70
|
+
onClick: () => {
|
|
71
|
+
setName(passkey.name ?? "");
|
|
72
|
+
setEditing({
|
|
73
|
+
kind: "rename",
|
|
74
|
+
passkey
|
|
75
|
+
});
|
|
76
|
+
},
|
|
77
|
+
children: "Rename"
|
|
78
|
+
}), /* @__PURE__ */ jsx(Button, {
|
|
79
|
+
size: "sm",
|
|
80
|
+
variant: "ghost",
|
|
81
|
+
disabled: keepLast,
|
|
82
|
+
title: keepLast ? "Passkeys are required: add another one before removing this one." : void 0,
|
|
83
|
+
onClick: () => setEditing({
|
|
84
|
+
kind: "remove",
|
|
85
|
+
passkey
|
|
86
|
+
}),
|
|
87
|
+
children: "Remove"
|
|
88
|
+
})]
|
|
89
|
+
})
|
|
90
|
+
]
|
|
91
|
+
}, passkey.id))
|
|
92
|
+
}),
|
|
93
|
+
rule !== "forbidden" && /* @__PURE__ */ jsxs(Button, {
|
|
94
|
+
loading: busy && !editing,
|
|
95
|
+
onClick: () => void attempt(onAdd),
|
|
96
|
+
children: [/* @__PURE__ */ jsx(Plus, { className: "size-3.5" }), "Add a passkey"]
|
|
97
|
+
}),
|
|
98
|
+
editing?.kind === "rename" && /* @__PURE__ */ jsxs(Dialog, {
|
|
99
|
+
title: "Rename passkey",
|
|
100
|
+
description: "A name that tells you where this passkey lives, such as the device or password manager.",
|
|
101
|
+
onClose: () => setEditing(void 0),
|
|
102
|
+
actions: /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(Button, {
|
|
103
|
+
onClick: () => setEditing(void 0),
|
|
104
|
+
children: "Cancel"
|
|
105
|
+
}), /* @__PURE__ */ jsx(Button, {
|
|
106
|
+
variant: "primary",
|
|
107
|
+
loading: busy,
|
|
108
|
+
disabled: !name.trim(),
|
|
109
|
+
onClick: () => void attempt(() => onRename(editing.passkey.id, name.trim())),
|
|
110
|
+
children: "Save"
|
|
111
|
+
})] }),
|
|
112
|
+
children: [/* @__PURE__ */ jsxs("label", {
|
|
113
|
+
className: "flex flex-col gap-1.5 text-[13px] font-medium",
|
|
114
|
+
children: ["Name", /* @__PURE__ */ jsx(Input, {
|
|
115
|
+
autoFocus: true,
|
|
116
|
+
value: name,
|
|
117
|
+
maxLength: 60,
|
|
118
|
+
onChange: (event) => setName(event.target.value)
|
|
119
|
+
})]
|
|
120
|
+
}), /* @__PURE__ */ jsx(SectionError, { message: error })]
|
|
121
|
+
}),
|
|
122
|
+
editing?.kind === "remove" && /* @__PURE__ */ jsx(Dialog, {
|
|
123
|
+
title: "Remove passkey?",
|
|
124
|
+
description: `${editing.passkey.name ?? "This passkey"} will no longer sign you in here. It stays on the device until you delete it there.`,
|
|
125
|
+
onClose: () => setEditing(void 0),
|
|
126
|
+
actions: /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(Button, {
|
|
127
|
+
onClick: () => setEditing(void 0),
|
|
128
|
+
children: "Cancel"
|
|
129
|
+
}), /* @__PURE__ */ jsx(Button, {
|
|
130
|
+
variant: "danger",
|
|
131
|
+
loading: busy,
|
|
132
|
+
onClick: () => void attempt(() => onRemove(editing.passkey.id)),
|
|
133
|
+
children: "Remove"
|
|
134
|
+
})] }),
|
|
135
|
+
children: error && /* @__PURE__ */ jsx(SectionError, { message: error })
|
|
136
|
+
})
|
|
137
|
+
]
|
|
138
|
+
});
|
|
139
|
+
};
|
|
140
|
+
//#endregion
|
|
141
|
+
export { PasskeysSection };
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Input } from "../primitives/input.js";
|
|
2
|
+
import { authMessage } from "../auth/auth-messages.js";
|
|
3
|
+
import { Button } from "../primitives/button.js";
|
|
4
|
+
import { Dialog } from "../primitives/dialog.js";
|
|
5
|
+
import { useState } from "react";
|
|
6
|
+
import { AuthError } from "@protobase/client";
|
|
7
|
+
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
8
|
+
//#region src/account/password-dialog.tsx
|
|
9
|
+
/** Confirms a change to the account's sign-in, with the password when it has one. */
|
|
10
|
+
const PasswordDialog = ({ title, description, confirmLabel, danger, needsPassword, onConfirm, onClose }) => {
|
|
11
|
+
const [password, setPassword] = useState("");
|
|
12
|
+
const [busy, setBusy] = useState(false);
|
|
13
|
+
const [error, setError] = useState();
|
|
14
|
+
const confirm = async () => {
|
|
15
|
+
setBusy(true);
|
|
16
|
+
setError(void 0);
|
|
17
|
+
try {
|
|
18
|
+
await onConfirm(needsPassword ? password : void 0);
|
|
19
|
+
} catch (failure) {
|
|
20
|
+
if (!(failure instanceof AuthError)) throw failure;
|
|
21
|
+
setError(authMessage(failure));
|
|
22
|
+
setBusy(false);
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
return /* @__PURE__ */ jsxs(Dialog, {
|
|
26
|
+
title,
|
|
27
|
+
description,
|
|
28
|
+
onClose,
|
|
29
|
+
actions: /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(Button, {
|
|
30
|
+
onClick: onClose,
|
|
31
|
+
children: "Cancel"
|
|
32
|
+
}), /* @__PURE__ */ jsx(Button, {
|
|
33
|
+
variant: danger ? "danger" : "primary",
|
|
34
|
+
loading: busy,
|
|
35
|
+
disabled: needsPassword && !password,
|
|
36
|
+
onClick: () => void confirm(),
|
|
37
|
+
children: confirmLabel
|
|
38
|
+
})] }),
|
|
39
|
+
children: [needsPassword && /* @__PURE__ */ jsxs("label", {
|
|
40
|
+
className: "flex flex-col gap-1.5 text-[13px] font-medium",
|
|
41
|
+
children: ["Your password", /* @__PURE__ */ jsx(Input, {
|
|
42
|
+
type: "password",
|
|
43
|
+
autoComplete: "current-password",
|
|
44
|
+
autoFocus: true,
|
|
45
|
+
value: password,
|
|
46
|
+
invalid: Boolean(error),
|
|
47
|
+
onChange: (event) => setPassword(event.target.value),
|
|
48
|
+
onKeyDown: (event) => event.key === "Enter" && password && void confirm()
|
|
49
|
+
})]
|
|
50
|
+
}), error && /* @__PURE__ */ jsx("p", {
|
|
51
|
+
role: "alert",
|
|
52
|
+
className: "mt-2 text-xs font-medium text-danger-text",
|
|
53
|
+
children: error
|
|
54
|
+
})]
|
|
55
|
+
});
|
|
56
|
+
};
|
|
57
|
+
//#endregion
|
|
58
|
+
export { PasswordDialog };
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { cn } from "../lib/cn.js";
|
|
2
|
+
import { formatDateTime } from "../format/date.js";
|
|
3
|
+
import { Button } from "../primitives/button.js";
|
|
4
|
+
import { policyRows, ruleLabel } from "./sign-in-policy-labels.js";
|
|
5
|
+
import { useState } from "react";
|
|
6
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
7
|
+
//#region src/account/sign-in-policy-form.tsx
|
|
8
|
+
const RuleChoice = ({ row, value, onChange }) => /* @__PURE__ */ jsx("span", {
|
|
9
|
+
role: "radiogroup",
|
|
10
|
+
"aria-label": row.label,
|
|
11
|
+
className: "inline-flex h-8 shrink-0 rounded-md bg-muted p-0.5",
|
|
12
|
+
children: row.rules.map((option) => /* @__PURE__ */ jsx("button", {
|
|
13
|
+
type: "button",
|
|
14
|
+
role: "radio",
|
|
15
|
+
"aria-checked": option.value === value,
|
|
16
|
+
onClick: () => onChange(option.value),
|
|
17
|
+
className: cn("rounded px-3 text-xs font-medium transition-colors", option.value === value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"),
|
|
18
|
+
children: option.label
|
|
19
|
+
}, option.value))
|
|
20
|
+
});
|
|
21
|
+
const rowNote = ({ method }, draft, { mail, effective, policy }) => {
|
|
22
|
+
if (method === "emailCode" && !mail) return "Needs mail settings (PROTOBASE_SMTP_URL and PROTOBASE_MAIL_FROM): off until they are set.";
|
|
23
|
+
if (method === "password" && !mail && draft.password === "forbidden") return "Without mail settings, password sign-in stays on so nobody is locked out.";
|
|
24
|
+
if (method === "staffAccess" && !mail && draft.staffAccess === "notify") return "Needs mail settings (PROTOBASE_SMTP_URL and PROTOBASE_MAIL_FROM): staff cannot sign in until they are set.";
|
|
25
|
+
if (draft[method] === policy[method] && effective[method] !== policy[method]) return `Applies as ${ruleLabel(method, effective[method])} for now.`;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* The admin's sign-in policy: for each way to sign in, whether people may use it, must set it up, or cannot use it;
|
|
29
|
+
* and, when the app has an operator provider, whether its staff may sign in as people.
|
|
30
|
+
*/
|
|
31
|
+
const SignInPolicyForm = ({ state, onSave, busy, error, notice }) => {
|
|
32
|
+
const [draft, setDraft] = useState(state.policy);
|
|
33
|
+
const rows = policyRows.filter(({ method }) => method !== "staffAccess" || state.operator);
|
|
34
|
+
const changed = rows.some(({ method }) => draft[method] !== state.policy[method]);
|
|
35
|
+
return /* @__PURE__ */ jsxs("form", {
|
|
36
|
+
className: "flex flex-col gap-4",
|
|
37
|
+
onSubmit: (event) => {
|
|
38
|
+
event.preventDefault();
|
|
39
|
+
onSave(draft);
|
|
40
|
+
},
|
|
41
|
+
children: [
|
|
42
|
+
/* @__PURE__ */ jsx("ul", {
|
|
43
|
+
className: "divide-y rounded-lg border bg-background",
|
|
44
|
+
children: rows.map((row) => {
|
|
45
|
+
const note = rowNote(row, draft, state);
|
|
46
|
+
return /* @__PURE__ */ jsxs("li", {
|
|
47
|
+
className: "flex flex-col gap-3 p-4 sm:flex-row sm:items-start sm:justify-between",
|
|
48
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
49
|
+
className: "min-w-0",
|
|
50
|
+
children: [
|
|
51
|
+
/* @__PURE__ */ jsx("h2", {
|
|
52
|
+
className: "text-[13px] font-semibold",
|
|
53
|
+
children: row.label
|
|
54
|
+
}),
|
|
55
|
+
/* @__PURE__ */ jsx("p", {
|
|
56
|
+
className: "mt-0.5 text-[13px] text-muted-foreground",
|
|
57
|
+
children: row.description
|
|
58
|
+
}),
|
|
59
|
+
note && /* @__PURE__ */ jsx("p", {
|
|
60
|
+
className: "mt-1.5 text-xs font-medium text-warning-text",
|
|
61
|
+
children: note
|
|
62
|
+
})
|
|
63
|
+
]
|
|
64
|
+
}), /* @__PURE__ */ jsx(RuleChoice, {
|
|
65
|
+
row,
|
|
66
|
+
value: draft[row.method],
|
|
67
|
+
onChange: (rule) => setDraft({
|
|
68
|
+
...draft,
|
|
69
|
+
[row.method]: rule
|
|
70
|
+
})
|
|
71
|
+
})]
|
|
72
|
+
}, row.method);
|
|
73
|
+
})
|
|
74
|
+
}),
|
|
75
|
+
error && /* @__PURE__ */ jsx("p", {
|
|
76
|
+
role: "alert",
|
|
77
|
+
className: "rounded-md bg-danger-soft px-3 py-2 text-xs font-medium text-danger-text",
|
|
78
|
+
children: error
|
|
79
|
+
}),
|
|
80
|
+
notice && !changed && /* @__PURE__ */ jsx("p", {
|
|
81
|
+
role: "status",
|
|
82
|
+
className: "rounded-md bg-success-soft px-3 py-2 text-xs font-medium text-success-text",
|
|
83
|
+
children: notice
|
|
84
|
+
}),
|
|
85
|
+
/* @__PURE__ */ jsxs("div", {
|
|
86
|
+
className: "flex flex-wrap items-center justify-between gap-3",
|
|
87
|
+
children: [/* @__PURE__ */ jsx("p", {
|
|
88
|
+
className: "text-xs text-muted-foreground",
|
|
89
|
+
children: state.savedAt ? `Last saved${state.savedBy ? ` by ${state.savedBy}` : ""} on ${formatDateTime(Date.parse(state.savedAt))}.` : "Never changed: everything is on, nothing is required."
|
|
90
|
+
}), /* @__PURE__ */ jsxs("div", {
|
|
91
|
+
className: "flex gap-2",
|
|
92
|
+
children: [changed && /* @__PURE__ */ jsx(Button, {
|
|
93
|
+
onClick: () => setDraft(state.policy),
|
|
94
|
+
children: "Reset"
|
|
95
|
+
}), /* @__PURE__ */ jsx(Button, {
|
|
96
|
+
variant: "primary",
|
|
97
|
+
type: "submit",
|
|
98
|
+
loading: busy,
|
|
99
|
+
disabled: !changed,
|
|
100
|
+
children: "Save"
|
|
101
|
+
})]
|
|
102
|
+
})]
|
|
103
|
+
})
|
|
104
|
+
]
|
|
105
|
+
});
|
|
106
|
+
};
|
|
107
|
+
//#endregion
|
|
108
|
+
export { SignInPolicyForm };
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
//#region src/account/sign-in-policy-labels.ts
|
|
2
|
+
const onOff = [{
|
|
3
|
+
value: "allowed",
|
|
4
|
+
label: "On"
|
|
5
|
+
}, {
|
|
6
|
+
value: "forbidden",
|
|
7
|
+
label: "Off"
|
|
8
|
+
}];
|
|
9
|
+
const optionalRequiredOff = [
|
|
10
|
+
{
|
|
11
|
+
value: "allowed",
|
|
12
|
+
label: "Optional"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
value: "required",
|
|
16
|
+
label: "Required"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
value: "forbidden",
|
|
20
|
+
label: "Off"
|
|
21
|
+
}
|
|
22
|
+
];
|
|
23
|
+
/** The rows of the sign-in policy page, in order, with the rules each method takes and their words. */
|
|
24
|
+
const policyRows = [
|
|
25
|
+
{
|
|
26
|
+
method: "password",
|
|
27
|
+
label: "Password",
|
|
28
|
+
description: "Sign in with the email address and a password.",
|
|
29
|
+
rules: onOff
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
method: "emailCode",
|
|
33
|
+
label: "Emailed sign-in code",
|
|
34
|
+
description: "Sign in with a one-time code mailed to the account's address.",
|
|
35
|
+
rules: onOff
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
method: "passkey",
|
|
39
|
+
label: "Passkeys",
|
|
40
|
+
description: "Sign in with Face ID, Touch ID, Windows Hello, a phone or a security key. Required: everyone adds one after signing in.",
|
|
41
|
+
rules: optionalRequiredOff
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
method: "twoFactor",
|
|
45
|
+
label: "Two-factor authentication",
|
|
46
|
+
description: "A code from an authenticator app, or an emailed one, after a password or an emailed sign-in code. Required: everyone turns it on after signing in.",
|
|
47
|
+
rules: optionalRequiredOff
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
method: "staffAccess",
|
|
51
|
+
label: "Staff sign-in as a person",
|
|
52
|
+
description: "The team that runs this app signs in as someone to help them, with a reason, for a short session. Each time is in the log below; with \"Email the person\" they are told too.",
|
|
53
|
+
rules: [
|
|
54
|
+
{
|
|
55
|
+
value: "allowed",
|
|
56
|
+
label: "On"
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
value: "notify",
|
|
60
|
+
label: "Email the person"
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
value: "forbidden",
|
|
64
|
+
label: "Off"
|
|
65
|
+
}
|
|
66
|
+
]
|
|
67
|
+
}
|
|
68
|
+
];
|
|
69
|
+
/** The word for `rule` in `method`'s row, for example "Optional" for an allowed passkey. Every rule the server stores has one. */
|
|
70
|
+
const ruleLabel = (method, rule) => {
|
|
71
|
+
const label = policyRows.find((row) => row.method === method)?.rules.find((option) => option.value === rule)?.label;
|
|
72
|
+
if (label === void 0) throw new Error(`No label for the ${rule} rule of ${method}`);
|
|
73
|
+
return label;
|
|
74
|
+
};
|
|
75
|
+
//#endregion
|
|
76
|
+
export { policyRows, ruleLabel };
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { Spinner } from "../primitives/spinner.js";
|
|
2
|
+
import { PageHeader } from "../app-shell/page-header.js";
|
|
3
|
+
import { authMessage } from "../auth/auth-messages.js";
|
|
4
|
+
import { useAuth } from "../auth/auth-provider.js";
|
|
5
|
+
import { SignInPolicyForm } from "./sign-in-policy-form.js";
|
|
6
|
+
import { StaffSignInLog } from "./staff-sign-in-log.js";
|
|
7
|
+
import { useEffect, useState } from "react";
|
|
8
|
+
import { AuthError } from "@protobase/client";
|
|
9
|
+
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
10
|
+
//#region src/account/sign-in-policy-page.tsx
|
|
11
|
+
const StaffSignIns = ({ operator }) => {
|
|
12
|
+
const { session } = useAuth();
|
|
13
|
+
const [signIns, setSignIns] = useState();
|
|
14
|
+
const [error, setError] = useState();
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
session.staff.log().then(setSignIns, (failure) => setError(failure instanceof AuthError ? authMessage(failure) : "Could not read the staff sign-ins"));
|
|
17
|
+
}, [session]);
|
|
18
|
+
return /* @__PURE__ */ jsx(StaffSignInLog, {
|
|
19
|
+
operator,
|
|
20
|
+
signIns,
|
|
21
|
+
error
|
|
22
|
+
});
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* The admin page for the sign-in policy; the server refuses a policy that would lock people out, and the page says why.
|
|
26
|
+
* With an operator provider, the log of staff sign-ins follows the policy.
|
|
27
|
+
*/
|
|
28
|
+
const SignInPolicyPage = () => {
|
|
29
|
+
const { session } = useAuth();
|
|
30
|
+
const [state, setState] = useState();
|
|
31
|
+
const [busy, setBusy] = useState(false);
|
|
32
|
+
const [error, setError] = useState();
|
|
33
|
+
const [notice, setNotice] = useState();
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
session.signInPolicy.read().then(setState, (failure) => setError(failure instanceof AuthError ? authMessage(failure) : "Could not read the sign-in policy"));
|
|
36
|
+
}, [session]);
|
|
37
|
+
const save = async (policy) => {
|
|
38
|
+
setBusy(true);
|
|
39
|
+
setError(void 0);
|
|
40
|
+
setNotice(void 0);
|
|
41
|
+
try {
|
|
42
|
+
setState(await session.signInPolicy.save(policy));
|
|
43
|
+
setNotice("Saved. It applies to every sign-in from now on.");
|
|
44
|
+
} catch (failure) {
|
|
45
|
+
if (!(failure instanceof AuthError)) throw failure;
|
|
46
|
+
setError(authMessage(failure));
|
|
47
|
+
}
|
|
48
|
+
setBusy(false);
|
|
49
|
+
};
|
|
50
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
51
|
+
className: "mx-auto flex w-full max-w-3xl flex-col gap-4 px-4 py-6 md:px-6",
|
|
52
|
+
children: [/* @__PURE__ */ jsx(PageHeader, {
|
|
53
|
+
title: "Sign-in policy",
|
|
54
|
+
subtitle: "How people sign in to this app. Signed-in people keep their session, and are asked for what is newly required within 15 minutes."
|
|
55
|
+
}), state ? /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(SignInPolicyForm, {
|
|
56
|
+
state,
|
|
57
|
+
busy,
|
|
58
|
+
error,
|
|
59
|
+
notice,
|
|
60
|
+
onSave: (policy) => void save(policy)
|
|
61
|
+
}, state.savedAt ?? "default"), state.operator && /* @__PURE__ */ jsx(StaffSignIns, { operator: state.operator })] }) : error ? /* @__PURE__ */ jsx("p", {
|
|
62
|
+
role: "alert",
|
|
63
|
+
className: "text-[13px] text-danger-text",
|
|
64
|
+
children: error
|
|
65
|
+
}) : /* @__PURE__ */ jsxs("div", {
|
|
66
|
+
className: "flex items-center gap-2 text-[13px] text-muted-foreground",
|
|
67
|
+
children: [/* @__PURE__ */ jsx(Spinner, {}), " Loading"]
|
|
68
|
+
})]
|
|
69
|
+
});
|
|
70
|
+
};
|
|
71
|
+
//#endregion
|
|
72
|
+
export { SignInPolicyPage };
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Badge } from "../primitives/badge.js";
|
|
2
|
+
import { formatDateTime } from "../format/date.js";
|
|
3
|
+
import { AccountSection, SectionError } from "./account-section.js";
|
|
4
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
5
|
+
//#region src/account/staff-sign-in-log.tsx
|
|
6
|
+
const stateOf = (entry, now) => entry.endedAt ? /* @__PURE__ */ jsxs(Badge, {
|
|
7
|
+
tone: "neutral",
|
|
8
|
+
children: ["Stopped ", formatDateTime(Date.parse(entry.endedAt))]
|
|
9
|
+
}) : Date.parse(entry.expiresAt) <= now ? /* @__PURE__ */ jsxs(Badge, {
|
|
10
|
+
tone: "neutral",
|
|
11
|
+
children: ["Ended ", formatDateTime(Date.parse(entry.expiresAt))]
|
|
12
|
+
}) : /* @__PURE__ */ jsxs(Badge, {
|
|
13
|
+
tone: "amber",
|
|
14
|
+
children: ["Signed in until ", formatDateTime(Date.parse(entry.expiresAt))]
|
|
15
|
+
});
|
|
16
|
+
/** The app's log of staff sign-ins, for admins: who signed in as whom, why, and when. */
|
|
17
|
+
const StaffSignInLog = ({ signIns, operator, error, now = Date.now() }) => /* @__PURE__ */ jsxs(AccountSection, {
|
|
18
|
+
title: "Staff sign-ins",
|
|
19
|
+
description: `Each time staff of ${operator} signed in as someone here, newest first.`,
|
|
20
|
+
children: [
|
|
21
|
+
/* @__PURE__ */ jsx(SectionError, { message: error }),
|
|
22
|
+
signIns && signIns.length === 0 && /* @__PURE__ */ jsx("p", {
|
|
23
|
+
className: "text-[13px] text-muted-foreground",
|
|
24
|
+
children: "No staff has signed in as anyone yet."
|
|
25
|
+
}),
|
|
26
|
+
signIns && signIns.length > 0 && /* @__PURE__ */ jsx("ul", {
|
|
27
|
+
className: "divide-y rounded-md border",
|
|
28
|
+
children: signIns.map((entry) => /* @__PURE__ */ jsxs("li", {
|
|
29
|
+
className: "flex flex-col gap-1.5 p-3",
|
|
30
|
+
children: [
|
|
31
|
+
/* @__PURE__ */ jsxs("div", {
|
|
32
|
+
className: "flex flex-wrap items-center gap-x-2 gap-y-1 text-[13px]",
|
|
33
|
+
children: [
|
|
34
|
+
/* @__PURE__ */ jsx("span", {
|
|
35
|
+
className: "font-medium break-all",
|
|
36
|
+
children: entry.staffName ? `${entry.staffName} (${entry.staff})` : entry.staff
|
|
37
|
+
}),
|
|
38
|
+
/* @__PURE__ */ jsx("span", {
|
|
39
|
+
className: "text-muted-foreground",
|
|
40
|
+
children: "as"
|
|
41
|
+
}),
|
|
42
|
+
/* @__PURE__ */ jsx("span", {
|
|
43
|
+
className: "font-medium break-all",
|
|
44
|
+
children: entry.user
|
|
45
|
+
})
|
|
46
|
+
]
|
|
47
|
+
}),
|
|
48
|
+
/* @__PURE__ */ jsx("p", {
|
|
49
|
+
className: "text-[13px] break-words",
|
|
50
|
+
children: entry.reason
|
|
51
|
+
}),
|
|
52
|
+
/* @__PURE__ */ jsxs("div", {
|
|
53
|
+
className: "flex flex-wrap items-center gap-2 text-xs text-muted-foreground",
|
|
54
|
+
children: [/* @__PURE__ */ jsx("span", { children: formatDateTime(Date.parse(entry.startedAt)) }), stateOf(entry, now)]
|
|
55
|
+
})
|
|
56
|
+
]
|
|
57
|
+
}, entry.id))
|
|
58
|
+
})
|
|
59
|
+
]
|
|
60
|
+
});
|
|
61
|
+
//#endregion
|
|
62
|
+
export { StaffSignInLog };
|