Release v1.8.0: promote development → master #173

Merged
developer merged 98 commits from development into master 2026-07-03 21:31:25 +00:00
4 changed files with 24 additions and 19 deletions
Showing only changes of commit 54af644429 - Show all commits
+6 -5
View File
@@ -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,
+1
View File
@@ -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…',
+1
View File
@@ -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': 'Подключение…',
+16 -14
View File
@@ -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>