feat(ui): auto-confirm the email deeplink on load
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 1m3s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m44s
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 1m3s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m44s
Drop the confirm button: the /confirm screen confirms the token as soon as it loads. The token rides the URL fragment (never sent to the server), so a plain link prefetch cannot reach it, and the manual six-digit code is the fallback if an aggressive scanner runs the page. Update the ARCHITECTURE/FUNCTIONAL wording accordingly and swap the confirm.prompt/action strings for confirm.busy (en+ru).
This commit is contained in:
@@ -244,8 +244,9 @@ arrive from a platform rather than completing a mandatory registration).
|
|||||||
256-bit token stored only as its SHA-256, 12-hour TTL): a login mints a session in the
|
256-bit token stored only as its SHA-256, 12-hour TTL): a login mints a session in the
|
||||||
browser that opens it (magic-link), a link confirms the identity and emits a `notify`
|
browser that opens it (magic-link), a link confirms the identity and emits a `notify`
|
||||||
profile-refresh to the in-app session, and a would-be merge is deferred to the
|
profile-refresh to the in-app session, and a would-be merge is deferred to the
|
||||||
interactive flow. The confirm page is prefetch-safe — it confirms only on a button
|
interactive flow. The confirm runs on load; the token rides the URL fragment (never
|
||||||
press, so a mail scanner's GET does not consume the single-use token. An
|
sent to the server), so a plain link prefetch cannot reach it, and the manual
|
||||||
|
six-digit code is the fallback if an aggressive scanner runs the page. An
|
||||||
**email-login** account is created flagged `is_guest` and stays reapable until the
|
**email-login** account is created flagged `is_guest` and stays reapable until the
|
||||||
code is confirmed — so an abandoned, never-confirmed login frees its reserved
|
code is confirmed — so an abandoned, never-confirmed login frees its reserved
|
||||||
address — with confirming clearing the flag. Accounts and identities use
|
address — with confirming clearing the flag. Accounts and identities use
|
||||||
|
|||||||
+2
-1
@@ -69,7 +69,8 @@ freed. Sends are rate-limited (a short cooldown between codes and a small hourly
|
|||||||
per address), so a mistyped address or an impatient tap cannot flood an inbox. The email
|
per address), so a mistyped address or an impatient tap cannot flood an inbox. The email
|
||||||
also carries a **one-tap link** that confirms the address — or signs the player straight
|
also carries a **one-tap link** that confirms the address — or signs the player straight
|
||||||
in — in a single tap; opening it in another browser reflects in the app at once, and a
|
in — in a single tap; opening it in another browser reflects in the app at once, and a
|
||||||
mail scanner that merely fetches the link never triggers it (it acts only on a button press).
|
mail scanner that merely fetches the link cannot reach it — the token rides the URL
|
||||||
|
fragment, which is never sent to the server.
|
||||||
Telegram runs a **single bot**: every player uses
|
Telegram runs a **single bot**: every player uses
|
||||||
the same bot, and all of its chat and out-of-app notifications are written in the
|
the same bot, and all of its chat and out-of-app notifications are written in the
|
||||||
player's own **interface language** (en/ru). A separate optional **promo bot** can run alongside the
|
player's own **interface language** (en/ru). A separate optional **promo bot** can run alongside the
|
||||||
|
|||||||
@@ -73,8 +73,8 @@ launch-параметрам VK (их проверяет gateway), и при пе
|
|||||||
пауза между кодами и небольшой часовой лимит на адрес), чтобы опечатка в адресе или нетерпеливый тап
|
пауза между кодами и небольшой часовой лимит на адрес), чтобы опечатка в адресе или нетерпеливый тап
|
||||||
не завалили почтовый ящик. В письме также есть **ссылка одного нажатия**, которая подтверждает
|
не завалили почтовый ящик. В письме также есть **ссылка одного нажатия**, которая подтверждает
|
||||||
адрес — или сразу выполняет вход — в один тап; открытие её в другом браузере тут же отражается в
|
адрес — или сразу выполняет вход — в один тап; открытие её в другом браузере тут же отражается в
|
||||||
приложении, а почтовый сканер, который просто загружает ссылку, её не срабатывает (действие
|
приложении, а почтовый сканер, который просто загружает ссылку, до токена не дотянется —
|
||||||
только по нажатию кнопки).
|
он едет в URL-фрагменте, который не уходит на сервер.
|
||||||
Telegram держит **единого бота**: все игроки пользуются одним
|
Telegram держит **единого бота**: все игроки пользуются одним
|
||||||
и тем же ботом, а весь его чат и внеприложенческие уведомления пишутся на **языке
|
и тем же ботом, а весь его чат и внеприложенческие уведомления пишутся на **языке
|
||||||
интерфейса** самого игрока (en/ru). Рядом с основным может работать отдельный опциональный
|
интерфейса** самого игрока (en/ru). Рядом с основным может работать отдельный опциональный
|
||||||
|
|||||||
@@ -36,8 +36,7 @@ export const en = {
|
|||||||
'login.sendCode': 'Send code',
|
'login.sendCode': 'Send code',
|
||||||
'login.codePlaceholder': '6-digit code',
|
'login.codePlaceholder': '6-digit code',
|
||||||
'login.signIn': 'Sign in',
|
'login.signIn': 'Sign in',
|
||||||
'confirm.prompt': 'Confirm your email to finish.',
|
'confirm.busy': 'Confirming your email…',
|
||||||
'confirm.action': 'Confirm',
|
|
||||||
'confirm.linked': 'Email confirmed.',
|
'confirm.linked': 'Email confirmed.',
|
||||||
'confirm.merge': 'Almost done — finish the merge in the app.',
|
'confirm.merge': 'Almost done — finish the merge in the app.',
|
||||||
'confirm.close': 'You can close this window and return to the game.',
|
'confirm.close': 'You can close this window and return to the game.',
|
||||||
|
|||||||
@@ -37,8 +37,7 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'login.sendCode': 'Отправить код',
|
'login.sendCode': 'Отправить код',
|
||||||
'login.codePlaceholder': 'Код из 6 цифр',
|
'login.codePlaceholder': 'Код из 6 цифр',
|
||||||
'login.signIn': 'Войти',
|
'login.signIn': 'Войти',
|
||||||
'confirm.prompt': 'Подтвердите почту, чтобы завершить.',
|
'confirm.busy': 'Подтверждаем почту…',
|
||||||
'confirm.action': 'Подтвердить',
|
|
||||||
'confirm.linked': 'Почта подтверждена.',
|
'confirm.linked': 'Почта подтверждена.',
|
||||||
'confirm.merge': 'Почти готово — завершите объединение в приложении.',
|
'confirm.merge': 'Почти готово — завершите объединение в приложении.',
|
||||||
'confirm.close': 'Можно закрыть это окно и вернуться в игру.',
|
'confirm.close': 'Можно закрыть это окно и вернуться в игру.',
|
||||||
|
|||||||
@@ -1,37 +1,35 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
import { confirmDeeplink } from '../lib/app.svelte';
|
import { confirmDeeplink } from '../lib/app.svelte';
|
||||||
import { t } from '../lib/i18n/index.svelte';
|
import { t } from '../lib/i18n/index.svelte';
|
||||||
|
|
||||||
let { token }: { token: string } = $props();
|
let { token }: { token: string } = $props();
|
||||||
// idle → busy → (login navigates away) | linked | merge | error. The token is
|
// The token rides the URL fragment (#/confirm/<token>), which is never sent to the
|
||||||
// confirmed only on the button press (a POST), so a mail scanner prefetching the
|
// server, so a plain link prefetch cannot reach it — confirm as soon as the screen
|
||||||
// link (a GET on the SPA route) never consumes it.
|
// loads. The manual 6-digit code in the same email is the fallback if an aggressive
|
||||||
let stage = $state<'idle' | 'busy' | 'linked' | 'merge' | 'error'>('idle');
|
// scanner ever runs the page and consumes the single-use token first.
|
||||||
|
let stage = $state<'busy' | 'linked' | 'merge' | 'error'>('busy');
|
||||||
|
|
||||||
async function confirm(): Promise<void> {
|
onMount(async () => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
stage = 'error';
|
stage = 'error';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
stage = 'busy';
|
|
||||||
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);
|
||||||
// A login has already navigated into the app; a link stays here with a result.
|
|
||||||
stage = r === 'merge_required' ? 'merge' : 'linked';
|
stage = r === 'merge_required' ? 'merge' : 'linked';
|
||||||
} catch {
|
} catch {
|
||||||
stage = 'error';
|
stage = 'error';
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<main class="confirm">
|
<main class="confirm">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="brand">{t('app.title')}</div>
|
<div class="brand">{t('app.title')}</div>
|
||||||
{#if stage === 'idle' || stage === 'busy'}
|
{#if stage === 'busy'}
|
||||||
<p>{t('confirm.prompt')}</p>
|
<p class="muted">{t('confirm.busy')}</p>
|
||||||
<button class="primary" disabled={stage === 'busy'} onclick={confirm}>
|
|
||||||
{t('confirm.action')}
|
|
||||||
</button>
|
|
||||||
{:else if stage === 'linked'}
|
{:else if stage === 'linked'}
|
||||||
<p class="ok">{t('confirm.linked')}</p>
|
<p class="ok">{t('confirm.linked')}</p>
|
||||||
<p class="muted">{t('confirm.close')}</p>
|
<p class="muted">{t('confirm.close')}</p>
|
||||||
@@ -81,15 +79,4 @@
|
|||||||
.err {
|
.err {
|
||||||
color: var(--danger, #c0392b);
|
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>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user