@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,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 };
@@ -0,0 +1,40 @@
1
+ import { authMessage } from "./auth-messages.js";
2
+ import { useAuth } from "./auth-provider.js";
3
+ import { EmailCodeForm } from "./email-code-form.js";
4
+ import { useState } from "react";
5
+ import { AuthError } from "@protobase/client";
6
+ import { jsx } from "react/jsx-runtime";
7
+ //#region src/auth/email-code-page.tsx
8
+ /** Signs in with the code emailed to `email`; the account's second step follows when it has one. */
9
+ const EmailCodePage = ({ email, onBack }) => {
10
+ const { signInWithCode, sendSignInCode } = useAuth();
11
+ const [busy, setBusy] = useState(false);
12
+ const [error, setError] = useState();
13
+ const [notice, setNotice] = useState();
14
+ const attempt = async (step) => {
15
+ setBusy(true);
16
+ setError(void 0);
17
+ setNotice(void 0);
18
+ try {
19
+ await step();
20
+ } catch (failure) {
21
+ if (!(failure instanceof AuthError)) throw failure;
22
+ setError(authMessage(failure));
23
+ }
24
+ setBusy(false);
25
+ };
26
+ return /* @__PURE__ */ jsx(EmailCodeForm, {
27
+ email,
28
+ busy,
29
+ error,
30
+ notice,
31
+ onBack,
32
+ onSubmit: (code) => void attempt(() => signInWithCode(email, code)),
33
+ onResend: () => void attempt(async () => {
34
+ await sendSignInCode(email);
35
+ setNotice("A new code is on its way. Only the newest one works.");
36
+ })
37
+ });
38
+ };
39
+ //#endregion
40
+ export { EmailCodePage };
@@ -1,8 +1,8 @@
1
1
  import { Button } from "../primitives/button.js";
2
2
  import { AuthLayout } from "./auth-layout.js";
3
3
  import { useState } from "react";
4
- import { jsx, jsxs } from "react/jsx-runtime";
5
4
  import { Check, Copy } from "lucide-react";
5
+ import { jsx, jsxs } from "react/jsx-runtime";
6
6
  //#region src/auth/first-run-page.tsx
7
7
  const createAdminCommand = "protobase users create you@example.com";
8
8
  /**
@@ -2,8 +2,8 @@ import { useAuth } from "./auth-provider.js";
2
2
  import { ForgotPasswordForm } from "./forgot-password-form.js";
3
3
  import { resetLinkTarget } from "./reset-link.js";
4
4
  import { useState } from "react";
5
- import { jsx } from "react/jsx-runtime";
6
5
  import { AuthError } from "@protobase/client";
6
+ import { jsx } from "react/jsx-runtime";
7
7
  //#region src/auth/forgot-password-page.tsx
8
8
  /** Asks the server to email a reset link that leads back to this page. */
