@protobase/ui 0.3.5 → 0.5.0

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 (100) 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 +37 -15
  17. package/dist/app/assistant-shell.js +3 -2
  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/merge-ui.d.ts +9 -0
  26. package/dist/app/pages/merge-ui.js +45 -0
  27. package/dist/app/pages/project-ui-provider.js +15 -0
  28. package/dist/app/pages/project-ui.d.ts +6 -3
  29. package/dist/app/pages/project-ui.js +3 -7
  30. package/dist/app/pages/record-table.js +1 -1
  31. package/dist/app/pages/use-run-action.js +1 -1
  32. package/dist/app/record-page.js +5 -5
  33. package/dist/app/runtime/runtime-update.js +1 -1
  34. package/dist/app/sensitive-field.js +1 -1
  35. package/dist/app/staff-banner.js +67 -0
  36. package/dist/app/use-record-editor.js +1 -1
  37. package/dist/app-shell/breadcrumb.js +1 -1
  38. package/dist/app-shell/global-search.js +1 -1
  39. package/dist/app-shell/profile-menu.js +1 -1
  40. package/dist/app-shell/sidebar-group.js +1 -1
  41. package/dist/app-shell/top-bar.js +1 -1
  42. package/dist/assistant-dock/assistant-dock.d.ts +8 -2
  43. package/dist/assistant-dock/assistant-dock.js +20 -13
  44. package/dist/assistant-dock/index.d.ts +3 -1
  45. package/dist/assistant-dock/part-view.d.ts +1 -1
  46. package/dist/assistant-dock/part-view.js +3 -1
  47. package/dist/assistant-dock/use-backend-data.d.ts +23 -0
  48. package/dist/assistant-dock/use-backend-data.js +67 -0
  49. package/dist/assistant-dock/widget-view.d.ts +1 -0
  50. package/dist/assistant-dock/widget-view.js +44 -0
  51. package/dist/auth/auth-gate.js +42 -4
  52. package/dist/auth/auth-messages.js +48 -0
  53. package/dist/auth/auth-provider.js +40 -14
  54. package/dist/auth/code-input.js +17 -0
  55. package/dist/auth/email-code-form.js +81 -0
  56. package/dist/auth/email-code-page.js +40 -0
  57. package/dist/auth/first-run-page.js +1 -1
  58. package/dist/auth/forgot-password-page.js +1 -1
  59. package/dist/auth/reset-password-page.js +1 -1
  60. package/dist/auth/setup-required-form.js +43 -0
  61. package/dist/auth/setup-required-page.js +51 -0
  62. package/dist/auth/sign-in-form.js +53 -20
  63. package/dist/auth/sign-in-page.js +14 -13
  64. package/dist/auth/staff-link.js +30 -0
  65. package/dist/auth/staff-sign-in-form.js +93 -0
  66. package/dist/auth/staff-sign-in-page.js +41 -0
  67. package/dist/auth/two-factor-form.js +137 -0
  68. package/dist/auth/two-factor-page.js +39 -0
  69. package/dist/auth/user-to-shell.js +5 -2
  70. package/dist/chat/composer.js +1 -1
  71. package/dist/data/api-provider.d.ts +1 -1
  72. package/dist/data/api-provider.js +1 -1
  73. package/dist/data/use-aggregates.js +1 -1
  74. package/dist/data/use-list.js +1 -1
  75. package/dist/data/use-update-record.js +1 -1
  76. package/dist/data-grid/cells.js +3 -3
  77. package/dist/data-grid/grid-header.js +2 -2
  78. package/dist/data-grid/grid-row.js +1 -1
  79. package/dist/data-grid/header-menu.js +1 -1
  80. package/dist/data-grid/row-menu.js +1 -1
  81. package/dist/diff-view/diff-view.js +1 -1
  82. package/dist/filter-panel/active-filter-chips.js +1 -1
  83. package/dist/filter-panel/facet-group.js +1 -1
  84. package/dist/filter-panel/filter-menu.js +1 -1
  85. package/dist/index.d.ts +3 -1
  86. package/dist/index.js +4 -2
  87. package/dist/inline-child-table/inline-child-table.js +1 -1
  88. package/dist/inline-child-table/line-menu.js +1 -1
  89. package/dist/inline-child-table/line-row.js +2 -2
  90. package/dist/inline-child-table/totals-footer.js +1 -1
  91. package/dist/live/use-filter-config.js +1 -1
  92. package/dist/primitives/checkbox.js +1 -1
  93. package/dist/primitives/sheet.js +1 -1
  94. package/dist/primitives/spinner.js +1 -1
  95. package/dist/record-view/field.js +1 -1
  96. package/dist/record-view/record-sidebar.js +1 -1
  97. package/dist/side-panel/side-panel.js +1 -1
  98. package/dist/step-list/step-list.js +1 -1
  99. package/dist/toasts/toast.js +1 -1
  100. 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,12 +1,16 @@
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";
9
12
  import { ToastProvider } from "../toasts/toast-provider.js";
