@protobase/ui 0.3.5 → 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 (84) 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.js +35 -14
  17. package/dist/app/assistant-shell.js +1 -1
  18. package/dist/app/create/create-page.js +1 -1
  19. package/dist/app/delete/use-delete-flow.js +1 -1
  20. package/dist/app/error-banner.js +1 -1
  21. package/dist/app/list-page.js +1 -1
  22. package/dist/app/list-search.js +1 -1
  23. package/dist/app/meta-gate.js +1 -1
  24. package/dist/app/pages/blocks/modal-form.js +3 -3
  25. package/dist/app/pages/record-table.js +1 -1
  26. package/dist/app/record-page.js +5 -5
  27. package/dist/app/runtime/runtime-update.js +1 -1
  28. package/dist/app/sensitive-field.js +1 -1
  29. package/dist/app/staff-banner.js +67 -0
  30. package/dist/app/use-record-editor.js +1 -1
  31. package/dist/app-shell/breadcrumb.js +1 -1
  32. package/dist/app-shell/global-search.js +1 -1
  33. package/dist/app-shell/profile-menu.js +1 -1
  34. package/dist/app-shell/sidebar-group.js +1 -1
  35. package/dist/app-shell/top-bar.js +1 -1
  36. package/dist/assistant-dock/assistant-dock.js +1 -1
  37. package/dist/auth/auth-gate.js +42 -4
  38. package/dist/auth/auth-messages.js +48 -0
  39. package/dist/auth/auth-provider.js +40 -14
  40. package/dist/auth/code-input.js +17 -0
  41. package/dist/auth/email-code-form.js +81 -0
  42. package/dist/auth/email-code-page.js +40 -0
  43. package/dist/auth/first-run-page.js +1 -1
  44. package/dist/auth/forgot-password-page.js +1 -1
  45. package/dist/auth/reset-password-page.js +1 -1
  46. package/dist/auth/setup-required-form.js +43 -0
  47. package/dist/auth/setup-required-page.js +51 -0
  48. package/dist/auth/sign-in-form.js +53 -20
  49. package/dist/auth/sign-in-page.js +14 -13
  50. package/dist/auth/staff-link.js +30 -0
  51. package/dist/auth/staff-sign-in-form.js +93 -0
  52. package/dist/auth/staff-sign-in-page.js +41 -0
  53. package/dist/auth/two-factor-form.js +137 -0
  54. package/dist/auth/two-factor-page.js +39 -0
  55. package/dist/auth/user-to-shell.js +5 -2
  56. package/dist/chat/composer.js +1 -1
  57. package/dist/data/api-provider.d.ts +1 -1
  58. package/dist/data/api-provider.js +1 -1
  59. package/dist/data/use-aggregates.js +1 -1
  60. package/dist/data/use-list.js +1 -1
  61. package/dist/data/use-update-record.js +1 -1
  62. package/dist/data-grid/cells.js +3 -3
  63. package/dist/data-grid/grid-header.js +2 -2
  64. package/dist/data-grid/grid-row.js +1 -1
  65. package/dist/data-grid/header-menu.js +1 -1
  66. package/dist/data-grid/row-menu.js +1 -1
  67. package/dist/diff-view/diff-view.js +1 -1
  68. package/dist/filter-panel/active-filter-chips.js +1 -1
  69. package/dist/filter-panel/facet-group.js +1 -1
  70. package/dist/filter-panel/filter-menu.js +1 -1
  71. package/dist/inline-child-table/inline-child-table.js +1 -1
  72. package/dist/inline-child-table/line-menu.js +1 -1
  73. package/dist/inline-child-table/line-row.js +2 -2
  74. package/dist/inline-child-table/totals-footer.js +1 -1
  75. package/dist/live/use-filter-config.js +1 -1
  76. package/dist/primitives/checkbox.js +1 -1
  77. package/dist/primitives/sheet.js +1 -1
  78. package/dist/primitives/spinner.js +1 -1
  79. package/dist/record-view/field.js +1 -1
  80. package/dist/record-view/record-sidebar.js +1 -1
  81. package/dist/side-panel/side-panel.js +1 -1
  82. package/dist/step-list/step-list.js +1 -1
  83. package/dist/toasts/toast.js +1 -1
  84. package/package.json +6 -5
