@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,25 @@
1
+ import { formatDateTime } from "../../format/date.js";
2
+ import { isApiError } from "@protobase/client";
3
+ //#region src/app/runtime/runtime-status.ts
4
+ /** The top bar shows the update button only when there is an update and the endpoint wants it shown. */
5
+ const showsUpdateButton = (status) => Boolean(status?.updateAvailable && status.indicator);
6
+ const policyLabels = {
7
+ immediate: "Updates as soon as a release is out",
8
+ weekly: "Updates at a set time every week",
9
+ scheduled: "Updates at the time set for this release",
10
+ manual: "Updates only when asked to"
11
+ };
12
+ /** When the latest version will be applied on its own, in words. */
13
+ const nextUpdateText = (status) => {
14
+ if (status.updating) return "Updating now";
15
+ if (status.nextUpdateAt) return `${policyLabels[status.policy]}: next on ${formatDateTime(Date.parse(status.nextUpdateAt))} UTC`;
16
+ return policyLabels[status.policy];
17
+ };
18
+ /** Why "Update now" failed, in words. */
19
+ const updateErrorText = (error) => {
20
+ if (isApiError(error) && error.status === 409) return "Already on the latest version, or an update is running.";
21
+ if (isApiError(error) && error.status === 403) return "Only an admin can update the runtime.";
22
+ return "The update could not be started. Try again later.";
23
+ };
24
+ //#endregion
25
+ export { nextUpdateText, showsUpdateButton, updateErrorText };
@@ -0,0 +1,77 @@
1
+ import { Button } from "../../primitives/button.js";
2
+ import { Dialog } from "../../primitives/dialog.js";
3
+ import { nextUpdateText, showsUpdateButton, updateErrorText } from "./runtime-status.js";
4
+ import { useRuntimeClient, useRuntimeStatus } from "./use-runtime-status.js";
5
+ import { useState } from "react";
6
+ import { ArrowUpCircle } from "lucide-react";
7
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
8
+ //#region src/app/runtime/runtime-update.tsx
9
+ const Row = ({ label, value }) => /* @__PURE__ */ jsxs("div", {
10
+ className: "flex justify-between gap-4 py-1 text-[13px]",
11
+ children: [/* @__PURE__ */ jsx("span", {
12
+ className: "text-muted-foreground",
13
+ children: label
14
+ }), /* @__PURE__ */ jsx("span", {
15
+ className: "font-medium tabular-nums",
16
+ children: value
17
+ })]
18
+ });
19
+ /**
20
+ * The top bar's runtime update button, shown while the endpoint reports an update and wants it shown, and the dialog
21
+ * it opens: the versions, when the update policy applies the latest one, and "Update now". Below `md` only the icon shows.
22
+ */
23
+ const RuntimeUpdate = ({ client }) => {
24
+ const { status, update, requesting, error, checkError } = useRuntimeStatus(client);
25
+ const [open, setOpen] = useState(false);
26
+ if (!status || !open && !showsUpdateButton(status)) return null;
27
+ return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsxs(Button, {
28
+ size: "sm",
29
+ variant: "ghost",
30
+ "aria-label": "Update available",
31
+ title: "Update available",
32
+ onClick: () => setOpen(true),
33
+ className: "max-md:size-9 max-md:p-0",
34
+ children: [/* @__PURE__ */ jsx(ArrowUpCircle, { className: "size-3.5 text-primary" }), /* @__PURE__ */ jsx("span", {
35
+ className: "max-md:sr-only",
36
+ children: "Update"
37
+ })]
38
+ }), open && /* @__PURE__ */ jsxs(Dialog, {
39
+ title: "Runtime update",
40
+ description: nextUpdateText(status),
41
+ onClose: () => setOpen(false),
42
+ actions: /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(Button, {
43
+ onClick: () => setOpen(false),
44
+ children: "Close"
45
+ }), /* @__PURE__ */ jsx(Button, {
46
+ variant: "primary",
47
+ loading: requesting,
48
+ disabled: status.updating || !status.updateAvailable,
49
+ onClick: () => void update(),
50
+ children: status.updating ? "Updating…" : "Update now"
51
+ })] }),
52
+ children: [
53
+ /* @__PURE__ */ jsx(Row, {
54
+ label: "Current version",
55
+ value: status.version
56
+ }),
57
+ /* @__PURE__ */ jsx(Row, {
58
+ label: "Latest version",
59
+ value: status.latest
60
+ }),
61
+ error !== void 0 && /* @__PURE__ */ jsx("p", {
62
+ role: "alert",
63
+ className: "mt-3 text-[13px] text-danger-text",
64
+ children: updateErrorText(error)
65
+ }),
66
+ checkError !== void 0 && /* @__PURE__ */ jsx("p", {
67
+ className: "mt-3 text-[13px] text-muted-foreground",
68
+ children: "The status could not be checked again; this is the last one read."
69
+ })
70
+ ]
71
+ })] });
72
+ };
73
+ const OwnRuntimeUpdate = ({ url }) => /* @__PURE__ */ jsx(RuntimeUpdate, { client: useRuntimeClient(url) });
74
+ /** The update button for the endpoint `/meta` names, signed in as the user; `client` replaces it in stories and tests. */
75
+ const RuntimeUpdateButton = ({ url, client }) => client ? /* @__PURE__ */ jsx(RuntimeUpdate, { client }) : /* @__PURE__ */ jsx(OwnRuntimeUpdate, { url });
76
+ //#endregion
77
+ export { RuntimeUpdateButton };
@@ -0,0 +1,59 @@
1
+ import { useApiToken } from "../../auth/use-api-token.js";
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
+ import { createRuntimeClient } from "@protobase/client";
4
+ //#region src/app/runtime/use-runtime-status.ts
5
+ /** How often the status is read again: rarely, and more often while an update runs so the button goes away when it is done. */
6
+ const refreshMs = 18e5;
7
+ const updatingRefreshMs = 3e4;
8
+ /** A client of the runtime endpoint at `url`, authenticated as the signed-in user; `undefined` until their token is there. */
9
+ const useRuntimeClient = (url) => {
10
+ const token = useApiToken();
11
+ const latest = useRef(token);
12
+ latest.current = token;
13
+ const ready = token !== void 0;
14
+ return useMemo(() => ready ? createRuntimeClient({
15
+ url,
16
+ token: () => latest.current
17
+ }) : void 0, [url, ready]);
18
+ };
19
+ /**
20
+ * The runtime's status, read on load and every half hour after, and `update` to apply the latest version now.
21
+ * `status` keeps the last answer when a later read fails, which `checkError` then holds; `error` is why "Update now" failed.
22
+ */
23
+ const useRuntimeStatus = (client) => {
24
+ const [status, setStatus] = useState();
25
+ const [requesting, setRequesting] = useState(false);
26
+ const [error, setError] = useState();
27
+ const [checkError, setCheckError] = useState();
28
+ const updating = status?.updating ?? false;
29
+ useEffect(() => {
30
+ if (!client) return;
31
+ let active = true;
32
+ const read = () => client.status().then((next) => {
33
+ if (!active) return;
34
+ setStatus(next);
35
+ setCheckError(void 0);
36
+ }, (reason) => active && setCheckError(reason));
37
+ read();
38
+ const timer = setInterval(() => void read(), updating ? updatingRefreshMs : refreshMs);
39
+ return () => {
40
+ active = false;
41
+ clearInterval(timer);
42
+ };
43
+ }, [client, updating]);
44
+ return {
45
+ status,
46
+ update: useCallback(async () => {
47
+ if (!client) return;
48
+ setRequesting(true);
49
+ setError(void 0);
50
+ await client.update().then(setStatus, setError);
51
+ setRequesting(false);
52
+ }, [client]),
53
+ requesting,
54
+ error,
55
+ checkError
56
+ };
57
+ };
58
+ //#endregion
59
+ export { useRuntimeClient, useRuntimeStatus };
@@ -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 = "••••••••••";
@@ -0,0 +1,67 @@
1
+ import { authMessage } from "../auth/auth-messages.js";
2
+ import { formatDateTime } from "../format/date.js";
3
+ import { Button } from "../primitives/button.js";
4
+ import { useState } from "react";
5
+ import { UserRoundCog } from "lucide-react";
6
+ import { AuthError } from "@protobase/client";
7
+ import { jsx, jsxs } from "react/jsx-runtime";
8
+ //#region src/app/staff-banner.tsx
9
+ /** Across the top of the app while staff are signed in as someone: who, as whom, why, until when, and a way to stop. */
10
+ const StaffBanner = ({ signIn, onStop }) => {
11
+ const [stopping, setStopping] = useState(false);
12
+ const [error, setError] = useState();
13
+ const stop = async () => {
14
+ setStopping(true);
15
+ setError(void 0);
16
+ try {
17
+ await onStop();
18
+ } catch (failure) {
19
+ if (!(failure instanceof AuthError)) throw failure;
20
+ setError(authMessage(failure));
21
+ setStopping(false);
22
+ }
23
+ };
24
+ return /* @__PURE__ */ jsxs("div", {
25
+ role: "region",
26
+ "aria-label": "Staff session",
27
+ className: "flex flex-wrap items-center gap-x-3 gap-y-2 border-b border-warning/40 bg-warning-soft px-4 py-2 text-[13px] text-warning-text",
28
+ children: [
29
+ /* @__PURE__ */ jsx(UserRoundCog, {
30
+ "aria-hidden": true,
31
+ className: "size-4 shrink-0"
32
+ }),
33
+ /* @__PURE__ */ jsxs("p", {
34
+ className: "min-w-64 flex-1 break-words",
35
+ children: [
36
+ /* @__PURE__ */ jsx("span", {
37
+ className: "font-semibold",
38
+ children: signIn.staffName ?? signIn.staff
39
+ }),
40
+ " is signed in as ",
41
+ /* @__PURE__ */ jsx("span", {
42
+ className: "font-semibold",
43
+ children: signIn.user
44
+ }),
45
+ " until",
46
+ " ",
47
+ formatDateTime(Date.parse(signIn.expiresAt)),
48
+ ". Reason: ",
49
+ signIn.reason
50
+ ]
51
+ }),
52
+ error && /* @__PURE__ */ jsx("p", {
53
+ role: "alert",
54
+ className: "text-xs font-medium text-danger-text",
55
+ children: error
56
+ }),
57
+ /* @__PURE__ */ jsx(Button, {
58
+ size: "sm",
59
+ loading: stopping,
60
+ onClick: () => void stop(),
61
+ children: "Stop staff session"
62
+ })
63
+ ]
64
+ });
65
+ };
66
+ //#endregion
67
+ export { StaffBanner };
@@ -6,8 +6,8 @@ import { readDraft, writeDraft } from "./draft-storage.js";
6
6
  import { isConflict, useUpdateRecord } from "../data/use-update-record.js";
