@nebulr-group/bridge-svelte 0.5.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.
Files changed (30) hide show
  1. package/dist/auth/route-guard.js +27 -4
  2. package/dist/client/BridgeBootstrap.js +35 -5
  3. package/dist/client/BridgeBootstrap.svelte +17 -6
  4. package/dist/client/components/sdk-auth/ForgotPassword.svelte +48 -20
  5. package/dist/client/components/sdk-auth/ForgotPassword.svelte.d.ts +9 -0
  6. package/dist/client/components/sdk-auth/LoginForm.svelte +43 -36
  7. package/dist/client/components/sdk-auth/LoginForm.svelte.d.ts +3 -1
  8. package/dist/client/components/sdk-auth/MagicLink.svelte +36 -12
  9. package/dist/client/components/sdk-auth/MagicLink.svelte.d.ts +5 -0
  10. package/dist/client/components/sdk-auth/MfaChallenge.svelte +21 -14
  11. package/dist/client/components/sdk-auth/MfaChallenge.svelte.d.ts +3 -0
  12. package/dist/client/components/sdk-auth/MfaSetup.svelte +55 -19
  13. package/dist/client/components/sdk-auth/MfaSetup.svelte.d.ts +14 -0
  14. package/dist/client/components/sdk-auth/PasskeyLogin.svelte +11 -4
  15. package/dist/client/components/sdk-auth/PasskeyLogin.svelte.d.ts +3 -0
  16. package/dist/client/components/sdk-auth/PasskeyRequestSetupLink.svelte +38 -15
  17. package/dist/client/components/sdk-auth/PasskeyRequestSetupLink.svelte.d.ts +10 -0
  18. package/dist/client/components/sdk-auth/PasskeySetup.svelte +45 -19
  19. package/dist/client/components/sdk-auth/PasskeySetup.svelte.d.ts +13 -0
  20. package/dist/client/components/sdk-auth/SignupForm.svelte +49 -14
  21. package/dist/client/components/sdk-auth/SignupForm.svelte.d.ts +16 -0
  22. package/dist/client/components/sdk-auth/auth-form-description.test.d.ts +1 -0
  23. package/dist/client/components/sdk-auth/auth-form-description.test.js +470 -0
  24. package/dist/client/components/sdk-auth/shared/AuthFormWrapper.svelte +37 -1
  25. package/dist/client/components/sdk-auth/shared/AuthFormWrapper.svelte.d.ts +22 -0
  26. package/dist/client/stores/i18n.d.ts +18 -0
  27. package/dist/client/stores/i18n.js +30 -0
  28. package/dist/index.d.ts +2 -0
  29. package/dist/index.js +4 -0
  30. package/package.json +3 -3
@@ -1,9 +1,22 @@
1
1
  // src/lib/auth/route-guard.ts — thin wrapper delegating to auth-core via bridge-instance
2
2
  import { getBridgeAuth } from '../core/bridge-instance.js';