@@ -0,0 +1,37 @@
1
+ import { useMemo } from "react";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ import { encode } from "uqr";
4
+ //#region src/account/totp-qr.tsx
5
+ /** The `otpauth://` URI as a QR code for an authenticator app to scan: one square per dark module, with a quiet border. */
6
+ const TotpQr = ({ uri, className }) => {
7
+ const { size, path } = useMemo(() => {
8
+ const qr = encode(uri, {
9
+ ecc: "M",
10
+ border: 2
11
+ });
12
+ const squares = qr.data.flatMap((row, y) => row.flatMap((dark, x) => dark ? [`M${x} ${y}h1v1h-1z`] : []));
13
+ return {
14
+ size: qr.size,
15
+ path: squares.join("")
16
+ };
17
+ }, [uri]);
18
+ return /* @__PURE__ */ jsxs("svg", {
19
+ viewBox: `0 0 ${size} ${size}`,
20
+ role: "img",
21
+ "aria-label": "QR code for your authenticator app",
22
+ shapeRendering: "crispEdges",
23
+ className,
24
+ children: [/* @__PURE__ */ jsx("rect", {
25
+ width: size,
26
+ height: size,
27
+ fill: "#fff"
28
+ }), /* @__PURE__ */ jsx("path", {
29
+ d: path,
30
+ fill: "#000"
31
+ })]
32
+ });
33
+ };
34
+ /** The secret in an `otpauth://` URI, in groups of four, for typing into an app that cannot scan. */
35
+ const totpKey = (uri) => (new URL(uri).searchParams.get("secret") ?? "").replace(/(.{4})(?=.)/g, "$1 ");
36
+ //#endregion
37
+ export { TotpQr, totpKey };
@@ -0,0 +1,123 @@
1
+ import { Badge } from "../primitives/badge.js";
2
+ import { Button } from "../primitives/button.js";
3
+ import { Dialog } from "../primitives/dialog.js";
4
+ import { AccountSection } from "./account-section.js";
5
+ import { BackupCodes } from "./backup-codes.js";
6
+ import { PasswordDialog } from "./password-dialog.js";
7
+ import { TwoFactorSetup } from "./two-factor-setup.js";
8
+ import { useState } from "react";
9
+ import { jsx, jsxs } from "react/jsx-runtime";
10
+ //#region src/account/two-factor-section.tsx
11
+ const offDescriptions = {
12
+ allowed: "Ask for a second step after your password or an emailed sign-in code: a code from an authenticator app, or one emailed to you. A passkey needs no second step.",
13
+ required: "This app requires two-factor authentication for every account. Turn it on to keep using the app.",
14
+ forbidden: "Your admin turned two-factor authentication off for this app."
15
+ };
16
+ const TwoFactorSection = (props) => {
17
+ const { rule, enabled, authenticatorApp, mail, needsPassword, onTurnOff, onNewBackupCodes, onChanged } = props;
18
+ const [open, setOpen] = useState();
19
+ const close = () => setOpen(void 0);
20
+ const status = enabled ? /* @__PURE__ */ jsx(Badge, {
21
+ tone: "green",
22
+ children: "On"
23
+ }) : rule === "required" ? /* @__PURE__ */ jsx(Badge, {
24
+ tone: "amber",
25
+ children: "Required"
26
+ }) : /* @__PURE__ */ jsx(Badge, { children: "Off" });
27
+ const description = !enabled ? offDescriptions[rule] : authenticatorApp ? `Signing in with a password or an emailed sign-in code also asks for a code from your authenticator app${mail ? ", or one emailed to you" : ""}. Backup codes work when you do not have the app.` : "Signing in with a password or an emailed sign-in code also asks for a code emailed to you.";
28
+ if (open?.kind === "setup") return /* @__PURE__ */ jsx(AccountSection, {
29
+ title: "Two-factor authentication",
30
+ status,
31
+ description,
32
+ children: /* @__PURE__ */ jsx("div", {
33
+ className: "max-w-sm",
34
+ children: /* @__PURE__ */ jsx(TwoFactorSetup, {
35
+ offerEmailedCodes: mail && !open.appOnly,
36
+ needsPassword,
37
+ onStartApp: props.onStartApp,
38
+ onConfirmApp: props.onConfirmApp,
39
+ onEmailedCodes: props.onEmailedCodes,
40
+ onCancel: close,
41
+ onDone: () => {
42
+ close();
43
+ onChanged();
44
+ }
45
+ })
46
+ })
47
+ });
48
+ return /* @__PURE__ */ jsxs(AccountSection, {
49
+ title: "Two-factor authentication",
50
+ status,
51
+ description,
52
+ children: [
53
+ /* @__PURE__ */ jsxs("div", {
54
+ className: "flex flex-wrap gap-2",
55
+ children: [
56
+ !enabled && rule !== "forbidden" && /* @__PURE__ */ jsx(Button, {
57
+ variant: "primary",
58
+ onClick: () => setOpen({
59
+ kind: "setup",
60
+ appOnly: false
61
+ }),
62
+ children: "Turn on"
63
+ }),
64
+ enabled && !authenticatorApp && rule !== "forbidden" && /* @__PURE__ */ jsx(Button, {
65
+ onClick: () => setOpen({
66
+ kind: "setup",
67
+ appOnly: true
68
+ }),
69
+ children: "Add an authenticator app"
70
+ }),
71
+ enabled && authenticatorApp && /* @__PURE__ */ jsx(Button, {
72
+ onClick: () => setOpen({ kind: "new-codes" }),
73
+ children: "New backup codes"
74
+ }),
75
+ enabled && /* @__PURE__ */ jsx(Button, {
76
+ variant: "danger",
77
+ disabled: rule === "required",
78
+ title: rule === "required" ? "This app requires two-factor authentication." : void 0,
79
+ onClick: () => setOpen({ kind: "turn-off" }),
80
+ children: "Turn off"
81
+ })
82
+ ]
83
+ }),
84
+ open?.kind === "turn-off" && /* @__PURE__ */ jsx(PasswordDialog, {
85
+ title: "Turn off two-factor authentication?",
86
+ description: "Signing in then needs only your password or an emailed code. Your backup codes stop working.",
87
+ confirmLabel: "Turn off",
88
+ danger: true,
89
+ needsPassword,
90
+ onClose: close,
91
+ onConfirm: async (password) => {
92
+ await onTurnOff(password);
93
+ close();
94
+ onChanged();
95
+ }
96
+ }),
97
+ open?.kind === "new-codes" && /* @__PURE__ */ jsx(PasswordDialog, {
98
+ title: "Make new backup codes?",
99
+ description: "Your current backup codes stop working, and you get ten new ones.",
100
+ confirmLabel: "Make new codes",
101
+ needsPassword,
102
+ onClose: close,
103
+ onConfirm: async (password) => setOpen({
104
+ kind: "codes",
105
+ codes: await onNewBackupCodes(password)
106
+ })
107
+ }),
108
+ open?.kind === "codes" && /* @__PURE__ */ jsx(Dialog, {
109
+ title: "Your new backup codes",
110
+ description: "Keep them somewhere safe: each one signs you in once if you lose your phone. They are not shown again.",
111
+ onClose: close,
112
+ actions: /* @__PURE__ */ jsx(Button, {
113
+ variant: "primary",
114
+ onClick: close,
115
+ children: "I saved my backup codes"
116
+ }),
117
+ children: /* @__PURE__ */ jsx(BackupCodes, { codes: open.codes })
118
+ })
119
+ ]
120
+ });
121
+ };
122
+ //#endregion
123
+ export { TwoFactorSection };
@@ -0,0 +1,188 @@
1
+ import { cn } from "../lib/cn.js";
2
+ import { Input } from "../primitives/input.js";
3
+ import { authMessage } from "../auth/auth-messages.js";
4
+ import { Button } from "../primitives/button.js";
5
+ import { BackupCodes } from "./backup-codes.js";
6
+ import { CodeInput } from "../auth/code-input.js";
7
+ import { TotpQr, totpKey } from "./totp-qr.js";
8
+ import { useState } from "react";
9
+ import { AuthError } from "@protobase/client";
10
+ import { jsx, jsxs } from "react/jsx-runtime";
11
+ //#region src/account/two-factor-setup.tsx
12
+ const choices = [{
13
+ value: "app",
14
+ label: "Authenticator app",
15
+ description: "A code from an app such as Google Authenticator, Microsoft Authenticator or 1Password. Comes with backup codes."
16
+ }, {
17
+ value: "email",
18
+ label: "Emailed code",
19
+ description: "A code mailed to your address each time you sign in with a password or an emailed sign-in code."
20
+ }];
21
+ /** Turns on two-factor authentication: choose the app or emailed codes, confirm the password, scan, and keep the backup codes. */
22
+ const TwoFactorSetup = ({ offerEmailedCodes, needsPassword, onStartApp, onConfirmApp, onEmailedCodes, onDone, onCancel }) => {
23
+ const [step, setStep] = useState({ kind: "choose" });
24
+ const [choice, setChoice] = useState("app");
25
+ const [password, setPassword] = useState("");
26
+ const [code, setCode] = useState("");
27
+ const [busy, setBusy] = useState(false);
28
+ const [error, setError] = useState();
29
+ const offered = offerEmailedCodes ? choices : choices.filter((option) => option.value === "app");
30
+ const attempt = async (action) => {
31
+ setBusy(true);
32
+ setError(void 0);
33
+ try {
34
+ await action();
35
+ } catch (failure) {
36
+ if (!(failure instanceof AuthError)) throw failure;
37
+ setError(authMessage(failure));
38
+ }
39
+ setBusy(false);
40
+ };
41
+ const start = () => attempt(async () => {
42
+ const given = needsPassword ? password : void 0;
43
+ if (choice === "email") {
44
+ await onEmailedCodes(given);
45
+ return onDone();
46
+ }
47
+ setStep({
48
+ kind: "scan",
49
+ setup: await onStartApp(given)
50
+ });
51
+ });
52
+ const confirm = (setup) => attempt(async () => {
53
+ await onConfirmApp(code.trim());
54
+ setStep({
55
+ kind: "backup-codes",
56
+ codes: setup.backupCodes
57
+ });
58
+ });
59
+ const errorLine = error && /* @__PURE__ */ jsx("p", {
60
+ role: "alert",
61
+ className: "text-xs font-medium text-danger-text",
62
+ children: error
63
+ });
64
+ if (step.kind === "backup-codes") return /* @__PURE__ */ jsxs("div", {
65
+ className: "flex flex-col gap-4",
66
+ children: [
67
+ /* @__PURE__ */ jsx("p", {
68
+ className: "text-[13px] text-muted-foreground",
69
+ children: "Two-factor authentication is on. Keep these backup codes somewhere safe: each one signs you in once if you lose your phone. They are not shown again."
70
+ }),
71
+ /* @__PURE__ */ jsx(BackupCodes, { codes: step.codes }),
72
+ /* @__PURE__ */ jsx(Button, {
73
+ variant: "primary",
74
+ onClick: onDone,
75
+ className: "min-h-10",
76
+ children: "I saved my backup codes"
77
+ })
78
+ ]
79
+ });
80
+ if (step.kind === "scan") return /* @__PURE__ */ jsxs("form", {
81
+ className: "flex flex-col gap-4",
82
+ onSubmit: (event) => {
83
+ event.preventDefault();
84
+ confirm(step.setup);
85
+ },
86
+ children: [
87
+ /* @__PURE__ */ jsx("p", {
88
+ className: "text-[13px] text-muted-foreground",
89
+ children: "Scan this code with your authenticator app, then enter the 6-digit code it shows."
90
+ }),
91
+ /* @__PURE__ */ jsxs("div", {
92
+ className: "flex flex-col items-center gap-2",
93
+ children: [/* @__PURE__ */ jsx(TotpQr, {
94
+ uri: step.setup.totpURI,
95
+ className: "size-44 rounded-md border"
96
+ }), /* @__PURE__ */ jsxs("p", {
97
+ className: "text-center text-xs text-muted-foreground",
98
+ children: ["Can't scan it? Enter this key in the app:", /* @__PURE__ */ jsx("span", {
99
+ className: "mt-1 block break-all font-mono text-[13px] text-foreground",
100
+ children: totpKey(step.setup.totpURI)
101
+ })]
102
+ })]
103
+ }),
104
+ /* @__PURE__ */ jsxs("label", {
105
+ className: "flex flex-col gap-1.5 text-[13px] font-medium",
106
+ children: ["Code from the app", /* @__PURE__ */ jsx(CodeInput, {
107
+ name: "code",
108
+ required: true,
109
+ autoFocus: true,
110
+ value: code,
111
+ invalid: Boolean(error),
112
+ onChange: (event) => setCode(event.target.value)
113
+ })]
114
+ }),
115
+ errorLine,
116
+ /* @__PURE__ */ jsx(Button, {
117
+ variant: "primary",
118
+ type: "submit",
119
+ loading: busy,
120
+ className: "min-h-10",
121
+ children: "Turn on"
122
+ }),
123
+ onCancel && /* @__PURE__ */ jsx(Button, {
124
+ variant: "ghost",
125
+ onClick: onCancel,
126
+ className: "min-h-10",
127
+ children: "Cancel"
128
+ })
129
+ ]
130
+ });
131
+ return /* @__PURE__ */ jsxs("form", {
132
+ className: "flex flex-col gap-4",
133
+ onSubmit: (event) => {
134
+ event.preventDefault();
135
+ start();
136
+ },
137
+ children: [
138
+ /* @__PURE__ */ jsx("div", {
139
+ role: "radiogroup",
140
+ "aria-label": "Second step",
141
+ className: "flex flex-col gap-2",
142
+ children: offered.map((option) => /* @__PURE__ */ jsxs("button", {
143
+ type: "button",
144
+ role: "radio",
145
+ "aria-checked": choice === option.value,
146
+ onClick: () => setChoice(option.value),
147
+ className: cn("rounded-md border p-3 text-left transition-colors", choice === option.value ? "border-primary bg-primary-soft" : "border-border-strong hover:bg-muted"),
148
+ children: [/* @__PURE__ */ jsx("span", {
149
+ className: "block text-[13px] font-medium",
150
+ children: option.label
151
+ }), /* @__PURE__ */ jsx("span", {
152
+ className: "mt-0.5 block text-xs text-muted-foreground",
153
+ children: option.description
154
+ })]
155
+ }, option.value))
156
+ }),
157
+ needsPassword && /* @__PURE__ */ jsxs("label", {
158
+ className: "flex flex-col gap-1.5 text-[13px] font-medium",
159
+ children: ["Your password", /* @__PURE__ */ jsx(Input, {
160
+ type: "password",
161
+ name: "password",
162
+ autoComplete: "current-password",
163
+ required: true,
164
+ value: password,
165
+ invalid: Boolean(error),
166
+ onChange: (event) => setPassword(event.target.value),
167
+ className: "min-h-6"
168
+ })]
169
+ }),
170
+ errorLine,
171
+ /* @__PURE__ */ jsx(Button, {
172
+ variant: "primary",
173
+ type: "submit",
174
+ loading: busy,
175
+ className: "min-h-10",
176
+ children: "Continue"
177
+ }),
178
+ onCancel && /* @__PURE__ */ jsx(Button, {
179
+ variant: "ghost",
180
+ onClick: onCancel,
181
+ className: "min-h-10",
182
+ children: "Cancel"
183
+ })
184
+ ]
185
+ });
186
+ };
187
+ //#endregion
188
+ export { TwoFactorSetup };
@@ -1,8 +1,8 @@
1
1
  import { cn } from "../lib/cn.js";