7
7
  import { nameFailingOperation } from "./batch-failure.js";
8
8
  import { useEffect, useState } from "react";
9
- import { useQueryClient } from "@tanstack/react-query";
10
9
  import { isApiError } from "@protobase/client";
10
+ import { useQueryClient } from "@tanstack/react-query";
11
11
  //#region src/app/use-record-editor.ts
12
12
  const without = (source, name) => Object.fromEntries(Object.entries(source).filter(([key]) => key !== name));
13
13
  /**
@@ -1,6 +1,6 @@
1
1
  import { cn } from "../lib/cn.js";
2
- import { jsx, jsxs } from "react/jsx-runtime";
3
2
  import { ChevronRight } from "lucide-react";
3
+ import { jsx, jsxs } from "react/jsx-runtime";
4
4
  //#region src/app-shell/breadcrumb.tsx
5
5
  const inApp = (event) => event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey;
6
6
  /** The trail of where you are; the last crumb, the current page, shows on phones too. */
@@ -3,8 +3,8 @@ import { Input } from "../primitives/input.js";
3
3
  import { Kbd } from "../primitives/kbd.js";
4
4
  import { Spinner } from "../primitives/spinner.js";
5
5
  import { useId, useRef, useState } from "react";
6
- import { jsx, jsxs } from "react/jsx-runtime";
7
6
  import { Search, X } from "lucide-react";
