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:
@@ -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>
|
||||
Reference in New Issue
Block a user