2
2
  import { useMediaQuery } from "../lib/use-media-query.js";
3
+ import { formatInt } from "../format/number.js";
3
4
  import { formatDayShort } from "../format/date.js";
4
5
  import { activitySeries } from "../mocks/activity.js";
5
- import { formatInt } from "../format/number.js";
6
6
  import { Segmented } from "../primitives/segmented.js";
7
7
  import { halfOverHalfDelta, totalCount } from "./activity-math.js";
8
8
  import { ActivityTooltip } from "./activity-tooltip.js";
@@ -1,5 +1,5 @@
1
- import { formatDate } from "../format/date.js";
2
1
  import { formatInt } from "../format/number.js";
2
+ import { formatDate } from "../format/date.js";
3
3
  import { jsx, jsxs } from "react/jsx-runtime";
4
4
  //#region src/activity-chart/activity-tooltip.tsx
5
5
  const ActivityTooltip = ({ active, payload, noun }) => {
package/dist/app/app.js CHANGED
@@ -1,8 +1,11 @@
1
+ import { accountMenuItems } from "../account/account-pages.js";
1
2
  import { AppShell } from "../app-shell/app-shell.js";
2
- import { ApiProvider } from "../data/api-provider.js";
3
3
  import { AuthProvider, useAuth } from "../auth/auth-provider.js";
4
+ import { AccountSecurityPage } from "../account/account-security-page.js";
5
+ import { SignInPolicyPage } from "../account/sign-in-policy-page.js";
6
+ import { ApiProvider } from "../data/api-provider.js";
4
7
  import { AuthGate } from "../auth/auth-gate.js";
5
- import { userToShell } from "../auth/user-to-shell.js";
8
+ import { isAdmin, userToShell } from "../auth/user-to-shell.js";
6
9
  import { useRecord } from "../data/use-record.js";
7
10
  import { recordTitle } from "../live/model-helpers.js";
8
11
  import { humanize } from "../live/naming.js";
@@ -20,15 +23,16 @@ import { ComposedPage } from "./pages/composed-page.js";
20
23
  import { RecordPage } from "./record-page.js";
21
24
  import { useGlobalSearch } from "./search/use-global-search.js";
22
25
  import { RuntimeUpdateButton } from "./runtime/runtime-update.js";
26
+ import { StaffBanner } from "./staff-banner.js";
23
27
  import { useNavRecent } from "./use-nav-recent.js";
24
28
  import { useEffect, useState } from "react";
25
29
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
26
30
  //#region src/app/app.tsx
27
31
  const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
28
32
  const meta = useAdminMeta();
29
- const { state, signOut } = useAuth();
33
+ const { state, signOut, stopStaffSession } = useAuth();
30
34
  if (state.kind !== "signed-in") throw new Error("The shell is only rendered for a signed-in user");
31
- const { user } = state;
35
+ const { user, staff } = state;
32
36
  const { path, params, basePath, navigate } = useRouter();
33
37
  const route = matchRoute(path);
34
38
  const search = useGlobalSearch();
@@ -69,7 +73,9 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
69
73
  url: runtimeUrl,
70
74
  client: runtime
71
75
  });
