@nebulr-group/bridge-svelte 0.1.0-beta.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/LICENSE +21 -0
- package/README.md +63 -0
- package/dist/auth/route-guard.d.ts +2 -0
- package/dist/auth/route-guard.js +28 -0
- package/dist/client/BridgeBootstrap.d.ts +8 -0
- package/dist/client/BridgeBootstrap.js +105 -0
- package/dist/client/BridgeBootstrap.svelte +43 -0
- package/dist/client/BridgeBootstrap.svelte.d.ts +6 -0
- package/dist/client/components/FeatureFlag.svelte +37 -0
- package/dist/client/components/FeatureFlag.svelte.d.ts +15 -0
- package/dist/client/components/ProfileName.svelte +18 -0
- package/dist/client/components/ProfileName.svelte.d.ts +6 -0
- package/dist/client/components/developer/ApiTokenManagement.svelte +365 -0
- package/dist/client/components/developer/ApiTokenManagement.svelte.d.ts +6 -0
- package/dist/client/components/sdk-auth/ForgotPassword.svelte +170 -0
- package/dist/client/components/sdk-auth/ForgotPassword.svelte.d.ts +10 -0
- package/dist/client/components/sdk-auth/LoginForm.svelte +414 -0
- package/dist/client/components/sdk-auth/LoginForm.svelte.d.ts +25 -0
- package/dist/client/components/sdk-auth/MagicLink.svelte +92 -0
- package/dist/client/components/sdk-auth/MagicLink.svelte.d.ts +9 -0
- package/dist/client/components/sdk-auth/MfaChallenge.svelte +150 -0
- package/dist/client/components/sdk-auth/MfaChallenge.svelte.d.ts +9 -0
- package/dist/client/components/sdk-auth/MfaSetup.svelte +182 -0
- package/dist/client/components/sdk-auth/MfaSetup.svelte.d.ts +8 -0
- package/dist/client/components/sdk-auth/PasskeyLogin.svelte +99 -0
- package/dist/client/components/sdk-auth/PasskeyLogin.svelte.d.ts +10 -0
- package/dist/client/components/sdk-auth/PasskeyRequestSetupLink.svelte +83 -0
- package/dist/client/components/sdk-auth/PasskeyRequestSetupLink.svelte.d.ts +7 -0
- package/dist/client/components/sdk-auth/PasskeySetup.svelte +151 -0
- package/dist/client/components/sdk-auth/PasskeySetup.svelte.d.ts +11 -0
- package/dist/client/components/sdk-auth/SignupForm.svelte +119 -0
- package/dist/client/components/sdk-auth/SignupForm.svelte.d.ts +14 -0
- package/dist/client/components/sdk-auth/SsoButton.svelte +70 -0
- package/dist/client/components/sdk-auth/SsoButton.svelte.d.ts +13 -0
- package/dist/client/components/sdk-auth/SsoProviderIcon.svelte +56 -0
- package/dist/client/components/sdk-auth/SsoProviderIcon.svelte.d.ts +7 -0
- package/dist/client/components/sdk-auth/TenantSelector.svelte +83 -0
- package/dist/client/components/sdk-auth/TenantSelector.svelte.d.ts +11 -0
- package/dist/client/components/sdk-auth/WorkspaceSelector.svelte +108 -0
- package/dist/client/components/sdk-auth/WorkspaceSelector.svelte.d.ts +16 -0
- package/dist/client/components/sdk-auth/shared/Alert.svelte +17 -0
- package/dist/client/components/sdk-auth/shared/Alert.svelte.d.ts +9 -0
- package/dist/client/components/sdk-auth/shared/AuthFormWrapper.svelte +23 -0
- package/dist/client/components/sdk-auth/shared/AuthFormWrapper.svelte.d.ts +10 -0
- package/dist/client/components/sdk-auth/shared/Spinner.svelte +16 -0
- package/dist/client/components/sdk-auth/shared/Spinner.svelte.d.ts +7 -0
- package/dist/client/components/subscription/PlanSelector.svelte +204 -0
- package/dist/client/components/subscription/PlanSelector.svelte.d.ts +22 -0
- package/dist/client/components/team/TeamAddUserDialog.svelte +125 -0
- package/dist/client/components/team/TeamAddUserDialog.svelte.d.ts +17 -0
- package/dist/client/components/team/TeamConfirmDialog.svelte +80 -0
- package/dist/client/components/team/TeamConfirmDialog.svelte.d.ts +21 -0
- package/dist/client/components/team/TeamEditUserDialog.svelte +131 -0
- package/dist/client/components/team/TeamEditUserDialog.svelte.d.ts +19 -0
- package/dist/client/components/team/TeamManagementPanel.svelte +72 -0
- package/dist/client/components/team/TeamManagementPanel.svelte.d.ts +20 -0
- package/dist/client/components/team/TeamProfileForm.svelte +129 -0
- package/dist/client/components/team/TeamProfileForm.svelte.d.ts +7 -0
- package/dist/client/components/team/TeamUserActionsMenu.svelte +63 -0
- package/dist/client/components/team/TeamUserActionsMenu.svelte.d.ts +9 -0
- package/dist/client/components/team/TeamUserList.svelte +226 -0
- package/dist/client/components/team/TeamUserList.svelte.d.ts +7 -0
- package/dist/client/components/team/TeamWorkspaceForm.svelte +134 -0
- package/dist/client/components/team/TeamWorkspaceForm.svelte.d.ts +7 -0
- package/dist/client/stores/config.store.d.ts +6 -0
- package/dist/client/stores/config.store.js +76 -0
- package/dist/client/utils/cn.d.ts +1 -0
- package/dist/client/utils/cn.js +3 -0
- package/dist/core/bridge-instance.d.ts +52 -0
- package/dist/core/bridge-instance.js +160 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.js +39 -0
- package/dist/shared/feature-flag.d.ts +6 -0
- package/dist/shared/feature-flag.js +13 -0
- package/dist/shared/logger.d.ts +18 -0
- package/dist/shared/logger.js +32 -0
- package/dist/shared/profile.d.ts +18 -0
- package/dist/shared/profile.js +25 -0
- package/dist/shared/types/config.d.ts +42 -0
- package/dist/shared/types/config.js +1 -0
- package/dist/styles.css +1089 -0
- package/package.json +86 -0
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
4
|
+
import type { AppConfig, FederationConnection } from '@thebridge/auth-core';
|
|
5
|
+
import { onMount } from 'svelte';
|
|
6
|
+
import { getBridgeAuth, authState, appConfigStore } from '../../../core/bridge-instance.js';
|
|
7
|
+
import { getConfig } from '../../stores/config.store.js';
|
|
8
|
+
import AuthFormWrapper from './shared/AuthFormWrapper.svelte';
|
|
9
|
+
import Spinner from './shared/Spinner.svelte';
|
|
10
|
+
import Alert from './shared/Alert.svelte';
|
|
11
|
+
import SsoButton from './SsoButton.svelte';
|
|
12
|
+
import SsoProviderIcon from './SsoProviderIcon.svelte';
|
|
13
|
+
import MfaChallenge from './MfaChallenge.svelte';
|
|
14
|
+
import MfaSetup from './MfaSetup.svelte';
|
|
15
|
+
import TenantSelector from './TenantSelector.svelte';
|
|
16
|
+
import PasskeyLogin from './PasskeyLogin.svelte';
|
|
17
|
+
import PasskeyRequestSetupLink from './PasskeyRequestSetupLink.svelte';
|
|
18
|
+
|
|
19
|
+
interface Props extends HTMLAttributes<HTMLDivElement> {
|
|
20
|
+
/** Override signup link visibility. Default: derived from app config (signupEnabled). */
|
|
21
|
+
showSignupLink?: boolean | undefined;
|
|
22
|
+
/** Override signup page URL. Default: derived from BridgeConfig signupRoute. */
|
|
23
|
+
signupHref?: string | undefined;
|
|
24
|
+
/** Override forgot password visibility. Default: derived from app config (true when password auth is available). */
|
|
25
|
+
showForgotPassword?: boolean | undefined;
|
|
26
|
+
forgotPasswordHref?: string;
|
|
27
|
+
/** Override magic link visibility. Default: derived from app config (magicLinkEnabled). */
|
|
28
|
+
showMagicLink?: boolean | undefined;
|
|
29
|
+
/** Override passkey visibility. Default: derived from app config (passkeysEnabled). */
|
|
30
|
+
showPasskeys?: boolean | undefined;
|
|
31
|
+
onLogin?: () => void;
|
|
32
|
+
onError?: (error: Error) => void;
|
|
33
|
+
onSsoClick?: (connectionType: string) => void;
|
|
34
|
+
heading?: string;
|
|
35
|
+
ssoConnections?: FederationConnection[];
|
|
36
|
+
footer?: Snippet;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let {
|
|
40
|
+
showSignupLink = undefined,
|
|
41
|
+
signupHref = undefined,
|
|
42
|
+
showForgotPassword = undefined,
|
|
43
|
+
forgotPasswordHref,
|
|
44
|
+
showMagicLink = undefined,
|
|
45
|
+
showPasskeys = undefined,
|
|
46
|
+
onLogin,
|
|
47
|
+
onError,
|
|
48
|
+
onSsoClick,
|
|
49
|
+
heading = '',
|
|
50
|
+
ssoConnections = [],
|
|
51
|
+
footer,
|
|
52
|
+
class: className,
|
|
53
|
+
style,
|
|
54
|
+
...rest
|
|
55
|
+
}: Props = $props();
|
|
56
|
+
|
|
57
|
+
type Step = 'credentials' | 'forgot-password' | 'magic-link' | 'passkey-request';
|
|
58
|
+
|
|
59
|
+
let step = $state<Step>('credentials');
|
|
60
|
+
let email = $state('');
|
|
61
|
+
let password = $state('');
|
|
62
|
+
let loading = $state(false);
|
|
63
|
+
let error = $state<string | null>(null);
|
|
64
|
+
let showPassword = $state(false);
|
|
65
|
+
|
|
66
|
+
// Forgot password inline state
|
|
67
|
+
let fpEmailSent = $state(false);
|
|
68
|
+
let fpLoading = $state(false);
|
|
69
|
+
|
|
70
|
+
// Magic link inline state
|
|
71
|
+
let mlSent = $state(false);
|
|
72
|
+
let mlExpiresIn = $state(0);
|
|
73
|
+
let mlLoading = $state(false);
|
|
74
|
+
|
|
75
|
+
function buildSsoConnections(appConfig: AppConfig | null): FederationConnection[] {
|
|
76
|
+
if (!appConfig) return [];
|
|
77
|
+
const out: FederationConnection[] = [];
|
|
78
|
+
if (appConfig.googleSsoEnabled) out.push({ id: 'google', type: 'google', name: 'Google' });
|
|
79
|
+
if (appConfig.azureAdSsoEnabled) out.push({ id: 'azure', type: 'ms-azure-ad', name: 'Microsoft' });
|
|
80
|
+
if (appConfig.linkedinSsoEnabled) out.push({ id: 'linkedin', type: 'linkedin', name: 'LinkedIn' });
|
|
81
|
+
if (appConfig.githubSsoEnabled) out.push({ id: 'github', type: 'github', name: 'GitHub' });
|
|
82
|
+
if (appConfig.facebookSsoEnabled) out.push({ id: 'facebook', type: 'facebook', name: 'Facebook' });
|
|
83
|
+
if (appConfig.appleSsoEnabled) out.push({ id: 'apple', type: 'apple', name: 'Apple' });
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ssoConnections prop overrides auto-derived; when empty, derive from app config
|
|
88
|
+
let effectiveSsoConnections = $derived(
|
|
89
|
+
ssoConnections.length > 0 ? ssoConnections : buildSsoConnections($appConfigStore)
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
// Derive auth method visibility from app config when props are not explicitly set
|
|
93
|
+
let effectiveShowMagicLink = $derived(showMagicLink ?? ($appConfigStore?.magicLinkEnabled ?? false));
|
|
94
|
+
let effectiveShowPasskeys = $derived(showPasskeys ?? ($appConfigStore?.passkeysEnabled ?? false));
|
|
95
|
+
let effectiveShowForgotPassword = $derived(showForgotPassword ?? true);
|
|
96
|
+
let effectiveShowSignupLink = $derived(showSignupLink ?? ($appConfigStore?.signupEnabled ?? true));
|
|
97
|
+
let effectiveSignupHref = $derived(signupHref ?? getConfig().signupRoute);
|
|
98
|
+
|
|
99
|
+
// State-driven rendering: when authState changes, override local step
|
|
100
|
+
let currentAuthState = $derived($authState);
|
|
101
|
+
|
|
102
|
+
async function handleSubmit() {
|
|
103
|
+
if (loading) return;
|
|
104
|
+
error = null;
|
|
105
|
+
loading = true;
|
|
106
|
+
try {
|
|
107
|
+
await getBridgeAuth().authenticate(email, password);
|
|
108
|
+
// authState store will drive MFA / tenant-selection / authenticated transitions
|
|
109
|
+
} catch (err: any) {
|
|
110
|
+
error = err.message || 'Invalid email or password.';
|
|
111
|
+
onError?.(err);
|
|
112
|
+
loading = false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function handleForgotSend() {
|
|
117
|
+
if (fpLoading) return;
|
|
118
|
+
error = null;
|
|
119
|
+
fpLoading = true;
|
|
120
|
+
try {
|
|
121
|
+
await getBridgeAuth().sendResetPasswordLink(email);
|
|
122
|
+
fpEmailSent = true;
|
|
123
|
+
} catch (err: any) {
|
|
124
|
+
error = err.message || 'Failed to send reset link.';
|
|
125
|
+
} finally {
|
|
126
|
+
fpLoading = false;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function handleMagicLinkSend() {
|
|
131
|
+
if (mlLoading) return;
|
|
132
|
+
error = null;
|
|
133
|
+
mlLoading = true;
|
|
134
|
+
try {
|
|
135
|
+
const result = await getBridgeAuth().sendMagicLink(email);
|
|
136
|
+
mlExpiresIn = result.expiresIn;
|
|
137
|
+
mlSent = true;
|
|
138
|
+
} catch (err: any) {
|
|
139
|
+
error = err.message || 'Failed to send magic link.';
|
|
140
|
+
} finally {
|
|
141
|
+
mlLoading = false;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function goBackToCredentials() {
|
|
146
|
+
error = null;
|
|
147
|
+
fpEmailSent = false;
|
|
148
|
+
mlSent = false;
|
|
149
|
+
step = 'credentials';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function formatExpiry(seconds: number): string {
|
|
153
|
+
if (seconds >= 60) {
|
|
154
|
+
return `${Math.floor(seconds / 60)} minute${Math.floor(seconds / 60) !== 1 ? 's' : ''}`;
|
|
155
|
+
}
|
|
156
|
+
return `${seconds} seconds`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
$effect(() => {
|
|
160
|
+
if (currentAuthState === 'authenticated') {
|
|
161
|
+
onLogin?.();
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
onMount(async () => {
|
|
166
|
+
if (typeof window === 'undefined') return;
|
|
167
|
+
const params = new URLSearchParams(window.location.search);
|
|
168
|
+
const magicToken = params.get('bridge_magic_link_token');
|
|
169
|
+
if (!magicToken) return;
|
|
170
|
+
|
|
171
|
+
// Remove token from URL without reloading
|
|
172
|
+
params.delete('bridge_magic_link_token');
|
|
173
|
+
const newSearch = params.toString();
|
|
174
|
+
const newUrl = window.location.pathname + (newSearch ? `?${newSearch}` : '');
|
|
175
|
+
window.history.replaceState({}, '', newUrl);
|
|
176
|
+
|
|
177
|
+
loading = true;
|
|
178
|
+
error = null;
|
|
179
|
+
try {
|
|
180
|
+
await getBridgeAuth().authenticateWithMagicLinkToken(magicToken);
|
|
181
|
+
// authState effect handles onLogin callback
|
|
182
|
+
} catch (err: any) {
|
|
183
|
+
error = err.message || 'Magic link authentication failed.';
|
|
184
|
+
onError?.(err);
|
|
185
|
+
loading = false;
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
</script>
|
|
189
|
+
|
|
190
|
+
<!-- Auth state overrides: MFA / Tenant Selection -->
|
|
191
|
+
{#if currentAuthState === 'mfa-required'}
|
|
192
|
+
<MfaChallenge onError={onError} />
|
|
193
|
+
|
|
194
|
+
{:else if currentAuthState === 'mfa-setup-required'}
|
|
195
|
+
<MfaSetup onError={onError} />
|
|
196
|
+
|
|
197
|
+
{:else if currentAuthState === 'tenant-selection'}
|
|
198
|
+
<TenantSelector onError={onError} />
|
|
199
|
+
|
|
200
|
+
<!-- Inline forgot password -->
|
|
201
|
+
{:else if step === 'forgot-password'}
|
|
202
|
+
<AuthFormWrapper heading="Reset your password">
|
|
203
|
+
{#if error}
|
|
204
|
+
<Alert variant="error">{error}</Alert>
|
|
205
|
+
{/if}
|
|
206
|
+
{#if fpEmailSent}
|
|
207
|
+
<Alert variant="success">Check your email for a password reset link.</Alert>
|
|
208
|
+
<div class="bridge-form-footer">
|
|
209
|
+
<button type="button" class="bridge-link" onclick={goBackToCredentials}>Back to login</button>
|
|
210
|
+
</div>
|
|
211
|
+
{:else}
|
|
212
|
+
<form onsubmit={(e) => { e.preventDefault(); handleForgotSend(); }}>
|
|
213
|
+
<div class="bridge-form-group">
|
|
214
|
+
<label for="forgot-email">Email</label>
|
|
215
|
+
<input
|
|
216
|
+
id="forgot-email"
|
|
217
|
+
type="email"
|
|
218
|
+
placeholder="you@example.com"
|
|
219
|
+
required
|
|
220
|
+
bind:value={email}
|
|
221
|
+
disabled={fpLoading}
|
|
222
|
+
/>
|
|
223
|
+
</div>
|
|
224
|
+
<button
|
|
225
|
+
type="submit"
|
|
226
|
+
class="bridge-btn bridge-btn-primary"
|
|
227
|
+
disabled={fpLoading || !email.trim()}
|
|
228
|
+
>
|
|
229
|
+
{#if fpLoading}<Spinner size={16} />{:else}Send reset link{/if}
|
|
230
|
+
</button>
|
|
231
|
+
</form>
|
|
232
|
+
<div class="bridge-form-footer">
|
|
233
|
+
<button type="button" class="bridge-link" onclick={goBackToCredentials}>Back to login</button>
|
|
234
|
+
</div>
|
|
235
|
+
{/if}
|
|
236
|
+
</AuthFormWrapper>
|
|
237
|
+
|
|
238
|
+
<!-- Inline magic link -->
|
|
239
|
+
{:else if step === 'magic-link'}
|
|
240
|
+
<AuthFormWrapper heading="Sign in with email link">
|
|
241
|
+
{#if error}
|
|
242
|
+
<Alert variant="error">{error}</Alert>
|
|
243
|
+
{/if}
|
|
244
|
+
{#if mlSent}
|
|
245
|
+
<Alert variant="success">Check your email — link expires in {formatExpiry(mlExpiresIn)}.</Alert>
|
|
246
|
+
<div class="bridge-form-footer">
|
|
247
|
+
<button type="button" class="bridge-link" onclick={goBackToCredentials}>Back to login</button>
|
|
248
|
+
</div>
|
|
249
|
+
{:else}
|
|
250
|
+
<form onsubmit={(e) => { e.preventDefault(); handleMagicLinkSend(); }}>
|
|
251
|
+
<div class="bridge-form-group">
|
|
252
|
+
<label for="magic-link-email">Email</label>
|
|
253
|
+
<input
|
|
254
|
+
id="magic-link-email"
|
|
255
|
+
type="email"
|
|
256
|
+
placeholder="you@example.com"
|
|
257
|
+
required
|
|
258
|
+
bind:value={email}
|
|
259
|
+
disabled={mlLoading}
|
|
260
|
+
/>
|
|
261
|
+
</div>
|
|
262
|
+
<button
|
|
263
|
+
type="submit"
|
|
264
|
+
class="bridge-btn bridge-btn-primary"
|
|
265
|
+
disabled={mlLoading || !email.trim()}
|
|
266
|
+
>
|
|
267
|
+
{#if mlLoading}<Spinner size={16} />{:else}Send magic link{/if}
|
|
268
|
+
</button>
|
|
269
|
+
</form>
|
|
270
|
+
<div class="bridge-form-footer">
|
|
271
|
+
<button type="button" class="bridge-link" onclick={goBackToCredentials}>Back to login</button>
|
|
272
|
+
</div>
|
|
273
|
+
{/if}
|
|
274
|
+
</AuthFormWrapper>
|
|
275
|
+
|
|
276
|
+
<!-- Inline passkey request -->
|
|
277
|
+
{:else if step === 'passkey-request'}
|
|
278
|
+
<PasskeyRequestSetupLink initialEmail={email} onBack={goBackToCredentials} />
|
|
279
|
+
|
|
280
|
+
<!-- Credentials step (email + password together) -->
|
|
281
|
+
{:else}
|
|
282
|
+
<AuthFormWrapper {heading} class={className} {style} {...rest}>
|
|
283
|
+
{#if error}
|
|
284
|
+
<Alert variant="error">{error}</Alert>
|
|
285
|
+
{/if}
|
|
286
|
+
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
|
|
287
|
+
<div class="bridge-form-group">
|
|
288
|
+
<label for="login-email">Email</label>
|
|
289
|
+
<input
|
|
290
|
+
id="login-email"
|
|
291
|
+
type="email"
|
|
292
|
+
placeholder="you@example.com"
|
|
293
|
+
required
|
|
294
|
+
bind:value={email}
|
|
295
|
+
disabled={loading}
|
|
296
|
+
/>
|
|
297
|
+
</div>
|
|
298
|
+
|
|
299
|
+
<div class="bridge-form-group">
|
|
300
|
+
<label for="login-password">Password</label>
|
|
301
|
+
<div class="bridge-password-wrapper">
|
|
302
|
+
<input
|
|
303
|
+
id="login-password"
|
|
304
|
+
type={showPassword ? 'text' : 'password'}
|
|
305
|
+
placeholder="Enter your password"
|
|
306
|
+
required
|
|
307
|
+
bind:value={password}
|
|
308
|
+
disabled={loading}
|
|
309
|
+
/>
|
|
310
|
+
<button
|
|
311
|
+
type="button"
|
|
312
|
+
class="bridge-password-toggle"
|
|
313
|
+
onclick={() => showPassword = !showPassword}
|
|
314
|
+
tabindex={-1}
|
|
315
|
+
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
|
316
|
+
>
|
|
317
|
+
{#if showPassword}
|
|
318
|
+
<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>
|
|
319
|
+
{:else}
|
|
320
|
+
<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="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
|
321
|
+
{/if}
|
|
322
|
+
</button>
|
|
323
|
+
</div>
|
|
324
|
+
</div>
|
|
325
|
+
|
|
326
|
+
<button type="submit" class="bridge-btn bridge-btn-primary" disabled={loading || !email.trim() || !password}>
|
|
327
|
+
{#if loading}<Spinner size={16} /> Signing in…{:else}Sign in{/if}
|
|
328
|
+
</button>
|
|
329
|
+
|
|
330
|
+
{#if effectiveShowForgotPassword}
|
|
331
|
+
<div class="bridge-forgot-row">
|
|
332
|
+
{#if forgotPasswordHref}
|
|
333
|
+
<a href={forgotPasswordHref} class="bridge-link">Forgot password?</a>
|
|
334
|
+
{:else}
|
|
335
|
+
<button type="button" class="bridge-link" onclick={() => { step = 'forgot-password'; error = null; }}>
|
|
336
|
+
Forgot password?
|
|
337
|
+
</button>
|
|
338
|
+
{/if}
|
|
339
|
+
</div>
|
|
340
|
+
{/if}
|
|
341
|
+
</form>
|
|
342
|
+
|
|
343
|
+
{#if effectiveShowPasskeys || effectiveShowMagicLink || effectiveSsoConnections.length > 0}
|
|
344
|
+
<div class="bridge-divider">or</div>
|
|
345
|
+
{/if}
|
|
346
|
+
|
|
347
|
+
{#if effectiveShowPasskeys}
|
|
348
|
+
<div class="bridge-sso-row">
|
|
349
|
+
<PasskeyLogin onLogin={onLogin} onError={onError} onSetupPasskey={() => { step = 'passkey-request'; error = null; }} class="bridge-btn bridge-btn-secondary bridge-sso-btn" />
|
|
350
|
+
</div>
|
|
351
|
+
{/if}
|
|
352
|
+
|
|
353
|
+
{#if effectiveShowMagicLink}
|
|
354
|
+
<div class="bridge-sso-row">
|
|
355
|
+
<button
|
|
356
|
+
type="button"
|
|
357
|
+
class="bridge-btn bridge-btn-secondary bridge-sso-btn"
|
|
358
|
+
onclick={() => { step = 'magic-link'; error = null; }}
|
|
359
|
+
>
|
|
360
|
+
<span class="bridge-sso-btn-inner">
|
|
361
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
362
|
+
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
|
363
|
+
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
|
364
|
+
<g transform="translate(0, 0) scale(0.3)">
|
|
365
|
+
<path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z" fill="currentColor" stroke="none"/>
|
|
366
|
+
</g>
|
|
367
|
+
<g transform="translate(15, 17) scale(0.25)">
|
|
368
|
+
<path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z" fill="currentColor" stroke="none"/>
|
|
369
|
+
</g>
|
|
370
|
+
<g transform="translate(19, 15) scale(0.14)">
|
|
371
|
+
<path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z" fill="currentColor" stroke="none"/>
|
|
372
|
+
</g>
|
|
373
|
+
</svg>
|
|
374
|
+
<span>Sign in with Magic Link</span>
|
|
375
|
+
</span>
|
|
376
|
+
</button>
|
|
377
|
+
</div>
|
|
378
|
+
{/if}
|
|
379
|
+
|
|
380
|
+
{#each effectiveSsoConnections as conn (conn.id)}
|
|
381
|
+
<div class="bridge-sso-row">
|
|
382
|
+
{#if onSsoClick}
|
|
383
|
+
<button
|
|
384
|
+
type="button"
|
|
385
|
+
class="bridge-btn bridge-btn-secondary bridge-sso-btn"
|
|
386
|
+
onclick={() => onSsoClick(conn.type)}
|
|
387
|
+
>
|
|
388
|
+
<SsoProviderIcon type={conn.type} />
|
|
389
|
+
<span>{conn.name}</span>
|
|
390
|
+
</button>
|
|
391
|
+
{:else}
|
|
392
|
+
<SsoButton
|
|
393
|
+
connection={conn}
|
|
394
|
+
onSuccess={onLogin}
|
|
395
|
+
onError={onError}
|
|
396
|
+
class="bridge-btn bridge-btn-secondary bridge-sso-btn"
|
|
397
|
+
>
|
|
398
|
+
{#snippet icon()}
|
|
399
|
+
<SsoProviderIcon type={conn.type} />
|
|
400
|
+
{/snippet}
|
|
401
|
+
</SsoButton>
|
|
402
|
+
{/if}
|
|
403
|
+
</div>
|
|
404
|
+
{/each}
|
|
405
|
+
|
|
406
|
+
{#if footer}
|
|
407
|
+
{@render footer()}
|
|
408
|
+
{:else if effectiveShowSignupLink}
|
|
409
|
+
<div class="bridge-form-footer">
|
|
410
|
+
Don't have an account? <a href={effectiveSignupHref}>Sign up</a>
|
|
411
|
+
</div>
|
|
412
|
+
{/if}
|
|
413
|
+
</AuthFormWrapper>
|
|
414
|
+
{/if}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
3
|
+
import type { FederationConnection } from '@thebridge/auth-core';
|
|
4
|
+
interface Props extends HTMLAttributes<HTMLDivElement> {
|
|
5
|
+
/** Override signup link visibility. Default: derived from app config (signupEnabled). */
|
|
6
|
+
showSignupLink?: boolean | undefined;
|
|
7
|
+
/** Override signup page URL. Default: derived from BridgeConfig signupRoute. */
|
|
8
|
+
signupHref?: string | undefined;
|
|
9
|
+
/** Override forgot password visibility. Default: derived from app config (true when password auth is available). */
|
|
10
|
+
showForgotPassword?: boolean | undefined;
|
|
11
|
+
forgotPasswordHref?: string;
|
|
12
|
+
/** Override magic link visibility. Default: derived from app config (magicLinkEnabled). */
|
|
13
|
+
showMagicLink?: boolean | undefined;
|
|
14
|
+
/** Override passkey visibility. Default: derived from app config (passkeysEnabled). */
|
|
15
|
+
showPasskeys?: boolean | undefined;
|
|
16
|
+
onLogin?: () => void;
|
|
17
|
+
onError?: (error: Error) => void;
|
|
18
|
+
onSsoClick?: (connectionType: string) => void;
|
|
19
|
+
heading?: string;
|
|
20
|
+
ssoConnections?: FederationConnection[];
|
|
21
|
+
footer?: Snippet;
|
|
22
|
+
}
|
|
23
|
+
declare const LoginForm: import("svelte").Component<Props, {}, "">;
|
|
24
|
+
type LoginForm = ReturnType<typeof LoginForm>;
|
|
25
|
+
export default LoginForm;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
3
|
+
import { getBridgeAuth } from '../../../core/bridge-instance.js';
|
|
4
|
+
import AuthFormWrapper from './shared/AuthFormWrapper.svelte';
|
|
5
|
+
import Spinner from './shared/Spinner.svelte';
|
|
6
|
+
import Alert from './shared/Alert.svelte';
|
|
7
|
+
|
|
8
|
+
interface Props extends HTMLAttributes<HTMLDivElement> {
|
|
9
|
+
onSent?: () => void;
|
|
10
|
+
onError?: (error: Error) => void;
|
|
11
|
+
loginHref?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let {
|
|
15
|
+
onSent,
|
|
16
|
+
onError,
|
|
17
|
+
loginHref = '/login',
|
|
18
|
+
class: className,
|
|
19
|
+
style,
|
|
20
|
+
...rest
|
|
21
|
+
}: Props = $props();
|
|
22
|
+
|
|
23
|
+
let email = $state('');
|
|
24
|
+
let loading = $state(false);
|
|
25
|
+
let error = $state<string | null>(null);
|
|
26
|
+
let sent = $state(false);
|
|
27
|
+
let expiresIn = $state(0);
|
|
28
|
+
|
|
29
|
+
async function handleSend() {
|
|
30
|
+
if (loading) return;
|
|
31
|
+
error = null;
|
|
32
|
+
loading = true;
|
|
33
|
+
try {
|
|
34
|
+
const result = await getBridgeAuth().sendMagicLink(email);
|
|
35
|
+
expiresIn = result.expiresIn;
|
|
36
|
+
sent = true;
|
|
37
|
+
onSent?.();
|
|
38
|
+
} catch (err: any) {
|
|
39
|
+
error = err.message || 'Failed to send magic link.';
|
|
40
|
+
onError?.(err);
|
|
41
|
+
} finally {
|
|
42
|
+
loading = false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function formatExpiry(seconds: number): string {
|
|
47
|
+
if (seconds >= 60) {
|
|
48
|
+
return `${Math.floor(seconds / 60)} minute${Math.floor(seconds / 60) !== 1 ? 's' : ''}`;
|
|
49
|
+
}
|
|
50
|
+
return `${seconds} seconds`;
|
|
51
|
+
}
|
|
52
|
+
</script>
|
|
53
|
+
|
|
54
|
+
<AuthFormWrapper heading="Sign in with email link" class={className} {style} {...rest}>
|
|
55
|
+
{#if error}
|
|
56
|
+
<Alert variant="error">{error}</Alert>
|
|
57
|
+
{/if}
|
|
58
|
+
|
|
59
|
+
{#if sent}
|
|
60
|
+
<Alert variant="success">
|
|
61
|
+
Check your email — link expires in {formatExpiry(expiresIn)}.
|
|
62
|
+
</Alert>
|
|
63
|
+
{#if loginHref}
|
|
64
|
+
<div class="bridge-form-footer">
|
|
65
|
+
<a href={loginHref}>Back to login</a>
|
|
66
|
+
</div>
|
|
67
|
+
{/if}
|
|
68
|
+
{:else}
|
|
69
|
+
<p class="bridge-step-desc">Enter your email and we'll send you a sign-in link. No password needed.</p>
|
|
70
|
+
<form onsubmit={(e) => { e.preventDefault(); handleSend(); }}>
|
|
71
|
+
<div class="bridge-form-group">
|
|
72
|
+
<label for="magic-email">Email</label>
|
|
73
|
+
<input
|
|
74
|
+
id="magic-email"
|
|
75
|
+
type="email"
|
|
76
|
+
placeholder="you@example.com"
|
|
77
|
+
required
|
|
78
|
+
bind:value={email}
|
|
79
|
+
disabled={loading}
|
|
80
|
+
/>
|
|
81
|
+
</div>
|
|
82
|
+
<button type="submit" class="bridge-btn bridge-btn-primary" disabled={loading || !email.trim()}>
|
|
83
|
+
{#if loading}<Spinner size={16} />{:else}Send magic link{/if}
|
|
84
|
+
</button>
|
|
85
|
+
</form>
|
|
86
|
+
{#if loginHref}
|
|
87
|
+
<div class="bridge-form-footer">
|
|
88
|
+
<a href={loginHref}>Back to login</a>
|
|
89
|
+
</div>
|
|
90
|
+
{/if}
|
|
91
|
+
{/if}
|
|
92
|
+
</AuthFormWrapper>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
2
|
+
interface Props extends HTMLAttributes<HTMLDivElement> {
|
|
3
|
+
onSent?: () => void;
|
|
4
|
+
onError?: (error: Error) => void;
|
|
5
|
+
loginHref?: string;
|
|
6
|
+
}
|
|
7
|
+
declare const MagicLink: import("svelte").Component<Props, {}, "">;
|
|
8
|
+
type MagicLink = ReturnType<typeof MagicLink>;
|
|
9
|
+
export default MagicLink;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
3
|
+
import { getBridgeAuth } from '../../../core/bridge-instance.js';
|
|
4
|
+
import AuthFormWrapper from './shared/AuthFormWrapper.svelte';
|
|
5
|
+
import Spinner from './shared/Spinner.svelte';
|
|
6
|
+
import Alert from './shared/Alert.svelte';
|
|
7
|
+
|
|
8
|
+
interface Props extends HTMLAttributes<HTMLDivElement> {
|
|
9
|
+
onVerified?: () => void;
|
|
10
|
+
onError?: (error: Error) => void;
|
|
11
|
+
showRecoveryOption?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let {
|
|
15
|
+
onVerified,
|
|
16
|
+
onError,
|
|
17
|
+
showRecoveryOption = true,
|
|
18
|
+
class: className,
|
|
19
|
+
style,
|
|
20
|
+
...rest
|
|
21
|
+
}: Props = $props();
|
|
22
|
+
|
|
23
|
+
let code = $state('');
|
|
24
|
+
let backupCode = $state('');
|
|
25
|
+
let loading = $state(false);
|
|
26
|
+
let error = $state<string | null>(null);
|
|
27
|
+
let useRecovery = $state(false);
|
|
28
|
+
let resendCountdown = $state(0);
|
|
29
|
+
let resendTimer: ReturnType<typeof setInterval> | null = null;
|
|
30
|
+
|
|
31
|
+
function startResendCooldown() {
|
|
32
|
+
resendCountdown = 60;
|
|
33
|
+
if (resendTimer) clearInterval(resendTimer);
|
|
34
|
+
resendTimer = setInterval(() => {
|
|
35
|
+
resendCountdown--;
|
|
36
|
+
if (resendCountdown <= 0) {
|
|
37
|
+
if (resendTimer) clearInterval(resendTimer);
|
|
38
|
+
resendTimer = null;
|
|
39
|
+
}
|
|
40
|
+
}, 1000);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function handleResendCode() {
|
|
44
|
+
if (loading || resendCountdown > 0) return;
|
|
45
|
+
error = null;
|
|
46
|
+
loading = true;
|
|
47
|
+
try {
|
|
48
|
+
await getBridgeAuth().resendMfaCode();
|
|
49
|
+
code = '';
|
|
50
|
+
startResendCooldown();
|
|
51
|
+
} catch (err: any) {
|
|
52
|
+
error = err.message || 'Failed to resend code.';
|
|
53
|
+
onError?.(err);
|
|
54
|
+
} finally {
|
|
55
|
+
loading = false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function handleVerify() {
|
|
60
|
+
if (loading) return;
|
|
61
|
+
error = null;
|
|
62
|
+
loading = true;
|
|
63
|
+
try {
|
|
64
|
+
await getBridgeAuth().verifyMfa(code);
|
|
65
|
+
onVerified?.();
|
|
66
|
+
} catch (err: any) {
|
|
67
|
+
error = err.message || 'Invalid code. Please try again.';
|
|
68
|
+
onError?.(err);
|
|
69
|
+
} finally {
|
|
70
|
+
loading = false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function handleRecovery() {
|
|
75
|
+
if (loading) return;
|
|
76
|
+
error = null;
|
|
77
|
+
loading = true;
|
|
78
|
+
try {
|
|
79
|
+
await getBridgeAuth().resetMfa(backupCode);
|
|
80
|
+
onVerified?.();
|
|
81
|
+
} catch (err: any) {
|
|
82
|
+
error = err.message || 'Invalid recovery code.';
|
|
83
|
+
onError?.(err);
|
|
84
|
+
} finally {
|
|
85
|
+
loading = false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
</script>
|
|
89
|
+
|
|
90
|
+
<AuthFormWrapper heading="Two-factor authentication" class={className} {style} {...rest}>
|
|
91
|
+
{#if error}
|
|
92
|
+
<Alert variant="error">{error}</Alert>
|
|
93
|
+
{/if}
|
|
94
|
+
|
|
95
|
+
{#if !useRecovery}
|
|
96
|
+
<form onsubmit={(e) => { e.preventDefault(); handleVerify(); }}>
|
|
97
|
+
<div class="bridge-form-group">
|
|
98
|
+
<label for="mfa-code">Authentication code</label>
|
|
99
|
+
<input
|
|
100
|
+
id="mfa-code"
|
|
101
|
+
type="text"
|
|
102
|
+
inputmode="numeric"
|
|
103
|
+
autocomplete="one-time-code"
|
|
104
|
+
placeholder="Enter 6-digit code"
|
|
105
|
+
maxlength={6}
|
|
106
|
+
bind:value={code}
|
|
107
|
+
disabled={loading}
|
|
108
|
+
/>
|
|
109
|
+
</div>
|
|
110
|
+
<button type="submit" class="bridge-btn bridge-btn-primary" disabled={loading || code.length < 6}>
|
|
111
|
+
{#if loading}<Spinner size={16} />{:else}Verify{/if}
|
|
112
|
+
</button>
|
|
113
|
+
</form>
|
|
114
|
+
<p class="bridge-mfa-help">
|
|
115
|
+
{#if resendCountdown > 0}
|
|
116
|
+
Didn't get your text message? You can resend in {resendCountdown}s.
|
|
117
|
+
{:else}
|
|
118
|
+
Didn't get your text message? <button type="button" class="bridge-link" onclick={handleResendCode} disabled={loading}>Resend code</button>.
|
|
119
|
+
{/if}
|
|
120
|
+
</p>
|
|
121
|
+
{#if showRecoveryOption}
|
|
122
|
+
<div class="bridge-form-footer">
|
|
123
|
+
<button type="button" class="bridge-link" onclick={() => { useRecovery = true; error = null; }}>
|
|
124
|
+
Use recovery code
|
|
125
|
+
</button>
|
|
126
|
+
</div>
|
|
127
|
+
{/if}
|
|
128
|
+
{:else}
|
|
129
|
+
<form onsubmit={(e) => { e.preventDefault(); handleRecovery(); }}>
|
|
130
|
+
<div class="bridge-form-group">
|
|
131
|
+
<label for="backup-code">Recovery code</label>
|
|
132
|
+
<input
|
|
133
|
+
id="backup-code"
|
|
134
|
+
type="text"
|
|
135
|
+
placeholder="Enter recovery code"
|
|
136
|
+
bind:value={backupCode}
|
|
137
|
+
disabled={loading}
|
|
138
|
+
/>
|
|
139
|
+
</div>
|
|
140
|
+
<button type="submit" class="bridge-btn bridge-btn-primary" disabled={loading || !backupCode.trim()}>
|
|
141
|
+
{#if loading}<Spinner size={16} />{:else}Recover{/if}
|
|
142
|
+
</button>
|
|
143
|
+
</form>
|
|
144
|
+
<div class="bridge-form-footer">
|
|
145
|
+
<button type="button" class="bridge-link" onclick={() => { useRecovery = false; error = null; }}>
|
|
146
|
+
Use authentication code
|
|
147
|
+
</button>
|
|
148
|
+
</div>
|
|
149
|
+
{/if}
|
|
150
|
+
</AuthFormWrapper>
|