@protobase/ui 0.4.1 → 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/app/app.js +2 -1
- package/dist/app/assistant-shell.js +2 -1
- package/dist/app/pages/merge-ui.d.ts +9 -0
- package/dist/app/pages/merge-ui.js +45 -0
- package/dist/app/pages/project-ui-provider.js +15 -0
- package/dist/app/pages/project-ui.d.ts +6 -3
- package/dist/app/pages/project-ui.js +3 -7
- package/dist/app/pages/use-run-action.js +1 -1
- package/dist/assistant-dock/assistant-dock.d.ts +8 -2
- package/dist/assistant-dock/assistant-dock.js +19 -12
- package/dist/assistant-dock/index.d.ts +3 -1
- package/dist/assistant-dock/part-view.d.ts +1 -1
- package/dist/assistant-dock/part-view.js +3 -1
- package/dist/assistant-dock/use-backend-data.d.ts +23 -0
- package/dist/assistant-dock/use-backend-data.js +67 -0
- package/dist/assistant-dock/widget-view.d.ts +1 -0
- package/dist/assistant-dock/widget-view.js +44 -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/dist/index.d.ts +3 -1
- package/dist/index.js +4 -2
- 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/app/app.js
CHANGED
|
@@ -10,6 +10,7 @@ import { useRecord } from "../data/use-record.js";
|
|
|
10
10
|
import { recordTitle } from "../live/model-helpers.js";
|
|
11
11
|
import { humanize } from "../live/naming.js";
|
|
12
12
|
import { ToastProvider } from "../toasts/toast-provider.js";
|
|
13
|
+
import { useProjectUi } from "./pages/project-ui.js";
|
|
13
14
|
import { AssistantButton, AssistantPanel } from "./assistant-shell.js";
|
|
14
15
|
import { breadcrumbFor } from "./breadcrumb-for.js";
|
|
15
16
|
import { Router, matchRoute, useRouter } from "./router.js";
|
|
@@ -18,8 +19,8 @@ import { CreatePage } from "./create/create-page.js";
|
|
|
18
19
|
import { ListPage } from "./list-page.js";
|
|
19
20
|
import { userMenuFromMeta } from "./user-menu-from-meta.js";
|
|
20
21
|
import { navFromMeta } from "./nav-from-meta.js";
|
|
21
|
-
import { ProjectUiProvider, useProjectUi } from "./pages/project-ui.js";
|
|
22
22
|
import { ComposedPage } from "./pages/composed-page.js";
|
|
23
|
+
import { ProjectUiProvider } from "./pages/project-ui-provider.js";
|
|
23
24
|
import { RecordPage } from "./record-page.js";
|
|
24
25
|
import { useGlobalSearch } from "./search/use-global-search.js";
|
|
25
26
|
import { RuntimeUpdateButton } from "./runtime/runtime-update.js";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { ProjectUi } from "./project-ui.js";
|
|
2
|
+
//#region src/app/pages/merge-ui.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* One UI config from several, each after the ones it `extends`, the last being the app's own: components and action
|
|
5
|
+
* handlers are all kept, and one name defined by two configs is an error naming both; every shell slot is drawn,
|
|
6
|
+
* the extended configs' first.
|
|
7
|
+
*/
|
|
8
|
+
export declare const mergeUi: (...uis: ProjectUi[]) => ProjectUi;
|
|
9
|
+
//#endregion
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { jsx } from "react/jsx-runtime";
|
|
2
|
+
//#region src/app/pages/merge-ui.tsx
|
|
3
|
+
const flatten = (ui, label) => [...(ui.extends ?? []).flatMap((parent, index) => flatten(parent, parent.name ?? `${label} extends[${index}]`)), {
|
|
4
|
+
ui,
|
|
5
|
+
label
|
|
6
|
+
}];
|
|
7
|
+
const union = (kind, named, pick) => {
|
|
8
|
+
const merged = {};
|
|
9
|
+
const owners = /* @__PURE__ */ new Map();
|
|
10
|
+
for (const { ui, label } of named) for (const [name, value] of Object.entries(pick(ui) ?? {})) {
|
|
11
|
+
if (Object.hasOwn(merged, name) && merged[name] !== value) throw new Error(`${kind} "${name}" is defined twice, by ${owners.get(name)} and by ${label}; rename one of them`);
|
|
12
|
+
merged[name] = value;
|
|
13
|
+
owners.set(name, owners.get(name) ?? label);
|
|
14
|
+
}
|
|
15
|
+
return merged;
|
|
16
|
+
};
|
|
17
|
+
const slot = (slots) => {
|
|
18
|
+
if (slots.length <= 1) return slots[0];
|
|
19
|
+
const All = () => slots.map((Slot, index) => /* @__PURE__ */ jsx(Slot, {}, index));
|
|
20
|
+
return All;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* One UI config from several, each after the ones it `extends`, the last being the app's own: components and action
|
|
24
|
+
* handlers are all kept, and one name defined by two configs is an error naming both; every shell slot is drawn,
|
|
25
|
+
* the extended configs' first.
|
|
26
|
+
*/
|
|
27
|
+
const mergeUi = (...uis) => {
|
|
28
|
+
const named = uis.flatMap((ui, index) => flatten(ui, ui.name ?? `UI config ${index + 1}`));
|
|
29
|
+
const components = union("The component", named, (ui) => ui.components);
|
|
30
|
+
const actions = union("The action handler", named, (ui) => ui.actions);
|
|
31
|
+
const actionSlot = slot(named.flatMap(({ ui }) => ui.shell?.actions ? [ui.shell.actions] : []));
|
|
32
|
+
const rightPanel = slot(named.flatMap(({ ui }) => ui.shell?.rightPanel ? [ui.shell.rightPanel] : []));
|
|
33
|
+
const name = uis.at(-1)?.name;
|
|
34
|
+
return {
|
|
35
|
+
...name && { name },
|
|
36
|
+
components,
|
|
37
|
+
actions,
|
|
38
|
+
...(actionSlot || rightPanel) && { shell: {
|
|
39
|
+
...actionSlot && { actions: actionSlot },
|
|
40
|
+
...rightPanel && { rightPanel }
|
|
41
|
+
} }
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
//#endregion
|
|
45
|
+
export { mergeUi };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { ProjectUiContext } from "./project-ui.js";
|
|
2
|
+
import { mergeUi } from "./merge-ui.js";
|
|
3
|
+
import { useMemo } from "react";
|
|
4
|
+
import { jsx } from "react/jsx-runtime";
|
|
5
|
+
//#region src/app/pages/project-ui-provider.tsx
|
|
6
|
+
/** Provides `ui` merged with the UI configs it extends. */
|
|
7
|
+
const ProjectUiProvider = ({ ui, children }) => {
|
|
8
|
+
const merged = useMemo(() => ui ? mergeUi(ui) : {}, [ui]);
|
|
9
|
+
return /* @__PURE__ */ jsx(ProjectUiContext.Provider, {
|
|
10
|
+
value: merged,
|
|
11
|
+
children
|
|
12
|
+
});
|
|
13
|
+
};
|
|
14
|
+
//#endregion
|
|
15
|
+
export { ProjectUiProvider };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ComponentType
|
|
1
|
+
import { ComponentType } from "react";
|
|
2
2
|
import { Client } from "@protobase/client";
|
|
3
3
|
//#region src/app/pages/project-ui.d.ts
|
|
4
4
|
/** What a named action's handler gets: the record it runs on (inside a RecordCard or CardRow), the API and the router. */
|
|
@@ -14,9 +14,12 @@ export type ActionContext = {
|
|
|
14
14
|
export type ActionHandler = (context: ActionContext) => void | Promise<void>;
|
|
15
15
|
/**
|
|
16
16
|
* The project's own React code for composed pages: custom components by the name their layout declares
|
|
17
|
-
* (`component('UsageChart')`), handlers for named actions that have no built-in behaviour, and
|
|
17
|
+
* (`component('UsageChart')`) or a widget part names, handlers for named actions that have no built-in behaviour, and
|
|
18
|
+
* the shell's slots. `extends` lists UI configs merged in before it (see `mergeUi`); `name` names it in their errors.
|
|
18
19
|
*/
|
|
19
20
|
export type ProjectUi = {
|
|
21
|
+
name?: string;
|
|
22
|
+
extends?: ProjectUi[];
|
|
20
23
|
components?: Record<string, ComponentType<any>>;
|
|
21
24
|
actions?: Record<string, ActionHandler>;
|
|
22
25
|
/** Rendered in the shell on every page: `actions` at the end of the top bar, `rightPanel` beside the page. */
|
|
@@ -25,6 +28,6 @@ export type ProjectUi = {
|
|
|
25
28
|
rightPanel?: ComponentType;
|
|
26
29
|
};
|
|
27
30
|
};
|
|
28
|
-
/** Typed identity, for `export default defineUi({ components, actions })` in `protobase.ui.tsx`. */
|
|
31
|
+
/** Typed identity, for `export default defineUi({ extends, components, actions })` in `protobase.ui.tsx`. */
|
|
29
32
|
export declare const defineUi: (ui: ProjectUi) => ProjectUi;
|
|
30
33
|
//#endregion
|
|
@@ -1,13 +1,9 @@
|
|
|
1
1
|
import { createContext, useContext } from "react";
|
|
2
|
-
import { jsx } from "react/jsx-runtime";
|
|
3
2
|
//#region src/app/pages/project-ui.tsx
|
|
4
|
-
/** Typed identity, for `export default defineUi({ components, actions })` in `protobase.ui.tsx`. */
|
|
3
|
+
/** Typed identity, for `export default defineUi({ extends, components, actions })` in `protobase.ui.tsx`. */
|
|
5
4
|
const defineUi = (ui) => ui;
|
|
5
|
+
/** The project's UI, merged with the UI configs it extends; `ProjectUiProvider` provides it. */
|
|
6
6
|
const ProjectUiContext = createContext({});
|
|
7
|
-
const ProjectUiProvider = ({ ui, children }) => /* @__PURE__ */ jsx(ProjectUiContext.Provider, {
|
|
8
|
-
value: ui ?? {},
|
|
9
|
-
children
|
|
10
|
-
});
|
|
11
7
|
const useProjectUi = () => useContext(ProjectUiContext);
|
|
12
8
|
//#endregion
|
|
13
|
-
export {
|
|
9
|
+
export { ProjectUiContext, defineUi, useProjectUi };
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { useClient } from "../../data/api-provider.js";
|
|
2
2
|
import { useToast } from "../../toasts/toast-provider.js";
|
|
3
|
+
import { useProjectUi } from "./project-ui.js";
|
|
3
4
|
import { useRouter } from "../router.js";
|
|
4
5
|
import { useAdminMeta, usePermissions } from "../meta-gate.js";
|
|
5
6
|
import { can } from "../permissions.js";
|
|
6
7
|
import { fillHref, isAppPath } from "./action-href.js";
|
|
7
|
-
import { useProjectUi } from "./project-ui.js";
|
|
8
8
|
import { useRefreshResource } from "./use-layout-data.js";
|
|
9
9
|
import { useState } from "react";
|
|
10
10
|
//#region src/app/pages/use-run-action.ts
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { AssistantClient } from "@protobase/client";
|
|
1
2
|
import { AssistantState } from "@protobase/schema";
|
|
2
3
|
//#region src/assistant-dock/assistant-dock.d.ts
|
|
3
4
|
export type AssistantDockProps = {
|
|
@@ -9,7 +10,12 @@ export type AssistantDockProps = {
|
|
|
9
10
|
onSend?: (text: string) => void;
|
|
10
11
|
onAction?: (partId: string, actionId: string) => void;
|
|
11
12
|
onClose?: () => void;
|
|
13
|
+
/** The backend's client, which widgets read and post through with `useBackendData`. */
|
|
14
|
+
client?: AssistantClient;
|
|
12
15
|
};
|
|
13
|
-
/**
|
|
14
|
-
|
|
16
|
+
/**
|
|
17
|
+
* The assistant beside the app: renders a backend's chat, its cards, the app's widgets and status line, and sends
|
|
18
|
+
* messages and clicks back.
|
|
19
|
+
*/
|
|
20
|
+
export declare const AssistantDock: ({ state, offline, error, onSend, onAction, onClose, client }: AssistantDockProps) => import("react").JSX.Element;
|
|
15
21
|
//#endregion
|
|
@@ -3,13 +3,17 @@ import { ChatThread } from "../chat/chat-thread.js";
|
|
|
3
3
|
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
|
+
import { AssistantBackendContext } from "./use-backend-data.js";
|
|
6
7
|
import { useEffect, useRef } from "react";
|
|
7
8
|
import { Sparkles } from "lucide-react";
|
|
8
9
|
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
9
10
|
//#region src/assistant-dock/assistant-dock.tsx
|
|
10
11
|
const defaultPlaceholder = "Ask a question...";
|
|
11
|
-
/**
|
|
12
|
-
|
|
12
|
+
/**
|
|
13
|
+
* The assistant beside the app: renders a backend's chat, its cards, the app's widgets and status line, and sends
|
|
14
|
+
* messages and clicks back.
|
|
15
|
+
*/
|
|
16
|
+
const AssistantDock = ({ state, offline, error, onSend, onAction, onClose, client }) => {
|
|
13
17
|
const input = useRef(null);
|
|
14
18
|
useEffect(() => {
|
|
15
19
|
if (state.placeholder) input.current?.focus();
|
|
@@ -29,16 +33,19 @@ const AssistantDock = ({ state, offline, error, onSend, onAction, onClose }) =>
|
|
|
29
33
|
busy: state.replying,
|
|
30
34
|
placeholder: state.placeholder ?? defaultPlaceholder
|
|
31
35
|
})] }),
|
|
32
|
-
children: /* @__PURE__ */ jsx(
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
children:
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
36
|
+
children: /* @__PURE__ */ jsx(AssistantBackendContext.Provider, {
|
|
37
|
+
value: client,
|
|
38
|
+
children: /* @__PURE__ */ jsx(ChatThread, {
|
|
39
|
+
length: state.messages.reduce((sum, message) => sum + message.parts.length, 0),
|
|
40
|
+
empty: "Ask a question about this app or its data.",
|
|
41
|
+
children: state.messages.map((message) => /* @__PURE__ */ jsx(ChatMessage, {
|
|
42
|
+
from: message.from,
|
|
43
|
+
children: message.parts.map((part) => /* @__PURE__ */ jsx(PartView, {
|
|
44
|
+
part,
|
|
45
|
+
onAction
|
|
46
|
+
}, part.id))
|
|
47
|
+
}, message.id))
|
|
48
|
+
})
|
|
42
49
|
})
|
|
43
50
|
});
|
|
44
51
|
};
|
|
@@ -2,4 +2,6 @@ import { AssistantDock, AssistantDockProps } from "./assistant-dock.js";
|
|
|
2
2
|
import { PartView } from "./part-view.js";
|
|
3
3
|
import { useAssistant } from "./use-assistant.js";
|
|
4
4
|
import { useAssistantClient } from "./use-assistant-client.js";
|
|
5
|
-
|
|
5
|
+
import { BackendData, BackendRefresh, useBackendData } from "./use-backend-data.js";
|
|
6
|
+
import "./widget-view.js";
|
|
7
|
+
export { AssistantDock, type AssistantDockProps, type BackendData, type BackendRefresh, PartView, useAssistant, useAssistantClient, useBackendData };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { AssistantPart } from "@protobase/schema";
|
|
2
2
|
//#region src/assistant-dock/part-view.d.ts
|
|
3
|
-
/** One part of a message, drawn with the primitive it names. */
|
|
3
|
+
/** One part of a message, drawn with the primitive it names, or a widget with the app's component it names. */
|
|
4
4
|
export declare const PartView: ({ part, onAction }: {
|
|
5
5
|
part: AssistantPart;
|
|
6
6
|
onAction?: (partId: string, actionId: string) => void;
|
|
@@ -3,9 +3,10 @@ import { FieldList } from "../action-card/field-list.js";
|
|
|
3
3
|
import { CompactTable } from "../compact-table/compact-table.js";
|
|
4
4
|
import { DiffView } from "../diff-view/diff-view.js";
|
|
5
5
|
import { StepList } from "../step-list/step-list.js";
|
|
6
|
+
import { WidgetView } from "./widget-view.js";
|
|
6
7
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
7
8
|
//#region src/assistant-dock/part-view.tsx
|
|
8
|
-
/** One part of a message, drawn with the primitive it names. */
|
|
9
|
+
/** One part of a message, drawn with the primitive it names, or a widget with the app's component it names. */
|
|
9
10
|
const PartView = ({ part, onAction }) => {
|
|
10
11
|
switch (part.type) {
|
|
11
12
|
case "text": return /* @__PURE__ */ jsx("p", { children: part.text });
|
|
@@ -41,6 +42,7 @@ const PartView = ({ part, onAction }) => {
|
|
|
41
42
|
}, `${index}-${diff.path}`))
|
|
42
43
|
]
|
|
43
44
|
});
|
|
45
|
+
case "widget": return /* @__PURE__ */ jsx(WidgetView, { part });
|
|
44
46
|
}
|
|
45
47
|
};
|
|
46
48
|
//#endregion
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { AssistantClient } from "@protobase/client";
|
|
2
|
+
//#region src/assistant-dock/use-backend-data.d.ts
|
|
3
|
+
/** How long to wait before reading again: a number of milliseconds while the data can still change, `false` once it cannot. */
|
|
4
|
+
export type BackendRefresh<T> = number | false | ((data: T | undefined) => number | false);
|
|
5
|
+
export type BackendData<T> = {
|
|
6
|
+
/** The last answer read or posted; `undefined` until the first one. */
|
|
7
|
+
data?: T;
|
|
8
|
+
/** Why the last read or post failed, such as an `ApiError` with the backend's detail; cleared by the next success. */
|
|
9
|
+
error?: Error;
|
|
10
|
+
/** Reads again now. */
|
|
11
|
+
reload: () => void;
|
|
12
|
+
/** Posts `body` as JSON to `path` on the backend; its answer replaces `data`. */
|
|
13
|
+
post: (path: string, body?: unknown) => Promise<void>;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* JSON from the assistant backend at `path` (see `AssistantClient.fetch`), read as the signed-in user each time the
|
|
17
|
+
* widget mounts, so a widget in an old chat shows the data as it is now; with `refreshMs`, read again while it can
|
|
18
|
+
* still change. Only inside the dock, which provides the backend.
|
|
19
|
+
*/
|
|
20
|
+
export declare const useBackendData: <T = unknown>(path: string, { refreshMs }?: {
|
|
21
|
+
refreshMs?: BackendRefresh<T>;
|
|
22
|
+
}) => BackendData<T>;
|
|
23
|
+
//#endregion
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { createContext, useContext, useEffect, useRef, useState } from "react";
|
|
2
|
+
//#region src/assistant-dock/use-backend-data.ts
|
|
3
|
+
/** The assistant backend the dock talks to, for the widgets it draws; `AssistantDock` provides it from its `client`. */
|
|
4
|
+
const AssistantBackendContext = createContext(void 0);
|
|
5
|
+
const asError = (reason) => reason instanceof Error ? reason : /* @__PURE__ */ new Error("The assistant backend did not answer");
|
|
6
|
+
const refreshOf = (refresh, data) => typeof refresh === "function" ? refresh(data) : refresh;
|
|
7
|
+
/**
|
|
8
|
+
* JSON from the assistant backend at `path` (see `AssistantClient.fetch`), read as the signed-in user each time the
|
|
9
|
+
* widget mounts, so a widget in an old chat shows the data as it is now; with `refreshMs`, read again while it can
|
|
10
|
+
* still change. Only inside the dock, which provides the backend.
|
|
11
|
+
*/
|
|
12
|
+
const useBackendData = (path, { refreshMs = false } = {}) => {
|
|
13
|
+
const client = useContext(AssistantBackendContext);
|
|
14
|
+
const [state, setState] = useState({});
|
|
15
|
+
const [reads, setReads] = useState(0);
|
|
16
|
+
const refresh = useRef(refreshMs);
|
|
17
|
+
refresh.current = refreshMs;
|
|
18
|
+
const latest = useRef(void 0);
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (!client) {
|
|
21
|
+
setState({ error: /* @__PURE__ */ new Error("useBackendData works only in a widget the assistant dock draws") });
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
let active = true;
|
|
25
|
+
let timer;
|
|
26
|
+
const read = async () => {
|
|
27
|
+
const answer = await client.fetch(path).then((response) => response.json()).then((data) => ({ data }), (reason) => ({
|
|
28
|
+
data: latest.current,
|
|
29
|
+
error: asError(reason)
|
|
30
|
+
}));
|
|
31
|
+
if (!active) return;
|
|
32
|
+
latest.current = answer.data;
|
|
33
|
+
setState(answer);
|
|
34
|
+
const wait = refreshOf(refresh.current, answer.data);
|
|
35
|
+
if (wait !== false) timer = setTimeout(read, wait);
|
|
36
|
+
};
|
|
37
|
+
read();
|
|
38
|
+
return () => {
|
|
39
|
+
active = false;
|
|
40
|
+
clearTimeout(timer);
|
|
41
|
+
};
|
|
42
|
+
}, [
|
|
43
|
+
client,
|
|
44
|
+
path,
|
|
45
|
+
reads
|
|
46
|
+
]);
|
|
47
|
+
return {
|
|
48
|
+
...state,
|
|
49
|
+
reload: () => setReads((count) => count + 1),
|
|
50
|
+
post: async (to, body) => {
|
|
51
|
+
if (!client) throw new Error("useBackendData works only in a widget the assistant dock draws");
|
|
52
|
+
const init = {
|
|
53
|
+
method: "POST",
|
|
54
|
+
headers: { "content-type": "application/json" },
|
|
55
|
+
body: JSON.stringify(body ?? {})
|
|
56
|
+
};
|
|
57
|
+
const answer = await client.fetch(to, init).then((response) => response.json()).then((data) => ({ data }), (reason) => ({
|
|
58
|
+
data: latest.current,
|
|
59
|
+
error: asError(reason)
|
|
60
|
+
}));
|
|
61
|
+
latest.current = answer.data;
|
|
62
|
+
setState(answer);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
//#endregion
|
|
67
|
+
export { AssistantBackendContext, useBackendData };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import "@protobase/schema";
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { ActionCard } from "../action-card/action-card.js";
|
|
2
|
+
import { useProjectUi } from "../app/pages/project-ui.js";
|
|
3
|
+
import { Component } from "react";
|
|
4
|
+
import { jsx } from "react/jsx-runtime";
|
|
5
|
+
//#region src/assistant-dock/widget-view.tsx
|
|
6
|
+
/** The part's fallback as a plain card with `note`, or a muted line naming the component when it has none. */
|
|
7
|
+
const Fallback = ({ part, note }) => part.fallback ? /* @__PURE__ */ jsx(ActionCard, {
|
|
8
|
+
title: part.fallback.title,
|
|
9
|
+
note,
|
|
10
|
+
children: part.fallback.body && /* @__PURE__ */ jsx("p", {
|
|
11
|
+
className: "text-[13px]",
|
|
12
|
+
children: part.fallback.body
|
|
13
|
+
})
|
|
14
|
+
}) : /* @__PURE__ */ jsx("p", {
|
|
15
|
+
className: "text-xs text-muted-foreground",
|
|
16
|
+
children: `${part.name}: ${note}`
|
|
17
|
+
});
|
|
18
|
+
var WidgetBoundary = class extends Component {
|
|
19
|
+
state = { failed: false };
|
|
20
|
+
static getDerivedStateFromError() {
|
|
21
|
+
return { failed: true };
|
|
22
|
+
}
|
|
23
|
+
render() {
|
|
24
|
+
return this.state.failed ? /* @__PURE__ */ jsx(Fallback, {
|
|
25
|
+
part: this.props.part,
|
|
26
|
+
note: "It could not be drawn."
|
|
27
|
+
}) : this.props.children;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
/** A widget part, drawn by the app's component of its name with its props; the fallback when the app has none. */
|
|
31
|
+
const WidgetView = ({ part }) => {
|
|
32
|
+
const { components = {} } = useProjectUi();
|
|
33
|
+
const Widget = Object.hasOwn(components, part.name) ? components[part.name] : void 0;
|
|
34
|
+
if (!Widget) return /* @__PURE__ */ jsx(Fallback, {
|
|
35
|
+
part,
|
|
36
|
+
note: "This version of the app cannot show it live."
|
|
37
|
+
});
|
|
38
|
+
return /* @__PURE__ */ jsx(WidgetBoundary, {
|
|
39
|
+
part,
|
|
40
|
+
children: /* @__PURE__ */ jsx(Widget, { ...part.props })
|
|
41
|
+
});
|
|
42
|
+
};
|
|
43
|
+
//#endregion
|
|
44
|
+
export { WidgetView };
|
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/dist/index.d.ts
CHANGED
|
@@ -8,12 +8,14 @@ import { useList } from "./data/use-list.js";
|
|
|
8
8
|
import { useRecord } from "./data/use-record.js";
|
|
9
9
|
import { useFacets, useHistogram, useSeries } from "./data/use-aggregates.js";
|
|
10
10
|
import { isConflict, useUpdateRecord } from "./data/use-update-record.js";
|
|
11
|
+
import { mergeUi } from "./app/pages/merge-ui.js";
|
|
11
12
|
import { LayoutRecord, useLayoutRecord } from "./app/pages/record-scope.js";
|
|
12
13
|
import { useApiToken } from "./auth/use-api-token.js";
|
|
13
14
|
import { AssistantDock, AssistantDockProps } from "./assistant-dock/assistant-dock.js";
|
|
14
15
|
import { PartView } from "./assistant-dock/part-view.js";
|
|
15
16
|
import { useAssistant } from "./assistant-dock/use-assistant.js";
|
|
16
17
|
import { useAssistantClient } from "./assistant-dock/use-assistant-client.js";
|
|
18
|
+
import { BackendData, BackendRefresh, useBackendData } from "./assistant-dock/use-backend-data.js";
|
|
17
19
|
import "./assistant-dock/index.js";
|
|
18
20
|
import { ChatMessage } from "./chat/chat-message.js";
|
|
19
21
|
import { ChatThread, ChatThreadProps } from "./chat/chat-thread.js";
|
|
@@ -28,4 +30,4 @@ import { Step, StepList, StepState } from "./step-list/step-list.js";
|
|
|
28
30
|
import { DiffView, DiffViewProps } from "./diff-view/diff-view.js";
|
|
29
31
|
import { DiffLine, diffStats, parseDiff } from "./diff-view/diff.js";
|
|
30
32
|
import { CompactTable, CompactTableProps } from "./compact-table/compact-table.js";
|
|
31
|
-
export { ActionCard, type ActionCardProps, type ActionContext, type ActionHandler, ApiProvider, type ApiProviderProps, App, type AppProps, AssistantDock, type AssistantDockProps, type CardAction, type CardTone, ChatMessage, ChatThread, type ChatThreadProps, CompactTable, type CompactTableProps, Composer, type ComposerProps, type DiffLine, DiffView, type DiffViewProps, FieldList, type LayoutRecord, type MetaData, PartView, type ProjectUi, SidePanel, type SidePanelProps, type Step, StepList, type StepState, defineUi, diffStats, isConflict, parseDiff, useApiToken, useAssistant, useAssistantClient, useClient, useFacets, useHistogram, useLayoutRecord, useList, useMeta, useRecord, useSeries, useUpdateRecord };
|
|
33
|
+
export { ActionCard, type ActionCardProps, type ActionContext, type ActionHandler, ApiProvider, type ApiProviderProps, App, type AppProps, AssistantDock, type AssistantDockProps, type BackendData, type BackendRefresh, type CardAction, type CardTone, ChatMessage, ChatThread, type ChatThreadProps, CompactTable, type CompactTableProps, Composer, type ComposerProps, type DiffLine, DiffView, type DiffViewProps, FieldList, type LayoutRecord, type MetaData, PartView, type ProjectUi, SidePanel, type SidePanelProps, type Step, StepList, type StepState, defineUi, diffStats, isConflict, mergeUi, parseDiff, useApiToken, useAssistant, useAssistantClient, useBackendData, useClient, useFacets, useHistogram, useLayoutRecord, useList, useMeta, useRecord, useSeries, useUpdateRecord };
|
package/dist/index.js
CHANGED
|
@@ -11,15 +11,17 @@ import { CompactTable } from "./compact-table/compact-table.js";
|
|
|
11
11
|
import { diffStats, parseDiff } from "./diff-view/diff.js";
|
|
12
12
|
import { DiffView } from "./diff-view/diff-view.js";
|
|
13
13
|
import { StepList } from "./step-list/step-list.js";
|
|
14
|
+
import { defineUi } from "./app/pages/project-ui.js";
|
|
14
15
|
import { PartView } from "./assistant-dock/part-view.js";
|
|
16
|
+
import { useBackendData } from "./assistant-dock/use-backend-data.js";
|
|
15
17
|
import { AssistantDock } from "./assistant-dock/assistant-dock.js";
|
|
16
18
|
import { useAssistant } from "./assistant-dock/use-assistant.js";
|
|
17
19
|
import { useAssistantClient } from "./assistant-dock/use-assistant-client.js";
|
|
18
20
|
import { useMeta } from "./data/use-meta.js";
|
|
19
21
|
import { useFacets, useHistogram, useSeries } from "./data/use-aggregates.js";
|
|
20
22
|
import { useLayoutRecord } from "./app/pages/record-scope.js";
|
|
21
|
-
import {
|
|
23
|
+
import { mergeUi } from "./app/pages/merge-ui.js";
|
|
22
24
|
import { useList } from "./data/use-list.js";
|
|
23
25
|
import { isConflict, useUpdateRecord } from "./data/use-update-record.js";
|
|
24
26
|
import { App } from "./app/app.js";
|
|
25
|
-
export { ActionCard, ApiProvider, App, AssistantDock, ChatMessage, ChatThread, CompactTable, Composer, DiffView, FieldList, PartView, SidePanel, StepList, defineUi, diffStats, isConflict, parseDiff, useApiToken, useAssistant, useAssistantClient, useClient, useFacets, useHistogram, useLayoutRecord, useList, useMeta, useRecord, useSeries, useUpdateRecord };
|
|
27
|
+
export { ActionCard, ApiProvider, App, AssistantDock, ChatMessage, ChatThread, CompactTable, Composer, DiffView, FieldList, PartView, SidePanel, StepList, defineUi, diffStats, isConflict, mergeUi, parseDiff, useApiToken, useAssistant, useAssistantClient, useBackendData, useClient, useFacets, useHistogram, useLayoutRecord, useList, useMeta, useRecord, useSeries, useUpdateRecord };
|
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",
|