72
- const breadcrumb = breadcrumbFor({
76
+ const admin = isAdmin(user);
77
+ const accountPage = path === "/-/account" ? "Sign-in & security" : path === "/-/sign-in-policy" ? "Sign-in policy" : void 0;
78
+ const breadcrumb = accountPage ? [accountPage] : breadcrumbFor({
73
79
  basePath,
74
80
  route,
75
81
  group: group?.label,
@@ -77,7 +83,17 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
77
83
  resourceLabel: view?.names?.plural ?? (route.resource && humanize(route.resource)),
78
84
  recordTitle: title
79
85
  });
80
- return /* @__PURE__ */ jsx(AppShell, {
86
+ const content = path === "/-/account" ? /* @__PURE__ */ jsx(AccountSecurityPage, { email: user.email }) : path === "/-/sign-in-policy" ? admin ? /* @__PURE__ */ jsx(SignInPolicyPage, {}) : /* @__PURE__ */ jsx(Notice, {
87
+ title: "Admins only",
88
+ children: "Only an admin can change how people sign in."
89
+ }) : !route.resource ? /* @__PURE__ */ jsx(Notice, {
90
+ title: "Choose a resource",
91
+ children: "Pick one from the sidebar."
92
+ }) : page ? /* @__PURE__ */ jsx(ComposedPage, { name: page.name }, page.name) : route.key === "new" ? /* @__PURE__ */ jsx(CreatePage, { resource: route.resource }, `${route.resource}/new`) : route.key ? /* @__PURE__ */ jsx(RecordPage, {
93
+ resource: route.resource,
94
+ recordKey: route.key
95
+ }, `${route.resource}/${route.key}`) : /* @__PURE__ */ jsx(ListPage, { resource: route.resource }, route.resource);
96
+ const appShell = /* @__PURE__ */ jsx(AppShell, {
81
97
  sidebarMode: sidebarMode ?? "text-small",
82
98
  activeItem: route.resource ?? "",
83
99
  breadcrumb,
@@ -85,7 +101,7 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
85
101
  onSignOut: () => void signOut(),
86
102
  workspace,
87
103
  navGroups: groups,
88
- userMenu: userMenuFromMeta(meta, basePath, route.resource),
104
+ userMenu: [...userMenuFromMeta(meta, basePath, route.resource), ...accountMenuItems(basePath, path, admin)],
89
105
  onNavigate: open,
90
106
  search: search.available ? {
91
107
  placeholder: search.placeholder,
@@ -102,13 +118,18 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
102
118
  assistantButton
103
119
  ] }),
104
120
  rightPanel: (RightPanel || assistantPanel) && /* @__PURE__ */ jsxs(Fragment$1, { children: [RightPanel && /* @__PURE__ */ jsx(RightPanel, {}), assistantPanel] }),
105
- children: !route.resource ? /* @__PURE__ */ jsx(Notice, {
106
- title: "Choose a resource",
107
- children: "Pick one from the sidebar."
108
- }) : page ? /* @__PURE__ */ jsx(ComposedPage, { name: page.name }, page.name) : route.key === "new" ? /* @__PURE__ */ jsx(CreatePage, { resource: route.resource }, `${route.resource}/new`) : route.key ? /* @__PURE__ */ jsx(RecordPage, {
109
- resource: route.resource,
110
- recordKey: route.key
111
- }, `${route.resource}/${route.key}`) : /* @__PURE__ */ jsx(ListPage, { resource: route.resource }, route.resource)
121
+ children: content
122
+ });
123
+ if (!staff) return appShell;
124
+ return /* @__PURE__ */ jsxs("div", {
125
+ className: "flex h-full min-h-0 flex-col",
126
+ children: [/* @__PURE__ */ jsx(StaffBanner, {
127
+ signIn: staff,
128
+ onStop: stopStaffSession
129
+ }), /* @__PURE__ */ jsx("div", {
130
+ className: "min-h-0 flex-1",
131
+ children: appShell
132
+ })]
112
133
  });
113
134
  };
114
135
  const Authenticated = ({ baseUrl, client, basePath, initialUrl, workspace, sidebarMode, ui, assistant, runtime }) => {
@@ -2,8 +2,8 @@ import { Button } from "../primitives/button.js";
2
2
  import { AssistantDock } from "../assistant-dock/assistant-dock.js";
3
3
  import { useAssistant } from "../assistant-dock/use-assistant.js";
4
4
  import { useAssistantClient } from "../assistant-dock/use-assistant-client.js";
5
- import { jsx, jsxs } from "react/jsx-runtime";
6
5
  import { Sparkles } from "lucide-react";
6
+ import { jsx, jsxs } from "react/jsx-runtime";
7
7
  //#region src/app/assistant-shell.tsx
8
8
  /** Below `md`, where search is a button too, only the icon shows; the hidden text still names it. */
9
9
  const AssistantButton = ({ open, onToggle }) => /* @__PURE__ */ jsxs(Button, {
@@ -9,9 +9,9 @@ import { useLiveSave } from "../use-live-save.js";
9
9
  import { CreateRecordForm } from "./create-record-form.js";
10
10
  import { createBody, createSections, hasDatabaseDefault, initialDraft, isChosen, isRequired } from "./create-values.js";
11
11
  import { useState } from "react";
12
+ import { isApiError } from "@protobase/client";
12
13
  import { jsx, jsxs } from "react/jsx-runtime";
13
14
  import { useQueryClient } from "@tanstack/react-query";
14
- import { isApiError } from "@protobase/client";
15
15
  import { encodeKey } from "@protobase/schema";
16
16
  //#region src/app/create/create-page.tsx
17
17
  const without = (source, name) => Object.fromEntries(Object.entries(source).filter(([key]) => key !== name));
@@ -4,9 +4,9 @@ import { useToast } from "../../toasts/toast-provider.js";
4
4
  import { BulkDeleteDialog, ConfirmDeleteDialog, ConflictDialog } from "./dialogs.js";
5
5
  import { diffRecords } from "./record-diff.js";
6
6
  import { useState } from "react";
7
+ import { PreconditionFailedError, isApiError } from "@protobase/client";
7
8
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
8
9
  import { useQueryClient } from "@tanstack/react-query";
9
- import { PreconditionFailedError, isApiError } from "@protobase/client";
10
10
  //#region src/app/delete/use-delete-flow.tsx
11
11
  const concurrency = 4;
12
12
  /**
@@ -1,6 +1,6 @@
1
- import { jsx, jsxs } from "react/jsx-runtime";
2
1
  import { TriangleAlert } from "lucide-react";
3
2
  import { isApiError } from "@protobase/client";
3
+ import { jsx, jsxs } from "react/jsx-runtime";
4
4
  //#region src/app/error-banner.tsx
5
5
  const describe = (error) => {
6
6
  if (!isApiError(error)) return [{ message: error instanceof Error ? error.message : "Something went wrong" }];
@@ -25,8 +25,8 @@ import { ListChart } from "./list-chart.js";
25
25
  import { defaultOrderBy, parseSort, sortToOrderBy } from "./list-order.js";
26
26
  import { ListSearch } from "./list-search.js";
27
27
  import { useMemo, useState } from "react";
28
- import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
29
28
  import { Plus, SlidersHorizontal } from "lucide-react";
29
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
30
30
  import { createWhere, encodeKey } from "@protobase/schema";
31
31
  //#region src/app/list-page.tsx
32
32
  const where = createWhere();
@@ -1,7 +1,7 @@
1
1
  import { Input } from "../primitives/input.js";
2
2
  import { useEffect, useState } from "react";
3
- import { jsx } from "react/jsx-runtime";
4
3
  import { Search } from "lucide-react";
4
+ import { jsx } from "react/jsx-runtime";
5
5
  //#region src/app/list-search.tsx
6
6
  /** A search box that commits after a short pause or on Enter. */
7
7
  const ListSearch = ({ value, onCommit, placeholder }) => {
@@ -2,8 +2,8 @@ import { Spinner } from "../primitives/spinner.js";
2
2
  import { allowEverything } from "../data/meta-data.js";
3
3
  import { useMeta } from "../data/use-meta.js";
4
4
  import { createContext, useContext } from "react";
5
- import { jsx, jsxs } from "react/jsx-runtime";
6
5
  import { isApiError } from "@protobase/client";
6
+ import { jsx, jsxs } from "react/jsx-runtime";
7
7
  //#region src/app/meta-gate.tsx
8
8
  const MetaContext = createContext(null);
9
9
  const useAdminMeta = () => {
@@ -1,8 +1,8 @@
1
- import { useClient } from "../../../data/api-provider.js";
2
1
  import { Button } from "../../../primitives/button.js";
2
+ import { Dialog } from "../../../primitives/dialog.js";
3
+ import { useClient } from "../../../data/api-provider.js";
3
4
  import { humanize } from "../../../live/naming.js";
4
5
  import { useToast } from "../../../toasts/toast-provider.js";
5
- import { Dialog } from "../../../primitives/dialog.js";
6
6
  import { differs, toDraft, toPatchValue } from "../../../live/field-values.js";
7
7
  import { ErrorBanner } from "../../error-banner.js";
8
8
  import { FieldEditor } from "../../field-editor.js";
@@ -14,8 +14,8 @@ import { useLayoutRecord } from "../record-scope.js";
14
14
  import { useFirstRecord, useRefreshResource } from "../use-layout-data.js";
15
15
  import { propsOf } from "./block-props.js";
16
16
  import { useState } from "react";
17
- import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
18
17
  import { isApiError } from "@protobase/client";
18
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
19
19
  //#region src/app/pages/blocks/modal-form.tsx
20
20
  /** The literal defaults of new records, or the record's values when editing; relations as `{ key, label }` once chosen. */
21
21
  const startingDraft = (model, fields, record) => Object.fromEntries(fields.map((name) => {
@@ -6,8 +6,8 @@ import { scopeOf } from "./use-layout-data.js";
6
6
  import { FieldValue } from "./field-value.js";
7
7
  import { usePathValue } from "./use-path-value.js";
8
8
  import { useRelationLabels } from "./use-relation-labels.js";
9
- import { Fragment, jsx, jsxs } from "react/jsx-runtime";
10
9
  import { ChevronLeft, ChevronRight } from "lucide-react";
10
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
11
11
  import { resolveFieldPath } from "@protobase/layout";
12
12
  //#region src/app/pages/record-table.tsx
13
13
  const isPath = (column) => column.includes(".");
@@ -1,13 +1,13 @@
1
1
  import { Badge } from "../primitives/badge.js";
2
- import { keys } from "../data/query-keys.js";
3
- import { useClient } from "../data/api-provider.js";
4
2
  import { useAuth } from "../auth/auth-provider.js";
3
+ import { formatRelativeTime } from "../format/date.js";
5
4
  import { Button } from "../primitives/button.js";
5
+ import { keys } from "../data/query-keys.js";
6
+ import { useClient } from "../data/api-provider.js";
6
7
  import { useRecord } from "../data/use-record.js";
7
8
  import { recordTitle } from "../live/model-helpers.js";
8
9
  import { humanize } from "../live/naming.js";
9
10
  import { useNavigationGuard, useRouter } from "./router.js";
10
- import { formatRelativeTime } from "../format/date.js";
11
11
  import { displayValue } from "../live/display-value.js";
12
12
  import { ErrorBanner } from "./error-banner.js";
13
13
  import { FieldEditor } from "./field-editor.js";
@@ -29,10 +29,10 @@ import { useRecordEditor } from "./use-record-editor.js";
29
29
  import { useRelatedRecords } from "./use-related-records.js";
30
30
  import { RelatedSection } from "./related-section.js";
31
31
  import { useMemo } from "react";
32
- import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
33
32
  import { Check } from "lucide-react";
34
- import { useQueryClient } from "@tanstack/react-query";
35
33
  import { isApiError } from "@protobase/client";
34
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
35
+ import { useQueryClient } from "@tanstack/react-query";
36
36
  import { enumLabel } from "@protobase/schema";
37
37
  //#region src/app/record-page.tsx
38
38
  const hidden = (model, name) => name === model.tenant || model.primaryKey.includes(name);
@@ -3,8 +3,8 @@ import { Dialog } from "../../primitives/dialog.js";
3
3
  import { nextUpdateText, showsUpdateButton, updateErrorText } from "./runtime-status.js";
4
4
  import { useRuntimeClient, useRuntimeStatus } from "./use-runtime-status.js";
5
5
  import { useState } from "react";
6
- import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
7
6
  import { ArrowUpCircle } from "lucide-react";
7
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
8
8
  //#region src/app/runtime/runtime-update.tsx
9
9
  const Row = ({ label, value }) => /* @__PURE__ */ jsxs("div", {
10
10
  className: "flex justify-between gap-4 py-1 text-[13px]",
@@ -6,8 +6,8 @@ import { displayValue } from "../live/display-value.js";
6
6
  import { toDraft } from "../live/field-values.js";
7
7
  import { ErrorBanner } from "./error-banner.js";
8
8
  import { useState } from "react";
9
- import { jsx, jsxs } from "react/jsx-runtime";
10
9
  import { Eye, EyeOff } from "lucide-react";
10
+ import { jsx, jsxs } from "react/jsx-runtime";
11
11
  import { useMutation } from "@tanstack/react-query";
12
12
  //#region src/app/sensitive-field.tsx
13
13
  const mask = "••••••••••";