9
9
  const ForgotPasswordPage = ({ onBack }) => {
@@ -1,8 +1,8 @@
1
1
  import { useAuth } from "./auth-provider.js";
2
2
  import { ResetPasswordForm } from "./reset-password-form.js";
3
3
  import { useState } from "react";
4
- import { jsx } from "react/jsx-runtime";
5
4
  import { AuthError } from "@protobase/client";
5
+ import { jsx } from "react/jsx-runtime";
6
6
  //#region src/auth/reset-password-page.tsx
7
7
  /** Better Auth's answers to a new password, in words for the person at the keyboard. */
8
8
  const resetPasswordMessage = (error) => {
@@ -0,0 +1,43 @@
1
+ import { Button } from "../primitives/button.js";
2
+ import { TwoFactorSetup } from "../account/two-factor-setup.js";
3
+ import { AuthLayout } from "./auth-layout.js";
4
+ import { KeyRound } from "lucide-react";
5
+ import { jsx, jsxs } from "react/jsx-runtime";
6
+ //#region src/auth/setup-required-form.tsx
7
+ const titles = {
8
+ twoFactor: "Turn on two-factor authentication",
9
+ passkey: "Add a passkey"
10
+ };
11
+ /** Shown after signing in while the account lacks something the sign-in policy requires; the app opens once it is set up. */
12
+ const SetupRequiredForm = ({ step, twoFactor, onAddPasskey, busy, error, onSignOut }) => /* @__PURE__ */ jsxs(AuthLayout, {
13
+ title: titles[step],
14
+ description: "This app requires it for every account.",
15
+ children: [step === "twoFactor" ? /* @__PURE__ */ jsx(TwoFactorSetup, { ...twoFactor }) : /* @__PURE__ */ jsxs("div", {
16
+ className: "flex flex-col gap-4",
17
+ children: [
18
+ /* @__PURE__ */ jsx("p", {
19
+ className: "text-[13px] text-muted-foreground",
20
+ children: "A passkey signs you in with Face ID, Touch ID, Windows Hello, your phone or a security key. Your browser asks which one to use."
21
+ }),
22
+ error && /* @__PURE__ */ jsx("p", {
23
+ role: "alert",
24
+ className: "text-xs font-medium text-danger-text",
25
+ children: error
26
+ }),
27
+ /* @__PURE__ */ jsxs(Button, {
28
+ variant: "primary",
29
+ loading: busy,
30
+ onClick: onAddPasskey,
31
+ className: "min-h-10",
32
+ children: [/* @__PURE__ */ jsx(KeyRound, { className: "size-4" }), "Add a passkey"]
33
+ })
34
+ ]
35
+ }), /* @__PURE__ */ jsx("button", {
36
+ type: "button",
37
+ onClick: onSignOut,
38
+ className: "mt-4 w-full text-center text-[13px] font-medium text-muted-foreground hover:underline",
39
+ children: "Sign out"
40
+ })]
41
+ });
42
+ //#endregion
43
+ export { SetupRequiredForm };
@@ -0,0 +1,51 @@
1
+ import { Spinner } from "../primitives/spinner.js";
2
+ import { authMessage } from "./auth-messages.js";
3
+ import { useAuth } from "./auth-provider.js";
4
+ import { SetupRequiredForm } from "./setup-required-form.js";
5
+ import { useEffect, useState } from "react";
6
+ import { AuthError } from "@protobase/client";
7
+ import { jsx, jsxs } from "react/jsx-runtime";
8
+ //#region src/auth/setup-required-page.tsx
9
+ /** Sets up what the sign-in policy requires, one thing at a time, then reads the account again to open the app. */
10
+ const SetupRequiredPage = ({ missing }) => {
11
+ const { session, recheck, signOut } = useAuth();
12
+ const [methods, setMethods] = useState();
13
+ const [busy, setBusy] = useState(false);
14
+ const [error, setError] = useState();
15
+ useEffect(() => {
16
+ session.account.signInMethods().then(setMethods, (failure) => setError(failure instanceof AuthError ? authMessage(failure) : "Could not read your account"));
17
+ }, [session]);
18
+ const addPasskey = async () => {
19
+ setBusy(true);
20
+ setError(void 0);
21
+ try {
22
+ await session.account.addPasskey();
23
+ recheck();
24
+ } catch (failure) {
25
+ if (!(failure instanceof AuthError)) throw failure;
26
+ setError(authMessage(failure));
27
+ setBusy(false);
28
+ }
29
+ };
30
+ if (!methods && !error) return /* @__PURE__ */ jsxs("div", {
31
+ className: "flex h-full min-h-48 items-center justify-center gap-2 text-[13px] text-muted-foreground",
32
+ children: [/* @__PURE__ */ jsx(Spinner, {}), " Loading"]
33
+ });
34
+ return /* @__PURE__ */ jsx(SetupRequiredForm, {
35
+ step: missing[0],
36
+ busy,
37
+ error,
38
+ onAddPasskey: () => void addPasskey(),
39
+ onSignOut: () => void signOut(),
40
+ twoFactor: {
41
+ offerEmailedCodes: methods?.mail ?? false,
42
+ needsPassword: methods?.account.password ?? true,
43
+ onStartApp: (password) => session.account.startAuthenticatorApp(password),
44
+ onConfirmApp: (code) => session.account.confirmAuthenticatorApp(code),
45
+ onEmailedCodes: (password) => session.account.turnOnEmailedCodes(password),
46
+ onDone: recheck
47
+ }
48
+ });
49
+ };
50
+ //#endregion
51
+ export { SetupRequiredPage };