3
- import { getRouteGuardConfig } from '../client/stores/config.store.js';
3
+ import { getConfig, getRouteGuardConfig } from '../client/stores/config.store.js';
4
4
  export function createRouteGuard(flagsReady) {
5
5
  const config = getRouteGuardConfig();
6
- const guard = getBridgeAuth().createRouteGuard(config);
6
+ // TBP-629 — feed the app's own loginRoute into the guard so it can refuse to
7
+ // make the login page its own return target. The consumer already told us
8
+ // where their login page is via BridgeConfig; making them repeat it under
9
+ // routeConfig.returnTo would be a second source of truth that can drift.
10
+ // An explicit routeConfig value still wins.
11
+ const { loginRoute } = getConfig();
12
+ const guardConfig = {
13
+ ...config,
14
+ returnTo: {
15
+ ...config?.returnTo,
16
+ loginRoute: config?.returnTo?.loginRoute ?? loginRoute,
17
+ },
18
+ };
19
+ const guard = getBridgeAuth().createRouteGuard(guardConfig);
7
20
  if (!flagsReady)
8
21
  return guard;
9
22
  // Wrap checkRouteRestrictions to await flagsReady before evaluating
@@ -13,9 +26,19 @@ export function createRouteGuard(flagsReady) {
13
26
  await flagsReady;
14
27
  return guard.checkRouteRestrictions(pathname);
15
28
  },
16
- async getNavigationDecision(pathname) {
29
+ async getNavigationDecision(pathname, attempted) {
17
30
  if (guard.shouldRedirectToLogin(pathname)) {
18
- return { type: 'login', loginUrl: guard.getLoginRedirect() };
31
+ // TBP-629 this branch short-circuits before flagsReady on purpose
32
+ // (an unauthenticated visitor needs no flag evaluation), which is
33
+ // exactly why `attempted` has to be threaded through here too. The
34
+ // wrapper previously rebuilt the decision by hand and would silently
35
+ // drop any argument auth-core's version learned to accept.
36
+ const returnTo = guard.resolveReturnTo(attempted ?? pathname);
37
+ return {
38
+ type: 'login',
39
+ loginUrl: guard.getLoginRedirect(),
40
+ ...(returnTo ? { returnTo } : {}),
41
+ };
19
42
  }
20
43
  await flagsReady;
21
44
  const redirectTo = await guard.checkRouteRestrictions(pathname);
@@ -4,9 +4,9 @@ import { get } from 'svelte/store';
4
4
  import { createRouteGuard } from '../auth/route-guard.js';
5
5
  import { getBridgeAuth, bridgeReadyStore, markReady, waitForBridge as _waitForBridge, } from '../core/bridge-instance.js';
6
6
  import { installBridgeAuthFetch } from '../core/bridge-runtime.js';
7
- import { useBridge } from '@nebulr-group/bridge-auth-core';
7
+ import { useBridge, stashReturnTo, takeReturnTo, withReturnTo } from '@nebulr-group/bridge-auth-core';
8
8
  import { logger } from '../shared/logger.js';
9
- import { bridgeConfig, getConfig } from './stores/config.store.js';
9
+ import { bridgeConfig, getConfig, getRouteGuardConfig } from './stores/config.store.js';
10
10
  export async function bridgeBootstrap(url, config, routeConfig = { rules: [], defaultAccess: 'protected' }, kitFetch) {
11
11
  // If we've already completed bootstrap once, short-circuit
12
12
  if (get(bridgeReadyStore)) {
@@ -58,7 +58,17 @@ export async function bridgeBootstrap(url, config, routeConfig = { rules: [], de
58
58
  history.replaceState = svelteReplaceState;
59
59
  }
60
60
  const payment = url.searchParams.get('payment');
61
- redirect(303, payment ? `/?payment=${payment}` : '/');
61
+ // TBP-629 this line used to hard-code '/', which is where hosted
62
+ // mode lost the deep link even though the OAuth round-trip itself
63
+ // worked fine. `takeReturnTo()` is one-shot and re-sanitizes, and
64
+ // returns null when nothing was stashed, so the old behaviour is
65
+ // exactly what happens when there is no deep link to restore.
66
+ //
67
+ // `payment` wins: it signals a just-completed checkout whose landing
68
+ // page the billing flow owns, and that is a deliberate destination
69
+ // rather than a remembered one.
70
+ const stashedReturnTo = takeReturnTo();
71
+ redirect(303, payment ? `/?payment=${payment}` : (stashedReturnTo ?? '/'));
62
72
  }
63
73
  catch (err) {
64
74
  if (isRedirect(err))
@@ -170,13 +180,33 @@ export async function bridgeBootstrap(url, config, routeConfig = { rules: [], de
170
180
  hasTokens: !!currentTokens?.accessToken,
171
181
  isAuthenticated: currentAuth
172
182
  });
173
- const decision = await guard.getNavigationDecision(url.pathname);
183
+ // TBP-629 hand the guard the FULL attempted target, not just the pathname.
184
+ // `?key=…` style query is part of the deep link for plenty of routes, and an
185
+ // exported-file link that loses its query is as broken as one that loses its
186
+ // path.
187
+ const attempted = `${url.pathname}${url.search}`;
188
+ const decision = await guard.getNavigationDecision(url.pathname, attempted);
174
189
  logger.debug('[bridgeBootstrap] navigation decision', decision);
175
190
  if (decision.type === 'login') {
176
191
  const { loginRoute } = getConfig();
192
+ const returnToParam = getRouteGuardConfig()?.returnTo?.param;
177
193
  // SDK mode: consumer explicitly set loginRoute → redirect to in-app login view
178
194
  // Hosted mode (default): no loginRoute → redirect to hosted auth portal
179
- redirect(303, loginRoute ?? bridge.createLoginUrl());
195
+ //
196
+ // TBP-629: only SDK mode gains the return target. Hosted mode is left exactly
197
+ // as it was — `createLoginUrl()` already carries its own `redirectUri` (the
198
+ // configured callbackUrl) and changing what that means would alter an OAuth
199
+ // round-trip that consumers have registered redirect URIs against.
200
+ if (loginRoute) {
201
+ redirect(303, withReturnTo(loginRoute, decision.returnTo, returnToParam));
202
+ }
203
+ // Hosted mode (TBP-629): the target CANNOT ride on the URL. `createLoginUrl()`
204
+ // feeds `redirectUri` to the OAuth authorize call and bridge-api validates it
205
+ // with an exact `allowedRedirectUris.includes()` match, so adding a query to
206
+ // it would break login rather than improve it. Stash it instead and pick it
207
+ // up at the callback below — the OAuth request itself stays untouched.
208
+ stashReturnTo(decision.returnTo);
209
+ redirect(303, bridge.createLoginUrl());
180
210
  }
181
211
  if (decision.type === 'redirect' && url.pathname !== decision.to) {
182
212
  redirect(303, decision.to);
@@ -3,6 +3,7 @@
3
3
  import { page } from '$app/stores';
4
4
  import { onMount, onDestroy } from 'svelte';
5
5
  import { createRouteGuard, routeRulesReferenceFlag } from '../auth/route-guard.js';
6
+ import { stashReturnTo, withReturnTo } from '@nebulr-group/bridge-auth-core';
6
7
  import {
7
8
  getBridgeAuth,
8
9
  isAuthenticated,
@@ -12,7 +13,7 @@
12
13
  } from '../core/bridge-instance.js';
13
14
  import { bridge as bridgeSurface } from '../core/bridge.js';
14
15
  import { setBridgeContext } from '../core/use-bridge.js';
15
- import { getConfig } from './stores/config.store.js';
16
+ import { getConfig, getRouteGuardConfig } from './stores/config.store.js';
16
17
  import {
17
18
  onBridgeFlagChange,
18
19
  startBridgeRuntime,
@@ -73,14 +74,24 @@
73
74
  }
74
75
  });
75
76
 
76
- async function handleRoute(pathname: string, cancel?: () => void) {
77
- const decision = await guard.getNavigationDecision(pathname);
77
+ async function handleRoute(pathname: string, cancel?: () => void, search?: string) {
78
+ // TBP-629 client-side navigation loses the deep link the same way the
79
+ // load-time path did. Fixing only BridgeBootstrap.ts would leave somebody
80
+ // who clicks an in-app link into a protected route while their session is
81
+ // gone landing on the default route, which is the same bug with a different
82
+ // trigger.
83
+ const attempted = `${pathname}${search ?? ''}`;
84
+ const decision = await guard.getNavigationDecision(pathname, attempted);
78
85
  if (decision.type === 'login') {
79
86
  if (cancel) cancel();
80
87
  const { loginRoute } = getConfig();
81
88
  if (loginRoute) {
82
- goto(loginRoute);
89
+ goto(withReturnTo(loginRoute, decision.returnTo, getRouteGuardConfig()?.returnTo?.param));
83
90
  } else {
91
+ // Hosted mode (TBP-629) — stash before handing off to the portal; the
92
+ // callback in BridgeBootstrap.ts picks it up. Same reason as there: the
93
+ // OAuth redirectUri is exact-matched server-side and must not be touched.
94
+ stashReturnTo(decision.returnTo);
84
95
  getBridgeAuth().login();
85
96
  }
86
97
  return;
@@ -110,7 +121,7 @@
110
121
  _recheckTimer = undefined;
111
122
  // No `cancel` here: there is no navigation in flight to cancel. A denied
112
123
  // verdict redirects the user off the page they are already on.
113
- handleRoute(window.location.pathname).catch(() => {
124
+ handleRoute(window.location.pathname, undefined, window.location.search).catch(() => {
114
125
  /* a failed re-check must never break the page; the next navigation
115
126
  re-evaluates anyway */
116
127
  });
@@ -180,6 +191,6 @@
180
191
 
181
192
  beforeNavigate(async ({ to, cancel }) => {
182
193
  if (!to) return;
183
- await handleRoute(to.url.pathname, cancel);
194
+ await handleRoute(to.url.pathname, cancel, to.url.search);
184
195
  });
185
196
  </script>
@@ -1,6 +1,8 @@
1
1
  <script lang="ts">
2
2
  import type { HTMLAttributes } from 'svelte/elements';
3
+ import type { MessageOverrides } from '@nebulr-group/bridge-auth-core';
3
4
  import { getBridgeAuth } from '../../../core/bridge-instance.js';
5
+ import { getTranslator } from '../../stores/i18n.js';
4
6
  import AuthFormWrapper from './shared/AuthFormWrapper.svelte';
5
7
  import Spinner from './shared/Spinner.svelte';
6
8
  import Alert from './shared/Alert.svelte';
@@ -15,6 +17,14 @@
15
17
  * Pass `null`/`''` to render no heading and use your own page title.
16
18
  */
17
19
  heading?: string | null;
20
+ /**
21
+ * Step description. Pass `null`/`''` to render nothing and use your own
22
+ * subtitle (TBP-631). Only ever shown on the send-link step; the set-password
23
+ * and success states carry none.
24
+ */
25
+ description?: string | null;
26
+ /** Per-key copy overrides for this component only (TBP-630). */
27
+ messages?: MessageOverrides;
18
28
  }
19
29
 
20
30
  let {
@@ -23,11 +33,15 @@
23
33
  onError,
24
34
  loginHref = '/login',
25
35
  heading = undefined,
36
+ description = undefined,
37
+ messages,
26
38
  class: className,
27
39
  style,
28
40
  ...rest
29
41
  }: Props = $props();
30
42
 
43
+ const t = $derived(getTranslator(messages));
44
+
31
45
  let email = $state('');
32
46
  let password = $state('');
33
47
  let confirmPassword = $state('');
@@ -42,11 +56,25 @@
42
56
  // In a success state ("Password set" / the email-sent alert) the form heading
43
57
  // is redundant, so suppress it. Otherwise use the override (if provided) or
44
58
  // the built-in, state-appropriate heading.
45
- const builtInHeading = $derived(isSetMode ? 'Set new password' : 'Reset your password');
59
+ const builtInHeading = $derived(
60
+ isSetMode ? t('forgot.headingSet') : t('forgot.headingRequest'),
61
+ );
46
62
  const wrapperHeading = $derived(
47
63
  passwordReset || emailSent ? null : heading !== undefined ? heading : builtInHeading,
48
64
  );
49
65
 
66
+ // TBP-631 — same shape as the heading above: the description belongs to the
67
+ // send-link step only. `undefined` means "not overridden" and falls through to
68
+ // the built-in; `null` is an explicit suppression from the host and must be
69
+ // respected, which is why this cannot collapse to `description ?? builtIn`.
70
+ const wrapperDescription = $derived(
71
+ isSetMode || passwordReset || emailSent
72
+ ? null
73
+ : description !== undefined
74
+ ? description
75
+ : t('forgot.description'),
76
+ );
77
+
50
78
  async function handleSendLink() {
51
79
  if (loading) return;
52
80
  error = null;
@@ -55,7 +83,7 @@
55
83
  await getBridgeAuth().sendResetPasswordLink(email);
56
84
  emailSent = true;
57
85
  } catch (err: any) {
58
- error = err.message || 'Failed to send reset link.';
86
+ error = err.message || t('forgot.error.send');
59
87
  onError?.(err);
60
88
  } finally {
61
89
  loading = false;
@@ -67,12 +95,12 @@
67
95
  error = null;
68
96
 
69
97
  if (password !== confirmPassword) {
70
- error = 'Passwords do not match.';
98
+ error = t('forgot.error.mismatch');
71
99
  return;
72
100
  }
73
101
 
74
102
  if (password.length < 8) {
75
- error = 'Password must be at least 8 characters.';
103
+ error = t('forgot.error.tooShort');
76
104
  return;
77
105
  }
78
106
 
@@ -82,7 +110,7 @@
82
110
  passwordReset = true;
83
111
  onComplete?.();
84
112
  } catch (err: any) {
85
- error = err.message || 'Failed to update password.';
113
+ error = err.message || t('forgot.error.update');
86
114
  onError?.(err);
87
115
  } finally {
88
116
  loading = false;
@@ -92,6 +120,7 @@
92
120
 
93
121
  <AuthFormWrapper
94
122
  heading={wrapperHeading}
123
+ description={wrapperDescription}
95
124
  class={className}
96
125
  {style}
97
126
  {...rest}
@@ -102,19 +131,19 @@
102
131
 
103
132
  {#if isSetMode}
104
133
  {#if passwordReset}
105
- <h2 class="bridge-success-heading">Password set</h2>
134
+ <h2 class="bridge-success-heading">{t('forgot.successHeading')}</h2>
106
135
  <div class="bridge-form-footer">
107
- <a href={loginHref}>Back to login</a>
136
+ <a href={loginHref}>{t('action.backToLogin')}</a>
108
137
  </div>
109
138
  {:else}
110
139
  <form onsubmit={(e) => { e.preventDefault(); handleSetPassword(); }}>
111
140
  <div class="bridge-form-group">
112
- <label for="newPassword">New password</label>
141
+ <label for="newPassword">{t('field.newPassword')}</label>
113
142
  <div class="bridge-password-wrapper">
114
143
  <input
115
144
  id="newPassword"
116
145
  type={showPasswords ? 'text' : 'password'}
117
- placeholder="At least 8 characters"
146
+ placeholder={t('placeholder.newPassword')}
118
147
  required
119
148
  bind:value={password}
120
149
  disabled={loading}
@@ -124,7 +153,7 @@
124
153
  class="bridge-password-toggle"
125
154
  onclick={() => showPasswords = !showPasswords}
126
155
  tabindex={-1}
127
- aria-label={showPasswords ? 'Hide passwords' : 'Show passwords'}
156
+ aria-label={showPasswords ? t('action.hidePasswords') : t('action.showPasswords')}
128
157
  >
129
158
  {#if showPasswords}
130
159
  <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/><path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
@@ -135,12 +164,12 @@
135
164
  </div>
136
165
  </div>
137
166
  <div class="bridge-form-group">
138
- <label for="confirmPassword">Confirm password</label>
167
+ <label for="confirmPassword">{t('field.confirmPassword')}</label>
139
168
  <div class="bridge-password-wrapper">
140
169
  <input
141
170
  id="confirmPassword"
142
171
  type={showPasswords ? 'text' : 'password'}
143
- placeholder="Repeat password"
172
+ placeholder={t('placeholder.confirmPassword')}
144
173
  required
145
174
  bind:value={confirmPassword}
146
175
  disabled={loading}
@@ -148,36 +177,35 @@
148
177
  </div>
149
178
  </div>
150
179
  <button type="submit" class="bridge-btn bridge-btn-primary" disabled={loading || !password}>
151
- {#if loading}<Spinner size={16} />{:else}Set a password{/if}
180
+ {#if loading}<Spinner size={16} />{:else}{t('forgot.setSubmit')}{/if}
152
181
  </button>
153
182
  </form>
154
183
  {/if}
155
184
  {:else}
156
185
  {#if emailSent}
157
- <Alert variant="success">Check your email for a password reset link.</Alert>
186
+ <Alert variant="success">{t('forgot.emailSent')}</Alert>
158
187
  <div class="bridge-form-footer">
159
- <a href={loginHref}>Back to login</a>
188
+ <a href={loginHref}>{t('action.backToLogin')}</a>
160
189
  </div>
161
190
  {:else}
162
- <p class="bridge-step-desc">Enter your email and we'll send you a link to reset your password.</p>
163
191
  <form onsubmit={(e) => { e.preventDefault(); handleSendLink(); }}>
164
192
  <div class="bridge-form-group">
165
- <label for="reset-email">Email</label>
193
+ <label for="reset-email">{t('field.email')}</label>
166
194
  <input
167
195
  id="reset-email"
168
196
  type="email"
169
- placeholder="you@example.com"
197
+ placeholder={t('placeholder.email')}
170
198
  required
171
199
  bind:value={email}
172
200
  disabled={loading}
173
201
  />
174
202
  </div>
175
203
  <button type="submit" class="bridge-btn bridge-btn-primary" disabled={loading || !email.trim()}>
176
- {#if loading}<Spinner size={16} />{:else}Send reset link{/if}
204
+ {#if loading}<Spinner size={16} />{:else}{t('forgot.submit')}{/if}
177
205
  </button>
178
206
  </form>
179
207
  <div class="bridge-form-footer">
180
- <a href={loginHref}>Back to login</a>
208
+ <a href={loginHref}>{t('action.backToLogin')}</a>
181
209
  </div>
182
210
  {/if}
183
211
  {/if}
@@ -1,4 +1,5 @@
1
1
  import type { HTMLAttributes } from 'svelte/elements';
2
+ import type { MessageOverrides } from '@nebulr-group/bridge-auth-core';
2
3
  interface Props extends HTMLAttributes<HTMLDivElement> {
3
4
  token?: string;
4
5
  onComplete?: () => void;
@@ -9,6 +10,14 @@ interface Props extends HTMLAttributes<HTMLDivElement> {
9
10
  * Pass `null`/`''` to render no heading and use your own page title.
10
11
  */
11
12
  heading?: string | null;
13
+ /**
14
+ * Step description. Pass `null`/`''` to render nothing and use your own
15
+ * subtitle (TBP-631). Only ever shown on the send-link step; the set-password
16
+ * and success states carry none.
17
+ */
18
+ description?: string | null;
19
+ /** Per-key copy overrides for this component only (TBP-630). */
20
+ messages?: MessageOverrides;
12
21
  }
13
22
  declare const ForgotPassword: import("svelte").Component<Props, {}, "">;
14
23
  type ForgotPassword = ReturnType<typeof ForgotPassword>;