@blocksdiy/blocks-client-sdk 1.24.2 → 1.25.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -0
- package/dist/Navigation.d.ts +40 -0
- package/dist/Navigation.d.ts.map +1 -0
- package/dist/Navigation.js +66 -0
- package/dist/ReactClientSdk.d.ts +1 -0
- package/dist/ReactClientSdk.d.ts.map +1 -1
- package/dist/ReactClientSdk.jsx +1 -0
- package/dist/app/AppRoutes.d.ts +8 -0
- package/dist/app/AppRoutes.d.ts.map +1 -0
- package/dist/app/AppRoutes.jsx +222 -0
- package/dist/app/BlocksApp.d.ts +22 -0
- package/dist/app/BlocksApp.d.ts.map +1 -0
- package/dist/app/BlocksApp.jsx +73 -0
- package/dist/app/SdkProvider.d.ts +6 -0
- package/dist/app/SdkProvider.d.ts.map +1 -0
- package/dist/app/SdkProvider.jsx +45 -0
- package/dist/app/app-shape/bundle-shape.d.ts +54 -0
- package/dist/app/app-shape/bundle-shape.d.ts.map +1 -0
- package/dist/app/app-shape/bundle-shape.js +118 -0
- package/dist/app/app-shape/route-tree.d.ts +33 -0
- package/dist/app/app-shape/route-tree.d.ts.map +1 -0
- package/dist/app/app-shape/route-tree.jsx +137 -0
- package/dist/app/app-shape/use-app-shape.d.ts +13 -0
- package/dist/app/app-shape/use-app-shape.d.ts.map +1 -0
- package/dist/app/app-shape/use-app-shape.js +13 -0
- package/dist/app/dev-server/bootstrap.d.ts +8 -0
- package/dist/app/dev-server/bootstrap.d.ts.map +1 -0
- package/dist/app/dev-server/bootstrap.js +43 -0
- package/dist/app/dev-server/fonts.d.ts +8 -0
- package/dist/app/dev-server/fonts.d.ts.map +1 -0
- package/dist/app/dev-server/fonts.js +67 -0
- package/dist/app/hooks/use-scroll-to-top-on-page-change.d.ts +17 -0
- package/dist/app/hooks/use-scroll-to-top-on-page-change.d.ts.map +1 -0
- package/dist/app/hooks/use-scroll-to-top-on-page-change.js +55 -0
- package/dist/app/host.d.ts +106 -0
- package/dist/app/host.d.ts.map +1 -0
- package/dist/app/host.js +67 -0
- package/dist/app/index.d.ts +14 -0
- package/dist/app/index.d.ts.map +1 -0
- package/dist/app/index.js +14 -0
- package/dist/app/sign-in/Login.d.ts +8 -0
- package/dist/app/sign-in/Login.d.ts.map +1 -0
- package/dist/app/sign-in/Login.jsx +194 -0
- package/dist/app/sign-in/api.d.ts +13 -0
- package/dist/app/sign-in/api.d.ts.map +1 -0
- package/dist/app/sign-in/api.js +47 -0
- package/dist/app/sign-in/auth-url.d.ts +7 -0
- package/dist/app/sign-in/auth-url.d.ts.map +1 -0
- package/dist/app/sign-in/auth-url.js +6 -0
- package/dist/app/types.d.ts +36 -0
- package/dist/app/types.d.ts.map +1 -0
- package/dist/app/types.js +1 -0
- package/dist/app/ui/icons.d.ts +12 -0
- package/dist/app/ui/icons.d.ts.map +1 -0
- package/dist/app/ui/icons.jsx +32 -0
- package/dist/app/ui/ui-styles.d.ts +15 -0
- package/dist/app/ui/ui-styles.d.ts.map +1 -0
- package/dist/app/ui/ui-styles.js +458 -0
- package/dist/dev-config.d.ts +15 -0
- package/dist/dev-config.d.ts.map +1 -0
- package/dist/dev-config.js +9 -0
- package/package.json +22 -6
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"host.d.ts","sourceRoot":"","sources":["../../src/app/host.ts"],"names":[],"mappings":"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"}
|
package/dist/app/host.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { useSyncExternalStore } from "react";
|
|
2
|
+
export const THEME_MODE_STORAGE_KEY = "app-theme-mode";
|
|
3
|
+
let state = null;
|
|
4
|
+
let extensions = {};
|
|
5
|
+
const listeners = new Set();
|
|
6
|
+
const emit = () => {
|
|
7
|
+
listeners.forEach((listener) => listener());
|
|
8
|
+
};
|
|
9
|
+
const requireState = () => {
|
|
10
|
+
if (!state) {
|
|
11
|
+
throw new Error("BlocksApp has no host: call setBlocksHost() before rendering it");
|
|
12
|
+
}
|
|
13
|
+
return state;
|
|
14
|
+
};
|
|
15
|
+
/** The theme mode to start from: the viewer's stored choice, else the app's default. */
|
|
16
|
+
export const initialThemeMode = (app) => {
|
|
17
|
+
const stored = window.localStorage.getItem(THEME_MODE_STORAGE_KEY);
|
|
18
|
+
return stored || (app?.theme ? "system" : "light");
|
|
19
|
+
};
|
|
20
|
+
const applyThemeModeClass = (themeMode) => {
|
|
21
|
+
const root = window.document.documentElement;
|
|
22
|
+
root.classList.remove("light", "dark");
|
|
23
|
+
if (themeMode === "system") {
|
|
24
|
+
root.classList.add(window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
root.classList.add(themeMode);
|
|
28
|
+
};
|
|
29
|
+
export const setBlocksHost = (next) => {
|
|
30
|
+
state = next;
|
|
31
|
+
applyThemeModeClass(next.themeMode);
|
|
32
|
+
emit();
|
|
33
|
+
};
|
|
34
|
+
export const updateBlocksHost = (patch) => {
|
|
35
|
+
state = { ...requireState(), ...patch };
|
|
36
|
+
emit();
|
|
37
|
+
};
|
|
38
|
+
export const getBlocksHostState = () => state;
|
|
39
|
+
export const setBlocksHostExtensions = (next) => {
|
|
40
|
+
extensions = next;
|
|
41
|
+
};
|
|
42
|
+
export const getBlocksHostExtensions = () => extensions;
|
|
43
|
+
/** The viewer's choice, or the host's: stored, applied to the document, and pushed to the tree. */
|
|
44
|
+
export const setThemeMode = (themeMode) => {
|
|
45
|
+
window.localStorage.setItem(THEME_MODE_STORAGE_KEY, themeMode);
|
|
46
|
+
applyThemeModeClass(themeMode);
|
|
47
|
+
updateBlocksHost({ themeMode });
|
|
48
|
+
};
|
|
49
|
+
const subscribe = (listener) => {
|
|
50
|
+
listeners.add(listener);
|
|
51
|
+
return () => {
|
|
52
|
+
listeners.delete(listener);
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* A value derived from the host state: the component re-renders only when that value changes. The
|
|
57
|
+
* value must stay the same between updates that don't change it: a field, or a primitive.
|
|
58
|
+
*/
|
|
59
|
+
export const useBlocksHostSelector = (select) => useSyncExternalStore(subscribe, () => select(requireState()), () => select(requireState()));
|
|
60
|
+
/** One field of the host state, re-rendering only when that field changes. */
|
|
61
|
+
export const useBlocksHostField = (key) => useBlocksHostSelector((state) => state[key]);
|
|
62
|
+
/** Tests only: forget the host between cases. */
|
|
63
|
+
export const resetBlocksHostForTests = () => {
|
|
64
|
+
state = null;
|
|
65
|
+
extensions = {};
|
|
66
|
+
listeners.clear();
|
|
67
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@blocksdiy/blocks-client-sdk/app`: a Blocks app's React tree.
|
|
3
|
+
*
|
|
4
|
+
* - An app renders `BlocksApp` from its `src/main.tsx`.
|
|
5
|
+
* - A page that hosts Blocks apps (Blocks' own hosting, or a page of your own) feeds it through the
|
|
6
|
+
* host contract: the state the tree reads, the layers it adds, and the reports it hears.
|
|
7
|
+
*/
|
|
8
|
+
export { BlocksApp, type BlocksAppProps } from "./BlocksApp";
|
|
9
|
+
export { type AppShape, type CompiledBundle, shapeFromBundle } from "./app-shape/bundle-shape.js";
|
|
10
|
+
export { type LoadedAppShape, useAppShape } from "./app-shape/use-app-shape.js";
|
|
11
|
+
export { type BlocksHostExtensions, type BlocksHostState, getBlocksHostExtensions, getBlocksHostState, initialThemeMode, setBlocksHost, setBlocksHostExtensions, setThemeMode, updateBlocksHost, useBlocksHostField, useBlocksHostSelector, } from "./host.js";
|
|
12
|
+
export { isAuthUrl } from "./sign-in/auth-url.js";
|
|
13
|
+
export type { AppType, BlocksRouteNode, BlocksRoutesTree, ThemeMode } from "./types.js";
|
|
14
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/app/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAGH,OAAO,EAAE,SAAS,EAAE,KAAK,cAAc,EAAE,MAAM,aAAa,CAAC;AAG7D,OAAO,EAAE,KAAK,QAAQ,EAAE,KAAK,cAAc,EAAE,eAAe,EAAE,MAAM,0BAA0B,CAAC;AAC/F,OAAO,EAAE,KAAK,cAAc,EAAE,WAAW,EAAE,MAAM,2BAA2B,CAAC;AAC7E,OAAO,EACL,KAAK,oBAAoB,EACzB,KAAK,eAAe,EACpB,uBAAuB,EACvB,kBAAkB,EAClB,gBAAgB,EAChB,aAAa,EACb,uBAAuB,EACvB,YAAY,EACZ,gBAAgB,EAChB,kBAAkB,EAClB,qBAAqB,GACtB,MAAM,QAAQ,CAAC;AAChB,OAAO,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAC/C,YAAY,EAAE,OAAO,EAAE,eAAe,EAAE,gBAAgB,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@blocksdiy/blocks-client-sdk/app`: a Blocks app's React tree.
|
|
3
|
+
*
|
|
4
|
+
* - An app renders `BlocksApp` from its `src/main.tsx`.
|
|
5
|
+
* - A page that hosts Blocks apps (Blocks' own hosting, or a page of your own) feeds it through the
|
|
6
|
+
* host contract: the state the tree reads, the layers it adds, and the reports it hears.
|
|
7
|
+
*/
|
|
8
|
+
// For an app's src/main.tsx
|
|
9
|
+
export { BlocksApp } from "./BlocksApp";
|
|
10
|
+
// For a page that hosts Blocks apps
|
|
11
|
+
export { shapeFromBundle } from "./app-shape/bundle-shape.js";
|
|
12
|
+
export { useAppShape } from "./app-shape/use-app-shape.js";
|
|
13
|
+
export { getBlocksHostExtensions, getBlocksHostState, initialThemeMode, setBlocksHost, setBlocksHostExtensions, setThemeMode, updateBlocksHost, useBlocksHostField, useBlocksHostSelector, } from "./host.js";
|
|
14
|
+
export { isAuthUrl } from "./sign-in/auth-url.js";
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
/**
|
|
3
|
+
* The app's sign-in outside the builder: a login link by email, or Google through the platform.
|
|
4
|
+
* Also the landing for the platform's magic-login, app-login and signup-invitation hops, which it
|
|
5
|
+
* forwards or completes and never renders.
|
|
6
|
+
*/
|
|
7
|
+
export declare const Login: () => React.JSX.Element | null;
|
|
8
|
+
//# sourceMappingURL=Login.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Login.d.ts","sourceRoot":"","sources":["../../../src/app/sign-in/Login.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA0D,MAAM,OAAO,CAAC;AAkC/E;;;;GAIG;AACH,eAAO,MAAM,KAAK,gCAsNjB,CAAC"}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import React, { useEffect, useInsertionEffect, useRef, useState } from "react";
|
|
2
|
+
import { useSearchParams } from "react-router";
|
|
3
|
+
import { tokenStorageKey } from "../../dev-config.js";
|
|
4
|
+
import { useBlocksHostField, useBlocksHostSelector } from "../host.js";
|
|
5
|
+
import { AlertIcon, GoogleIcon, MailIcon, SpinnerIcon } from "../ui/icons";
|
|
6
|
+
import { applyLoginAppTokens, ensureUiStyles } from "../ui/ui-styles.js";
|
|
7
|
+
import { getAppDataByDomain, login } from "./api.js";
|
|
8
|
+
const injectedAppData = () => window.__APP_DATA__;
|
|
9
|
+
/** The platform's links that land here and go on to the platform, which signs in and comes back. */
|
|
10
|
+
const FORWARDED_HOPS = new Set(["/auth/magic-login", "/auth/signup-invitation"]);
|
|
11
|
+
const APP_LOGIN_HOP = "/auth/app-login";
|
|
12
|
+
/** `path` when it stays on this app's origin, else undefined: a return path must not leave the app. */
|
|
13
|
+
const sameOriginPath = (path) => {
|
|
14
|
+
if (!path?.startsWith("/")) {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
const url = new URL(path, window.location.origin);
|
|
19
|
+
return url.origin === window.location.origin ? `${url.pathname}${url.search}${url.hash}` : undefined;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* The app's sign-in outside the builder: a login link by email, or Google through the platform.
|
|
27
|
+
* Also the landing for the platform's magic-login, app-login and signup-invitation hops, which it
|
|
28
|
+
* forwards or completes and never renders.
|
|
29
|
+
*/
|
|
30
|
+
export const Login = () => {
|
|
31
|
+
const [email, setEmail] = useState("");
|
|
32
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
33
|
+
const [isSuccess, setIsSuccess] = useState(false);
|
|
34
|
+
const [error, setError] = useState("");
|
|
35
|
+
const [searchParams] = useSearchParams();
|
|
36
|
+
const hasForwarded = useRef(false);
|
|
37
|
+
const [appData, setAppData] = useState(injectedAppData);
|
|
38
|
+
const token = useBlocksHostField("token");
|
|
39
|
+
const appVersion = useBlocksHostField("appVersion");
|
|
40
|
+
const config = useBlocksHostField("config");
|
|
41
|
+
const hostAppId = useBlocksHostSelector((state) => state.app?.id);
|
|
42
|
+
const { pathname } = window.location;
|
|
43
|
+
const isHop = FORWARDED_HOPS.has(pathname) || pathname === APP_LOGIN_HOP;
|
|
44
|
+
// Before the form is laid out (its autofocus would otherwise style it unthemed, and the buttons
|
|
45
|
+
// would fade in): the edge-injected app data carries the app's own theme tokens.
|
|
46
|
+
useInsertionEffect(() => {
|
|
47
|
+
ensureUiStyles();
|
|
48
|
+
applyLoginAppTokens(injectedAppData()?.indexCssTheme);
|
|
49
|
+
}, []);
|
|
50
|
+
// A page the edge didn't serve has no injected app data: the platform answers the same question.
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (injectedAppData()) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
getAppDataByDomain(config.baseUrl, window.location.hostname, appVersion, token, hostAppId)
|
|
56
|
+
.then((data) => {
|
|
57
|
+
applyLoginAppTokens(data.indexCssTheme);
|
|
58
|
+
setAppData(data);
|
|
59
|
+
})
|
|
60
|
+
.catch((error) => {
|
|
61
|
+
console.error("Failed to fetch app data:", error);
|
|
62
|
+
});
|
|
63
|
+
}, [token, appVersion, config.baseUrl, hostAppId]);
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
const hopToken = searchParams.get("token");
|
|
66
|
+
if (!hopToken) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (pathname === APP_LOGIN_HOP) {
|
|
70
|
+
localStorage.setItem(tokenStorageKey(), hopToken);
|
|
71
|
+
window.location.href = sameOriginPath(searchParams.get("returnPath")) ?? "/";
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (FORWARDED_HOPS.has(pathname) && !hasForwarded.current) {
|
|
75
|
+
hasForwarded.current = true;
|
|
76
|
+
const query = new URLSearchParams({ token: hopToken, redirectUrl: window.location.origin });
|
|
77
|
+
window.location.href = `${config.baseUrl}${pathname}?${query}`;
|
|
78
|
+
}
|
|
79
|
+
}, [searchParams, config.baseUrl, pathname]);
|
|
80
|
+
// Every app on localhost shares the origin: the platform is told which one (the host's, on the
|
|
81
|
+
// dev server, where the app data can't name it).
|
|
82
|
+
const localAppId = window.location.origin.includes("localhost") || window.location.origin.includes("127.0.0.1")
|
|
83
|
+
? (appData?.id ?? hostAppId)
|
|
84
|
+
: undefined;
|
|
85
|
+
const returnPath = !pathname.startsWith("/auth")
|
|
86
|
+
? pathname + window.location.search
|
|
87
|
+
: (searchParams.get("returnPath") ?? undefined);
|
|
88
|
+
const sendLink = async () => {
|
|
89
|
+
setIsSubmitting(true);
|
|
90
|
+
setError("");
|
|
91
|
+
try {
|
|
92
|
+
const desktopCallback = searchParams.get("desktopCallback") ?? undefined;
|
|
93
|
+
const { redirectToSsoUrl } = await login(config.baseUrl, email, window.location.origin, desktopCallback, localAppId);
|
|
94
|
+
if (redirectToSsoUrl) {
|
|
95
|
+
// Off to the company's sign-in: the button stays busy until the page goes.
|
|
96
|
+
window.location.href = redirectToSsoUrl;
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
setIsSuccess(true);
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
setError(err instanceof Error ? err.message : "Login failed");
|
|
103
|
+
}
|
|
104
|
+
setIsSubmitting(false);
|
|
105
|
+
};
|
|
106
|
+
const handleSubmit = (e) => {
|
|
107
|
+
e.preventDefault();
|
|
108
|
+
void sendLink();
|
|
109
|
+
};
|
|
110
|
+
const changeEmail = () => {
|
|
111
|
+
setIsSuccess(false);
|
|
112
|
+
setEmail("");
|
|
113
|
+
setError("");
|
|
114
|
+
};
|
|
115
|
+
if (isHop) {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
if (isSuccess) {
|
|
119
|
+
return (<div className="blocks-ui ui-login-page">
|
|
120
|
+
<div className="ui-login">
|
|
121
|
+
<div className="ui-card">
|
|
122
|
+
<div className="ui-card-header">
|
|
123
|
+
<div className="ui-icon-tile">
|
|
124
|
+
<MailIcon />
|
|
125
|
+
</div>
|
|
126
|
+
<h1 className="ui-card-title">Check your email</h1>
|
|
127
|
+
<p className="ui-card-description">
|
|
128
|
+
We sent a login link to <span className="ui-strong">{email}</span>. Open it to continue.
|
|
129
|
+
</p>
|
|
130
|
+
</div>
|
|
131
|
+
<div className="ui-card-content">
|
|
132
|
+
{error && (<div role="alert" className="ui-alert">
|
|
133
|
+
<AlertIcon />
|
|
134
|
+
<div className="ui-alert-description">{error}</div>
|
|
135
|
+
</div>)}
|
|
136
|
+
<div className="ui-actions">
|
|
137
|
+
<button type="button" onClick={() => void sendLink()} disabled={isSubmitting} className="ui-button ui-button-outline ui-button-full">
|
|
138
|
+
{isSubmitting && <SpinnerIcon className="ui-spinner"/>}
|
|
139
|
+
Send another link
|
|
140
|
+
</button>
|
|
141
|
+
<button type="button" onClick={changeEmail} disabled={isSubmitting} className="ui-button ui-button-outline ui-button-full">
|
|
142
|
+
Use a different email
|
|
143
|
+
</button>
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
</div>);
|
|
149
|
+
}
|
|
150
|
+
return (<div className="blocks-ui ui-login-page">
|
|
151
|
+
<div className="ui-login">
|
|
152
|
+
<div className="ui-card">
|
|
153
|
+
<div className="ui-card-header">
|
|
154
|
+
<h1 className="ui-card-title">{appData?.name ? `Welcome to ${appData?.name}` : "Welcome"}</h1>
|
|
155
|
+
<p className="ui-card-description">Enter your details below to login</p>
|
|
156
|
+
</div>
|
|
157
|
+
<div className="ui-card-content">
|
|
158
|
+
{error && (<div role="alert" className="ui-alert">
|
|
159
|
+
<AlertIcon />
|
|
160
|
+
<div className="ui-alert-description">{error}</div>
|
|
161
|
+
</div>)}
|
|
162
|
+
<form onSubmit={handleSubmit} className="ui-form">
|
|
163
|
+
<div className="ui-field">
|
|
164
|
+
<label htmlFor="email" className="ui-label">
|
|
165
|
+
Email
|
|
166
|
+
</label>
|
|
167
|
+
<input id="email" type="email" className="ui-input" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="name@company.com" required autoFocus disabled={isSubmitting}/>
|
|
168
|
+
</div>
|
|
169
|
+
<button type="submit" disabled={isSubmitting || !email} className="ui-button ui-button-default ui-button-full">
|
|
170
|
+
{isSubmitting && <SpinnerIcon className="ui-spinner"/>}
|
|
171
|
+
Send me a login link
|
|
172
|
+
</button>
|
|
173
|
+
</form>
|
|
174
|
+
|
|
175
|
+
<div className="ui-separator-text">
|
|
176
|
+
<span>Or</span>
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
<form action={`${config.googleBaseUrl}/authentication/oauth`} method="get" className="ui-form">
|
|
180
|
+
<input type="hidden" name="flow" value="app-login"/>
|
|
181
|
+
<input type="hidden" name="appUrl" value={window.location.origin}/>
|
|
182
|
+
{localAppId && <input type="hidden" name="appId" value={localAppId}/>}
|
|
183
|
+
{returnPath && <input type="hidden" name="returnPath" value={returnPath}/>}
|
|
184
|
+
{searchParams.get("desktopCallback") && (<input type="hidden" name="desktopCallback" value={searchParams.get("desktopCallback")}/>)}
|
|
185
|
+
<button className="ui-button ui-button-outline ui-button-full" type="submit">
|
|
186
|
+
<GoogleIcon />
|
|
187
|
+
Continue with Google
|
|
188
|
+
</button>
|
|
189
|
+
</form>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
</div>);
|
|
194
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { IndexCssTheme } from "../ui/ui-styles.js";
|
|
2
|
+
/** The platform calls the standalone login makes, against the host's `config.baseUrl`. */
|
|
3
|
+
export declare const getAppDataByDomain: (baseUrl: string, domain: string, version?: number, token?: string,
|
|
4
|
+
/** The app, where the domain alone can't name it (every app on localhost shares it). */
|
|
5
|
+
appId?: string) => Promise<{
|
|
6
|
+
id: string;
|
|
7
|
+
name: string;
|
|
8
|
+
indexCssTheme?: IndexCssTheme;
|
|
9
|
+
}>;
|
|
10
|
+
export declare const login: (baseUrl: string, email: string, appUrl: string, desktopCallback?: string, appId?: string) => Promise<{
|
|
11
|
+
redirectToSsoUrl?: string;
|
|
12
|
+
}>;
|
|
13
|
+
//# sourceMappingURL=api.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../../../src/app/sign-in/api.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAErD,0FAA0F;AAE1F,eAAO,MAAM,kBAAkB,GAC7B,SAAS,MAAM,EACf,QAAQ,MAAM,EACd,UAAU,MAAM,EAChB,QAAQ,MAAM;AACd,wFAAwF;AACxF,QAAQ,MAAM,KACb,OAAO,CAAC;IACT,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,aAAa,CAAC,EAAE,aAAa,CAAC;CAC/B,CAeA,CAAC;AASF,eAAO,MAAM,KAAK,GAChB,SAAS,MAAM,EACf,OAAO,MAAM,EACb,QAAQ,MAAM,EACd,kBAAkB,MAAM,EACxB,QAAQ,MAAM,KACb,OAAO,CAAC;IAAE,gBAAgB,CAAC,EAAE,MAAM,CAAA;CAAE,CA4BvC,CAAC"}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** The platform calls the standalone login makes, against the host's `config.baseUrl`. */
|
|
2
|
+
export const getAppDataByDomain = async (baseUrl, domain, version, token,
|
|
3
|
+
/** The app, where the domain alone can't name it (every app on localhost shares it). */
|
|
4
|
+
appId) => {
|
|
5
|
+
const query = new URLSearchParams();
|
|
6
|
+
if (version) {
|
|
7
|
+
query.set("version", String(version));
|
|
8
|
+
}
|
|
9
|
+
if (appId) {
|
|
10
|
+
query.set("appId", appId);
|
|
11
|
+
}
|
|
12
|
+
const search = query.toString();
|
|
13
|
+
const response = await fetch(`${baseUrl}/api/domains/get-app-data-by-domain/${domain}${search ? `?${search}` : ""}`, {
|
|
14
|
+
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
|
15
|
+
});
|
|
16
|
+
const data = await response.json();
|
|
17
|
+
return data;
|
|
18
|
+
};
|
|
19
|
+
function isValidDesktopCallback(callback) {
|
|
20
|
+
if (callback.startsWith("blocks-desktop://")) {
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
return /^https?:\/\/(127\.0\.0\.1|localhost)(:\d+)?\/auth\/callback\/?$/i.test(callback);
|
|
24
|
+
}
|
|
25
|
+
export const login = async (baseUrl, email, appUrl, desktopCallback, appId) => {
|
|
26
|
+
const formData = new FormData();
|
|
27
|
+
formData.append("email", email);
|
|
28
|
+
formData.append("appUrl", appUrl);
|
|
29
|
+
if (desktopCallback && isValidDesktopCallback(desktopCallback)) {
|
|
30
|
+
formData.append("desktopCallback", desktopCallback);
|
|
31
|
+
}
|
|
32
|
+
if (appId) {
|
|
33
|
+
formData.append("appId", appId);
|
|
34
|
+
}
|
|
35
|
+
const response = await fetch(`${baseUrl}/auth/login-request`, {
|
|
36
|
+
method: "POST",
|
|
37
|
+
body: formData,
|
|
38
|
+
});
|
|
39
|
+
// A link the platform couldn't send comes back as `{ errors }` with a 200: a list for a failed send,
|
|
40
|
+
// an object keyed by field for invalid input. Either way its message is the one shown.
|
|
41
|
+
const data = (await response.json().catch(() => ({})));
|
|
42
|
+
if (!response.ok || !data.success) {
|
|
43
|
+
const [firstError] = Object.values(data.errors ?? {});
|
|
44
|
+
throw new Error(firstError?.message || "We couldn't send the login link. Please try again.");
|
|
45
|
+
}
|
|
46
|
+
return { redirectToSsoUrl: data.redirectToSsoUrl };
|
|
47
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether `pathname` is a platform-owned auth URL. Segment boundary on
|
|
3
|
+
* purpose: /auth and /auth/* are the platform's (login/logout), but an app's
|
|
4
|
+
* own /authors or /authorize must route normally.
|
|
5
|
+
*/
|
|
6
|
+
export declare const isAuthUrl: (pathname: string) => boolean;
|
|
7
|
+
//# sourceMappingURL=auth-url.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth-url.d.ts","sourceRoot":"","sources":["../../../src/app/sign-in/auth-url.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,eAAO,MAAM,SAAS,GAAI,UAAU,MAAM,KAAG,OAAgE,CAAC"}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether `pathname` is a platform-owned auth URL. Segment boundary on
|
|
3
|
+
* purpose: /auth and /auth/* are the platform's (login/logout), but an app's
|
|
4
|
+
* own /authors or /authorize must route normally.
|
|
5
|
+
*/
|
|
6
|
+
export const isAuthUrl = (pathname) => pathname === "/auth" || pathname.startsWith("/auth/");
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ComponentType } from "react";
|
|
2
|
+
/** The viewer's light/dark choice; `system` follows the device. */
|
|
3
|
+
export type ThemeMode = "light" | "dark" | "system";
|
|
4
|
+
export interface AppType {
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
faviconUrl?: string;
|
|
8
|
+
defaultPageBlockId?: string;
|
|
9
|
+
compiledAt: number;
|
|
10
|
+
theme?: {
|
|
11
|
+
variables: Record<string, string>;
|
|
12
|
+
darkVariables: Record<string, string>;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Node of the file-system route tree emitted by routes-mode bundles
|
|
17
|
+
* (`window.compiler.__blocksRoutes`). `id` is the route id — the file path
|
|
18
|
+
* relative to src/ without extension (e.g. `routes/concerts/[city]`) —
|
|
19
|
+
* `path` the segment(s) relative to the parent route.
|
|
20
|
+
*/
|
|
21
|
+
export interface BlocksRouteNode {
|
|
22
|
+
id: string;
|
|
23
|
+
path?: string;
|
|
24
|
+
index?: boolean;
|
|
25
|
+
Component: ComponentType;
|
|
26
|
+
children?: BlocksRouteNode[];
|
|
27
|
+
}
|
|
28
|
+
/** Routes-mode bundle shape: the whole tree plus the src/root.tsx root layout. */
|
|
29
|
+
export interface BlocksRoutesTree {
|
|
30
|
+
root?: {
|
|
31
|
+
id: string;
|
|
32
|
+
Component: ComponentType;
|
|
33
|
+
};
|
|
34
|
+
routes: BlocksRouteNode[];
|
|
35
|
+
}
|
|
36
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/app/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,OAAO,CAAC;AAE3C,mEAAmE;AACnE,MAAM,MAAM,SAAS,GAAG,OAAO,GAAG,MAAM,GAAG,QAAQ,CAAC;AAEpD,MAAM,WAAW,OAAO;IACtB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE;QAAE,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;QAAC,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;KAAE,CAAC;CACtF;AAED;;;;;GAKG;AACH,MAAM,WAAW,eAAe;IAC9B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,SAAS,EAAE,aAAa,CAAC;IACzB,QAAQ,CAAC,EAAE,eAAe,EAAE,CAAC;CAC9B;AAED,kFAAkF;AAClF,MAAM,WAAW,gBAAgB;IAC/B,IAAI,CAAC,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,aAAa,CAAA;KAAE,CAAC;IAChD,MAAM,EAAE,eAAe,EAAE,CAAC;CAC3B"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { SVGProps } from "react";
|
|
2
|
+
/** Lucide `loader-circle`, shadcn's spinner. Decorative: what it waits for says so in text. */
|
|
3
|
+
export declare const SpinnerIcon: (props: SVGProps<SVGSVGElement>) => import("react").JSX.Element;
|
|
4
|
+
/** Lucide `circle-alert`. */
|
|
5
|
+
export declare const AlertIcon: (props: SVGProps<SVGSVGElement>) => import("react").JSX.Element;
|
|
6
|
+
/** Lucide `mail`. */
|
|
7
|
+
export declare const MailIcon: (props: SVGProps<SVGSVGElement>) => import("react").JSX.Element;
|
|
8
|
+
/** Lucide `file-question-mark`. */
|
|
9
|
+
export declare const MissingPageIcon: (props: SVGProps<SVGSVGElement>) => import("react").JSX.Element;
|
|
10
|
+
/** Google's "G", in its own colors. */
|
|
11
|
+
export declare const GoogleIcon: (props: SVGProps<SVGSVGElement>) => import("react").JSX.Element;
|
|
12
|
+
//# sourceMappingURL=icons.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"icons.d.ts","sourceRoot":"","sources":["../../../src/app/ui/icons.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAmBtC,+FAA+F;AAC/F,eAAO,MAAM,WAAW,GAAI,OAAO,QAAQ,CAAC,aAAa,CAAC,gCAIzD,CAAC;AAEF,6BAA6B;AAC7B,eAAO,MAAM,SAAS,GAAI,OAAO,QAAQ,CAAC,aAAa,CAAC,gCAMvD,CAAC;AAEF,qBAAqB;AACrB,eAAO,MAAM,QAAQ,GAAI,OAAO,QAAQ,CAAC,aAAa,CAAC,gCAKtD,CAAC;AAEF,mCAAmC;AACnC,eAAO,MAAM,eAAe,GAAI,OAAO,QAAQ,CAAC,aAAa,CAAC,gCAM7D,CAAC;AAEF,uCAAuC;AACvC,eAAO,MAAM,UAAU,GAAI,OAAO,QAAQ,CAAC,aAAa,CAAC,gCAmBxD,CAAC"}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** The Lucide icons shadcn's components use, inline: the platform's UI loads nothing from elsewhere. */
|
|
2
|
+
const Lucide = ({ children, ...props }) => (<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...props}>
|
|
3
|
+
{children}
|
|
4
|
+
</svg>);
|
|
5
|
+
/** Lucide `loader-circle`, shadcn's spinner. Decorative: what it waits for says so in text. */
|
|
6
|
+
export const SpinnerIcon = (props) => (<Lucide {...props}>
|
|
7
|
+
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
|
|
8
|
+
</Lucide>);
|
|
9
|
+
/** Lucide `circle-alert`. */
|
|
10
|
+
export const AlertIcon = (props) => (<Lucide {...props}>
|
|
11
|
+
<circle cx="12" cy="12" r="10"/>
|
|
12
|
+
<line x1="12" x2="12" y1="8" y2="12"/>
|
|
13
|
+
<line x1="12" x2="12.01" y1="16" y2="16"/>
|
|
14
|
+
</Lucide>);
|
|
15
|
+
/** Lucide `mail`. */
|
|
16
|
+
export const MailIcon = (props) => (<Lucide {...props}>
|
|
17
|
+
<rect width="20" height="16" x="2" y="4" rx="2"/>
|
|
18
|
+
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>
|
|
19
|
+
</Lucide>);
|
|
20
|
+
/** Lucide `file-question-mark`. */
|
|
21
|
+
export const MissingPageIcon = (props) => (<Lucide {...props}>
|
|
22
|
+
<path d="M12 17h.01"/>
|
|
23
|
+
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7z"/>
|
|
24
|
+
<path d="M9.1 9a3 3 0 0 1 5.82 1c0 2-3 3-3 3"/>
|
|
25
|
+
</Lucide>);
|
|
26
|
+
/** Google's "G", in its own colors. */
|
|
27
|
+
export const GoogleIcon = (props) => (<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" {...props}>
|
|
28
|
+
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
|
|
29
|
+
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
|
|
30
|
+
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
|
|
31
|
+
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
|
32
|
+
</svg>);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** The app's theme tokens as the platform's public app data carries them (`indexCssTheme`). */
|
|
2
|
+
export interface IndexCssTheme {
|
|
3
|
+
variables?: Record<string, string>;
|
|
4
|
+
darkVariables?: Record<string, string>;
|
|
5
|
+
}
|
|
6
|
+
/** Adds the stylesheet once; every component that renders this UI calls it before it lays out. */
|
|
7
|
+
export declare const ensureUiStyles: () => void;
|
|
8
|
+
/**
|
|
9
|
+
* The app's own tokens, on the login only. A signed-out visitor's page has no app CSS (the bundle
|
|
10
|
+
* loads after sign-in), so the platform's public app data carries the tokens of the app's
|
|
11
|
+
* `index.css`, and they are set on the login's page: light, and under the host's `dark` class.
|
|
12
|
+
* Checked again here, since it is network data written into a stylesheet.
|
|
13
|
+
*/
|
|
14
|
+
export declare const applyLoginAppTokens: (theme: IndexCssTheme | undefined) => void;
|
|
15
|
+
//# sourceMappingURL=ui-styles.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ui-styles.d.ts","sourceRoot":"","sources":["../../../src/app/ui/ui-styles.ts"],"names":[],"mappings":"AAaA,+FAA+F;AAC/F,MAAM,WAAW,aAAa;IAC5B,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACnC,aAAa,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACxC;AAsZD,kGAAkG;AAClG,eAAO,MAAM,cAAc,YAQ1B,CAAC;AAyBF;;;;;GAKG;AACH,eAAO,MAAM,mBAAmB,GAAI,OAAO,aAAa,GAAG,SAAS,SAcnE,CAAC"}
|