feat(email): one-tap confirm deeplink + client language (PR1b) #162
@@ -114,7 +114,7 @@ func (s *EmailService) issueCode(ctx context.Context, accountID uuid.UUID, email
|
|||||||
if err := s.store.replacePendingConfirmation(ctx, accountID, email, codeHash, tokenHash, purpose, s.now().Add(emailCodeTTL)); err != nil {
|
if err := s.store.replacePendingConfirmation(ctx, accountID, email, codeHash, tokenHash, purpose, s.now().Add(emailCodeTTL)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
msg, err := renderConfirmationEmail(purpose, code, s.confirmURL(token), s.baseURL, locale)
|
msg, err := renderConfirmationEmail(purpose, code, s.confirmURL(token, locale), s.baseURL, locale)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -122,13 +122,14 @@ func (s *EmailService) issueCode(ctx context.Context, accountID uuid.UUID, email
|
|||||||
return s.mailer.Send(ctx, msg)
|
return s.mailer.Send(ctx, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// confirmURL builds the absolute one-tap confirm deeplink for token, or "" when no
|
// confirmURL builds the absolute one-tap confirm deeplink for token in locale, or ""
|
||||||
// public base URL is configured.
|
// when no public base URL is configured. The locale rides the fragment as ?lang so the
|
||||||
func (s *EmailService) confirmURL(token string) string {
|
// confirm screen (opened in a browser with no session) renders in the email's language.
|
||||||
|
func (s *EmailService) confirmURL(token, locale string) string {
|
||||||
if s.baseURL == "" {
|
if s.baseURL == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return strings.TrimRight(s.baseURL, "/") + emailConfirmPath + token
|
return strings.TrimRight(s.baseURL, "/") + emailConfirmPath + token + "?lang=" + normalizeLocale(locale)
|
||||||
}
|
}
|
||||||
|
|
||||||
// accountLocale returns the account's preferred UI language for localising email,
|
// accountLocale returns the account's preferred UI language for localising email,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
export const en = {
|
export const en = {
|
||||||
'app.title': 'Scrabble',
|
'app.title': 'Scrabble',
|
||||||
|
'app.brand': 'Erudit',
|
||||||
'dict.loading': 'Loading…',
|
'dict.loading': 'Loading…',
|
||||||
'connection.connecting': 'Connecting…',
|
'connection.connecting': 'Connecting…',
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { MessageKey } from './en';
|
|||||||
|
|
||||||
export const ru: Record<MessageKey, string> = {
|
export const ru: Record<MessageKey, string> = {
|
||||||
'app.title': 'Scrabble',
|
'app.title': 'Scrabble',
|
||||||
|
'app.brand': 'Эрудит',
|
||||||
'dict.loading': 'Загрузка…',
|
'dict.loading': 'Загрузка…',
|
||||||
'connection.connecting': 'Подключение…',
|
'connection.connecting': 'Подключение…',
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,27 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { confirmDeeplink } from '../lib/app.svelte';
|
import { confirmDeeplink } from '../lib/app.svelte';
|
||||||
import { t } from '../lib/i18n/index.svelte';
|
import { setLocale, t } from '../lib/i18n/index.svelte';
|
||||||
|
|
||||||
let { token }: { token: string } = $props();
|
let { token }: { token: string } = $props();
|
||||||
// The token rides the URL fragment (#/confirm/<token>), which is never sent to the
|
// busy → (a login navigates into the app) | linked | merge | error. The token rides
|
||||||
// server, so a plain link prefetch cannot reach it — confirm as soon as the screen
|
// the URL fragment (#/confirm/<token>), which is never sent to the server, so a plain
|
||||||
// loads. The manual 6-digit code in the same email is the fallback if an aggressive
|
// link prefetch cannot reach it — confirm on load. The manual six-digit code in the
|
||||||
// scanner ever runs the page and consumes the single-use token first.
|
// same email is the fallback if an aggressive scanner runs the page first.
|
||||||
let stage = $state<'busy' | 'linked' | 'merge' | 'error'>('busy');
|
let stage = $state<'busy' | 'linked' | 'merge' | 'error'>('busy');
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
|
// The email carries the recipient's language on the link (?lang), so this
|
||||||
|
// session-less page renders in it rather than the default.
|
||||||
|
const query = window.location.hash.split('?')[1] ?? '';
|
||||||
|
const lang = new URLSearchParams(query).get('lang');
|
||||||
|
if (lang === 'ru' || lang === 'en') setLocale(lang);
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
stage = 'error';
|
stage = 'error';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
// A login navigates into the app; a link stays here with a result.
|
|
||||||
const r = await confirmDeeplink(token);
|
const r = await confirmDeeplink(token);
|
||||||
stage = r === 'merge_required' ? 'merge' : 'linked';
|
stage = r === 'merge_required' ? 'merge' : 'linked';
|
||||||
} catch {
|
} catch {
|
||||||
@@ -27,17 +32,14 @@
|
|||||||
|
|
||||||
<main class="confirm">
|
<main class="confirm">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="brand">{t('app.title')}</div>
|
|
||||||
{#if stage === 'busy'}
|
{#if stage === 'busy'}
|
||||||
<p class="muted">{t('confirm.busy')}</p>
|
<p class="muted">{t('confirm.busy')}</p>
|
||||||
{:else if stage === 'linked'}
|
{:else if stage === 'error'}
|
||||||
<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>
|
<p class="err">{t('confirm.expired')}</p>
|
||||||
|
{:else}
|
||||||
|
<div class="brand">{t('app.brand')}</div>
|
||||||
|
<p class="ok">{stage === 'merge' ? t('confirm.merge') : t('confirm.linked')}</p>
|
||||||
|
<p class="muted">{t('confirm.close')}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
Reference in New Issue
Block a user