Skip to content

Commit c21d82e

Browse files
Stefan ErnstStefan Ernst
authored andcommitted
Portal Magic Links implementation
1 parent 5e4da95 commit c21d82e

18 files changed

Lines changed: 1761 additions & 110 deletions

frontend/src/lib/api/index.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { auth } from './auth.js';
88
import { time, timer } from './time.js';
99
import { statusCategories, statuses, workflows } from './workflows.js';
1010
import { assetSets, assetRoles, assetTypes, assetCategories, assetStatuses, assets, itemLinkedAssets } from './assets.js';
11-
import { portal, portalCustomers, contactRoles, customerOrganisations } from './portal.js';
11+
import { portal, portalAuth, portalCustomers, contactRoles, customerOrganisations } from './portal.js';
1212
import { scmProviders, workspaceSCM, itemSCMLinks, userSCM } from './scm.js';
1313
import { channels, channelCategories, requestTypes } from './channels.js';
1414
import { milestoneCategories, milestones, iterationTypes, iterations } from './milestones.js';
@@ -253,6 +253,9 @@ export const api = {
253253
// Portal API (public endpoints, no authentication)
254254
portal,
255255

256+
// Portal Auth API (magic link authentication for portal customers)
257+
portalAuth,
258+
256259
// Portal Customers Management
257260
portalCustomers,
258261

frontend/src/lib/api/portal.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,25 @@
11
import { fetchAPI } from './core.js';
22

3+
// Portal Auth API (magic link authentication for portal customers)
4+
export const portalAuth = {
5+
// Request a magic link email
6+
requestMagicLink: (slug, email) => fetchAPI(`/portal/${slug}/auth/request`, {
7+
method: 'POST',
8+
body: JSON.stringify({ email }),
9+
}),
10+
11+
// Verify a magic link token (returns session on success)
12+
verifyMagicLink: (slug, token) => fetchAPI(`/portal/${slug}/auth/verify?token=${encodeURIComponent(token)}`),
13+
14+
// Get current authenticated portal customer
15+
getCurrentCustomer: (slug) => fetchAPI(`/portal/${slug}/auth/me`),
16+
17+
// Logout portal customer
18+
logout: (slug) => fetchAPI(`/portal/${slug}/auth/logout`, {
19+
method: 'POST',
20+
}),
21+
};
22+
323
// Portal API (uses fetchAPI for automatic CSRF handling)
424
export const portal = {
525
get: (slug) => fetchAPI(`/portal/${slug}`),
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
<script>
2+
import { X, Mail, Loader2 } from 'lucide-svelte';
3+
import { portalStore } from '../stores/portal.svelte.js';
4+
import { portalAuthStore } from '../stores/portalAuth.svelte.js';
5+
import { t } from '../stores/i18n.svelte.js';
6+
7+
let email = $state('');
8+
let authState = $state({});
9+
10+
// Subscribe to auth store
11+
portalAuthStore.subscribe(value => {
12+
authState = value;
13+
});
14+
15+
function closeModal() {
16+
portalStore.showLoginDialog = false;
17+
email = '';
18+
portalAuthStore.clearError();
19+
portalAuthStore.resetEmailSent();
20+
}
21+
22+
async function handleSubmit(e) {
23+
e.preventDefault();
24+
if (!email.trim()) return;
25+
26+
const result = await portalAuthStore.requestMagicLink(portalStore.slug, email.trim());
27+
if (result.success) {
28+
// Email sent - the store will update emailSent state
29+
}
30+
}
31+
32+
function handleBackdropClick(e) {
33+
if (e.target === e.currentTarget) {
34+
closeModal();
35+
}
36+
}
37+
38+
function handleKeydown(e) {
39+
if (e.key === 'Escape') {
40+
closeModal();
41+
}
42+
}
43+
</script>
44+
45+
<svelte:window onkeydown={handleKeydown} />
46+
47+
{#if portalStore.showLoginDialog}
48+
<!-- Modal Backdrop -->
49+
<div
50+
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
51+
onclick={handleBackdropClick}
52+
>
53+
<!-- Modal Content -->
54+
<div
55+
class="relative w-full max-w-md rounded-xl shadow-2xl overflow-hidden"
56+
style="background-color: var(--ds-surface-card);"
57+
>
58+
<!-- Close Button -->
59+
<button
60+
onclick={closeModal}
61+
class="absolute top-4 right-4 p-2 rounded-full transition-colors"
62+
style="color: var(--ds-text-subtle);"
63+
aria-label="Close"
64+
>
65+
<X class="w-5 h-5" />
66+
</button>
67+
68+
<div class="p-8">
69+
{#if authState.emailSent}
70+
<!-- Email Sent Confirmation -->
71+
<div class="text-center">
72+
<div class="w-16 h-16 mx-auto mb-6 rounded-full flex items-center justify-center" style="background-color: var(--ds-background-success-subtle);">
73+
<Mail class="w-8 h-8" style="color: var(--ds-icon-success);" />
74+
</div>
75+
<h2 class="text-2xl font-bold mb-3" style="color: var(--ds-text);">
76+
{t('portal.checkYourEmail') || 'Check your email'}
77+
</h2>
78+
<p class="mb-6" style="color: var(--ds-text-subtle);">
79+
{t('portal.magicLinkSent') || "We've sent a sign-in link to your email. Click the link to access your portal account."}
80+
</p>
81+
<p class="text-sm mb-6" style="color: var(--ds-text-subtlest);">
82+
{t('portal.linkExpiresIn') || 'The link expires in 15 minutes.'}
83+
</p>
84+
<button
85+
onclick={() => { portalAuthStore.resetEmailSent(); email = ''; }}
86+
class="text-sm font-medium transition-colors"
87+
style="color: var(--ds-link);"
88+
>
89+
{t('portal.useAnotherEmail') || 'Use a different email'}
90+
</button>
91+
</div>
92+
{:else}
93+
<!-- Login Form -->
94+
<div class="text-center mb-8">
95+
<h2 class="text-2xl font-bold mb-2" style="color: var(--ds-text);">
96+
{t('portal.signInTitle') || 'Sign in to your account'}
97+
</h2>
98+
<p style="color: var(--ds-text-subtle);">
99+
{t('portal.signInDescription') || 'Enter your email to receive a sign-in link'}
100+
</p>
101+
</div>
102+
103+
<form onsubmit={handleSubmit} class="space-y-6">
104+
<div>
105+
<label for="email" class="block text-sm font-medium mb-2" style="color: var(--ds-text);">
106+
{t('common.email') || 'Email'}
107+
</label>
108+
<div class="relative">
109+
<Mail class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5" style="color: var(--ds-text-subtle);" />
110+
<input
111+
id="email"
112+
type="email"
113+
bind:value={email}
114+
placeholder={t('portal.enterEmail') || 'Enter your email address'}
115+
required
116+
class="w-full pl-10 pr-4 py-3 rounded-lg border transition-colors focus:outline-none focus:ring-2"
117+
style="
118+
background-color: var(--ds-background-input);
119+
border-color: var(--ds-border-input);
120+
color: var(--ds-text);
121+
"
122+
disabled={authState.loading}
123+
/>
124+
</div>
125+
</div>
126+
127+
{#if authState.error}
128+
<div class="p-3 rounded-lg text-sm" style="background-color: var(--ds-background-danger-subtle); color: var(--ds-text-danger);">
129+
{authState.error}
130+
</div>
131+
{/if}
132+
133+
<button
134+
type="submit"
135+
disabled={authState.loading || !email.trim()}
136+
class="w-full py-3 px-4 rounded-lg font-medium transition-colors flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
137+
style="background-color: var(--ds-background-brand-bold); color: var(--ds-text-inverse);"
138+
>
139+
{#if authState.loading}
140+
<Loader2 class="w-5 h-5 animate-spin" />
141+
{t('common.sending') || 'Sending...'}
142+
{:else}
143+
<Mail class="w-5 h-5" />
144+
{t('portal.sendMagicLink') || 'Send sign-in link'}
145+
{/if}
146+
</button>
147+
</form>
148+
149+
<p class="mt-6 text-center text-sm" style="color: var(--ds-text-subtlest);">
150+
{t('portal.noAccountNeeded') || "You don't need to create an account. Just enter your email and we'll send you a sign-in link."}
151+
</p>
152+
{/if}
153+
</div>
154+
</div>
155+
</div>
156+
{/if}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<script>
2+
import { Mail } from 'lucide-svelte';
3+
import { t } from '../stores/i18n.svelte.js';
4+
5+
// Props
6+
let { email = '', onRequestAnother } = $props();
7+
</script>
8+
9+
<div class="text-center p-8">
10+
<div class="w-16 h-16 mx-auto mb-6 rounded-full flex items-center justify-center" style="background-color: var(--ds-background-success-subtle);">
11+
<Mail class="w-8 h-8" style="color: var(--ds-icon-success);" />
12+
</div>
13+
14+
<h2 class="text-2xl font-bold mb-3" style="color: var(--ds-text);">
15+
{t('portal.checkYourEmail') || 'Check your email'}
16+
</h2>
17+
18+
<p class="mb-2" style="color: var(--ds-text-subtle);">
19+
{t('portal.magicLinkSentTo') || "We've sent a sign-in link to"}
20+
</p>
21+
22+
{#if email}
23+
<p class="font-medium mb-4" style="color: var(--ds-text);">
24+
{email}
25+
</p>
26+
{/if}
27+
28+
<p class="text-sm mb-6" style="color: var(--ds-text-subtlest);">
29+
{t('portal.clickLinkToSignIn') || 'Click the link in the email to sign in. The link expires in 15 minutes.'}
30+
</p>
31+
32+
<div class="space-y-3">
33+
<p class="text-sm" style="color: var(--ds-text-subtle);">
34+
{t('portal.didntReceiveEmail') || "Didn't receive the email?"}
35+
</p>
36+
37+
<ul class="text-sm text-left max-w-xs mx-auto space-y-1" style="color: var(--ds-text-subtlest);">
38+
<li>{t('portal.checkSpam') || '- Check your spam or junk folder'}</li>
39+
<li>{t('portal.waitFewMinutes') || '- Wait a few minutes and refresh'}</li>
40+
<li>{t('portal.checkEmailCorrect') || '- Make sure you entered the correct email'}</li>
41+
</ul>
42+
43+
{#if onRequestAnother}
44+
<button
45+
onclick={onRequestAnother}
46+
class="mt-4 text-sm font-medium transition-colors"
47+
style="color: var(--ds-link);"
48+
>
49+
{t('portal.tryAnotherEmail') || 'Try with a different email'}
50+
</button>
51+
{/if}
52+
</div>
53+
</div>
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
<script>
2+
import { onMount } from 'svelte';
3+
import { Loader2, CheckCircle, XCircle } from 'lucide-svelte';
4+
import { portalAuthStore } from '../stores/portalAuth.svelte.js';
5+
import { t } from '../stores/i18n.svelte.js';
6+
7+
// Props
8+
let { slug, token, onSuccess, onError } = $props();
9+
10+
let status = $state('verifying'); // verifying, success, error
11+
let errorMessage = $state('');
12+
13+
onMount(async () => {
14+
if (!token) {
15+
status = 'error';
16+
errorMessage = t('portal.invalidLink') || 'Invalid or missing token';
17+
onError?.(errorMessage);
18+
return;
19+
}
20+
21+
const result = await portalAuthStore.verifyMagicLink(slug, token);
22+
23+
if (result.success) {
24+
status = 'success';
25+
onSuccess?.(result.customer);
26+
} else {
27+
status = 'error';
28+
errorMessage = result.message || t('portal.verificationFailed') || 'Failed to verify link';
29+
onError?.(errorMessage);
30+
}
31+
});
32+
</script>
33+
34+
<div class="flex flex-col items-center justify-center min-h-[300px] p-8">
35+
{#if status === 'verifying'}
36+
<div class="text-center">
37+
<div class="w-16 h-16 mx-auto mb-6 rounded-full flex items-center justify-center" style="background-color: var(--ds-background-neutral);">
38+
<Loader2 class="w-8 h-8 animate-spin" style="color: var(--ds-icon-brand);" />
39+
</div>
40+
<h2 class="text-xl font-semibold mb-2" style="color: var(--ds-text);">
41+
{t('portal.verifying') || 'Verifying your link...'}
42+
</h2>
43+
<p style="color: var(--ds-text-subtle);">
44+
{t('portal.pleaseWait') || 'Please wait while we sign you in.'}
45+
</p>
46+
</div>
47+
{:else if status === 'success'}
48+
<div class="text-center">
49+
<div class="w-16 h-16 mx-auto mb-6 rounded-full flex items-center justify-center" style="background-color: var(--ds-background-success-subtle);">
50+
<CheckCircle class="w-8 h-8" style="color: var(--ds-icon-success);" />
51+
</div>
52+
<h2 class="text-xl font-semibold mb-2" style="color: var(--ds-text);">
53+
{t('portal.signInSuccess') || 'Successfully signed in!'}
54+
</h2>
55+
<p style="color: var(--ds-text-subtle);">
56+
{t('portal.redirecting') || 'Redirecting you to the portal...'}
57+
</p>
58+
</div>
59+
{:else if status === 'error'}
60+
<div class="text-center">
61+
<div class="w-16 h-16 mx-auto mb-6 rounded-full flex items-center justify-center" style="background-color: var(--ds-background-danger-subtle);">
62+
<XCircle class="w-8 h-8" style="color: var(--ds-icon-danger);" />
63+
</div>
64+
<h2 class="text-xl font-semibold mb-2" style="color: var(--ds-text);">
65+
{t('portal.verificationFailed') || 'Sign in failed'}
66+
</h2>
67+
<p class="mb-6" style="color: var(--ds-text-subtle);">
68+
{errorMessage}
69+
</p>
70+
<a
71+
href="/portal/{slug}"
72+
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors"
73+
style="background-color: var(--ds-background-brand-bold); color: var(--ds-text-inverse);"
74+
>
75+
{t('portal.backToPortal') || 'Back to Portal'}
76+
</a>
77+
</div>
78+
{/if}
79+
</div>

0 commit comments

Comments
 (0)