@protobase/ui 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,23 +1,36 @@
1
1
  import { Spinner } from "../primitives/spinner.js";
2
2
  import { PageHeader } from "../app-shell/page-header.js";
3
- import { authMessage } from "../auth/auth-messages.js";
3
+ import { authMessage, providerMessage } from "../auth/auth-messages.js";
4
+ import { readProviderError, withoutSignInLink } from "../auth/provider-link.js";
4
5
  import { useAuth } from "../auth/auth-provider.js";
5
6
  import { PasskeysSection } from "./passkeys-section.js";
7
+ import { SignInProvidersSection } from "./sign-in-providers-section.js";
6
8
  import { TwoFactorSection } from "./two-factor-section.js";
7
9
  import { useCallback, useEffect, useState } from "react";
8
10
  import { AuthError } from "@protobase/client";
9
11
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
10
12
  //#region src/account/account-security-page.tsx
11
- /** The signed-in person's passkeys and two-factor authentication, within what the sign-in policy allows. */
13
+ const takeLinkError = () => {
14
+ const code = readProviderError(window.location.href);
15
+ if (code) window.history.replaceState(window.history.state, "", withoutSignInLink(window.location.href));
16
+ return code;
17
+ };
18
+ /** The signed-in person's passkeys, two-factor authentication and connected accounts, within what the sign-in policy allows. */
12
19
  const AccountSecurityPage = ({ email }) => {
13
- const { session } = useAuth();
20
+ const { session, signInProviders, linkProvider } = useAuth();
14
21
  const { account } = session;
15
22
  const [loaded, setLoaded] = useState();
16
23
  const [error, setError] = useState();
24
+ const [linkError] = useState(takeLinkError);
17
25
  const reload = useCallback(() => {
18
- Promise.all([account.signInMethods(), account.passkeys()]).then(([methods, passkeys]) => setLoaded({
26
+ Promise.all([
27
+ account.signInMethods(),
28
+ account.passkeys(),
29
+ account.linkedProviders()
30
+ ]).then(([methods, passkeys, linked]) => setLoaded({
19
31
  methods,
20
- passkeys
32
+ passkeys,
33
+ linked
21
34
  }), (failure) => setError(failure instanceof AuthError ? authMessage(failure) : "Could not read your sign-in methods"));
22
35
  }, [account]);
23
36
  useEffect(reload, [reload]);
@@ -27,25 +40,34 @@ const AccountSecurityPage = ({ email }) => {
27
40
  children: [/* @__PURE__ */ jsx(PageHeader, {
28
41
  title: "Sign-in & security",
29
42
  subtitle: email
30
- }), loaded ? /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(PasskeysSection, {
31
- rule: loaded.methods.policy.passkey,
32
- passkeys: loaded.passkeys,
33
- onAdd: () => thenReload(account.addPasskey()),
34
- onRename: (id, name) => thenReload(account.renamePasskey(id, name)),
35
- onRemove: (id) => thenReload(account.removePasskey(id))
36
- }), /* @__PURE__ */ jsx(TwoFactorSection, {
37
- rule: loaded.methods.policy.twoFactor,
38
- enabled: loaded.methods.account.twoFactor,
39
- authenticatorApp: loaded.methods.account.authenticatorApp,
40
- mail: loaded.methods.mail,
41
- needsPassword: loaded.methods.account.password,
42
- onStartApp: (password) => account.startAuthenticatorApp(password),
43
- onConfirmApp: (code) => account.confirmAuthenticatorApp(code),
44
- onEmailedCodes: (password) => account.turnOnEmailedCodes(password),
45
- onTurnOff: (password) => account.turnOffTwoFactor(password),
46
- onNewBackupCodes: (password) => account.newBackupCodes(password),
47
- onChanged: reload
48
- })] }) : error ? /* @__PURE__ */ jsx("p", {
43
+ }), loaded ? /* @__PURE__ */ jsxs(Fragment$1, { children: [
44
+ /* @__PURE__ */ jsx(PasskeysSection, {
45
+ rule: loaded.methods.policy.passkey,
46
+ passkeys: loaded.passkeys,
47
+ onAdd: () => thenReload(account.addPasskey()),
48
+ onRename: (id, name) => thenReload(account.renamePasskey(id, name)),
49
+ onRemove: (id) => thenReload(account.removePasskey(id))
50
+ }),
51
+ /* @__PURE__ */ jsx(TwoFactorSection, {
52
+ rule: loaded.methods.policy.twoFactor,
53
+ enabled: loaded.methods.account.twoFactor,
54
+ authenticatorApp: loaded.methods.account.authenticatorApp,
55
+ mail: loaded.methods.mail,
56
+ needsPassword: loaded.methods.account.password,
57
+ onStartApp: (password) => account.startAuthenticatorApp(password),
58
+ onConfirmApp: (code) => account.confirmAuthenticatorApp(code),
59
+ onEmailedCodes: (password) => account.turnOnEmailedCodes(password),
60
+ onTurnOff: (password) => account.turnOffTwoFactor(password),
61
+ onNewBackupCodes: (password) => account.newBackupCodes(password),
62
+ onChanged: reload
63
+ }),
64
+ signInProviders.length > 0 && /* @__PURE__ */ jsx(SignInProvidersSection, {
65
+ providers: signInProviders,
66
+ linked: loaded.linked,
67
+ onConnect: linkProvider,
68
+ error: linkError && providerMessage(linkError, signInProviders.length === 1 ? signInProviders[0].name : "the provider")
69
+ })
70
+ ] }) : error ? /* @__PURE__ */ jsx("p", {
49
71
  role: "alert",
50
72
  className: "text-[13px] text-danger-text",
51
73
  children: error
@@ -22,15 +22,25 @@ const rowNote = ({ method }, draft, { mail, effective, policy }) => {
22
22
  if (method === "emailCode" && !mail) return "Needs mail settings (PROTOBASE_SMTP_URL and PROTOBASE_MAIL_FROM): off until they are set.";
23
23
  if (method === "password" && !mail && draft.password === "forbidden") return "Without mail settings, password sign-in stays on so nobody is locked out.";
24
24
  if (method === "staffAccess" && !mail && draft.staffAccess === "notify") return "Needs mail settings (PROTOBASE_SMTP_URL and PROTOBASE_MAIL_FROM): staff cannot sign in until they are set.";
25
+ if (method === "platformSignIn" && draft.platformSignIn === "allowed" && (draft.passkey === "required" || draft.twoFactor === "required")) return "Off while passkeys or two-factor authentication are required: it skips the second step.";
25
26
  if (draft[method] === policy[method] && effective[method] !== policy[method]) return `Applies as ${ruleLabel(method, effective[method])} for now.`;
26
27
  };
28
+ const rowsFor = ({ operator, platformSignIn }) => policyRows.flatMap((row) => {
29
+ if (row.method === "staffAccess" && !operator) return [];
30
+ if (row.method === "platformSignIn") return platformSignIn ? [{
31
+ ...row,
32
+ label: `Sign in with ${platformSignIn} through the platform`
33
+ }] : [];
34
+ return [row];
35
+ });
27
36
  /**
28
37
  * The admin's sign-in policy: for each way to sign in, whether people may use it, must set it up, or cannot use it;
29
- * and, when the app has an operator provider, whether its staff may sign in as people.
38
+ * when the app has an operator provider, whether its staff may sign in as people; and when the platform adds a sign-in
39
+ * provider, whether people may use it.
30
40
  */
31
41
  const SignInPolicyForm = ({ state, onSave, busy, error, notice }) => {
32
42
  const [draft, setDraft] = useState(state.policy);
33
- const rows = policyRows.filter(({ method }) => method !== "staffAccess" || state.operator);
43
+ const rows = rowsFor(state);
34
44
  const changed = rows.some(({ method }) => draft[method] !== state.policy[method]);
35
45
  return /* @__PURE__ */ jsxs("form", {
36
46
  className: "flex flex-col gap-4",
@@ -64,6 +64,12 @@ const policyRows = [
64
64
  label: "Off"
65
65
  }
66
66
  ]
67
+ },
68
+ {
69
+ method: "platformSignIn",
70
+ label: "Sign-in through the platform",
71
+ description: "The sign-in provider the platform that runs this app adds, for people who have an account here already. It never creates accounts.",
72
+ rules: onOff
67
73
  }
68
74
  ];
69
75
  /** The word for `rule` in `method`'s row, for example "Optional" for an allowed passkey. Every rule the server stores has one. */
@@ -0,0 +1,54 @@
1
+ import { Badge } from "../primitives/badge.js";
2
+ import { authMessage } from "../auth/auth-messages.js";
3
+ import { Button } from "../primitives/button.js";
4
+ import { AccountSection, SectionError } from "./account-section.js";
5
+ import { useState } from "react";
6
+ import { Link2 } from "lucide-react";
7
+ import { AuthError } from "@protobase/client";
8
+ import { jsx, jsxs } from "react/jsx-runtime";
9
+ //#region src/account/sign-in-providers-section.tsx
10
+ /** The providers someone can sign in with besides a password, and a way to connect one whatever address it has. */
11
+ const SignInProvidersSection = ({ providers, linked, onConnect, error: returned }) => {
12
+ const [busy, setBusy] = useState();
13
+ const [error, setError] = useState(returned);
14
+ const connect = async (provider) => {
15
+ setBusy(provider);
16
+ setError(void 0);
17
+ try {
18
+ await onConnect(provider);
19
+ } catch (failure) {
20
+ if (!(failure instanceof AuthError)) throw failure;
21
+ setError(authMessage(failure));
22
+ setBusy(void 0);
23
+ }
24
+ };
25
+ return /* @__PURE__ */ jsxs(AccountSection, {
26
+ title: "Connected accounts",
27
+ description: "Sign in with these instead of your password. Connecting one asks you to sign in there; its address may differ from yours here.",
28
+ children: [/* @__PURE__ */ jsx(SectionError, { message: error }), /* @__PURE__ */ jsx("ul", {
29
+ className: "divide-y rounded-md border",
30
+ children: providers.map((provider) => /* @__PURE__ */ jsxs("li", {
31
+ className: "flex flex-wrap items-center gap-3 px-3 py-2.5",
32
+ children: [
33
+ /* @__PURE__ */ jsx(Link2, { className: "size-4 shrink-0 text-muted-foreground" }),
34
+ /* @__PURE__ */ jsx("span", {
35
+ className: "min-w-0 flex-1 truncate text-[13px] font-medium",
36
+ children: provider.name
37
+ }),
38
+ linked.includes(provider.id) ? /* @__PURE__ */ jsx(Badge, {
39
+ tone: "green",
40
+ children: "Connected"
41
+ }) : /* @__PURE__ */ jsxs(Button, {
42
+ size: "sm",
43
+ loading: busy === provider.id,
44
+ disabled: busy !== void 0,
45
+ onClick: () => void connect(provider.id),
46
+ children: ["Connect ", provider.name]
47
+ })
48
+ ]
49
+ }, provider.id))
50
+ })]
51
+ });
52
+ };
53
+ //#endregion
54
+ export { SignInProvidersSection };
@@ -1,43 +1,68 @@
1
1
  import { Spinner } from "../primitives/spinner.js";
2
+ import { providerMessage } from "./auth-messages.js";
3
+ import { readProviderError, readSignInLink, withoutSignInLink } from "./provider-link.js";
2
4
  import { useAuth } from "./auth-provider.js";
3
5
  import { Button } from "../primitives/button.js";
4
6
  import { EmailCodePage } from "./email-code-page.js";
5
7
  import { FirstRunPage } from "./first-run-page.js";
6
8
  import { readResetLink, withoutResetLink } from "./reset-link.js";
7
9
  import { ForgotPasswordPage } from "./forgot-password-page.js";
10
+ import { ProviderSignInPage } from "./provider-sign-in-page.js";
8
11
  import { ResetPasswordPage } from "./reset-password-page.js";
9
12
  import { SetupRequiredPage } from "./setup-required-page.js";
10
13
  import { SignInPage } from "./sign-in-page.js";
11
14
  import { readStaffLink, withoutStaffLink } from "./staff-link.js";
12
15
  import { StaffSignInPage } from "./staff-sign-in-page.js";
13
16
  import { TwoFactorPage } from "./two-factor-page.js";
14
- import { useEffect, useState } from "react";
17
+ import { useCallback, useEffect, useState } from "react";
15
18
  import { jsx, jsxs } from "react/jsx-runtime";
16
19
  //#region src/auth/auth-gate.tsx
17
20
  const firstPage = () => {
18
- const link = readResetLink(window.location.href);
21
+ const href = window.location.href;
22
+ const link = readResetLink(href);
19
23
  if (link) return {
20
24
  kind: "reset-password",
21
25
  token: link.token
22
26
  };
23
- const staff = readStaffLink(window.location.href);
24
- return staff ? {
27
+ const staff = readStaffLink(href);
28
+ if (staff) return {
25
29
  kind: "staff-sign-in",
26
30
  link: staff
31
+ };
32
+ const provider = readSignInLink(href);
33
+ if (provider) return {
34
+ kind: "provider-sign-in",
35
+ provider
36
+ };
37
+ const providerError = readProviderError(href);
38
+ return providerError ? {
39
+ kind: "sign-in",
40
+ providerError
27
41
  } : { kind: "sign-in" };
28
42
  };
29
43
  const forgetResetLink = () => window.history.replaceState(window.history.state, "", withoutResetLink(window.location.href));
30
44
  const forgetStaffLink = () => window.history.replaceState(window.history.state, "", withoutStaffLink(window.location.href));
45
+ const forgetSignInLink = () => window.history.replaceState(window.history.state, "", withoutSignInLink(window.location.href));
31
46
  /**
32
47
  * Shows the app when the user is signed in (or the server has no login), and the right page otherwise: the sign-in
33
48
  * steps, or the page an emailed reset link or a staff link opened.
34
49
  */
35
50
  const AuthGate = ({ workspace, children }) => {
36
- const { state, recheck, passwordReset, signOut } = useAuth();
51
+ const { state, recheck, passwordReset, signOut, signInProviders } = useAuth();
37
52
  const [page, setPage] = useState(firstPage);
38
53
  useEffect(() => {
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);
54
+ if (state.kind === "signed-in" || state.kind === "two-factor" || state.kind === "setup-required") setPage((current) => {
55
+ if (current.kind === "provider-sign-in") forgetSignInLink();
56
+ return current.kind === "email-code" || current.kind === "provider-sign-in" || current.kind === "sign-in" && (current.notice || current.error || current.providerError) ? { kind: "sign-in" } : current;
57
+ });
40
58
  }, [state.kind]);
59
+ useEffect(() => {
60
+ if (state.kind === "signed-out" && page.kind === "sign-in" && page.providerError) forgetSignInLink();
61
+ }, [state.kind, page]);
62
+ const signInFailed = useCallback((error) => setPage({
63
+ kind: "sign-in",
64
+ error
65
+ }), []);
41
66
  const leaveResetPage = (next) => {
42
67
  forgetResetLink();
43
68
  setPage(next);
@@ -89,20 +114,28 @@ const AuthGate = ({ workspace, children }) => {
89
114
  onBack: backToSignIn
90
115
  });
91
116
  if (state.kind === "setup-required") return /* @__PURE__ */ jsx(SetupRequiredPage, { missing: state.missing }, state.missing.join());
117
+ if (page.kind === "provider-sign-in") return /* @__PURE__ */ jsx(ProviderSignInPage, {
118
+ provider: page.provider,
119
+ workspace,
120
+ onFailed: signInFailed
121
+ });
92
122
  if (page.kind === "forgot-password") return /* @__PURE__ */ jsx(ForgotPasswordPage, { onBack: () => setPage({ kind: "sign-in" }) });
93
123
  if (page.kind === "email-code") return /* @__PURE__ */ jsx(EmailCodePage, {
94
124
  email: page.email,
95
125
  onBack: () => setPage({ kind: "sign-in" })
96
126
  });
127
+ const providerError = page.providerError && providerMessage(page.providerError, signInProviders.length === 1 ? signInProviders[0].name : "the provider");
128
+ const error = page.error ?? providerError;
97
129
  return /* @__PURE__ */ jsx(SignInPage, {
98
130
  workspace,
99
131
  notice: page.notice,
132
+ error,
100
133
  onForgotPassword: passwordReset ? () => setPage({ kind: "forgot-password" }) : void 0,
101
134
  onCodeSent: (email) => setPage({
102
135
  kind: "email-code",
103
136
  email
104
137
  })
105
- });
138
+ }, error);
106
139
  };
107
140
  //#endregion
108
141
  export { AuthGate };
@@ -44,5 +44,14 @@ const staffSignInMessage = (failure) => {
44
44
  if (typeof failure === "string") return "The staff sign-in did not go through. Try again.";
45
45
  return failure.status === 429 ? tooManyRequests : failure.message;
46
46
  };
47
+ const providerMessages = {
48
+ signup_disabled: (name) => `No account here belongs to this ${name} account. Ask an admin to add you, or sign in another way.`,
49
+ account_not_linked: (name) => `Your ${name} account is not linked to your account here. Sign in another way, then connect ${name} on the Sign-in & security page.`,
50
+ account_already_linked_to_different_user: (name) => `This ${name} account is linked to someone else here already.`,
51
+ unable_to_link_account: (name) => `Your ${name} account could not be linked. Try again.`,
52
+ access_denied: (name) => `${name} did not let you in, or you cancelled.`
53
+ };
54
+ /** Why signing in or linking with the provider `name` did not go through, from the code it came back with. */
55
+ const providerMessage = (code, name) => providerMessages[code]?.(name) ?? `Signing in with ${name} did not go through. Try again.`;
47
56
  //#endregion
48
- export { authMessage, signInMessage, staffSignInMessage };
57
+ export { authMessage, providerMessage, signInMessage, staffSignInMessage };
@@ -1,3 +1,4 @@
1
+ import { providerName, withoutSignInLink } from "./provider-link.js";
1
2
  import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
2
3
  import { createAuthSession } from "@protobase/client";
3
4
  import { jsx } from "react/jsx-runtime";
@@ -14,6 +15,7 @@ const AuthProvider = ({ session: given, children }) => {
14
15
  const [signInMethods, setSignInMethods] = useState(["password"]);
15
16
  const [passwordReset, setPasswordReset] = useState(false);
16
17
  const [socialProviders, setSocialProviders] = useState([]);
18
+ const [platformSignIn, setPlatformSignIn] = useState();
17
19
  const [staffSignIn, setStaffSignIn] = useState();
18
20
  const markSignedOut = useCallback(() => setState((current) => current.kind === "signed-in" ? { kind: "signed-out" } : current), []);
19
21
  const signedOut = useRef(markSignedOut);
@@ -41,6 +43,7 @@ const AuthProvider = ({ session: given, children }) => {
41
43
  setSignInMethods(status.signInMethods);
42
44
  setPasswordReset(status.passwordReset);
43
45
  setSocialProviders(status.socialProviders);
46
+ setPlatformSignIn(status.platformSignIn);
44
47
  setStaffSignIn(status.staffSignIn);
45
48
  if (status.needsAdmin) return setState({ kind: "needs-admin" });
46
49
  const user = await session.session();
@@ -57,6 +60,10 @@ const AuthProvider = ({ session: given, children }) => {
57
60
  signInMethods,
58
61
  passwordReset,
59
62
  socialProviders,
63
+ signInProviders: socialProviders.map((id) => ({
64
+ id,
65
+ name: providerName(id, platformSignIn)
66
+ })),
60
67
  staffSignIn,
61
68
  markSignedOut,
62
69
  recheck,
@@ -66,7 +73,10 @@ const AuthProvider = ({ session: given, children }) => {
66
73
  signInWithPasskey: async () => enter(await session.signInWithPasskey()),
67
74
  verifyTwoFactor: async (input) => enter(await session.verifyTwoFactor(input)),
68
75
  signInSocial: async (provider) => {
69
- window.location.assign(await session.signInSocial(provider, window.location.href));
76
+ window.location.assign(await session.signInSocial(provider, new URL(withoutSignInLink(window.location.href), window.location.href).href));
77
+ },
78
+ linkProvider: async (provider) => {
79
+ window.location.assign(await session.account.linkProvider(provider, new URL(withoutSignInLink(window.location.href), window.location.href).href));
70
80
  },
71
81
  stopStaffSession: async () => {
72
82
  await session.staff.stop();
@@ -82,6 +92,7 @@ const AuthProvider = ({ session: given, children }) => {
82
92
  signInMethods,
83
93
  passwordReset,
84
94
  socialProviders,
95
+ platformSignIn,
85
96
  staffSignIn,
86
97
  markSignedOut,
87
98
  recheck,
@@ -0,0 +1,33 @@
1
+ //#region src/auth/provider-link.ts
2
+ /** Marks a link that starts signing in with a provider at once, for example `/?sign-in=github`. */
3
+ const signInMarker = "sign-in";
4
+ /** Where a provider's callback says why a sign-in or a link did not go through, for example `signup_disabled`. */
5
+ const errorParam = "error";
6
+ /**
7
+ * The provider a link asks to sign in with, or `undefined`. A link that came back from the provider with an error
8
+ * starts nothing, so a refused sign-in does not go round again.
9
+ */
10
+ const readSignInLink = (href) => {
11
+ const params = new URL(href).searchParams;
12
+ if (params.has(errorParam)) return void 0;
13
+ return params.get("sign-in") || void 0;
14
+ };
15
+ /** Why the provider sent the browser back here, or `undefined`. */
16
+ const readProviderError = (href) => new URL(href).searchParams.get(errorParam) || void 0;
17
+ /** The address without the link's marker and the provider's error: for the address bar, and the page to come back to. */
18
+ const withoutSignInLink = (href) => {
19
+ const url = new URL(href);
20
+ for (const name of [signInMarker, errorParam]) url.searchParams.delete(name);
21
+ return `${url.pathname}${url.search}${url.hash}`;
22
+ };
23
+ const knownNames = {
24
+ github: "GitHub",
25
+ gitlab: "GitLab",
26
+ google: "Google",
27
+ microsoft: "Microsoft",
28
+ apple: "Apple"
29
+ };
30
+ /** The name of a sign-in provider: the platform's own name for its provider, or a well-known provider's name. */
31
+ const providerName = (provider, platformSignIn) => platformSignIn?.provider === provider ? platformSignIn.name : knownNames[provider] ?? provider.charAt(0).toUpperCase() + provider.slice(1);
32
+ //#endregion
33
+ export { providerName, readProviderError, readSignInLink, signInMarker, withoutSignInLink };
@@ -0,0 +1,55 @@
1
+ import { Spinner } from "../primitives/spinner.js";
2
+ import { signInMessage } from "./auth-messages.js";
3
+ import { providerName } from "./provider-link.js";
4
+ import { useAuth } from "./auth-provider.js";
5
+ import { AuthLayout } from "./auth-layout.js";
6
+ import { useEffect, useRef } from "react";
7
+ import { AuthError } from "@protobase/client";
8
+ import { jsx, jsxs } from "react/jsx-runtime";
9
+ //#region src/auth/provider-sign-in-page.tsx
10
+ /** What the page shows while the browser leaves for the provider `name`. */
11
+ const ProviderRedirect = ({ name, workspace }) => /* @__PURE__ */ jsx(AuthLayout, {
12
+ title: `Signing in with ${name}`,
13
+ description: workspace,
14
+ children: /* @__PURE__ */ jsxs("p", {
15
+ role: "status",
16
+ className: "flex items-center justify-center gap-2 text-[13px] text-muted-foreground",
17
+ children: [
18
+ /* @__PURE__ */ jsx(Spinner, {}),
19
+ " Taking you to ",
20
+ name,
21
+ "…"
22
+ ]
23
+ })
24
+ });
25
+ /**
26
+ * Starts signing in with `provider` at once, for a link such as `/?sign-in=github`, so someone the provider knows lands
27
+ * signed in without a sign-in page. A provider the server does not offer, or a start it refuses, goes to `onFailed`.
28
+ */
29
+ const ProviderSignInPage = ({ provider, workspace, onFailed }) => {
30
+ const { signInProviders, signInSocial } = useAuth();
31
+ const offered = signInProviders.find((candidate) => candidate.id === provider);
32
+ const name = offered?.name ?? providerName(provider);
33
+ const started = useRef(false);
34
+ useEffect(() => {
35
+ if (started.current) return;
36
+ started.current = true;
37
+ if (!offered) return onFailed(`Signing in with ${name} is not offered here. Sign in another way.`);
38
+ signInSocial(provider).catch((failure) => {
39
+ if (!(failure instanceof AuthError)) throw failure;
40
+ onFailed(signInMessage(failure));
41
+ });
42
+ }, [
43
+ offered,
44
+ name,
45
+ provider,
46
+ signInSocial,
47
+ onFailed
48
+ ]);
49
+ return /* @__PURE__ */ jsx(ProviderRedirect, {
50
+ name,
51
+ workspace
52
+ });
53
+ };
54
+ //#endregion
55
+ export { ProviderRedirect, ProviderSignInPage };
@@ -13,13 +13,14 @@ const Divider = () => /* @__PURE__ */ jsxs("div", {
13
13
  /* @__PURE__ */ jsx("span", { className: "h-px flex-1 bg-border" })
14
14
  ]
15
15
  });
16
- const SignInForm = ({ onSubmit, busy, error, workspace, notice, methods = ["password"], onSendCode, onPasskey, onForgotPassword, onContinueWithGitHub }) => {
16
+ const SignInForm = ({ onSubmit, busy, error, workspace, notice, methods = ["password"], onSendCode, onPasskey, onForgotPassword, providers = [], onContinueWith }) => {
17
17
  const [email, setEmail] = useState("");
18
18
  const [password, setPassword] = useState("");
19
19
  const emailInput = useRef(null);
20
20
  const withPassword = methods.includes("password");
21
21
  const withCode = methods.includes("emailCode") && onSendCode !== void 0;
22
22
  const withPasskey = methods.includes("passkey") && onPasskey !== void 0;
23
+ const offered = onContinueWith ? providers : [];
23
24
  const sendCode = () => {
24
25
  if (!emailInput.current?.reportValidity()) return;
25
26
  onSendCode?.(email.trim());
@@ -33,14 +34,14 @@ const SignInForm = ({ onSubmit, busy, error, workspace, notice, methods = ["pass
33
34
  className: "mb-4 rounded-md bg-success-soft px-3 py-2 text-xs font-medium text-success-text",
34
35
  children: notice
35
36
  }),
36
- (onContinueWithGitHub || withPasskey) && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsxs("div", {
37
+ (offered.length > 0 || withPasskey) && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsxs("div", {
37
38
  className: "flex flex-col gap-2",
38
- children: [onContinueWithGitHub && /* @__PURE__ */ jsx(Button, {
39
- onClick: onContinueWithGitHub,
39
+ children: [offered.map((provider) => /* @__PURE__ */ jsxs(Button, {
40
+ onClick: () => onContinueWith?.(provider.id),
40
41
  disabled: busy,
41
42
  className: "min-h-10 w-full",
42
- children: "Continue with GitHub"
43
- }), withPasskey && /* @__PURE__ */ jsxs(Button, {
43
+ children: ["Continue with ", provider.name]
44
+ }, provider.id)), withPasskey && /* @__PURE__ */ jsxs(Button, {
44
45
  onClick: onPasskey,
45
46
  disabled: busy,
46
47
  className: "min-h-10 w-full",
@@ -5,10 +5,10 @@ import { useState } from "react";
5
5
  import { AuthError } from "@protobase/client";
6
6
  import { jsx } from "react/jsx-runtime";
7
7
  //#region src/auth/sign-in-page.tsx
8
- const SignInPage = ({ workspace, notice, onForgotPassword, onCodeSent }) => {
9
- const { signIn, signInSocial, socialProviders, signInMethods, sendSignInCode, signInWithPasskey } = useAuth();
8
+ const SignInPage = ({ workspace, notice, onForgotPassword, onCodeSent, error: initialError }) => {
9
+ const { signIn, signInSocial, signInProviders, signInMethods, sendSignInCode, signInWithPasskey } = useAuth();
10
10
  const [busy, setBusy] = useState(false);
11
- const [error, setError] = useState();
11
+ const [error, setError] = useState(initialError);
12
12
  const attempt = async (start, message) => {
13
13
  setBusy(true);
14
14
  setError(void 0);
@@ -27,7 +27,8 @@ const SignInPage = ({ workspace, notice, onForgotPassword, onCodeSent }) => {
27
27
  notice,
28
28
  methods: signInMethods,
29
29
  onForgotPassword,
30
- onContinueWithGitHub: socialProviders.includes("github") ? () => void attempt(() => signInSocial("github"), signInMessage) : void 0,
30
+ providers: signInProviders,
31
+ onContinueWith: (provider) => void attempt(() => signInSocial(provider), signInMessage),
31
32
  onPasskey: () => void attempt(signInWithPasskey, authMessage),
32
33
  onSendCode: (email) => void attempt(async () => {
33
34
  await sendSignInCode(email);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/ui",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "The Protobase admin app and the React hooks and components for custom UI.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -34,9 +34,9 @@
34
34
  "dependencies": {
35
35
  "@dnd-kit/core": "^6.3.1",
36
36
  "@dnd-kit/sortable": "^10.0.0",
37
- "@protobase/client": "0.5.0",
38
- "@protobase/layout": "0.5.0",
39
- "@protobase/schema": "0.5.0",
37
+ "@protobase/client": "0.6.0",
38
+ "@protobase/layout": "0.6.0",
39
+ "@protobase/schema": "0.6.0",
40
40
  "@tanstack/react-query": "^5.104.1",
41
41
  "@tanstack/react-table": "^9.2.6",
42
42
  "@tanstack/react-virtual": "^3.14.13",