13
+ import { useProjectUi } from "./pages/project-ui.js";
10
14
  import { AssistantButton, AssistantPanel } from "./assistant-shell.js";
11
15
  import { breadcrumbFor } from "./breadcrumb-for.js";
12
16
  import { Router, matchRoute, useRouter } from "./router.js";
@@ -15,20 +19,21 @@ import { CreatePage } from "./create/create-page.js";
15
19
  import { ListPage } from "./list-page.js";
16
20
  import { userMenuFromMeta } from "./user-menu-from-meta.js";
17
21
  import { navFromMeta } from "./nav-from-meta.js";
18
- import { ProjectUiProvider, useProjectUi } from "./pages/project-ui.js";
19
22
  import { ComposedPage } from "./pages/composed-page.js";
23
+ import { ProjectUiProvider } from "./pages/project-ui-provider.js";
20
24
  import { RecordPage } from "./record-page.js";
21
25
  import { useGlobalSearch } from "./search/use-global-search.js";
22
26
  import { RuntimeUpdateButton } from "./runtime/runtime-update.js";
27
+ import { StaffBanner } from "./staff-banner.js";
23
28
  import { useNavRecent } from "./use-nav-recent.js";
24
29
  import { useEffect, useState } from "react";
25
30
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
26
31
  //#region src/app/app.tsx
27
32
  const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
28
33
  const meta = useAdminMeta();
29
- const { state, signOut } = useAuth();
34
+ const { state, signOut, stopStaffSession } = useAuth();
30
35
  if (state.kind !== "signed-in") throw new Error("The shell is only rendered for a signed-in user");
31
- const { user } = state;
36
+ const { user, staff } = state;
32
37
  const { path, params, basePath, navigate } = useRouter();
33
38
  const route = matchRoute(path);
34
39
  const search = useGlobalSearch();
@@ -69,7 +74,9 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
69
74
  url: runtimeUrl,
70
75
  client: runtime
71
76
  });
72
- const breadcrumb = breadcrumbFor({
77
+ const admin = isAdmin(user);
78
+ const accountPage = path === "/-/account" ? "Sign-in & security" : path === "/-/sign-in-policy" ? "Sign-in policy" : void 0;
79
+ const breadcrumb = accountPage ? [accountPage] : breadcrumbFor({
73
80
  basePath,
74
81
  route,
75
82
  group: group?.label,
@@ -77,7 +84,17 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
77
84
  resourceLabel: view?.names?.plural ?? (route.resource && humanize(route.resource)),
78
85
  recordTitle: title
79
86
  });
80
- return /* @__PURE__ */ jsx(AppShell, {
87
+ const content = path === "/-/account" ? /* @__PURE__ */ jsx(AccountSecurityPage, { email: user.email }) : path === "/-/sign-in-policy" ? admin ? /* @__PURE__ */ jsx(SignInPolicyPage, {}) : /* @__PURE__ */ jsx(Notice, {
88
+ title: "Admins only",
89
+ children: "Only an admin can change how people sign in."
90
+ }) : !route.resource ? /* @__PURE__ */ jsx(Notice, {
91
+ title: "Choose a resource",
92
+ children: "Pick one from the sidebar."
93
+ }) : 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, {
94
+ resource: route.resource,
95
+ recordKey: route.key
96
+ }, `${route.resource}/${route.key}`) : /* @__PURE__ */ jsx(ListPage, { resource: route.resource }, route.resource);
97
+ const appShell = /* @__PURE__ */ jsx(AppShell, {
81
98
  sidebarMode: sidebarMode ?? "text-small",
82
99
  activeItem: route.resource ?? "",
83
100
  breadcrumb,
@@ -85,7 +102,7 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
85
102
  onSignOut: () => void signOut(),
86
103
  workspace,
87
104
  navGroups: groups,
88
- userMenu: userMenuFromMeta(meta, basePath, route.resource),
105
+ userMenu: [...userMenuFromMeta(meta, basePath, route.resource), ...accountMenuItems(basePath, path, admin)],
89
106
  onNavigate: open,
90
107
  search: search.available ? {
91
108
  placeholder: search.placeholder,
@@ -102,13 +119,18 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
102
119
  assistantButton
103
120
  ] }),
104
121
  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)
122
+ children: content
123
+ });
124
+ if (!staff) return appShell;
125
+ return /* @__PURE__ */ jsxs("div", {
126
+ className: "flex h-full min-h-0 flex-col",
127
+ children: [/* @__PURE__ */ jsx(StaffBanner, {
128
+ signIn: staff,
129
+ onStop: stopStaffSession
130
+ }), /* @__PURE__ */ jsx("div", {
131
+ className: "min-h-0 flex-1",
132
+ children: appShell
133
+ })]
112
134
  });
