@lowdefy/client 6.1.0 → 7.1.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/Client.js +54 -17
- package/dist/Context.js +30 -12
- package/dist/HtmlOverlay.js +127 -0
- package/dist/IconProvider.js +24 -0
- package/dist/MountEvents.js +3 -3
- package/dist/adapters/createRouter.js +9 -3
- package/dist/adapters/url.js +1 -7
- package/dist/auth/createAuthMethods.js +1034 -41
- package/dist/block/Block.js +6 -3
- package/dist/block/CategorySwitch.js +6 -0
- package/dist/block/Container.js +3 -0
- package/dist/block/InputContainer.js +8 -2
- package/dist/block/List.js +3 -0
- package/dist/block/LoadingBlock.js +6 -12
- package/dist/block/LoadingContainer.js +6 -1
- package/dist/block/LoadingList.js +6 -1
- package/dist/block/loadingBlockMethods.js +26 -0
- package/dist/claimCallerReload.js +38 -0
- package/dist/createAntdIconConfig.js +91 -0
- package/dist/createCallAPI.js +6 -2
- package/dist/createCallRequest.js +6 -2
- package/dist/createHandleError.js +6 -4
- package/dist/createIcon.js +138 -36
- package/dist/createPageLifecycleManager.js +172 -0
- package/dist/formatIconTitle.js +26 -0
- package/dist/getActiveLocale.js +27 -0
- package/dist/getContextKey.js +29 -0
- package/dist/getMessageIcon.js +43 -0
- package/dist/hasOtherRoles.js +30 -0
- package/dist/index.js +1 -0
- package/dist/initLowdefyContext.js +103 -4
- package/dist/isOtherCaller.js +29 -0
- package/dist/recordCallerStamp.js +31 -0
- package/dist/renderIconNodes.js +46 -0
- package/dist/request.js +72 -5
- package/dist/setLowdefyUser.js +32 -0
- package/dist/setupLink.js +19 -13
- package/dist/shouldReloadForAuthError.js +39 -0
- package/dist/shouldReloadForBuild.js +44 -0
- package/dist/shouldReloadForCaller.js +50 -0
- package/dist/storage/clearUserStorage.js +36 -0
- package/dist/storage/clearUserStorageOnOwnerChange.js +41 -0
- package/dist/storage/createStorage.js +48 -0
- package/dist/storage/listUserStorageKeys.js +36 -0
- package/dist/storage/readStorageItem.js +31 -0
- package/dist/storage/userStorageKey.js +24 -0
- package/dist/storage/userStoragePrefix.js +25 -0
- package/dist/useDarkMode.js +17 -4
- package/dist/useLocale.js +18 -5
- package/dist/websocket/createWebSocketClient.js +234 -98
- package/package.json +11 -10
|
@@ -12,59 +12,1052 @@
|
|
|
12
12
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
13
|
See the License for the specific language governing permissions and
|
|
14
14
|
limitations under the License.
|
|
15
|
-
*/ import {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
15
|
+
*/ import { rememberTarget, resolveTarget, stopChain } from '@lowdefy/engine';
|
|
16
|
+
import { ConfigError, UserError } from '@lowdefy/errors';
|
|
17
|
+
import { type } from '@lowdefy/helpers';
|
|
18
|
+
import { createUrl } from '../adapters/url.js';
|
|
19
|
+
import setLowdefyUser from '../setLowdefyUser.js';
|
|
20
|
+
// Accepts only a path-absolute URL from the ?callbackUrl= query. A bare
|
|
21
|
+
// startsWith('/') is not enough: "//evil.com" and "/\evil.com" also start with
|
|
22
|
+
// "/" but are protocol-relative (browsers normalize the backslash to a slash),
|
|
23
|
+
// so a crafted sign-in link would navigate off-site with a fresh session. This
|
|
24
|
+
// value is the one target that never reaches BetterAuth on the email, phone and
|
|
25
|
+
// passkey paths - it goes straight into window.location.assign - so its
|
|
26
|
+
// server-side originCheck / trustedOrigins do not cover it.
|
|
27
|
+
function isAppRelativePath(value) {
|
|
28
|
+
return type.isString(value) && /^\/([^/\\]|$)/.test(value);
|
|
29
|
+
}
|
|
30
|
+
// The action's callbackUrl param wins; otherwise honor the callbackUrl query
|
|
31
|
+
// param set by the unauthenticated page redirect, so login returns to the
|
|
32
|
+
// page the user asked for; otherwise the app's home page. Only app-relative
|
|
33
|
+
// paths are accepted from the query to avoid open redirects. The query fallback
|
|
34
|
+
// is exclusive to the primary callbackUrl - a new-user or error destination has
|
|
35
|
+
// no equivalent query source, so reading it for them would misroute.
|
|
36
|
+
//
|
|
37
|
+
// Returns resolveTarget's typed, un-prefixed union (or undefined), never a
|
|
38
|
+
// string: the caller navigates it via the router or serializes it for a wire
|
|
39
|
+
// hop, and basePath is applied only at those single boundaries.
|
|
40
|
+
function resolveCallbackURL({ lowdefy, callbackUrl }) {
|
|
41
|
+
// An explicit refusal to navigate, above the ladder so a bounced sign-in
|
|
42
|
+
// honors it too. Absence means "go home" below, so "stay put" needs its own
|
|
43
|
+
// spelling - a login form in a modal or an embedded panel relies on the
|
|
44
|
+
// session store re-rendering the tree with the new user in place.
|
|
45
|
+
if (callbackUrl === false) {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
// resolveTarget returns undefined for a non-object or empty target, so a bare
|
|
49
|
+
// string (the spelling the schemas wrongly declared before this change, still
|
|
50
|
+
// common in apps) falls through the ladder rather than failing the sign-in.
|
|
51
|
+
const explicit = resolveTarget({
|
|
52
|
+
lowdefy,
|
|
53
|
+
target: callbackUrl,
|
|
54
|
+
name: 'callbackUrl'
|
|
55
|
+
});
|
|
56
|
+
if (!type.isNone(explicit)) {
|
|
57
|
+
return explicit;
|
|
58
|
+
}
|
|
59
|
+
const window = lowdefy._internal?.globals?.window;
|
|
60
|
+
const fromQuery = new URLSearchParams(window?.location?.search ?? '').get('callbackUrl');
|
|
61
|
+
// The parser drops tabs and newlines the path test above cannot see, so
|
|
62
|
+
// "/\t/evil.com/x" reads as another host; only a same-origin result counts.
|
|
63
|
+
const parsed = isAppRelativePath(fromQuery) ? new URL(fromQuery, window.location.origin) : null;
|
|
64
|
+
if (parsed !== null && parsed.origin === window.location.origin) {
|
|
65
|
+
// The query value is a finished, basePath-prefixed URL string - both
|
|
66
|
+
// producers bake basePath in (renderPage.js, apiPage.js). This is the one
|
|
67
|
+
// boundary that parses it back to an un-prefixed page target: strip basePath
|
|
68
|
+
// conditionally so router.push does not re-apply it into /app/app/reports.
|
|
69
|
+
const basePath = lowdefy.basePath ?? '';
|
|
70
|
+
const pathname = basePath && parsed.pathname.startsWith(basePath) ? parsed.pathname.slice(basePath.length) : parsed.pathname;
|
|
71
|
+
return {
|
|
72
|
+
kind: 'page',
|
|
73
|
+
pathname,
|
|
74
|
+
query: parsed.search.replace(/^\?/, '')
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
// The bottom rung, below the query so a bounced sign-in still returns to the
|
|
78
|
+
// page the user asked for.
|
|
79
|
+
const home = resolveTarget({
|
|
80
|
+
lowdefy,
|
|
81
|
+
target: {
|
|
82
|
+
home: true
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
if (!type.isNone(home)) {
|
|
86
|
+
return home;
|
|
87
|
+
}
|
|
88
|
+
throw new ConfigError('Invalid callbackUrl: no destination resolved. The app has no resolvable home page - set homePageId, give an explicit callbackUrl, or use "callbackUrl: false" to stay on the page.');
|
|
89
|
+
}
|
|
90
|
+
// callbackUrl: false suppresses Lowdefy's own window.location.assign, so it is
|
|
91
|
+
// only honorable where that assign is the value's single consumer. The paths
|
|
92
|
+
// that hand callbackURL to BetterAuth make it the destination of a later hop -
|
|
93
|
+
// an emailed link, an OAuth return - which cannot be suppressed once the browser
|
|
94
|
+
// has left. Both alternatives there are worse than an error: omitting the param
|
|
95
|
+
// lands the user on BetterAuth's own "/" fallback, which resolves against the
|
|
96
|
+
// auth baseURL and so drops basePath entirely, and silently substituting the
|
|
97
|
+
// home default contradicts an explicit instruction.
|
|
98
|
+
function assertCallbackUrlNavigable({ callbackUrl, method }) {
|
|
99
|
+
if (callbackUrl === false) {
|
|
100
|
+
throw new ConfigError(`Invalid callbackUrl: "false" is not valid for ${method}, which redirects through an external hop. Give a destination.`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// Maps the captchaToken action param onto the BetterAuth client's per-call
|
|
104
|
+
// fetch options as the x-captcha-response header - the header the captcha
|
|
105
|
+
// middleware reads. One helper so no auth method implements its own header
|
|
106
|
+
// plumbing; the token never rides in the request body. Tokens are single-use
|
|
107
|
+
// and short-lived: a failed submit consumes the token, so retry chains reset
|
|
108
|
+
// the Captcha block for a fresh one.
|
|
109
|
+
function captchaFetchOptions(captchaToken) {
|
|
110
|
+
if (type.isNone(captchaToken)) {
|
|
111
|
+
return {};
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
fetchOptions: {
|
|
115
|
+
headers: {
|
|
116
|
+
'x-captcha-response': captchaToken
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
// BetterAuth client calls resolve with { data, error } instead of throwing -
|
|
122
|
+
// rethrow so action onError chains fire on failed sign-in attempts.
|
|
123
|
+
//
|
|
124
|
+
// A 4xx is the auth server rejecting the attempt: a wrong password, an expired
|
|
125
|
+
// or already-used link, an invalid code, a stale OAuth authorization query.
|
|
126
|
+
// That is an expected outcome of the user's interaction, not a fault in the
|
|
127
|
+
// page config, so it is a UserError - the chain's catch actions still run and
|
|
128
|
+
// the message still displays, but it logs to the browser console only instead
|
|
129
|
+
// of being posted to /api/client-error and logged on the server as an
|
|
130
|
+
// ActionError against the action's config location. Anything else (a network
|
|
131
|
+
// failure, a 5xx) is thrown plain so the action runner wraps it as an
|
|
132
|
+
// ActionError and reports it. The auth server's code and status ride along as
|
|
133
|
+
// metaData so page config can branch on them.
|
|
134
|
+
async function unwrap(promise) {
|
|
135
|
+
const { data, error } = await promise;
|
|
136
|
+
if (error) {
|
|
137
|
+
const message = error.message ?? error.statusText ?? 'Authentication failed.';
|
|
138
|
+
if (type.isInt(error.status) && error.status >= 400 && error.status < 500) {
|
|
139
|
+
throw new UserError(message, {
|
|
140
|
+
metaData: {
|
|
141
|
+
code: error.code,
|
|
142
|
+
status: error.status
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
const authError = new Error(message);
|
|
147
|
+
authError.code = error.code;
|
|
148
|
+
authError.status = error.status;
|
|
149
|
+
throw authError;
|
|
150
|
+
}
|
|
151
|
+
return data;
|
|
152
|
+
}
|
|
153
|
+
// The BetterAuth passkey client runs the WebAuthn ceremony in the browser
|
|
154
|
+
// itself, so it rejects with a DOMException when the user dismisses or aborts
|
|
155
|
+
// the prompt, the authenticator is already enrolled, or the prompt times out.
|
|
156
|
+
// Those are expected outcomes of the user's interaction, not config or system
|
|
157
|
+
// faults, so they surface as UserError. Anything else propagates.
|
|
158
|
+
const WEB_AUTHN_USER_ERROR_NAMES = new Set([
|
|
159
|
+
'AbortError',
|
|
160
|
+
'InvalidStateError',
|
|
161
|
+
'NotAllowedError',
|
|
162
|
+
'TimeoutError'
|
|
163
|
+
]);
|
|
164
|
+
async function runWebAuthnCeremony(promise) {
|
|
165
|
+
try {
|
|
166
|
+
return await promise;
|
|
167
|
+
} catch (error) {
|
|
168
|
+
if (WEB_AUTHN_USER_ERROR_NAMES.has(error?.name)) {
|
|
169
|
+
throw new UserError(error.message, {
|
|
170
|
+
cause: error,
|
|
171
|
+
metaData: {
|
|
172
|
+
code: error.name
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
throw error;
|
|
177
|
+
}
|
|
36
178
|
}
|
|
37
179
|
function createAuthMethods(lowdefy, auth) {
|
|
38
|
-
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
180
|
+
const router = lowdefy._internal.router;
|
|
181
|
+
// Every same-origin destination navigates through the router, which applies
|
|
182
|
+
// basePath once through createUrl and hard-reloads (forceReload) so the server
|
|
183
|
+
// re-runs the page auth fork - the new session's roles come from the active
|
|
184
|
+
// member row the client session does not carry, so a soft push would leave it
|
|
185
|
+
// half-applied. An external target leaves the app, so it cannot route through
|
|
186
|
+
// the in-app router. A resolved page target is un-prefixed; the router
|
|
187
|
+
// re-applies basePath, so nothing double-prefixes. A page target that names a
|
|
188
|
+
// page joins the path memory before the push, as a followed link does. The
|
|
189
|
+
// session provider's reloads on a session change are suppressed first: the
|
|
190
|
+
// full load renders the new session anyway, and a reload racing it would
|
|
191
|
+
// land back on the page being left.
|
|
192
|
+
function navigateToTarget(target) {
|
|
193
|
+
if (type.isNone(target)) {
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
auth.suppressSessionReload();
|
|
197
|
+
if (target.kind === 'external') {
|
|
198
|
+
lowdefy._internal.globals.window.location.assign(target.href);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
rememberTarget({
|
|
202
|
+
lowdefy,
|
|
203
|
+
target
|
|
204
|
+
});
|
|
205
|
+
router.push({
|
|
206
|
+
pathname: target.pathname,
|
|
207
|
+
query: target.query,
|
|
208
|
+
forceReload: true
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
// The wire form of a target: a string BetterAuth performs a later redirect hop
|
|
212
|
+
// to (an emailed link, an OAuth return). A page target is baked to its
|
|
213
|
+
// basePath-prefixed URL through the single boundary; an external target is
|
|
214
|
+
// already a whole URL. Off-site safety for the resolved URL is BetterAuth's
|
|
215
|
+
// server-side originCheck / trustedOrigins, not here.
|
|
216
|
+
function serializeTarget(target) {
|
|
217
|
+
if (type.isNone(target)) {
|
|
218
|
+
return undefined;
|
|
219
|
+
}
|
|
220
|
+
if (target.kind === 'external') {
|
|
221
|
+
return target.href;
|
|
222
|
+
}
|
|
223
|
+
return createUrl({
|
|
224
|
+
basePath: lowdefy.basePath ?? '',
|
|
225
|
+
pathname: target.pathname,
|
|
226
|
+
query: target.query
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
// The target of an authPages role (verifyEmail, resetPassword), or undefined
|
|
230
|
+
// when the app has not set it.
|
|
231
|
+
function resolveAuthPageTarget(role) {
|
|
232
|
+
const page = auth.authConfig?.authPages?.[role];
|
|
233
|
+
if (!type.isString(page)) {
|
|
234
|
+
return undefined;
|
|
235
|
+
}
|
|
236
|
+
return resolveTarget({
|
|
237
|
+
lowdefy,
|
|
238
|
+
target: {
|
|
239
|
+
url: page
|
|
240
|
+
},
|
|
241
|
+
name: `authPages.${role}`
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
// Where an emailed verification link lands once BetterAuth has verified the
|
|
245
|
+
// address: the action's own callbackUrl, else the app's authPages.verifyEmail
|
|
246
|
+
// page. BetterAuth appends ?error= to it when the link is invalid or has
|
|
247
|
+
// expired, so that page can say so. Undefined when neither is set, leaving the
|
|
248
|
+
// caller's own destination.
|
|
249
|
+
function resolveVerifyEmailTarget({ callbackUrl }) {
|
|
250
|
+
const explicit = resolveTarget({
|
|
251
|
+
lowdefy,
|
|
252
|
+
target: callbackUrl,
|
|
253
|
+
name: 'callbackUrl'
|
|
254
|
+
});
|
|
255
|
+
if (!type.isNone(explicit)) {
|
|
256
|
+
return explicit;
|
|
43
257
|
}
|
|
44
|
-
return
|
|
258
|
+
return resolveAuthPageTarget('verifyEmail');
|
|
259
|
+
}
|
|
260
|
+
// The engine owns the two-factor challenge destination on every sign-in path,
|
|
261
|
+
// so every method that can receive a challenge instead of a session navigates
|
|
262
|
+
// through here. Leaving it to the login page makes correctness opt-in: a page
|
|
263
|
+
// that omits the branch leaves an enrolled user unable to sign in at all and
|
|
264
|
+
// with nothing to show for the click.
|
|
265
|
+
//
|
|
266
|
+
// The destination the user asked for rides along as ?callbackUrl= for the
|
|
267
|
+
// challenge page to spend once the challenge is done - a wire value stapled
|
|
268
|
+
// onto the page's own query rather than replacing it, so an authPages.twoFactor
|
|
269
|
+
// carrying a query of its own keeps it. Only an app-relative (kind:'page')
|
|
270
|
+
// return is stapled, and only onto an in-app (kind:'page') challenge page: the
|
|
271
|
+
// value was validated at the ladder's front door so no open-redirect guard is
|
|
272
|
+
// needed here, but an off-app challenge page must not carry the app's cargo.
|
|
273
|
+
//
|
|
274
|
+
// Returns false rather than throwing when there is no page to navigate to: the
|
|
275
|
+
// build check guarantees authPages.twoFactor exists whenever two-factor is
|
|
276
|
+
// enabled, and a throw here would turn that guarantee into a failed sign-in.
|
|
277
|
+
// The no-window case is legitimate too (server rendering, tests). The caller
|
|
278
|
+
// then falls through to returning the response without navigating.
|
|
279
|
+
function navigateToTwoFactorChallenge({ callbackTarget }) {
|
|
280
|
+
const twoFactorPage = auth.authConfig?.authPages?.twoFactor;
|
|
281
|
+
if (!type.isString(twoFactorPage)) {
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
const target = resolveTarget({
|
|
285
|
+
lowdefy,
|
|
286
|
+
target: {
|
|
287
|
+
url: twoFactorPage
|
|
288
|
+
},
|
|
289
|
+
name: 'authPages.twoFactor'
|
|
290
|
+
});
|
|
291
|
+
const window = lowdefy._internal?.globals?.window;
|
|
292
|
+
if (type.isNone(target) || !window) {
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
if (target.kind === 'page') {
|
|
296
|
+
const ownQuery = Object.fromEntries(new URLSearchParams(target.query));
|
|
297
|
+
const query = callbackTarget?.kind === 'page' ? {
|
|
298
|
+
...ownQuery,
|
|
299
|
+
callbackUrl: serializeTarget(callbackTarget)
|
|
300
|
+
} : ownQuery;
|
|
301
|
+
navigateToTarget({
|
|
302
|
+
kind: 'page',
|
|
303
|
+
pathname: target.pathname,
|
|
304
|
+
query
|
|
305
|
+
});
|
|
306
|
+
return true;
|
|
307
|
+
}
|
|
308
|
+
navigateToTarget(target);
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
// login and logout are Lowdefy functions that handle action params;
|
|
312
|
+
// the auth object provides the BetterAuth client methods.
|
|
313
|
+
async function login({ callbackUrl, captchaToken, email, errorCallbackUrl, magicLink, newUserCallbackUrl, password, phoneNumber, providerId, ...rest } = {}) {
|
|
314
|
+
const callbackTarget = resolveCallbackURL({
|
|
315
|
+
lowdefy,
|
|
316
|
+
callbackUrl
|
|
317
|
+
});
|
|
318
|
+
// The wire form for the routes that hand the destination to BetterAuth for a
|
|
319
|
+
// later redirect hop; the direct-navigation routes use callbackTarget.
|
|
320
|
+
const callbackURL = serializeTarget(callbackTarget);
|
|
321
|
+
const captchaOptions = captchaFetchOptions(captchaToken);
|
|
322
|
+
const providers = auth.authConfig?.providers ?? [];
|
|
323
|
+
// The magic-link and social/OAuth routes accept these two extra callbacks;
|
|
324
|
+
// resolve each as a structured target, serialize to the wire string, and
|
|
325
|
+
// forward only the ones the app supplied, so BetterAuth's own
|
|
326
|
+
// default-to-callbackURL stands otherwise.
|
|
327
|
+
const routingCallbacks = {};
|
|
328
|
+
const newUserCallbackURL = serializeTarget(resolveTarget({
|
|
329
|
+
lowdefy,
|
|
330
|
+
target: newUserCallbackUrl,
|
|
331
|
+
name: 'newUserCallbackUrl'
|
|
332
|
+
}));
|
|
333
|
+
if (!type.isNone(newUserCallbackURL)) {
|
|
334
|
+
routingCallbacks.newUserCallbackURL = newUserCallbackURL;
|
|
335
|
+
}
|
|
336
|
+
const errorCallbackURL = serializeTarget(resolveTarget({
|
|
337
|
+
lowdefy,
|
|
338
|
+
target: errorCallbackUrl,
|
|
339
|
+
name: 'errorCallbackUrl'
|
|
340
|
+
}));
|
|
341
|
+
if (!type.isNone(errorCallbackURL)) {
|
|
342
|
+
routingCallbacks.errorCallbackURL = errorCallbackURL;
|
|
343
|
+
} else {
|
|
344
|
+
// No caller-supplied error destination: default to the app's declared
|
|
345
|
+
// auth-error page (authPages.error), serialized through the same single
|
|
346
|
+
// boundary so an app-relative page gains basePath and an absolute
|
|
347
|
+
// authPages.error passes through untouched. So a failed magic-link verify
|
|
348
|
+
// lands there with ?error= intact instead of on the success page. Mirrors
|
|
349
|
+
// the server-side onAPIError.errorURL default on the one path that default
|
|
350
|
+
// cannot structurally reach. Omitted when authPages.error is unset,
|
|
351
|
+
// letting BetterAuth's fallback stand.
|
|
352
|
+
const errorPage = auth.authConfig?.authPages?.error;
|
|
353
|
+
if (type.isString(errorPage)) {
|
|
354
|
+
routingCallbacks.errorCallbackURL = serializeTarget(resolveTarget({
|
|
355
|
+
lowdefy,
|
|
356
|
+
target: {
|
|
357
|
+
url: errorPage
|
|
358
|
+
},
|
|
359
|
+
name: 'errorCallbackUrl'
|
|
360
|
+
}));
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
if (type.isNone(providerId) && type.isNone(email) && type.isNone(phoneNumber) && providers.length === 1) {
|
|
364
|
+
providerId = providers[0].id;
|
|
365
|
+
}
|
|
366
|
+
if (!type.isNone(providerId)) {
|
|
367
|
+
assertCallbackUrlNavigable({
|
|
368
|
+
callbackUrl,
|
|
369
|
+
method: 'Login with a provider'
|
|
370
|
+
});
|
|
371
|
+
const provider = providers.find((configured)=>configured.id === providerId);
|
|
372
|
+
if (type.isNone(provider)) {
|
|
373
|
+
throw new ConfigError(`Login provider "${providerId}" is not a configured auth provider.`);
|
|
374
|
+
}
|
|
375
|
+
// BetterAuth registers a GenericOAuth provider as a social provider keyed
|
|
376
|
+
// by its providerId, the Lowdefy provider id; built-in ones are keyed by
|
|
377
|
+
// their lowercase type.
|
|
378
|
+
const socialProvider = provider.type === 'GenericOAuth' ? provider.id : provider.type.toLowerCase();
|
|
379
|
+
const data = await unwrap(auth.signInSocial({
|
|
380
|
+
provider: socialProvider,
|
|
381
|
+
callbackURL,
|
|
382
|
+
...routingCallbacks,
|
|
383
|
+
...rest,
|
|
384
|
+
...captchaOptions
|
|
385
|
+
}));
|
|
386
|
+
// An idToken passed through rest signs in within this request, so a
|
|
387
|
+
// 2FA-enrolled user gets the challenge here, as on the password paths.
|
|
388
|
+
if (data?.twoFactorRedirect) {
|
|
389
|
+
return navigateToTwoFactorChallenge({
|
|
390
|
+
callbackTarget
|
|
391
|
+
}) ? stopChain(data) : data;
|
|
392
|
+
}
|
|
393
|
+
// The redirect branch leaves navigation to BetterAuth's redirect to the
|
|
394
|
+
// IdP; a completed id token sign-in answers redirect: false with no url,
|
|
395
|
+
// so navigate to the callback as the password paths do.
|
|
396
|
+
const window = lowdefy._internal?.globals?.window;
|
|
397
|
+
if (data?.redirect === false && callbackTarget && window) {
|
|
398
|
+
navigateToTarget(callbackTarget);
|
|
399
|
+
}
|
|
400
|
+
return data;
|
|
401
|
+
}
|
|
402
|
+
if (magicLink === true) {
|
|
403
|
+
assertCallbackUrlNavigable({
|
|
404
|
+
callbackUrl,
|
|
405
|
+
method: 'Login with magicLink'
|
|
406
|
+
});
|
|
407
|
+
if (!type.isString(email)) {
|
|
408
|
+
throw new Error('Login with magicLink requires an "email" param.');
|
|
409
|
+
}
|
|
410
|
+
return unwrap(auth.signInMagicLink({
|
|
411
|
+
email,
|
|
412
|
+
callbackURL,
|
|
413
|
+
...routingCallbacks,
|
|
414
|
+
...rest,
|
|
415
|
+
...captchaOptions
|
|
416
|
+
}));
|
|
417
|
+
}
|
|
418
|
+
if (!type.isNone(phoneNumber)) {
|
|
419
|
+
if (!type.isString(password)) {
|
|
420
|
+
throw new Error('Login with phoneNumber requires a "password" param.');
|
|
421
|
+
}
|
|
422
|
+
const data = await unwrap(auth.signInPhoneNumber({
|
|
423
|
+
phoneNumber,
|
|
424
|
+
password,
|
|
425
|
+
...rest,
|
|
426
|
+
...captchaOptions
|
|
427
|
+
}));
|
|
428
|
+
if (data?.twoFactorRedirect) {
|
|
429
|
+
return navigateToTwoFactorChallenge({
|
|
430
|
+
callbackTarget
|
|
431
|
+
}) ? stopChain(data) : data;
|
|
432
|
+
}
|
|
433
|
+
const window = lowdefy._internal?.globals?.window;
|
|
434
|
+
if (callbackTarget && window) {
|
|
435
|
+
navigateToTarget(callbackTarget);
|
|
436
|
+
}
|
|
437
|
+
return data;
|
|
438
|
+
}
|
|
439
|
+
if (!type.isNone(email) || !type.isNone(password)) {
|
|
440
|
+
const data = await unwrap(auth.signInEmail({
|
|
441
|
+
email,
|
|
442
|
+
password,
|
|
443
|
+
...rest,
|
|
444
|
+
...captchaOptions
|
|
445
|
+
}));
|
|
446
|
+
// A 2FA-enrolled user gets a challenge, not a session - navigate to
|
|
447
|
+
// authPages.twoFactor instead of the callback URL, where TwoFactorVerify
|
|
448
|
+
// completes the session. The response still carries twoFactorRedirect and
|
|
449
|
+
// twoFactorMethods for an app that wants to read them, but the routing is
|
|
450
|
+
// not the app's job.
|
|
451
|
+
//
|
|
452
|
+
// The chain ends here: without that, the step the app put after Login
|
|
453
|
+
// races the challenge page load and re-renders the app with no session.
|
|
454
|
+
// When there was nothing to navigate to the chain continues as before, so
|
|
455
|
+
// a chain is never stranded by a navigation that did not happen.
|
|
456
|
+
if (data?.twoFactorRedirect) {
|
|
457
|
+
return navigateToTwoFactorChallenge({
|
|
458
|
+
callbackTarget
|
|
459
|
+
}) ? stopChain(data) : data;
|
|
460
|
+
}
|
|
461
|
+
const window = lowdefy._internal?.globals?.window;
|
|
462
|
+
if (callbackTarget && window) {
|
|
463
|
+
navigateToTarget(callbackTarget);
|
|
464
|
+
}
|
|
465
|
+
return data;
|
|
466
|
+
}
|
|
467
|
+
throw new Error('Login requires a "providerId", "email" and "password", "phoneNumber" and "password", or "magicLink: true" param.');
|
|
468
|
+
}
|
|
469
|
+
// Creates an email/password account (BetterAuth's one signup endpoint).
|
|
470
|
+
// Social, magic-link and passkey have no separate signup - the account is
|
|
471
|
+
// created on first sign-in via login - so SignUp is email/password only.
|
|
472
|
+
async function signUp({ callbackUrl, captchaToken, email, name, password, ...rest } = {}) {
|
|
473
|
+
// signUp is the case where the two consumers diverge: Lowdefy navigates on
|
|
474
|
+
// the session-bearing success, and BetterAuth puts the callbackURL in the
|
|
475
|
+
// verification email. An explicit callbackUrl serves both, so it cannot be
|
|
476
|
+
// suppressed for one of them alone. Without one, Lowdefy navigates down the
|
|
477
|
+
// callback ladder and the email lands on authPages.verifyEmail.
|
|
478
|
+
assertCallbackUrlNavigable({
|
|
479
|
+
callbackUrl,
|
|
480
|
+
method: 'SignUp'
|
|
481
|
+
});
|
|
482
|
+
const callbackTarget = resolveCallbackURL({
|
|
483
|
+
lowdefy,
|
|
484
|
+
callbackUrl
|
|
485
|
+
});
|
|
486
|
+
const verifyEmailTarget = resolveVerifyEmailTarget({
|
|
487
|
+
callbackUrl
|
|
488
|
+
}) ?? callbackTarget;
|
|
489
|
+
const data = await unwrap(auth.signUpEmail({
|
|
490
|
+
email,
|
|
491
|
+
password,
|
|
492
|
+
name,
|
|
493
|
+
callbackURL: serializeTarget(verifyEmailTarget),
|
|
45
494
|
...rest,
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
495
|
+
...captchaFetchOptions(captchaToken)
|
|
496
|
+
}));
|
|
497
|
+
// With requireEmailVerification the response carries no session - do not
|
|
498
|
+
// navigate; the page shows a "verify your email" message instead.
|
|
499
|
+
const window = lowdefy._internal?.globals?.window;
|
|
500
|
+
if (data?.token && callbackTarget && window) {
|
|
501
|
+
navigateToTarget(callbackTarget);
|
|
502
|
+
}
|
|
503
|
+
return data;
|
|
51
504
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
505
|
+
// Deliberately the one method outside resolveCallbackURL's ladder: it reads
|
|
506
|
+
// neither the ?callbackUrl= query nor the home default. With no callback the
|
|
507
|
+
// session provider's post-sign-out reload takes over and the server re-applies
|
|
508
|
+
// the page auth fork, which is the right landing for a sign-out - sending a
|
|
509
|
+
// signed-out user to the home page could land them on a page they may no
|
|
510
|
+
// longer see.
|
|
511
|
+
async function logout({ callbackUrl } = {}) {
|
|
512
|
+
const callbackTarget = resolveTarget({
|
|
513
|
+
lowdefy,
|
|
514
|
+
target: callbackUrl
|
|
59
515
|
});
|
|
516
|
+
const window = lowdefy._internal?.globals?.window;
|
|
517
|
+
const willNavigate = Boolean(callbackTarget && window);
|
|
518
|
+
if (willNavigate && auth.suppressSessionReload) {
|
|
519
|
+
// The sign-out reload in the session provider would race the callback
|
|
520
|
+
// navigation - suppress it for this sign-out.
|
|
521
|
+
auth.suppressSessionReload();
|
|
522
|
+
}
|
|
523
|
+
const data = await unwrap(auth.signOut());
|
|
524
|
+
// The next person on this browser must not read the signed-out person's data. Clearing
|
|
525
|
+
// before the navigation or the session provider's reload keeps it from any later page.
|
|
526
|
+
lowdefy._internal.storage.clearUserStorage({
|
|
527
|
+
keepUserId: null
|
|
528
|
+
});
|
|
529
|
+
if (willNavigate) {
|
|
530
|
+
navigateToTarget(callbackTarget);
|
|
531
|
+
}
|
|
532
|
+
return data;
|
|
533
|
+
}
|
|
534
|
+
// Switches the session's active organization - the session-scoped org
|
|
535
|
+
// switch. Roles and attributes resolve from the new active member row
|
|
536
|
+
// server-side; chain UpdateSession after to re-sync the client.
|
|
537
|
+
async function setActiveOrganization({ organizationId, organizationSlug } = {}) {
|
|
538
|
+
if (type.isNone(organizationId) && type.isNone(organizationSlug)) {
|
|
539
|
+
throw new Error('SetActiveOrganization requires an "organizationId" or "organizationSlug" param.');
|
|
540
|
+
}
|
|
541
|
+
return unwrap(auth.setActiveOrganization({
|
|
542
|
+
organizationId,
|
|
543
|
+
organizationSlug
|
|
544
|
+
}));
|
|
60
545
|
}
|
|
546
|
+
// Refreshes the BetterAuth client session store through an awaited
|
|
547
|
+
// store refetch, bypassing the cookie cache (a live re-resolve) so role,
|
|
548
|
+
// attribute or session changes surface immediately instead of after
|
|
549
|
+
// cookieCache.maxAge. The store holds a fresh session user before this
|
|
550
|
+
// resolves. Roles and merged attributes come from the server-resolved
|
|
551
|
+
// caller - the active member row read the base session does not carry.
|
|
61
552
|
async function updateSession() {
|
|
62
|
-
const session = await auth.
|
|
63
|
-
|
|
553
|
+
const session = await unwrap(auth.refreshSession({
|
|
554
|
+
disableCookieCache: true
|
|
555
|
+
}));
|
|
556
|
+
const { user } = await auth.getResolvedUser();
|
|
557
|
+
if (session && type.isNone(user)) {
|
|
558
|
+
// A session with no resolved caller is the admission wall rejecting a
|
|
559
|
+
// real session, not a logout - surface it instead of nulling the caller.
|
|
560
|
+
throw new Error('UpdateSession failed: a session is active but the server resolved no user.');
|
|
561
|
+
}
|
|
562
|
+
if (auth.updateResolvedUser) {
|
|
563
|
+
auth.updateResolvedUser(user ?? null);
|
|
564
|
+
}
|
|
565
|
+
setLowdefyUser({
|
|
566
|
+
lowdefy,
|
|
567
|
+
user
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
// Accepts an organization invitation. BetterAuth's endpoint enforces the
|
|
571
|
+
// session-email to invitation-email match - no re-check here.
|
|
572
|
+
async function acceptInvitation({ invitationId } = {}) {
|
|
573
|
+
if (!type.isString(invitationId)) {
|
|
574
|
+
throw new Error('AcceptInvitation requires an "invitationId" param.');
|
|
575
|
+
}
|
|
576
|
+
return unwrap(auth.acceptInvitation({
|
|
577
|
+
invitationId
|
|
578
|
+
}));
|
|
579
|
+
}
|
|
580
|
+
// Removes the caller's own membership from the active organization.
|
|
581
|
+
// BetterAuth's leave endpoint requires an explicit organizationId, so it
|
|
582
|
+
// is resolved from the session's active organization - the action can
|
|
583
|
+
// only ever leave the org the caller is acting in. Authorization is
|
|
584
|
+
// BetterAuth's own per-org access control, enforced server-side against
|
|
585
|
+
// the caller's member role in the active organization.
|
|
586
|
+
async function leaveOrganization() {
|
|
587
|
+
const session = await unwrap(auth.getSession());
|
|
588
|
+
const organizationId = session?.session?.activeOrganizationId;
|
|
589
|
+
if (type.isNone(organizationId)) {
|
|
590
|
+
throw new UserError('LeaveOrganization requires an active organization on the session.');
|
|
591
|
+
}
|
|
592
|
+
return unwrap(auth.leaveOrganization({
|
|
593
|
+
organizationId
|
|
594
|
+
}));
|
|
595
|
+
}
|
|
596
|
+
// The signed-in user's own memberships from BetterAuth's org plugin -
|
|
597
|
+
// keyed on the consent session alone, not the active organization, so a
|
|
598
|
+
// consent page can list every workspace the caller may connect. Returns
|
|
599
|
+
// the array of organization rows ({ id, name, slug, ... }). Under the
|
|
600
|
+
// pinned org policy the /organization/list route is disabled server-side
|
|
601
|
+
// and this rejects.
|
|
602
|
+
async function listOrganizations() {
|
|
603
|
+
return unwrap(auth.listOrganizations());
|
|
604
|
+
}
|
|
605
|
+
// Approves or denies the pending OAuth authorization request. The
|
|
606
|
+
// oauth-provider plugin redirected here with the signed authorization
|
|
607
|
+
// query in the page URL; none of it is posted back through params - the
|
|
608
|
+
// auth client's oauth-provider fetch plugin stamps it onto the request
|
|
609
|
+
// body as oauth_query from window.location.search, and the server trusts
|
|
610
|
+
// it only after verifying its signature. The consenting identity rides the
|
|
611
|
+
// session cookie. So the call must run while the page still holds the
|
|
612
|
+
// query it arrived with. Optional scope / claims params narrow the grant
|
|
613
|
+
// to a subset of what was requested; omitted, everything requested is
|
|
614
|
+
// granted. Resolves with { redirect: true, url } - url is the OAuth
|
|
615
|
+
// client's redirect URI, carrying an authorization code on accept and
|
|
616
|
+
// error=access_denied on deny. The auth client's built-in redirect fetch
|
|
617
|
+
// plugin navigates the browser there as the response lands; the url is
|
|
618
|
+
// still returned so page config owns any navigation it wants to do
|
|
619
|
+
// instead.
|
|
620
|
+
async function oauth2Consent({ accept, ...params } = {}) {
|
|
621
|
+
if (!type.isBoolean(accept)) {
|
|
622
|
+
throw new Error('OAuthConsent requires a boolean "accept" param.');
|
|
623
|
+
}
|
|
624
|
+
return unwrap(auth.oauth2Consent({
|
|
625
|
+
accept,
|
|
626
|
+
...params
|
|
627
|
+
}));
|
|
628
|
+
}
|
|
629
|
+
// Resumes the pending authorization after the post-login organization
|
|
630
|
+
// choice. Same contract as oauth2Consent: the signed query rides from
|
|
631
|
+
// window.location.search via the auth client's oauth-provider fetch plugin,
|
|
632
|
+
// and the session cookie carries the identity - so the page must call this
|
|
633
|
+
// before navigating, after SetActiveOrganization has recorded the chosen
|
|
634
|
+
// organization on the session. Resolves with { redirect: true, url } - the
|
|
635
|
+
// consent page, or the OAuth client's redirect URI when consent for that
|
|
636
|
+
// organization already stands.
|
|
637
|
+
async function oauth2Continue() {
|
|
638
|
+
return unwrap(auth.oauth2Continue({
|
|
639
|
+
postLogin: true
|
|
640
|
+
}));
|
|
641
|
+
}
|
|
642
|
+
async function changePassword({ currentPassword, newPassword, revokeOtherSessions } = {}) {
|
|
643
|
+
if (!type.isString(currentPassword) || !type.isString(newPassword)) {
|
|
644
|
+
throw new Error('ChangePassword requires "currentPassword" and "newPassword" params.');
|
|
645
|
+
}
|
|
646
|
+
return unwrap(auth.changePassword({
|
|
647
|
+
currentPassword,
|
|
648
|
+
newPassword,
|
|
649
|
+
revokeOtherSessions
|
|
650
|
+
}));
|
|
651
|
+
}
|
|
652
|
+
async function passkeyDelete({ passkeyId } = {}) {
|
|
653
|
+
if (!type.isString(passkeyId)) {
|
|
654
|
+
throw new Error('PasskeyDelete requires a "passkeyId" param.');
|
|
655
|
+
}
|
|
656
|
+
return unwrap(auth.deletePasskey({
|
|
657
|
+
id: passkeyId
|
|
658
|
+
}));
|
|
659
|
+
}
|
|
660
|
+
// Renames a passkey. The action param passkeyId maps onto BetterAuth's id,
|
|
661
|
+
// matching passkeyDelete. Ownership is enforced server-side, so a caller can
|
|
662
|
+
// only rename their own passkey.
|
|
663
|
+
async function passkeyUpdate({ name, passkeyId } = {}) {
|
|
664
|
+
if (!type.isString(passkeyId)) {
|
|
665
|
+
throw new Error('PasskeyUpdate requires a "passkeyId" param.');
|
|
666
|
+
}
|
|
667
|
+
if (!type.isString(name)) {
|
|
668
|
+
throw new Error('PasskeyUpdate requires a "name" param.');
|
|
669
|
+
}
|
|
670
|
+
return unwrap(auth.updatePasskey({
|
|
671
|
+
id: passkeyId,
|
|
672
|
+
name
|
|
673
|
+
}));
|
|
674
|
+
}
|
|
675
|
+
// The BetterAuth client method runs the whole WebAuthn browser ceremony
|
|
676
|
+
// itself - it fetches the registration options, prompts the authenticator
|
|
677
|
+
// and verifies the result - so the ceremony runs inside the action.
|
|
678
|
+
async function passkeyRegister(params = {}) {
|
|
679
|
+
return runWebAuthnCeremony(unwrap(auth.addPasskey(params)));
|
|
680
|
+
}
|
|
681
|
+
// Passkey sign-in: the BetterAuth client method runs the whole WebAuthn
|
|
682
|
+
// assertion ceremony itself (options fetch, navigator.credentials.get(),
|
|
683
|
+
// verification), so the ceremony runs inside the action. verify-authentication
|
|
684
|
+
// creates the session, so on success navigate to callbackUrl like login's
|
|
685
|
+
// email path. A successful assertion is terminal - passkey never returns a
|
|
686
|
+
// two-factor challenge - so there is no twoFactorRedirect branch.
|
|
687
|
+
async function passkeySignIn({ callbackUrl } = {}) {
|
|
688
|
+
const callbackTarget = resolveCallbackURL({
|
|
689
|
+
lowdefy,
|
|
690
|
+
callbackUrl
|
|
691
|
+
});
|
|
692
|
+
const data = await runWebAuthnCeremony(unwrap(auth.signInPasskey()));
|
|
693
|
+
const window = lowdefy._internal?.globals?.window;
|
|
694
|
+
if (callbackTarget && window) {
|
|
695
|
+
navigateToTarget(callbackTarget);
|
|
696
|
+
}
|
|
697
|
+
return data;
|
|
698
|
+
}
|
|
699
|
+
// Dispatches by parameter, matching login: a phoneNumber param requests the
|
|
700
|
+
// reset code over SMS (the "phone.passwordReset.send" hook), otherwise
|
|
701
|
+
// email carries the reset link. BetterAuth sends that link to redirectTo
|
|
702
|
+
// with ?token= (or ?error=INVALID_TOKEN) appended, and without one the link
|
|
703
|
+
// fails, so it defaults to the app's authPages.resetPassword page.
|
|
704
|
+
async function requestPasswordReset({ captchaToken, email, phoneNumber, redirectTo, ...rest } = {}) {
|
|
705
|
+
const captchaOptions = captchaFetchOptions(captchaToken);
|
|
706
|
+
if (type.isString(phoneNumber)) {
|
|
707
|
+
return unwrap(auth.phoneNumberRequestPasswordReset({
|
|
708
|
+
phoneNumber,
|
|
709
|
+
...rest,
|
|
710
|
+
...captchaOptions
|
|
711
|
+
}));
|
|
712
|
+
}
|
|
713
|
+
if (!type.isString(email)) {
|
|
714
|
+
throw new Error('RequestPasswordReset requires an "email" or "phoneNumber" param.');
|
|
715
|
+
}
|
|
716
|
+
return unwrap(auth.requestPasswordReset({
|
|
717
|
+
email,
|
|
718
|
+
redirectTo: redirectTo ?? serializeTarget(resolveAuthPageTarget('resetPassword')),
|
|
719
|
+
...rest,
|
|
720
|
+
...captchaOptions
|
|
721
|
+
}));
|
|
722
|
+
}
|
|
723
|
+
// Dispatches by parameter: a phoneNumber param resets with the SMS otp,
|
|
724
|
+
// otherwise token carries the emailed reset link's token.
|
|
725
|
+
async function resetPassword({ newPassword, otp, phoneNumber, token, ...rest } = {}) {
|
|
726
|
+
if (!type.isString(newPassword)) {
|
|
727
|
+
throw new Error('ResetPassword requires a "newPassword" param.');
|
|
728
|
+
}
|
|
729
|
+
if (type.isString(phoneNumber)) {
|
|
730
|
+
if (!type.isString(otp)) {
|
|
731
|
+
throw new Error('ResetPassword with phoneNumber requires an "otp" param.');
|
|
732
|
+
}
|
|
733
|
+
return unwrap(auth.phoneNumberResetPassword({
|
|
734
|
+
phoneNumber,
|
|
735
|
+
otp,
|
|
736
|
+
newPassword,
|
|
737
|
+
...rest
|
|
738
|
+
}));
|
|
739
|
+
}
|
|
740
|
+
return unwrap(auth.resetPassword({
|
|
741
|
+
newPassword,
|
|
742
|
+
token,
|
|
743
|
+
...rest
|
|
744
|
+
}));
|
|
745
|
+
}
|
|
746
|
+
// Revokes every session except the current one - the only session revoke
|
|
747
|
+
// exposed to config; per-session revoke would put session tokens in
|
|
748
|
+
// config reach.
|
|
749
|
+
async function revokeOtherSessions() {
|
|
750
|
+
return unwrap(auth.revokeOtherSessions());
|
|
751
|
+
}
|
|
752
|
+
// Resends the verification email for an unverified account - an unverified
|
|
753
|
+
// user holds no session, so this is a public call. The callbackUrl is
|
|
754
|
+
// where the emailed verification link lands after verifying, matching the
|
|
755
|
+
// signUp param of the same name, and authPages.verifyEmail without one.
|
|
756
|
+
async function sendVerificationEmail({ callbackUrl, captchaToken, email, ...rest } = {}) {
|
|
757
|
+
if (!type.isString(email)) {
|
|
758
|
+
throw new Error('SendVerificationEmail requires an "email" param.');
|
|
759
|
+
}
|
|
760
|
+
assertCallbackUrlNavigable({
|
|
761
|
+
callbackUrl,
|
|
762
|
+
method: 'SendVerificationEmail'
|
|
763
|
+
});
|
|
764
|
+
const verifyEmailTarget = resolveVerifyEmailTarget({
|
|
765
|
+
callbackUrl
|
|
766
|
+
}) ?? resolveCallbackURL({
|
|
767
|
+
lowdefy,
|
|
768
|
+
callbackUrl
|
|
769
|
+
});
|
|
770
|
+
return unwrap(auth.sendVerificationEmail({
|
|
771
|
+
email,
|
|
772
|
+
callbackURL: serializeTarget(verifyEmailTarget),
|
|
773
|
+
...rest,
|
|
774
|
+
...captchaFetchOptions(captchaToken)
|
|
775
|
+
}));
|
|
776
|
+
}
|
|
777
|
+
// Sends a one-time sign-in code to the email address. The code-only entry
|
|
778
|
+
// point; when magicLink is also enabled the Login action with magicLink: true
|
|
779
|
+
// sends the link email that already carries the code, so an app offering both
|
|
780
|
+
// needs only the one send.
|
|
781
|
+
async function emailOtpSend({ captchaToken, email, ...rest } = {}) {
|
|
782
|
+
if (!type.isString(email)) {
|
|
783
|
+
throw new Error('EmailOtpSend requires an "email" param.');
|
|
784
|
+
}
|
|
785
|
+
// Only the sign-in code is wired server-side - the other OTP types
|
|
786
|
+
// BetterAuth can mint have no email flow behind them - so the type is not
|
|
787
|
+
// an app-facing parameter.
|
|
788
|
+
return unwrap(auth.emailOtpSend({
|
|
789
|
+
email,
|
|
790
|
+
type: 'sign-in',
|
|
791
|
+
...rest,
|
|
792
|
+
...captchaFetchOptions(captchaToken)
|
|
793
|
+
}));
|
|
794
|
+
}
|
|
795
|
+
// The code sign-in: on success BetterAuth sets the session cookie (creating
|
|
796
|
+
// the user on first sign-in unless disableSignUp), and the browser navigates
|
|
797
|
+
// on the resolved callbackURL like every other method that mints a session.
|
|
798
|
+
async function emailOtpVerify({ callbackUrl, email, otp, ...rest } = {}) {
|
|
799
|
+
if (!type.isString(email) || !type.isString(otp)) {
|
|
800
|
+
throw new Error('EmailOtpVerify requires "email" and "otp" params.');
|
|
801
|
+
}
|
|
802
|
+
// Resolved before the call, as login and phoneNumberVerify do, so a
|
|
803
|
+
// misconfigured destination throws before the code is consumed rather than
|
|
804
|
+
// after. No assertCallbackUrlNavigable: this is a JSON sign-in with no
|
|
805
|
+
// redirect hop, so the resolved value's single consumer is the navigation
|
|
806
|
+
// below and callbackUrl: false is honourable.
|
|
807
|
+
const callbackTarget = resolveCallbackURL({
|
|
808
|
+
lowdefy,
|
|
809
|
+
callbackUrl
|
|
810
|
+
});
|
|
811
|
+
const data = await unwrap(auth.emailOtpVerify({
|
|
812
|
+
email,
|
|
813
|
+
otp,
|
|
814
|
+
...rest
|
|
815
|
+
}));
|
|
816
|
+
// An enrolled user signing in with a code gets a challenge instead of a
|
|
817
|
+
// session, in the same JSON shape the password paths return, so the
|
|
818
|
+
// navigation is the one Login performs. The halt keeps the app's remaining
|
|
819
|
+
// steps from re-rendering with no session while the challenge page load is
|
|
820
|
+
// still in flight.
|
|
821
|
+
if (data?.twoFactorRedirect) {
|
|
822
|
+
return navigateToTwoFactorChallenge({
|
|
823
|
+
callbackTarget
|
|
824
|
+
}) ? stopChain(data) : data;
|
|
825
|
+
}
|
|
826
|
+
const window = lowdefy._internal?.globals?.window;
|
|
827
|
+
if (data?.token && callbackTarget && window) {
|
|
828
|
+
navigateToTarget(callbackTarget);
|
|
829
|
+
}
|
|
830
|
+
return data;
|
|
831
|
+
}
|
|
832
|
+
// Sends a sign-in/verification OTP over SMS through the app's
|
|
833
|
+
// "phone.otp.send" hook binding.
|
|
834
|
+
async function phoneNumberSendOtp({ captchaToken, phoneNumber, ...rest } = {}) {
|
|
835
|
+
if (!type.isString(phoneNumber)) {
|
|
836
|
+
throw new Error('PhoneNumberSendOtp requires a "phoneNumber" param.');
|
|
837
|
+
}
|
|
838
|
+
return unwrap(auth.phoneNumberSendOtp({
|
|
839
|
+
phoneNumber,
|
|
840
|
+
...rest,
|
|
841
|
+
...captchaFetchOptions(captchaToken)
|
|
842
|
+
}));
|
|
843
|
+
}
|
|
844
|
+
// The OTP sign-in: on success BetterAuth sets the session cookie (and
|
|
845
|
+
// creates the account under signUpOnVerification), and the browser navigates
|
|
846
|
+
// on the resolved callbackURL like every other method that mints a session.
|
|
847
|
+
async function phoneNumberVerify({ callbackUrl, code, phoneNumber, ...rest } = {}) {
|
|
848
|
+
if (!type.isString(phoneNumber) || !type.isString(code)) {
|
|
849
|
+
throw new Error('PhoneNumberVerify requires "phoneNumber" and "code" params.');
|
|
850
|
+
}
|
|
851
|
+
// Resolved before the call, as login and signUp do, so a misconfigured
|
|
852
|
+
// destination throws before the OTP is consumed rather than after. No
|
|
853
|
+
// assertCallbackUrlNavigable: the resolved value has one consumer, the assign
|
|
854
|
+
// below, so callbackUrl: false is honorable here.
|
|
855
|
+
const callbackTarget = resolveCallbackURL({
|
|
856
|
+
lowdefy,
|
|
857
|
+
callbackUrl
|
|
858
|
+
});
|
|
859
|
+
const data = await unwrap(auth.phoneNumberVerify({
|
|
860
|
+
phoneNumber,
|
|
861
|
+
code,
|
|
862
|
+
...rest
|
|
863
|
+
}));
|
|
864
|
+
// An enrolled user verifying by SMS gets a challenge instead of a session, in
|
|
865
|
+
// the same JSON shape the password paths return, so the navigation is the one
|
|
866
|
+
// Login performs and the destination rides along identically.
|
|
867
|
+
//
|
|
868
|
+
// The halt keeps the app's remaining steps from re-rendering with no session
|
|
869
|
+
// while the challenge page load is still in flight.
|
|
870
|
+
if (data?.twoFactorRedirect) {
|
|
871
|
+
return navigateToTwoFactorChallenge({
|
|
872
|
+
callbackTarget
|
|
873
|
+
}) ? stopChain(data) : data;
|
|
874
|
+
}
|
|
875
|
+
// The two modes that mint no usable arrival stay put: disableSession returns
|
|
876
|
+
// token: null, and updatePhoneNumber is a signed-in user confirming a new
|
|
877
|
+
// number - it returns their existing session token, so a token check alone
|
|
878
|
+
// would yank them out of what is usually a modal. The discriminator has to
|
|
879
|
+
// come from the request, and updatePhoneNumber is still in rest because
|
|
880
|
+
// BetterAuth needs it.
|
|
881
|
+
const window = lowdefy._internal?.globals?.window;
|
|
882
|
+
if (data?.token && rest.updatePhoneNumber !== true && callbackTarget && window) {
|
|
883
|
+
navigateToTarget(callbackTarget);
|
|
884
|
+
}
|
|
885
|
+
return data;
|
|
886
|
+
}
|
|
887
|
+
// The landing-page half of the magic-link flow. BetterAuth's verify endpoint
|
|
888
|
+
// consumes the token atomically on its first GET, and corporate mail security
|
|
889
|
+
// (Defender Safe Links, Proofpoint URL Defense, Mimecast) GETs every link in a
|
|
890
|
+
// delivered message - so when the email points at verify directly, the scanner
|
|
891
|
+
// spends the token and the person lands on INVALID_TOKEN. With
|
|
892
|
+
// authPages.magicLink set, the engine points the email at a Lowdefy page
|
|
893
|
+
// instead, which is inert to a fetch, and this method walks to verify on a real
|
|
894
|
+
// click.
|
|
895
|
+
//
|
|
896
|
+
// No BetterAuth client call: verify answers with a Set-Cookie and its own
|
|
897
|
+
// redirect, which only a whole-document navigation can follow. That is also why
|
|
898
|
+
// the chain stops here, like every other navigating method - a step after it
|
|
899
|
+
// would race the page being replaced.
|
|
900
|
+
//
|
|
901
|
+
// The engine copied the verify URL's whole query onto the landing URL, so the
|
|
902
|
+
// token and the three destinations are already on this page. The params exist
|
|
903
|
+
// to override them; each falls back to the query value, and errorCallbackURL
|
|
904
|
+
// falls back once more to the app's authPages.error, mirroring the default
|
|
905
|
+
// Login applies when it asks for the link.
|
|
906
|
+
function magicLinkVerify({ callbackUrl, errorCallbackUrl, newUserCallbackUrl, token } = {}) {
|
|
907
|
+
const window = lowdefy._internal?.globals?.window;
|
|
908
|
+
const pageQuery = new URLSearchParams(window?.location?.search ?? '');
|
|
909
|
+
const verifyToken = type.isString(token) ? token : pageQuery.get('token');
|
|
910
|
+
if (!type.isString(verifyToken) || verifyToken === '') {
|
|
911
|
+
throw new Error('MagicLinkVerify requires a "token" param, or a "token" URL query parameter on the landing page the sign-in email links to.');
|
|
912
|
+
}
|
|
913
|
+
// Every destination here is spent by BetterAuth as a later redirect hop, so
|
|
914
|
+
// "stay put" cannot be honored - the browser has already left.
|
|
915
|
+
assertCallbackUrlNavigable({
|
|
916
|
+
callbackUrl,
|
|
917
|
+
method: 'MagicLinkVerify'
|
|
918
|
+
});
|
|
919
|
+
const verifyQuery = new URLSearchParams({
|
|
920
|
+
token: verifyToken
|
|
921
|
+
});
|
|
922
|
+
const params = {
|
|
923
|
+
callbackURL: callbackUrl,
|
|
924
|
+
newUserCallbackURL: newUserCallbackUrl,
|
|
925
|
+
errorCallbackURL: errorCallbackUrl
|
|
926
|
+
};
|
|
927
|
+
Object.entries(params).forEach(([key, param])=>{
|
|
928
|
+
const explicit = serializeTarget(resolveTarget({
|
|
929
|
+
lowdefy,
|
|
930
|
+
target: param,
|
|
931
|
+
name: key
|
|
932
|
+
}));
|
|
933
|
+
const resolved = type.isNone(explicit) ? pageQuery.get(key) : explicit;
|
|
934
|
+
if (!type.isNone(resolved)) {
|
|
935
|
+
verifyQuery.set(key, resolved);
|
|
936
|
+
}
|
|
937
|
+
});
|
|
938
|
+
if (!verifyQuery.has('errorCallbackURL')) {
|
|
939
|
+
const errorPage = auth.authConfig?.authPages?.error;
|
|
940
|
+
const errorTarget = type.isString(errorPage) ? serializeTarget(resolveTarget({
|
|
941
|
+
lowdefy,
|
|
942
|
+
target: {
|
|
943
|
+
url: errorPage
|
|
944
|
+
},
|
|
945
|
+
name: 'errorCallbackUrl'
|
|
946
|
+
})) : undefined;
|
|
947
|
+
if (!type.isNone(errorTarget)) {
|
|
948
|
+
verifyQuery.set('errorCallbackURL', errorTarget);
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
// The same address the email used to carry: basePath, then BetterAuth's
|
|
952
|
+
// mount, which AuthConfigured gives the client as `${basePath}/api/auth`.
|
|
953
|
+
const url = `${lowdefy.basePath ?? ''}/api/auth/magic-link/verify?${verifyQuery.toString()}`;
|
|
954
|
+
lowdefy._internal.globals.window.location.assign(url);
|
|
955
|
+
return stopChain({
|
|
956
|
+
url
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
async function twoFactorDisable({ password, ...rest } = {}) {
|
|
960
|
+
if (!type.isString(password)) {
|
|
961
|
+
throw new Error('TwoFactorDisable requires a "password" param.');
|
|
962
|
+
}
|
|
963
|
+
return unwrap(auth.twoFactorDisable({
|
|
964
|
+
password,
|
|
965
|
+
...rest
|
|
966
|
+
}));
|
|
967
|
+
}
|
|
968
|
+
// Returns the totpURI and backup codes the page must render once - the
|
|
969
|
+
// action response is the only carrier (readable via _actions in the same
|
|
970
|
+
// event chain); no side-channel state.
|
|
971
|
+
async function twoFactorEnable({ password, ...rest } = {}) {
|
|
972
|
+
if (!type.isString(password)) {
|
|
973
|
+
throw new Error('TwoFactorEnable requires a "password" param.');
|
|
974
|
+
}
|
|
975
|
+
return unwrap(auth.twoFactorEnable({
|
|
976
|
+
password,
|
|
977
|
+
...rest
|
|
978
|
+
}));
|
|
979
|
+
}
|
|
980
|
+
// Rotates recovery codes only - the TOTP secret, the verified flag and
|
|
981
|
+
// user.twoFactorEnabled are untouched. Returns the new backup codes the page
|
|
982
|
+
// must render once (readable via _actions in the same event chain); no
|
|
983
|
+
// side-channel state.
|
|
984
|
+
async function twoFactorGenerateBackupCodes({ password, ...rest } = {}) {
|
|
985
|
+
if (!type.isString(password)) {
|
|
986
|
+
throw new Error('TwoFactorGenerateBackupCodes requires a "password" param.');
|
|
987
|
+
}
|
|
988
|
+
return unwrap(auth.twoFactorGenerateBackupCodes({
|
|
989
|
+
password,
|
|
990
|
+
...rest
|
|
991
|
+
}));
|
|
992
|
+
}
|
|
993
|
+
// Serves both enrolment confirmation and the sign-in challenge, dispatching
|
|
994
|
+
// by parameter (matching login): a backupCode param verifies a backup code,
|
|
995
|
+
// otherwise code verifies TOTP.
|
|
996
|
+
//
|
|
997
|
+
// The engine owns both ends of the challenge hop, not only arrival: every
|
|
998
|
+
// sign-in path navigates to authPages.twoFactor carrying ?callbackUrl=, and
|
|
999
|
+
// this is where that value is spent. Leaving the last hop to the challenge
|
|
1000
|
+
// page is the same opt-in correctness the arrival navigation exists to avoid,
|
|
1001
|
+
// and the rung is awkward to consume from config besides - it already carries
|
|
1002
|
+
// basePath, so a Link's pageId double-prefixes it under a subpath. With the
|
|
1003
|
+
// engine finishing the hop, no app config reads the parameter at all.
|
|
1004
|
+
//
|
|
1005
|
+
// No stopChain: this navigation is the end of the flow rather than a departure
|
|
1006
|
+
// mid-chain, so an app may legitimately have a step after it.
|
|
1007
|
+
async function twoFactorVerify({ backupCode, callbackUrl, code, trustDevice, ...rest } = {}) {
|
|
1008
|
+
if (!type.isString(backupCode) && !type.isString(code)) {
|
|
1009
|
+
throw new Error('TwoFactorVerify requires a "code" or "backupCode" param.');
|
|
1010
|
+
}
|
|
1011
|
+
const callbackTarget = resolveCallbackURL({
|
|
1012
|
+
lowdefy,
|
|
1013
|
+
callbackUrl
|
|
1014
|
+
});
|
|
1015
|
+
const data = await unwrap(type.isString(backupCode) ? auth.twoFactorVerifyBackupCode({
|
|
1016
|
+
code: backupCode,
|
|
1017
|
+
trustDevice,
|
|
1018
|
+
...rest
|
|
1019
|
+
}) : auth.twoFactorVerifyTotp({
|
|
1020
|
+
code,
|
|
1021
|
+
trustDevice,
|
|
1022
|
+
...rest
|
|
1023
|
+
}));
|
|
1024
|
+
// A token is the whole guard here, unlike phoneNumberVerify's: verify has no
|
|
1025
|
+
// session-less mode, so a successful challenge always mints one and a failed
|
|
1026
|
+
// one throws to the action's catch.
|
|
1027
|
+
const window = lowdefy._internal?.globals?.window;
|
|
1028
|
+
if (data?.token && callbackTarget && window) {
|
|
1029
|
+
navigateToTarget(callbackTarget);
|
|
1030
|
+
}
|
|
1031
|
+
return data;
|
|
64
1032
|
}
|
|
65
1033
|
return {
|
|
1034
|
+
acceptInvitation,
|
|
1035
|
+
changePassword,
|
|
1036
|
+
emailOtpSend,
|
|
1037
|
+
emailOtpVerify,
|
|
1038
|
+
leaveOrganization,
|
|
1039
|
+
listOrganizations,
|
|
66
1040
|
login,
|
|
67
1041
|
logout,
|
|
1042
|
+
magicLinkVerify,
|
|
1043
|
+
oauth2Consent,
|
|
1044
|
+
oauth2Continue,
|
|
1045
|
+
passkeyDelete,
|
|
1046
|
+
passkeyRegister,
|
|
1047
|
+
passkeySignIn,
|
|
1048
|
+
passkeyUpdate,
|
|
1049
|
+
phoneNumberSendOtp,
|
|
1050
|
+
phoneNumberVerify,
|
|
1051
|
+
requestPasswordReset,
|
|
1052
|
+
resetPassword,
|
|
1053
|
+
revokeOtherSessions,
|
|
1054
|
+
sendVerificationEmail,
|
|
1055
|
+
setActiveOrganization,
|
|
1056
|
+
signUp,
|
|
1057
|
+
twoFactorDisable,
|
|
1058
|
+
twoFactorEnable,
|
|
1059
|
+
twoFactorGenerateBackupCodes,
|
|
1060
|
+
twoFactorVerify,
|
|
68
1061
|
updateSession
|
|
69
1062
|
};
|
|
70
1063
|
}
|