7
+ import { jsx, jsxs } from "react/jsx-runtime";
8
8
  //#region src/app-shell/global-search.tsx
9
9
  /** Moves the highlighted result by `delta`, wrapping around; from nothing (`-1`) it goes to the first or the last. */
10
10
  const moveActive = (active, count, delta) => {
@@ -2,8 +2,8 @@ import { cn } from "../lib/cn.js";
2
2
  import { Avatar } from "../primitives/avatar.js";
3
3
  import { Badge } from "../primitives/badge.js";
4
4
  import { useEffect, useRef, useState } from "react";
5
- import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
6
5
  import { ChevronsUpDown, LogOut } from "lucide-react";
6
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
7
7
  //#region src/app-shell/profile-menu.tsx
8
8
  const menuRow = "flex min-h-11 w-full items-center gap-2.5 px-3 py-1.5 text-left text-[13px] text-muted-foreground hover:bg-muted md:min-h-0";
9
9
  /** The person at the bottom of the sidebar; the menu is fixed to the viewport so a narrow sidebar cannot clip it. */
@@ -3,8 +3,8 @@ import { navOpenStorageKey, readNavOpen, writeNavOpen } from "./nav-open-storage
3
3
  import { SidebarItem } from "./sidebar-item.js";
4
4
  import { StatusDot } from "./status-dot.js";
5
5
  import { useId, useState } from "react";
6
- import { jsx, jsxs } from "react/jsx-runtime";
7
6
  import { ChevronRight } from "lucide-react";
7
+ import { jsx, jsxs } from "react/jsx-runtime";
8
8
  //#region src/app-shell/sidebar-group.tsx
9
9
  const row = {
10
10
  "text-large": "h-8 gap-2.5 px-2.5 text-[13px]",
@@ -2,8 +2,8 @@ import { cn } from "../lib/cn.js";
2
2
  import { Breadcrumb } from "./breadcrumb.js";
3
3
  import { GlobalSearch } from "./global-search.js";
4
4
  import { useEffect, useRef, useState } from "react";
5
- import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
6
5
  import { Menu, Search, X } from "lucide-react";
6
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
7
7
  //#region src/app-shell/top-bar.tsx
8
8
  const iconButton = "inline-flex size-11 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted md:hidden";
9
9
  const TopBar = ({ breadcrumb, onNavigate, actions, onMenu, menuOpen, menuControls, search }) => {
@@ -4,8 +4,8 @@ import { Composer } from "../chat/composer.js";
4
4
  import { SidePanel } from "../side-panel/side-panel.js";
5
5
  import { PartView } from "./part-view.js";
6
6
  import { useEffect, useRef } from "react";
7
- import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
8
7
  import { Sparkles } from "lucide-react";
8
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
9
9
  //#region src/assistant-dock/assistant-dock.tsx
10
10
  const defaultPlaceholder = "Ask a question...";
11
11
  /** The assistant beside the app: renders a backend's chat, its cards and status line, and sends messages and clicks back. */
@@ -1,28 +1,42 @@
1
1
  import { Spinner } from "../primitives/spinner.js";
2
2
  import { useAuth } from "./auth-provider.js";
3
3
  import { Button } from "../primitives/button.js";
4
+ import { EmailCodePage } from "./email-code-page.js";
4
5
  import { FirstRunPage } from "./first-run-page.js";
5
6
  import { readResetLink, withoutResetLink } from "./reset-link.js";
6
7
  import { ForgotPasswordPage } from "./forgot-password-page.js";
7
8
  import { ResetPasswordPage } from "./reset-password-page.js";
9
+ import { SetupRequiredPage } from "./setup-required-page.js";
8
10
  import { SignInPage } from "./sign-in-page.js";
11
+ import { readStaffLink, withoutStaffLink } from "./staff-link.js";
12
+ import { StaffSignInPage } from "./staff-sign-in-page.js";
13
+ import { TwoFactorPage } from "./two-factor-page.js";
9
14
  import { useEffect, useState } from "react";
10
15
  import { jsx, jsxs } from "react/jsx-runtime";
11
16
  //#region src/auth/auth-gate.tsx
12
17
  const firstPage = () => {
13
18
  const link = readResetLink(window.location.href);
14
- return link ? {
19
+ if (link) return {
15
20
  kind: "reset-password",
16
21
  token: link.token
22
+ };
23
+ const staff = readStaffLink(window.location.href);
24
+ return staff ? {
25
+ kind: "staff-sign-in",
26
+ link: staff
17
27
  } : { kind: "sign-in" };
18
28
  };
19
29
  const forgetResetLink = () => window.history.replaceState(window.history.state, "", withoutResetLink(window.location.href));
20
- /** Shows the app when the user is signed in (or the server has no login), and the right page otherwise. */
30
+ const forgetStaffLink = () => window.history.replaceState(window.history.state, "", withoutStaffLink(window.location.href));
31
+ /**
32
+ * Shows the app when the user is signed in (or the server has no login), and the right page otherwise: the sign-in
33
+ * steps, or the page an emailed reset link or a staff link opened.
34
+ */
21
35
  const AuthGate = ({ workspace, children }) => {
22
36
  const { state, recheck, passwordReset, signOut } = useAuth();
23
37
  const [page, setPage] = useState(firstPage);
24
38
  useEffect(() => {
25
- if (state.kind === "signed-in") setPage((current) => current.kind === "sign-in" && current.notice ? { kind: "sign-in" } : current);
39
+ if (state.kind === "signed-in" || state.kind === "two-factor" || state.kind === "setup-required") setPage((current) => current.kind === "email-code" || current.kind === "sign-in" && current.notice ? { kind: "sign-in" } : current);
26
40
  }, [state.kind]);
27
41
  const leaveResetPage = (next) => {
28
42
  forgetResetLink();
@@ -35,6 +49,10 @@ const AuthGate = ({ workspace, children }) => {
35
49
  notice: "Your password is changed. Sign in with the new one."
36
50
  });
37
51
  };
52
+ const backToSignIn = () => {
53
+ setPage({ kind: "sign-in" });
54
+ signOut();
55
+ };
38
56
  switch (state.kind) {
39
57
  case "loading": return /* @__PURE__ */ jsxs("div", {
40
58
  className: "flex h-full min-h-48 items-center justify-center gap-2 text-[13px] text-muted-foreground",
@@ -58,12 +76,32 @@ const AuthGate = ({ workspace, children }) => {
58
76
  onRequestNewLink: passwordReset ? () => leaveResetPage({ kind: "forgot-password" }) : void 0,
59
77
  onBack: () => leaveResetPage({ kind: "sign-in" })
60
78
  });
79
+ if (page.kind === "staff-sign-in") return /* @__PURE__ */ jsx(StaffSignInPage, {
80
+ link: page.link,
81
+ onBack: () => {
82
+ forgetStaffLink();
83
+ setPage({ kind: "sign-in" });
84
+ }
85
+ });
61
86
  if (state.kind === "signed-in") return children;
87
+ if (state.kind === "two-factor") return /* @__PURE__ */ jsx(TwoFactorPage, {
88
+ methods: state.methods,
89
+ onBack: backToSignIn
90
+ });
91
+ if (state.kind === "setup-required") return /* @__PURE__ */ jsx(SetupRequiredPage, { missing: state.missing }, state.missing.join());
62
92
  if (page.kind === "forgot-password") return /* @__PURE__ */ jsx(ForgotPasswordPage, { onBack: () => setPage({ kind: "sign-in" }) });
93
+ if (page.kind === "email-code") return /* @__PURE__ */ jsx(EmailCodePage, {
94
+ email: page.email,
95
+ onBack: () => setPage({ kind: "sign-in" })
96
+ });
63
97
  return /* @__PURE__ */ jsx(SignInPage, {
64
98
  workspace,
65
99
  notice: page.notice,
66
- onForgotPassword: passwordReset ? () => setPage({ kind: "forgot-password" }) : void 0
100
+ onForgotPassword: passwordReset ? () => setPage({ kind: "forgot-password" }) : void 0,
101
+ onCodeSent: (email) => setPage({
102
+ kind: "email-code",
103
+ email
104
+ })
67
105
  });
68
106
  };
69
107
  //#endregion
@@ -0,0 +1,48 @@
1
+ //#region src/auth/auth-messages.ts
2
+ const tooManyRequests = "Too many attempts. Wait a minute and try again.";
3
+ const passkeyClosed = "The passkey prompt closed before it finished. Try again.";
4
+ const messages = {
5
+ INVALID_EMAIL_OR_PASSWORD: "The email or password is not right.",
6
+ INVALID_PASSWORD: "The password is not right.",
7
+ INVALID_OTP: "The code is not right.",
8
+ OTP_EXPIRED: "The code has expired. Ask for a new one.",
9
+ TOO_MANY_ATTEMPTS: "Too many wrong codes. Ask for a new one.",
10
+ INVALID_CODE: "The code is not right.",
11
+ INVALID_BACKUP_CODE: "The backup code is not right, or was used before.",
12
+ TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE: "Too many wrong codes. Sign in again.",
13
+ ACCOUNT_TEMPORARILY_LOCKED: "Too many wrong codes. Wait 15 minutes and try again.",
14
+ INVALID_TWO_FACTOR_COOKIE: "This sign-in took too long. Sign in again.",
15
+ AUTH_CANCELLED: passkeyClosed,
16
+ ERROR_CEREMONY_ABORTED: passkeyClosed,
17
+ REGISTRATION_CANCELLED: passkeyClosed,
18
+ PASSKEY_NOT_FOUND: "This passkey is not registered for an account here.",
19
+ AUTHENTICATION_FAILED: "The passkey could not be checked. Try again.",
20
+ PREVIOUSLY_REGISTERED: "This passkey is added already.",
21
+ ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED: "This passkey is added already.",
22
+ SESSION_NOT_FRESH: "For this change, sign out and sign in again first."
23
+ };
24
+ const known = (error) => error.code ? messages[error.code] : void 0;
25
+ /** Better Auth's error from a sign-in step or an account change, in words for the person at the keyboard. */
26
+ const authMessage = (error) => known(error) ?? (error.status === 429 ? tooManyRequests : error.message);
27
+ /** As `authMessage`, for the password form: any other 401 is a wrong email or password. */
28
+ const signInMessage = (error) => known(error) ?? (error.status === 429 ? tooManyRequests : error.status === 401 ? messages.INVALID_EMAIL_OR_PASSWORD : error.message);
29
+ const staffMessages = {
30
+ STAFF_PERMISSION_MISSING: "Your staff account has no permission to sign in as people here.",
31
+ STAFF_SIGN_IN_NOT_STRONG: "Sign in to your staff account with a passkey or a second step, then try again.",
32
+ STAFF_SIGN_IN_STALE: "Your staff sign-in is too old. Sign in to your staff account again.",
33
+ STAFF_SIGN_IN_EXPIRED: "This staff sign-in took too long or was used already. Start again.",
34
+ STAFF_SIGN_IN_FAILED: "The staff sign-in did not go through at the provider. Try again.",
35
+ STAFF_USER_UNAVAILABLE: "There is no account with this address, or it is disabled.",
36
+ STAFF_REASON_REQUIRED: "Say why you sign in as this person, in at least 10 characters.",
37
+ SIGN_IN_METHOD_FORBIDDEN: "Staff sign-in is turned off for this app.",
38
+ INVALID_CALLBACK_URL: "This page is not on an address the server trusts."
39
+ };
40
+ /** Why a staff sign-in did not go through, from the code it came back with or an `AuthError` of its start. */
41
+ const staffSignInMessage = (failure) => {
42
+ const code = typeof failure === "string" ? failure : failure.code;
43
+ if (code && staffMessages[code]) return staffMessages[code];
44
+ if (typeof failure === "string") return "The staff sign-in did not go through. Try again.";
45
+ return failure.status === 429 ? tooManyRequests : failure.message;
46
+ };
47
+ //#endregion
48
+ export { authMessage, signInMessage, staffSignInMessage };
@@ -1,6 +1,6 @@
1
1
  import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
2
- import { jsx } from "react/jsx-runtime";
3
2
  import { createAuthSession } from "@protobase/client";
3
+ import { jsx } from "react/jsx-runtime";
4
4
  //#region src/auth/auth-provider.tsx
5
5
  const AuthContext = createContext(null);
6
6
  const useAuth = () => {
@@ -11,45 +11,67 @@ const useAuth = () => {
11
11
  /** Finds out whether the server has a login and who is signed in, and keeps that state for the app. */
12
12
  const AuthProvider = ({ session: given, children }) => {
13
13
  const [state, setState] = useState({ kind: "loading" });
14
+ const [signInMethods, setSignInMethods] = useState(["password"]);
14
15
  const [passwordReset, setPasswordReset] = useState(false);
15
16
  const [socialProviders, setSocialProviders] = useState([]);
17
+ const [staffSignIn, setStaffSignIn] = useState();
16
18
  const markSignedOut = useCallback(() => setState((current) => current.kind === "signed-in" ? { kind: "signed-out" } : current), []);
17
19
  const signedOut = useRef(markSignedOut);
18
20
  signedOut.current = markSignedOut;
19
21
  const session = useMemo(() => given ?? createAuthSession({ onSignedOut: () => signedOut.current() }), [given]);
22
+ const enter = useCallback(async (user) => {
23
+ const [{ missing }, staff] = await Promise.all([session.account.signInMethods(), session.staff.current()]);
24
+ setState(missing.length > 0 ? {
25
+ kind: "setup-required",
26
+ user,
27
+ missing
28
+ } : {
29
+ kind: "signed-in",
30
+ user,
31
+ ...staff && { staff }
32
+ });
33
+ }, [session]);
34
+ const finish = useCallback(async (result) => result.kind === "two-factor" ? setState({
35
+ kind: "two-factor",
36
+ methods: result.methods
37
+ }) : enter(result.user), [enter]);
20
38
  const recheck = useCallback(() => {
21
39
  setState({ kind: "loading" });
22
40
  session.status().then(async (status) => {
41
+ setSignInMethods(status.signInMethods);
23
42
  setPasswordReset(status.passwordReset);
24
43
  setSocialProviders(status.socialProviders);
44
+ setStaffSignIn(status.staffSignIn);
25
45
  if (status.needsAdmin) return setState({ kind: "needs-admin" });
26
46
  const user = await session.session();
27
- setState(user ? {
28
- kind: "signed-in",
29
- user
30
- } : { kind: "signed-out" });
31
- }, (error) => setState({
47
+ return user ? enter(user) : setState({ kind: "signed-out" });
48
+ }).catch((error) => setState({
32
49
  kind: "error",
33
50
  message: error instanceof Error ? error.message : "The server did not answer"
34
51
  }));
35
- }, [session]);
52
+ }, [session, enter]);
36
53
  useEffect(recheck, [recheck]);
37
54
  const api = useMemo(() => ({
38
55
  state,
39
56
  session,
57
+ signInMethods,
40
58
  passwordReset,
41
59
  socialProviders,
60
+ staffSignIn,
42
61
  markSignedOut,
43
62
  recheck,
44
- signIn: async (email, password) => {
45
- setState({
46
- kind: "signed-in",
47
- user: await session.signIn(email, password)
48
- });
49
- },
63
+ signIn: async (email, password) => finish(await session.signIn(email, password)),
64
+ sendSignInCode: (email) => session.sendSignInCode(email),
65
+ signInWithCode: async (email, code) => finish(await session.signInWithCode(email, code)),
66
+ signInWithPasskey: async () => enter(await session.signInWithPasskey()),
67
+ verifyTwoFactor: async (input) => enter(await session.verifyTwoFactor(input)),
50
68
  signInSocial: async (provider) => {
51
69
  window.location.assign(await session.signInSocial(provider, window.location.href));
52
70
  },
71
+ stopStaffSession: async () => {
72
+ await session.staff.stop();
73
+ setState({ kind: "signed-out" });
74
+ },
53
75
  signOut: async () => {
54
76
  await session.signOut();
55
77
  setState({ kind: "signed-out" });
@@ -57,10 +79,14 @@ const AuthProvider = ({ session: given, children }) => {
57
79
  }), [
58
80
  state,
59
81
  session,
82
+ signInMethods,
60
83
  passwordReset,
61
84
  socialProviders,
85
+ staffSignIn,
62
86
  markSignedOut,
63
- recheck
87
+ recheck,
88
+ finish,
89
+ enter
64
90
  ]);
65
91
  return /* @__PURE__ */ jsx(AuthContext.Provider, {
66
92
  value: api,
@@ -0,0 +1,17 @@
1
+ import { cn } from "../lib/cn.js";
2
+ import { Input } from "../primitives/input.js";
3
+ import { jsx } from "react/jsx-runtime";
4
+ //#region src/auth/code-input.tsx
5
+ /** A 6-digit one-time code field: digits on phone keyboards, filled from the mail app where the system offers it. */
6
+ const CodeInput = ({ className, ...rest }) => /* @__PURE__ */ jsx(Input, {
7
+ type: "text",
8
+ inputMode: "numeric",
9
+ autoComplete: "one-time-code",
10
+ pattern: "[0-9]{6}",
11
+ maxLength: 6,
12
+ spellCheck: false,
13
+ className: cn("min-h-6 font-mono tracking-[0.3em]", className),
14
+ ...rest
15
+ });
16
+ //#endregion
17
+ export { CodeInput };
@@ -0,0 +1,81 @@
1
+ import { Button } from "../primitives/button.js";
2
+ import { CodeInput } from "./code-input.js";
3
+ import { AuthLayout } from "./auth-layout.js";
4
+ import { useState } from "react";
5
+ import { jsx, jsxs } from "react/jsx-runtime";
6
+ //#region src/auth/email-code-form.tsx
7
+ /** The second half of signing in with an emailed code: the code from the email. */
8
+ const EmailCodeForm = ({ email, onSubmit, onResend, onBack, busy, error, notice }) => {
9
+ const [code, setCode] = useState("");
10
+ return /* @__PURE__ */ jsxs(AuthLayout, {
11
+ title: "Check your email",
12
+ description: "Enter the code we sent to sign in.",
13
+ children: [
14
+ /* @__PURE__ */ jsxs("p", {
15
+ className: "mb-4 text-[13px] text-muted-foreground",
16
+ children: [
17
+ "If ",
18
+ /* @__PURE__ */ jsx("span", {
19
+ className: "font-medium text-foreground",
20
+ children: email
21
+ }),
22
+ " has an account, a 6-digit code is on its way. It works for 5 minutes, once."
23
+ ]
24
+ }),
25
+ notice && /* @__PURE__ */ jsx("p", {
26
+ role: "status",
27
+ className: "mb-4 rounded-md bg-success-soft px-3 py-2 text-xs font-medium text-success-text",
28
+ children: notice
29
+ }),
30
+ /* @__PURE__ */ jsxs("form", {
31
+ className: "flex flex-col gap-4",
32
+ onSubmit: (event) => {
33
+ event.preventDefault();
34
+ onSubmit(code.trim());
35
+ },
36
+ children: [
37
+ /* @__PURE__ */ jsxs("label", {
38
+ className: "flex flex-col gap-1.5 text-[13px] font-medium",
39
+ children: ["Code", /* @__PURE__ */ jsx(CodeInput, {
40
+ name: "code",
41
+ required: true,
42
+ autoFocus: true,
43
+ value: code,
44
+ invalid: Boolean(error),
45
+ onChange: (event) => setCode(event.target.value)
46
+ })]
47
+ }),
48
+ error && /* @__PURE__ */ jsx("p", {
49
+ role: "alert",
50
+ className: "text-xs font-medium text-danger-text",
51
+ children: error
52
+ }),
53
+ /* @__PURE__ */ jsx(Button, {
54
+ variant: "primary",
55
+ type: "submit",
56
+ loading: busy,
57
+ className: "min-h-10",
58
+ children: "Sign in"
59
+ }),
60
+ /* @__PURE__ */ jsxs("div", {
61
+ className: "flex justify-between gap-2",
62
+ children: [/* @__PURE__ */ jsx(Button, {
63
+ variant: "ghost",
64
+ onClick: onBack,
65
+ className: "min-h-10",
66
+ children: "Back to sign in"
67
+ }), /* @__PURE__ */ jsx(Button, {
68
+ variant: "ghost",
69
+ onClick: onResend,
70
+ disabled: busy,
71
+ className: "min-h-10",
72
+ children: "Send a new code"
73
+ })]
74
+ })
75
+ ]
76
+ })
77
+ ]
78
+ });
79
+ };
80
+ //#endregion
81
+ export { EmailCodeForm };