@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.
- package/dist/account/account-security-page.js +46 -24
- package/dist/account/sign-in-policy-form.js +12 -2
- package/dist/account/sign-in-policy-labels.js +6 -0
- package/dist/account/sign-in-providers-section.js +54 -0
- package/dist/auth/auth-gate.js +40 -7
- package/dist/auth/auth-messages.js +10 -1
- package/dist/auth/auth-provider.js +12 -1
- package/dist/auth/provider-link.js +33 -0
- package/dist/auth/provider-sign-in-page.js +55 -0
- package/dist/auth/sign-in-form.js +7 -6
- package/dist/auth/sign-in-page.js +5 -4
- package/package.json +4 -4
|
@@ -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
|
-
|
|
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([
|
|
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: [
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
*
|
|
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 =
|
|
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 };
|
package/dist/auth/auth-gate.js
CHANGED
|
@@ -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
|
|
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(
|
|
24
|
-
|
|
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) =>
|
|
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,
|
|
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
|
-
(
|
|
37
|
+
(offered.length > 0 || withPasskey) && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsxs("div", {
|
|
37
38
|
className: "flex flex-col gap-2",
|
|
38
|
-
children: [
|
|
39
|
-
onClick:
|
|
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
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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.
|
|
38
|
-
"@protobase/layout": "0.
|
|
39
|
-
"@protobase/schema": "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",
|