@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
@@ -1,12 +1,29 @@
1
1
  import { Input } from "../primitives/input.js";
2
2
  import { Button } from "../primitives/button.js";
3
3
  import { AuthLayout } from "./auth-layout.js";
4
- import { useState } from "react";
4
+ import { useRef, useState } from "react";
5
+ import { KeyRound, Mail } from "lucide-react";
5
6
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
6
7
  //#region src/auth/sign-in-form.tsx
7
- const SignInForm = ({ onSubmit, busy, error, workspace, notice, onForgotPassword, onContinueWithGitHub }) => {
8
+ const Divider = () => /* @__PURE__ */ jsxs("div", {
9
+ className: "my-4 flex items-center gap-3 text-xs text-muted-foreground",
10
+ children: [
11
+ /* @__PURE__ */ jsx("span", { className: "h-px flex-1 bg-border" }),
12
+ "or",
13
+ /* @__PURE__ */ jsx("span", { className: "h-px flex-1 bg-border" })
14
+ ]
15
+ });
16
+ const SignInForm = ({ onSubmit, busy, error, workspace, notice, methods = ["password"], onSendCode, onPasskey, onForgotPassword, onContinueWithGitHub }) => {
8
17
  const [email, setEmail] = useState("");
9
18
  const [password, setPassword] = useState("");
19
+ const emailInput = useRef(null);
20
+ const withPassword = methods.includes("password");
21
+ const withCode = methods.includes("emailCode") && onSendCode !== void 0;
22
+ const withPasskey = methods.includes("passkey") && onPasskey !== void 0;
23
+ const sendCode = () => {
24
+ if (!emailInput.current?.reportValidity()) return;
25
+ onSendCode?.(email.trim());
26
+ };
10
27
  return /* @__PURE__ */ jsxs(AuthLayout, {
11
28
  title: "Sign in to Protobase",
12
29
  description: workspace,
@@ -16,32 +33,39 @@ const SignInForm = ({ onSubmit, busy, error, workspace, notice, onForgotPassword
16
33
  className: "mb-4 rounded-md bg-success-soft px-3 py-2 text-xs font-medium text-success-text",
17
34
  children: notice
18
35
  }),
19
- onContinueWithGitHub && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(Button, {
20
- onClick: onContinueWithGitHub,
21
- disabled: busy,
22
- className: "min-h-10 w-full",
23
- children: "Continue with GitHub"
24
- }), /* @__PURE__ */ jsxs("div", {
25
- className: "my-4 flex items-center gap-3 text-xs text-muted-foreground",
26
- children: [
27
- /* @__PURE__ */ jsx("span", { className: "h-px flex-1 bg-border" }),
28
- "or",
29
- /* @__PURE__ */ jsx("span", { className: "h-px flex-1 bg-border" })
30
- ]
36
+ (onContinueWithGitHub || withPasskey) && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsxs("div", {
37
+ className: "flex flex-col gap-2",
38
+ children: [onContinueWithGitHub && /* @__PURE__ */ jsx(Button, {
39
+ onClick: onContinueWithGitHub,
40
+ disabled: busy,
41
+ className: "min-h-10 w-full",
42
+ children: "Continue with GitHub"
43
+ }), withPasskey && /* @__PURE__ */ jsxs(Button, {
44
+ onClick: onPasskey,
45
+ disabled: busy,
46
+ className: "min-h-10 w-full",
47
+ children: [/* @__PURE__ */ jsx(KeyRound, { className: "size-4" }), "Sign in with a passkey"]
48
+ })]
49
+ }), withPassword || withCode ? /* @__PURE__ */ jsx(Divider, {}) : error && /* @__PURE__ */ jsx("p", {
50
+ role: "alert",
51
+ className: "mt-4 text-xs font-medium text-danger-text",
52
+ children: error
31
53
  })] }),
32
- /* @__PURE__ */ jsxs("form", {
54
+ (withPassword || withCode) && /* @__PURE__ */ jsxs("form", {
33
55
  className: "flex flex-col gap-4",
34
56
  onSubmit: (event) => {
35
57
  event.preventDefault();
36
- onSubmit(email.trim(), password);
58
+ if (withPassword) onSubmit(email.trim(), password);
59
+ else sendCode();
37
60
  },
38
61
  children: [
39
62
  /* @__PURE__ */ jsxs("label", {
40
63
  className: "flex flex-col gap-1.5 text-[13px] font-medium",
41
64
  children: ["Email", /* @__PURE__ */ jsx(Input, {
65
+ ref: emailInput,
42
66
  type: "email",
43
67
  name: "email",
44
- autoComplete: "username",
68
+ autoComplete: "username webauthn",
45
69
  required: true,
46
70
  autoFocus: true,
47
71
  value: email,
@@ -50,7 +74,7 @@ const SignInForm = ({ onSubmit, busy, error, workspace, notice, onForgotPassword
50
74
  className: "min-h-6"
51
75
  })]
52
76
  }),
53
- /* @__PURE__ */ jsxs("label", {
77
+ withPassword && /* @__PURE__ */ jsxs("label", {
54
78
  className: "flex flex-col gap-1.5 text-[13px] font-medium",
55
79
  children: ["Password", /* @__PURE__ */ jsx(Input, {
56
80
  type: "password",
@@ -68,14 +92,23 @@ const SignInForm = ({ onSubmit, busy, error, workspace, notice, onForgotPassword
68
92
  className: "text-xs font-medium text-danger-text",
69
93
  children: error
70
94
  }),
71
- /* @__PURE__ */ jsx(Button, {
95
+ withPassword && /* @__PURE__ */ jsx(Button, {
72
96
  variant: "primary",
73
97
  type: "submit",
74
98
  loading: busy,
75
99
  className: "min-h-10",
76
100
  children: "Sign in"
77
101
  }),
78
- onForgotPassword && /* @__PURE__ */ jsx("button", {
102
+ withCode && /* @__PURE__ */ jsxs(Button, {
103
+ variant: withPassword ? "secondary" : "primary",
104
+ type: withPassword ? "button" : "submit",
105
+ onClick: withPassword ? sendCode : void 0,
106
+ loading: busy && !withPassword,
107
+ disabled: busy,
108
+ className: "min-h-10",
109
+ children: [/* @__PURE__ */ jsx(Mail, { className: "size-4" }), "Email me a sign-in code"]
110
+ }),
111
+ withPassword && onForgotPassword && /* @__PURE__ */ jsx("button", {
79
112
  type: "button",
80
113
  onClick: onForgotPassword,
81
114
  className: "self-center text-[13px] font-medium text-primary-text hover:underline",
@@ -1,27 +1,22 @@
1
+ import { authMessage, signInMessage } from "./auth-messages.js";
1
2
  import { useAuth } from "./auth-provider.js";
2
3
  import { SignInForm } from "./sign-in-form.js";
3
4
  import { useState } from "react";
4
- import { jsx } from "react/jsx-runtime";
5
5
  import { AuthError } from "@protobase/client";
6
+ import { jsx } from "react/jsx-runtime";
6
7
  //#region src/auth/sign-in-page.tsx
7
- /** Better Auth's messages, in words for the person at the keyboard. */
8
- const signInMessage = (error) => {
9
- if (error.status === 429) return "Too many attempts. Wait a minute and try again.";
10
- if (error.status === 401 || error.code === "INVALID_EMAIL_OR_PASSWORD") return "The email or password is not right.";
11
- return error.message;
12
- };
13
- const SignInPage = ({ workspace, notice, onForgotPassword }) => {
14
- const { signIn, signInSocial, socialProviders } = useAuth();
8
+ const SignInPage = ({ workspace, notice, onForgotPassword, onCodeSent }) => {
9
+ const { signIn, signInSocial, socialProviders, signInMethods, sendSignInCode, signInWithPasskey } = useAuth();
15
10
  const [busy, setBusy] = useState(false);
16
11
  const [error, setError] = useState();
17
- const attempt = async (start) => {
12
+ const attempt = async (start, message) => {
18
13
  setBusy(true);
19
14
  setError(void 0);
20
15
  try {
21
16
  await start();
22
17
  } catch (failure) {
23
18
  if (!(failure instanceof AuthError)) throw failure;
24
- setError(signInMessage(failure));
19
+ setError(message(failure));
25
20
  setBusy(false);
26
21
  }
27
22
  };
@@ -30,9 +25,15 @@ const SignInPage = ({ workspace, notice, onForgotPassword }) => {
30
25
  error,
31
26
  workspace,
32
27
  notice,
28
+ methods: signInMethods,
33
29
  onForgotPassword,
34
- onContinueWithGitHub: socialProviders.includes("github") ? () => void attempt(() => signInSocial("github")) : void 0,
35
- onSubmit: (email, password) => void attempt(() => signIn(email, password))
30
+ onContinueWithGitHub: socialProviders.includes("github") ? () => void attempt(() => signInSocial("github"), signInMessage) : void 0,
31
+ onPasskey: () => void attempt(signInWithPasskey, authMessage),
32
+ onSendCode: (email) => void attempt(async () => {
33
+ await sendSignInCode(email);
34
+ onCodeSent(email);
35
+ }, authMessage),
36
+ onSubmit: (email, password) => void attempt(() => signIn(email, password), signInMessage)
36
37
  });
37
38
  };
38
39
  //#endregion
@@ -0,0 +1,30 @@
1
+ //#region src/auth/staff-link.ts
2
+ /** Marks a link to the staff sign-in page, for example `/?staff-sign-in&email=sanne@example.com&reason=Ticket+4211`. */
3
+ const staffMarker = "staff-sign-in";
4
+ /** Where the server's staff callback says why a staff sign-in did not go through. */
5
+ const errorParam = "staff-error";
6
+ /** The staff sign-in this page was opened for, or `undefined`. */
7
+ const readStaffLink = (href) => {
8
+ const params = new URL(href).searchParams;
9
+ const error = params.get(errorParam);
10
+ if (!params.has("staff-sign-in") && !error) return void 0;
11
+ const marked = params.has(staffMarker);
12
+ return {
13
+ email: marked && params.get("email") || "",
14
+ reason: marked && params.get("reason") || "",
15
+ ...error && { error }
16
+ };
17
+ };
18
+ /** The address without the staff link's parameters: for the address bar, and the page staff come back to. */
19
+ const withoutStaffLink = (href) => {
20
+ const url = new URL(href);
21
+ if (url.searchParams.has("staff-sign-in")) for (const name of [
22
+ staffMarker,
23
+ "email",
24
+ "reason"
25
+ ]) url.searchParams.delete(name);
26
+ url.searchParams.delete(errorParam);
27
+ return `${url.pathname}${url.search}${url.hash}`;
28
+ };
29
+ //#endregion
30
+ export { readStaffLink, staffMarker, withoutStaffLink };
@@ -0,0 +1,93 @@
1
+ import { cn } from "../lib/cn.js";
2
+ import { Input } from "../primitives/input.js";
3
+ import { Button } from "../primitives/button.js";
4
+ import { AuthLayout } from "./auth-layout.js";
5
+ import { useState } from "react";
6
+ import { jsx, jsxs } from "react/jsx-runtime";
7
+ //#region src/auth/staff-sign-in-form.tsx
8
+ const minimumReason = 10;
9
+ /** For staff of the operator: sign in as a person of this app, with a reason the app's admins see in their log. */
10
+ const StaffSignInForm = ({ operator, email: givenEmail = "", reason: givenReason = "", onSubmit, onBack, busy, error }) => {
11
+ const [email, setEmail] = useState(givenEmail);
12
+ const [reason, setReason] = useState(givenReason);
13
+ if (!operator) return /* @__PURE__ */ jsxs(AuthLayout, {
14
+ title: "Staff sign-in",
15
+ description: "Staff of the team that runs this app cannot sign in as people here.",
16
+ children: [error && /* @__PURE__ */ jsx("p", {
17
+ role: "alert",
18
+ className: "mb-4 text-xs font-medium text-danger-text",
19
+ children: error
20
+ }), /* @__PURE__ */ jsx(Button, {
21
+ variant: "primary",
22
+ className: "min-h-10 w-full",
23
+ onClick: onBack,
24
+ children: "Back to sign in"
25
+ })]
26
+ });
27
+ return /* @__PURE__ */ jsx(AuthLayout, {
28
+ title: "Sign in as a person",
29
+ description: `For staff of ${operator}, to help someone with this app.`,
30
+ children: /* @__PURE__ */ jsxs("form", {
31
+ className: "flex flex-col gap-4",
32
+ onSubmit: (event) => {
33
+ event.preventDefault();
34
+ onSubmit(email.trim(), reason.trim());
35
+ },
36
+ children: [
37
+ /* @__PURE__ */ jsxs("label", {
38
+ className: "flex flex-col gap-1.5 text-[13px] font-medium",
39
+ children: ["Their email", /* @__PURE__ */ jsx(Input, {
40
+ type: "email",
41
+ name: "email",
42
+ required: true,
43
+ autoFocus: !givenEmail,
44
+ value: email,
45
+ onChange: (event) => setEmail(event.target.value),
46
+ className: "min-h-6"
47
+ })]
48
+ }),
49
+ /* @__PURE__ */ jsxs("label", {
50
+ className: "flex flex-col gap-1.5 text-[13px] font-medium",
51
+ children: ["Reason", /* @__PURE__ */ jsx("textarea", {
52
+ name: "reason",
53
+ required: true,
54
+ minLength: minimumReason,
55
+ rows: 3,
56
+ value: reason,
57
+ placeholder: "Ticket 4211: the invoice totals look wrong",
58
+ onChange: (event) => setReason(event.target.value),
59
+ className: cn("resize-y rounded-md border bg-background px-2.5 py-1.5 text-[13px] font-normal shadow-sm outline-none placeholder:text-faint-foreground focus:border-primary focus:ring-2 focus:ring-primary/20", error ? "border-danger" : "border-border-strong")
60
+ })]
61
+ }),
62
+ /* @__PURE__ */ jsxs("p", {
63
+ className: "text-xs text-muted-foreground",
64
+ children: [
65
+ "You sign in at ",
66
+ operator,
67
+ " with a passkey or a second step. The session is short, shows a banner, and cannot change their sign-in. The app's admins see who signed in as whom, and why."
68
+ ]
69
+ }),
70
+ error && /* @__PURE__ */ jsx("p", {
71
+ role: "alert",
72
+ className: "text-xs font-medium text-danger-text",
73
+ children: error
74
+ }),
75
+ /* @__PURE__ */ jsxs(Button, {
76
+ variant: "primary",
77
+ type: "submit",
78
+ loading: busy,
79
+ className: "min-h-10",
80
+ children: ["Continue with ", operator]
81
+ }),
82
+ /* @__PURE__ */ jsx(Button, {
83
+ variant: "ghost",
84
+ onClick: onBack,
85
+ className: "min-h-10",
86
+ children: "Back to sign in"
87
+ })
88
+ ]
89
+ })
90
+ });
91
+ };
92
+ //#endregion
93
+ export { StaffSignInForm };
@@ -0,0 +1,41 @@
1
+ import { staffSignInMessage } from "./auth-messages.js";
2
+ import { useAuth } from "./auth-provider.js";
3
+ import { withoutStaffLink } from "./staff-link.js";
4
+ import { StaffSignInForm } from "./staff-sign-in-form.js";
5
+ import { useState } from "react";
6
+ import { AuthError } from "@protobase/client";
7
+ import { jsx } from "react/jsx-runtime";
8
+ //#region src/auth/staff-sign-in-page.tsx
9
+ /** Starts a staff sign-in and leaves for the operator provider, which comes back to this page without the staff link. */
10
+ const StaffSignInPage = ({ link, onBack }) => {
11
+ const { session, staffSignIn } = useAuth();
12
+ const [busy, setBusy] = useState(false);
13
+ const [error, setError] = useState(link.error && staffSignInMessage(link.error));
14
+ const submit = async (email, reason) => {
15
+ setBusy(true);
16
+ setError(void 0);
17
+ try {
18
+ const callbackURL = `${window.location.origin}${withoutStaffLink(window.location.href)}`;
19
+ window.location.assign(await session.staff.start({
20
+ email,
21
+ reason,
22
+ callbackURL
23
+ }));
24
+ } catch (failure) {
25
+ if (!(failure instanceof AuthError)) throw failure;
26
+ setError(staffSignInMessage(failure));
27
+ setBusy(false);
28
+ }
29
+ };
30
+ return /* @__PURE__ */ jsx(StaffSignInForm, {
31
+ operator: staffSignIn,
32
+ email: link.email,
33
+ reason: link.reason,
34
+ busy,
35
+ error,
36
+ onBack,
37
+ onSubmit: (email, reason) => void submit(email, reason)
38
+ });
39
+ };
40
+ //#endregion
41
+ export { StaffSignInPage };
@@ -0,0 +1,137 @@
1
+ import { Input } from "../primitives/input.js";
2
+ import { Button } from "../primitives/button.js";
3
+ import { CodeInput } from "./code-input.js";
4
+ import { AuthLayout } from "./auth-layout.js";
5
+ import { Checkbox } from "../primitives/checkbox.js";
6
+ import { useState } from "react";
7
+ import { jsx, jsxs } from "react/jsx-runtime";
8
+ //#region src/auth/two-factor-form.tsx
9
+ const instructions = {
10
+ totp: "Enter the 6-digit code from your authenticator app.",
11
+ otp: "Enter the 6-digit code we emailed you. It works for 5 minutes.",
12
+ backup: "Enter one of the backup codes you saved when you set up the authenticator app. Each works once."
13
+ };
14
+ /** The second step of a sign-in, for an account with two-factor authentication. */
15
+ const TwoFactorForm = ({ methods, onVerify, onSendCode, onBack, busy, error, codeSent, initialStep }) => {
16
+ const [step, setStep] = useState(initialStep ?? (methods.includes("totp") ? "totp" : "otp"));
17
+ const [code, setCode] = useState("");
18
+ const [trustDevice, setTrustDevice] = useState(false);
19
+ const waitingForEmail = step === "otp" && !codeSent;
20
+ const switchTo = (next) => {
21
+ setStep(next);
22
+ setCode("");
23
+ if (next === "otp" && !codeSent) onSendCode();
24
+ };
25
+ return /* @__PURE__ */ jsxs(AuthLayout, {
26
+ title: "Two-factor authentication",
27
+ description: "One more step to sign in.",
28
+ children: [waitingForEmail ? /* @__PURE__ */ jsxs("div", {
29
+ className: "flex flex-col gap-4",
30
+ children: [
31
+ /* @__PURE__ */ jsx("p", {
32
+ className: "text-[13px] text-muted-foreground",
33
+ children: "We email you a code to finish signing in."
34
+ }),
35
+ error && /* @__PURE__ */ jsx("p", {
36
+ role: "alert",
37
+ className: "text-xs font-medium text-danger-text",
38
+ children: error
39
+ }),
40
+ /* @__PURE__ */ jsx(Button, {
41
+ variant: "primary",
42
+ loading: busy,
43
+ onClick: onSendCode,
44
+ className: "min-h-10",
45
+ children: "Email me a code"
46
+ })
47
+ ]
48
+ }) : /* @__PURE__ */ jsxs("form", {
49
+ className: "flex flex-col gap-4",
50
+ onSubmit: (event) => {
51
+ event.preventDefault();
52
+ onVerify({
53
+ method: step,
54
+ code: code.trim(),
55
+ trustDevice
56
+ });
57
+ },
58
+ children: [
59
+ /* @__PURE__ */ jsx("p", {
60
+ className: "text-[13px] text-muted-foreground",
61
+ children: instructions[step]
62
+ }),
63
+ /* @__PURE__ */ jsxs("label", {
64
+ className: "flex flex-col gap-1.5 text-[13px] font-medium",
65
+ children: [step === "backup" ? "Backup code" : "Code", step === "backup" ? /* @__PURE__ */ jsx(Input, {
66
+ name: "code",
67
+ autoComplete: "off",
68
+ spellCheck: false,
69
+ required: true,
70
+ autoFocus: true,
71
+ value: code,
72
+ invalid: Boolean(error),
73
+ onChange: (event) => setCode(event.target.value),
74
+ className: "min-h-6 font-mono"
75
+ }) : /* @__PURE__ */ jsx(CodeInput, {
76
+ name: "code",
77
+ required: true,
78
+ autoFocus: true,
79
+ value: code,
80
+ invalid: Boolean(error),
81
+ onChange: (event) => setCode(event.target.value)
82
+ })]
83
+ }),
84
+ /* @__PURE__ */ jsxs("label", {
85
+ className: "flex items-center gap-2 text-[13px] text-muted-foreground",
86
+ children: [/* @__PURE__ */ jsx(Checkbox, {
87
+ checked: trustDevice,
88
+ onChange: setTrustDevice,
89
+ label: "Trust this browser"
90
+ }), "Don't ask again in this browser for 30 days"]
91
+ }),
92
+ error && /* @__PURE__ */ jsx("p", {
93
+ role: "alert",
94
+ className: "text-xs font-medium text-danger-text",
95
+ children: error
96
+ }),
97
+ /* @__PURE__ */ jsx(Button, {
98
+ variant: "primary",
99
+ type: "submit",
100
+ loading: busy,
101
+ className: "min-h-10",
102
+ children: "Verify"
103
+ })
104
+ ]
105
+ }), /* @__PURE__ */ jsxs("div", {
106
+ className: "mt-4 flex flex-col items-center gap-2 border-t pt-4 text-[13px]",
107
+ children: [
108
+ step !== "totp" && methods.includes("totp") && /* @__PURE__ */ jsx("button", {
109
+ type: "button",
110
+ onClick: () => switchTo("totp"),
111
+ className: "font-medium text-primary-text hover:underline",
112
+ children: "Use the authenticator app"
113
+ }),
114
+ step !== "otp" && methods.includes("otp") && /* @__PURE__ */ jsx("button", {
115
+ type: "button",
116
+ onClick: () => switchTo("otp"),
117
+ className: "font-medium text-primary-text hover:underline",
118
+ children: "Email me a code instead"
119
+ }),
120
+ step !== "backup" && methods.includes("totp") && /* @__PURE__ */ jsx("button", {
121
+ type: "button",
122
+ onClick: () => switchTo("backup"),
123
+ className: "font-medium text-primary-text hover:underline",
124
+ children: "Use a backup code"
125
+ }),
126
+ /* @__PURE__ */ jsx("button", {
127
+ type: "button",
128
+ onClick: onBack,
129
+ className: "font-medium text-muted-foreground hover:underline",
130
+ children: "Back to sign in"
131
+ })
132
+ ]
133
+ })]
134
+ });
135
+ };
136
+ //#endregion
137
+ export { TwoFactorForm };
@@ -0,0 +1,39 @@
1
+ import { authMessage } from "./auth-messages.js";
2
+ import { useAuth } from "./auth-provider.js";
3
+ import { TwoFactorForm } from "./two-factor-form.js";
4
+ import { useState } from "react";
5
+ import { AuthError } from "@protobase/client";
6
+ import { jsx } from "react/jsx-runtime";
7
+ //#region src/auth/two-factor-page.tsx
8
+ /** The second step after a password or an emailed code, with the methods the server named. */
9
+ const TwoFactorPage = ({ methods, onBack }) => {
10
+ const { verifyTwoFactor, session } = useAuth();
11
+ const [busy, setBusy] = useState(false);
12
+ const [error, setError] = useState();
13
+ const [codeSent, setCodeSent] = useState(false);
14
+ const attempt = async (step) => {
15
+ setBusy(true);
16
+ setError(void 0);
17
+ try {
18
+ await step();
19
+ } catch (failure) {
20
+ if (!(failure instanceof AuthError)) throw failure;
21
+ setError(authMessage(failure));
22
+ }
23
+ setBusy(false);
24
+ };
25
+ return /* @__PURE__ */ jsx(TwoFactorForm, {
26
+ methods,
27
+ busy,
28
+ error,
29
+ codeSent,
30
+ onBack,
31
+ onVerify: (input) => void attempt(() => verifyTwoFactor(input)),
32
+ onSendCode: () => void attempt(async () => {
33
+ await session.sendTwoFactorCode();
34
+ setCodeSent(true);
35
+ })
36
+ });
37
+ };
38
+ //#endregion
39
+ export { TwoFactorPage };
@@ -1,9 +1,12 @@
1
1
  //#region src/auth/user-to-shell.ts
2
2
  const hueOf = (text) => [...text].reduce((sum, char) => (sum * 31 + char.charCodeAt(0)) % 360, 7);
3
3
  const initialsOf = (name) => name.split(/[\s@.]+/).filter(Boolean).slice(0, 2).map((part) => part[0].toUpperCase()).join("");
4
+ const rolesOf = (user) => (user.role ?? "user").split(",").map((role) => role.trim());
5
+ /** The user holds the `admin` role, among others perhaps. */
6
+ const isAdmin = (user) => rolesOf(user).includes("admin");
4
7
  /** What the sidebar's profile menu shows for a signed-in user. */
5
8
  const userToShell = (user) => {
6
- const roles = (user.role ?? "user").split(",").map((role) => role.trim());
9
+ const roles = rolesOf(user);
7
10
  const admin = roles.includes("admin");
8
11
  return {
9
12
  name: user.name || user.email,
@@ -15,4 +18,4 @@ const userToShell = (user) => {
15
18
  };
16
19
  };
17
20
  //#endregion
18
- export { userToShell };
21
+ export { isAdmin, userToShell };
@@ -1,8 +1,8 @@
1
1
  import { Input } from "../primitives/input.js";
2
2
  import { Button } from "../primitives/button.js";
3
3
  import { useState } from "react";
4
- import { jsx, jsxs } from "react/jsx-runtime";
5
4
  import { SendHorizontal } from "lucide-react";
5
+ import { jsx, jsxs } from "react/jsx-runtime";
6
6
  //#region src/chat/composer.tsx
7
7
  /** The text box under a chat. Enter or the button sends the trimmed text and clears the box. */
8
8
  const Composer = ({ onSend, busy, placeholder, inputRef }) => {
@@ -1,6 +1,6 @@
1
1
  import { ReactNode } from "react";
2
- import { QueryClient } from "@tanstack/react-query";
3
2
  import { Client, ClientOptions } from "@protobase/client";
3
+ import { QueryClient } from "@tanstack/react-query";
4
4
  //#region src/data/api-provider.d.ts
5
5
  export declare const useClient: () => {
6
6
  list: <R extends import("@protobase/client").ResourceRef>(resource: R, params?: import("@protobase/client").ListParams) => Promise<import("@protobase/client").ListPage<import("@protobase/client").RecordOf<R>>>;
@@ -1,8 +1,8 @@
1
1
  import { keys } from "./query-keys.js";
2
2
  import { createContext, useContext, useMemo, useRef, useState } from "react";
3
+ import { createClient } from "@protobase/client";
3
4
  import { jsx } from "react/jsx-runtime";
4
5
  import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
5
- import { createClient } from "@protobase/client";
6
6
  //#region src/data/api-provider.tsx
7
7
  const ApiContext = createContext(null);
8
8
  const useClient = () => {
@@ -1,7 +1,7 @@
1
1
  import { keys } from "./query-keys.js";
2
2
  import { useClient } from "./api-provider.js";
3
- import { useQuery } from "@tanstack/react-query";
4
3
  import { filterText } from "@protobase/client";
4
+ import { useQuery } from "@tanstack/react-query";
5
5
  //#region src/data/use-aggregates.ts
6
6
  const useFacets = (resource, field, filter, enabled = true) => {
7
7
  const client = useClient();
@@ -1,7 +1,7 @@
1
1
  import { keys } from "./query-keys.js";
2
2
  import { useClient } from "./api-provider.js";
3
- import { useInfiniteQuery } from "@tanstack/react-query";
4
3
  import { filterText } from "@protobase/client";
4
+ import { useInfiniteQuery } from "@tanstack/react-query";
5
5
  //#region src/data/use-list.ts
6
6
  /** Infinite pages of a resource, following `next_page_token`. */
7
7
  const useList = (resource, { filter, orderBy, pageSize = 50, fields, startToken = "", enabled = true } = {}) => {
@@ -1,7 +1,7 @@
1
1
  import { keys } from "./query-keys.js";
2
2
  import { useClient } from "./api-provider.js";
3
- import { useMutation, useQueryClient } from "@tanstack/react-query";
4
3
  import { PreconditionFailedError } from "@protobase/client";
4
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
5
5
  import { encodeKey } from "@protobase/schema";
6
6
  //#region src/data/use-update-record.ts
7
7
  /**
@@ -1,12 +1,12 @@
1
1
  import { cn } from "../lib/cn.js";
2
2
  import { Avatar } from "../primitives/avatar.js";
3
3
  import { Badge } from "../primitives/badge.js";
4
- import { formatDate, formatDateTime } from "../format/date.js";
5
- import { userById } from "../mocks/users.js";
6
4
  import { formatMoney } from "../format/money.js";
7
5
  import { formatInt, formatPercent, formatSigned } from "../format/number.js";
8
- import { jsx, jsxs } from "react/jsx-runtime";
6
+ import { formatDate, formatDateTime } from "../format/date.js";
7
+ import { userById } from "../mocks/users.js";
9
8
  import { Check } from "lucide-react";
9
+ import { jsx, jsxs } from "react/jsx-runtime";
10
10
  import { enumLabel } from "@protobase/schema";
11
11
  //#region src/data-grid/cells.tsx
12
12
  const muted = (text) => /* @__PURE__ */ jsx("span", {
@@ -1,10 +1,10 @@
1
1
  import { cn } from "../lib/cn.js";
2
- import { gridTemplate } from "./grid-template.js";
3
2
  import { Checkbox } from "../primitives/checkbox.js";
3
+ import { gridTemplate } from "./grid-template.js";
4
4
  import { isRightAligned } from "./column-spec.js";
5
5
  import { HeaderMenu } from "./header-menu.js";
6
- import { jsx, jsxs } from "react/jsx-runtime";
7
6
  import { ArrowDown, ArrowUp, ChevronsUpDown } from "lucide-react";
7
+ import { jsx, jsxs } from "react/jsx-runtime";
8
8
  //#region src/data-grid/grid-header.tsx
9
9
  const SortIcon = ({ sorted }) => {
10
10
  if (sorted === "asc") return /* @__PURE__ */ jsx(ArrowUp, { className: "size-3 text-foreground" });
@@ -1,7 +1,7 @@
1
1
  import { cn } from "../lib/cn.js";
2
2
  import { formatInt } from "../format/number.js";
3
- import { gridTemplate } from "./grid-template.js";
4
3
  import { Checkbox } from "../primitives/checkbox.js";
4
+ import { gridTemplate } from "./grid-template.js";
5
5
  import { isRightAligned } from "./column-spec.js";
6
6
  import { Skeleton } from "../primitives/skeleton.js";
7
7
  import { jsx, jsxs } from "react/jsx-runtime";
@@ -1,6 +1,6 @@
1
1
  import { useEffect, useRef, useState } from "react";
2
- import { jsx, jsxs } from "react/jsx-runtime";
3
2
  import { MoreHorizontal } from "lucide-react";
3
+ import { jsx, jsxs } from "react/jsx-runtime";
4
4
  //#region src/data-grid/header-menu.tsx
5
5
  /** The "..." in a column header: for now, pinning the column to the left edge. */
6
6
  const HeaderMenu = ({ header, pinned, onTogglePin }) => {
@@ -1,7 +1,7 @@
1
1
  import { cn } from "../lib/cn.js";
2
2
  import { useEffect, useLayoutEffect, useRef, useState } from "react";
3
- import { jsx, jsxs } from "react/jsx-runtime";
4
3
  import { MoreHorizontal } from "lucide-react";
4
+ import { jsx, jsxs } from "react/jsx-runtime";
5
5
  import { createPortal } from "react-dom";
6
6
  //#region src/data-grid/row-menu.tsx
7
7
  /** The actions that apply to a row; a row with none gets no menu at all. */