feat(ui): one-tap confirm deeplink screen + client language

Add the /confirm/<token> SPA route and Confirm screen: a prefetch-safe button
POSTs the token via a new confirmEmailLink RPC (client/transport/mock/codec +
ConfirmLinkResult model). A login adopts the minted session and enters the app; a
link shows confirmed / merge-in-the-app; an invalid or expired token asks for a new
code. Exempt /confirm from the no-session /login redirect. Forward the client
locale on the email request (authEmailRequest gains language → app.locale) so a
fresh web login email is localised. Handle the new 'profile' live-event sub-kind by
re-fetching the profile, so a link confirmed in another browser reflects in-app at
once. i18n en+ru.
This commit is contained in:
Ilia Denisov
2026-07-03 04:30:16 +02:00
parent 762155a55e
commit 409462fc09
12 changed files with 184 additions and 8 deletions
+95
View File
@@ -0,0 +1,95 @@
<script lang="ts">
import { confirmDeeplink } from '../lib/app.svelte';
import { t } from '../lib/i18n/index.svelte';
let { token }: { token: string } = $props();
// idle → busy → (login navigates away) | linked | merge | error. The token is
// confirmed only on the button press (a POST), so a mail scanner prefetching the
// link (a GET on the SPA route) never consumes it.
let stage = $state<'idle' | 'busy' | 'linked' | 'merge' | 'error'>('idle');
async function confirm(): Promise<void> {
if (!token) {
stage = 'error';
return;
}
stage = 'busy';
try {
const r = await confirmDeeplink(token);
// A login has already navigated into the app; a link stays here with a result.
stage = r === 'merge_required' ? 'merge' : 'linked';
} catch {
stage = 'error';
}
}
</script>
<main class="confirm">
<div class="card">
<div class="brand">{t('app.title')}</div>
{#if stage === 'idle' || stage === 'busy'}
<p>{t('confirm.prompt')}</p>
<button class="primary" disabled={stage === 'busy'} onclick={confirm}>
{t('confirm.action')}
</button>
{:else if stage === 'linked'}
<p class="ok">{t('confirm.linked')}</p>
<p class="muted">{t('confirm.close')}</p>
{:else if stage === 'merge'}
<p class="ok">{t('confirm.merge')}</p>
<p class="muted">{t('confirm.close')}</p>
{:else}
<p class="err">{t('confirm.expired')}</p>
{/if}
</div>
</main>
<style>
.confirm {
height: 100%;
display: grid;
place-items: center;
padding: 16px;
}
.card {
width: min(94vw, 360px);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 24px;
display: flex;
flex-direction: column;
gap: 12px;
text-align: center;
}
.brand {
font-weight: 700;
letter-spacing: 0.04em;
color: var(--accent);
}
p {
margin: 0;
}
.muted {
color: var(--text-muted);
font-size: 0.9rem;
}
.ok {
font-weight: 600;
}
.err {
color: var(--danger, #c0392b);
}
button {
padding: 12px;
border-radius: var(--radius-sm);
border: 1px solid var(--accent);
font-weight: 600;
background: var(--accent);
color: var(--accent-text);
}
button:disabled {
opacity: 0.5;
}
</style>