@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.
Files changed (91) hide show
  1. package/dist/account/account-pages.js +20 -0
  2. package/dist/account/account-section.js +31 -0
  3. package/dist/account/account-security-page.js +59 -0
  4. package/dist/account/backup-codes.js +44 -0
  5. package/dist/account/passkeys-section.js +141 -0
  6. package/dist/account/password-dialog.js +58 -0
  7. package/dist/account/sign-in-policy-form.js +108 -0
  8. package/dist/account/sign-in-policy-labels.js +76 -0
  9. package/dist/account/sign-in-policy-page.js +72 -0
  10. package/dist/account/staff-sign-in-log.js +62 -0
  11. package/dist/account/totp-qr.js +37 -0
  12. package/dist/account/two-factor-section.js +123 -0
  13. package/dist/account/two-factor-setup.js +188 -0
  14. package/dist/activity-chart/activity-chart.js +1 -1
  15. package/dist/activity-chart/activity-tooltip.js +1 -1
  16. package/dist/app/app.d.ts +3 -1
  17. package/dist/app/app.js +50 -18
  18. package/dist/app/assistant-shell.js +1 -1
  19. package/dist/app/create/create-page.js +1 -1
  20. package/dist/app/delete/use-delete-flow.js +1 -1
  21. package/dist/app/error-banner.js +1 -1
  22. package/dist/app/list-page.js +1 -1
  23. package/dist/app/list-search.js +1 -1
  24. package/dist/app/meta-gate.js +1 -1
  25. package/dist/app/pages/blocks/modal-form.js +3 -3
  26. package/dist/app/pages/record-table.js +1 -1
  27. package/dist/app/record-page.js +5 -5
  28. package/dist/app/runtime/runtime-status.js +25 -0
  29. package/dist/app/runtime/runtime-update.js +77 -0
  30. package/dist/app/runtime/use-runtime-status.js +59 -0
  31. package/dist/app/sensitive-field.js +1 -1
  32. package/dist/app/staff-banner.js +67 -0
  33. package/dist/app/use-record-editor.js +1 -1
  34. package/dist/app-shell/breadcrumb.js +1 -1
  35. package/dist/app-shell/global-search.js +1 -1
  36. package/dist/app-shell/profile-menu.js +1 -1
  37. package/dist/app-shell/sidebar-group.js +1 -1
  38. package/dist/app-shell/top-bar.js +1 -1
  39. package/dist/assistant-dock/assistant-dock.js +1 -1
  40. package/dist/auth/auth-gate.js +42 -4
  41. package/dist/auth/auth-messages.js +48 -0
  42. package/dist/auth/auth-provider.js +40 -14
  43. package/dist/auth/code-input.js +17 -0
  44. package/dist/auth/email-code-form.js +81 -0
  45. package/dist/auth/email-code-page.js +40 -0
  46. package/dist/auth/first-run-page.js +1 -1
  47. package/dist/auth/forgot-password-page.js +1 -1
  48. package/dist/auth/reset-password-page.js +1 -1
  49. package/dist/auth/setup-required-form.js +43 -0
  50. package/dist/auth/setup-required-page.js +51 -0
  51. package/dist/auth/sign-in-form.js +53 -20
  52. package/dist/auth/sign-in-page.js +14 -13
  53. package/dist/auth/staff-link.js +30 -0
  54. package/dist/auth/staff-sign-in-form.js +93 -0
  55. package/dist/auth/staff-sign-in-page.js +41 -0
  56. package/dist/auth/two-factor-form.js +137 -0
  57. package/dist/auth/two-factor-page.js +39 -0
  58. package/dist/auth/user-to-shell.js +5 -2
  59. package/dist/chat/composer.js +1 -1
  60. package/dist/data/api-provider.d.ts +1 -1
  61. package/dist/data/api-provider.js +1 -1
  62. package/dist/data/meta-data.d.ts +4 -0
  63. package/dist/data/use-aggregates.js +1 -1
  64. package/dist/data/use-list.js +1 -1
  65. package/dist/data/use-meta.js +2 -1
  66. package/dist/data/use-update-record.js +1 -1
  67. package/dist/data-grid/cells.js +3 -3
  68. package/dist/data-grid/grid-header.js +2 -2
  69. package/dist/data-grid/grid-row.js +1 -1
  70. package/dist/data-grid/header-menu.js +1 -1
  71. package/dist/data-grid/row-menu.js +1 -1
  72. package/dist/diff-view/diff-view.js +1 -1
  73. package/dist/filter-panel/active-filter-chips.js +1 -1
  74. package/dist/filter-panel/facet-group.js +1 -1
  75. package/dist/filter-panel/filter-menu.js +1 -1
  76. package/dist/inline-child-table/inline-child-table.js +1 -1
  77. package/dist/inline-child-table/line-menu.js +1 -1
  78. package/dist/inline-child-table/line-row.js +2 -2
  79. package/dist/inline-child-table/totals-footer.js +1 -1
  80. package/dist/lib/use-visual-viewport.js +27 -0
  81. package/dist/live/use-filter-config.js +1 -1
  82. package/dist/primitives/checkbox.js +1 -1
  83. package/dist/primitives/sheet.js +1 -1
  84. package/dist/primitives/spinner.js +1 -1
  85. package/dist/record-view/field.js +1 -1
  86. package/dist/record-view/record-sidebar.js +1 -1
  87. package/dist/side-panel/side-panel.d.ts +5 -1
  88. package/dist/side-panel/side-panel.js +49 -35
  89. package/dist/step-list/step-list.js +1 -1
  90. package/dist/toasts/toast.js +1 -1
  91. 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 };