113
135
  };
114
136
  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, {
@@ -25,7 +25,8 @@ const ConnectedDock = ({ client, page, onClose }) => {
25
25
  error,
26
26
  onSend: (text) => send(text, page),
27
27
  onAction: act,
28
- onClose
28
+ onClose,
29
+ client
29
30
  });
30
31
  };
31
32
  const OwnDock = ({ url, ...props }) => /* @__PURE__ */ jsx(ConnectedDock, {
@@ -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) => {
@@ -0,0 +1,9 @@
1
+ import { ProjectUi } from "./project-ui.js";
2
+ //#region src/app/pages/merge-ui.d.ts
3
+ /**
4
+ * One UI config from several, each after the ones it `extends`, the last being the app's own: components and action
5
+ * handlers are all kept, and one name defined by two configs is an error naming both; every shell slot is drawn,
6
+ * the extended configs' first.
7
+ */
8
+ export declare const mergeUi: (...uis: ProjectUi[]) => ProjectUi;
9
+ //#endregion
@@ -0,0 +1,45 @@
1
+ import { jsx } from "react/jsx-runtime";
2
+ //#region src/app/pages/merge-ui.tsx
3
+ const flatten = (ui, label) => [...(ui.extends ?? []).flatMap((parent, index) => flatten(parent, parent.name ?? `${label} extends[${index}]`)), {
4
+ ui,
5
+ label
6
+ }];
7
+ const union = (kind, named, pick) => {
8
+ const merged = {};
9
+ const owners = /* @__PURE__ */ new Map();
10
+ for (const { ui, label } of named) for (const [name, value] of Object.entries(pick(ui) ?? {})) {
11
+ if (Object.hasOwn(merged, name) && merged[name] !== value) throw new Error(`${kind} "${name}" is defined twice, by ${owners.get(name)} and by ${label}; rename one of them`);
12
+ merged[name] = value;
13
+ owners.set(name, owners.get(name) ?? label);
14
+ }
15
+ return merged;
16
+ };
17
+ const slot = (slots) => {
18
+ if (slots.length <= 1) return slots[0];
19
+ const All = () => slots.map((Slot, index) => /* @__PURE__ */ jsx(Slot, {}, index));
20
+ return All;
21
+ };
22
+ /**
23
+ * One UI config from several, each after the ones it `extends`, the last being the app's own: components and action
24
+ * handlers are all kept, and one name defined by two configs is an error naming both; every shell slot is drawn,
25
+ * the extended configs' first.
26
+ */
27
+ const mergeUi = (...uis) => {
28
+ const named = uis.flatMap((ui, index) => flatten(ui, ui.name ?? `UI config ${index + 1}`));
29
+ const components = union("The component", named, (ui) => ui.components);
30
+ const actions = union("The action handler", named, (ui) => ui.actions);
31
+ const actionSlot = slot(named.flatMap(({ ui }) => ui.shell?.actions ? [ui.shell.actions] : []));
32
+ const rightPanel = slot(named.flatMap(({ ui }) => ui.shell?.rightPanel ? [ui.shell.rightPanel] : []));
33
+ const name = uis.at(-1)?.name;
34
+ return {
35
+ ...name && { name },
36
+ components,
37
+ actions,
38
+ ...(actionSlot || rightPanel) && { shell: {
39
+ ...actionSlot && { actions: actionSlot },
40
+ ...rightPanel && { rightPanel }
41
+ } }
42
+ };
43
+ };
44
+ //#endregion
45
+ export { mergeUi };
@@ -0,0 +1,15 @@
1
+ import { ProjectUiContext } from "./project-ui.js";
2
+ import { mergeUi } from "./merge-ui.js";
3
+ import { useMemo } from "react";
4
+ import { jsx } from "react/jsx-runtime";
5
+ //#region src/app/pages/project-ui-provider.tsx
6
+ /** Provides `ui` merged with the UI configs it extends. */
7
+ const ProjectUiProvider = ({ ui, children }) => {
8
+ const merged = useMemo(() => ui ? mergeUi(ui) : {}, [ui]);
9
+ return /* @__PURE__ */ jsx(ProjectUiContext.Provider, {
10
+ value: merged,
11
+ children
12
+ });
13
+ };
14
+ //#endregion
15
+ export { ProjectUiProvider };