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:
@@ -15,6 +15,7 @@
|
|||||||
import NewGame from './screens/NewGame.svelte';
|
import NewGame from './screens/NewGame.svelte';
|
||||||
import SettingsHub from './screens/SettingsHub.svelte';
|
import SettingsHub from './screens/SettingsHub.svelte';
|
||||||
import Stats from './screens/Stats.svelte';
|
import Stats from './screens/Stats.svelte';
|
||||||
|
import Confirm from './screens/Confirm.svelte';
|
||||||
import Game from './game/Game.svelte';
|
import Game from './game/Game.svelte';
|
||||||
import CommsHub from './game/CommsHub.svelte';
|
import CommsHub from './game/CommsHub.svelte';
|
||||||
import Feedback from './screens/Feedback.svelte';
|
import Feedback from './screens/Feedback.svelte';
|
||||||
@@ -110,6 +111,8 @@
|
|||||||
<Feedback />
|
<Feedback />
|
||||||
{:else if router.route.name === 'stats'}
|
{:else if router.route.name === 'stats'}
|
||||||
<Stats />
|
<Stats />
|
||||||
|
{:else if router.route.name === 'confirm'}
|
||||||
|
<Confirm token={router.route.params.token} />
|
||||||
{:else}
|
{:else}
|
||||||
<Lobby />
|
<Lobby />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -409,6 +409,11 @@ function openStream(): void {
|
|||||||
if (e.sub === 'banner') {
|
if (e.sub === 'banner') {
|
||||||
void refreshProfile();
|
void refreshProfile();
|
||||||
}
|
}
|
||||||
|
// The viewer's own profile changed out of band (an email confirmed via the
|
||||||
|
// one-tap deeplink opened in another browser): re-fetch it.
|
||||||
|
if (e.sub === 'profile') {
|
||||||
|
void refreshProfile();
|
||||||
|
}
|
||||||
void refreshNotifications();
|
void refreshNotifications();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -738,7 +743,7 @@ export async function bootstrap(): Promise<void> {
|
|||||||
if (saved) {
|
if (saved) {
|
||||||
await adoptSession(saved);
|
await adoptSession(saved);
|
||||||
if (router.route.name === 'login') navigate('/');
|
if (router.route.name === 'login') navigate('/');
|
||||||
} else if (router.route.name !== 'login') {
|
} else if (router.route.name !== 'login' && router.route.name !== 'confirm') {
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
}
|
}
|
||||||
app.ready = true;
|
app.ready = true;
|
||||||
@@ -915,7 +920,7 @@ export async function loginGuest(): Promise<void> {
|
|||||||
|
|
||||||
export async function requestEmailCode(email: string): Promise<boolean> {
|
export async function requestEmailCode(email: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
await gateway.authEmailRequest(email);
|
await gateway.authEmailRequest(email, app.locale);
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
handleError(err);
|
handleError(err);
|
||||||
@@ -933,6 +938,20 @@ export async function loginEmail(email: string, code: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// confirmDeeplink verifies a one-tap email deeplink token opened from a confirmation
|
||||||
|
// email. A login adopts the minted session and enters the app; a link reports whether
|
||||||
|
// it confirmed or needs the interactive merge. It throws on an invalid/expired token,
|
||||||
|
// which the confirm screen surfaces. This browser need not be signed in.
|
||||||
|
export async function confirmDeeplink(token: string): Promise<'login' | 'linked' | 'merge_required'> {
|
||||||
|
const r = await gateway.confirmEmailLink(token);
|
||||||
|
if (r.purpose === 'login' && r.session) {
|
||||||
|
await adoptSession(r.session);
|
||||||
|
navigate('/');
|
||||||
|
return 'login';
|
||||||
|
}
|
||||||
|
return r.status === 'merge_required' ? 'merge_required' : 'linked';
|
||||||
|
}
|
||||||
|
|
||||||
export async function logout(): Promise<void> {
|
export async function logout(): Promise<void> {
|
||||||
closeStream();
|
closeStream();
|
||||||
resetConnection();
|
resetConnection();
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import type {
|
|||||||
HintResult,
|
HintResult,
|
||||||
Invitation,
|
Invitation,
|
||||||
InvitationSettings,
|
InvitationSettings,
|
||||||
|
ConfirmLinkResult,
|
||||||
LinkResult,
|
LinkResult,
|
||||||
MatchResult,
|
MatchResult,
|
||||||
MoveResult,
|
MoveResult,
|
||||||
@@ -64,8 +65,11 @@ export interface GatewayClient {
|
|||||||
* cosmetic seed for a brand-new account). */
|
* cosmetic seed for a brand-new account). */
|
||||||
authVK(params: string, displayName: string): Promise<Session>;
|
authVK(params: string, displayName: string): Promise<Session>;
|
||||||
authGuest(locale?: string): Promise<Session>;
|
authGuest(locale?: string): Promise<Session>;
|
||||||
authEmailRequest(email: string): Promise<void>;
|
authEmailRequest(email: string, language: string): Promise<void>;
|
||||||
authEmailLogin(email: string, code: string): Promise<Session>;
|
authEmailLogin(email: string, code: string): Promise<Session>;
|
||||||
|
/** Confirm a one-tap email deeplink token: a login returns a session to adopt; a
|
||||||
|
* link returns a status ('confirmed' | 'merge_required'). */
|
||||||
|
confirmEmailLink(token: string): Promise<ConfirmLinkResult>;
|
||||||
|
|
||||||
// --- profile / lists ---
|
// --- profile / lists ---
|
||||||
profileGet(): Promise<Profile>;
|
profileGet(): Promise<Profile>;
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ describe('codec', () => {
|
|||||||
expect(guest.browserTz()).toBe('-05:30');
|
expect(guest.browserTz()).toBe('-05:30');
|
||||||
|
|
||||||
const email = fb.EmailRequestRequest.getRootAsEmailRequestRequest(
|
const email = fb.EmailRequestRequest.getRootAsEmailRequestRequest(
|
||||||
new ByteBuffer(encodeEmailRequest('a@example.com', '+00:00')),
|
new ByteBuffer(encodeEmailRequest('a@example.com', '+00:00', 'en')),
|
||||||
);
|
);
|
||||||
expect(email.email()).toBe('a@example.com');
|
expect(email.email()).toBe('a@example.com');
|
||||||
expect(email.browserTz()).toBe('+00:00');
|
expect(email.browserTz()).toBe('+00:00');
|
||||||
|
|||||||
+22
-1
@@ -31,6 +31,7 @@ import type {
|
|||||||
Invitation,
|
Invitation,
|
||||||
InvitationInvitee,
|
InvitationInvitee,
|
||||||
InvitationSettings,
|
InvitationSettings,
|
||||||
|
ConfirmLinkResult,
|
||||||
LinkResult,
|
LinkResult,
|
||||||
MatchResult,
|
MatchResult,
|
||||||
MoveRecord,
|
MoveRecord,
|
||||||
@@ -212,13 +213,15 @@ export function encodeGuestLogin(locale: string, browserTz: string): Uint8Array
|
|||||||
return finish(b, fb.GuestLoginRequest.endGuestLoginRequest(b));
|
return finish(b, fb.GuestLoginRequest.endGuestLoginRequest(b));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function encodeEmailRequest(email: string, browserTz: string): Uint8Array {
|
export function encodeEmailRequest(email: string, browserTz: string, language: string): Uint8Array {
|
||||||
const b = new Builder(128);
|
const b = new Builder(128);
|
||||||
const e = b.createString(email);
|
const e = b.createString(email);
|
||||||
const tz = b.createString(browserTz);
|
const tz = b.createString(browserTz);
|
||||||
|
const l = b.createString(language);
|
||||||
fb.EmailRequestRequest.startEmailRequestRequest(b);
|
fb.EmailRequestRequest.startEmailRequestRequest(b);
|
||||||
fb.EmailRequestRequest.addEmail(b, e);
|
fb.EmailRequestRequest.addEmail(b, e);
|
||||||
fb.EmailRequestRequest.addBrowserTz(b, tz);
|
fb.EmailRequestRequest.addBrowserTz(b, tz);
|
||||||
|
fb.EmailRequestRequest.addLanguage(b, l);
|
||||||
return finish(b, fb.EmailRequestRequest.endEmailRequestRequest(b));
|
return finish(b, fb.EmailRequestRequest.endEmailRequestRequest(b));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,6 +235,14 @@ export function encodeEmailLogin(email: string, code: string): Uint8Array {
|
|||||||
return finish(b, fb.EmailLoginRequest.endEmailLoginRequest(b));
|
return finish(b, fb.EmailLoginRequest.endEmailLoginRequest(b));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function encodeEmailConfirmLink(token: string): Uint8Array {
|
||||||
|
const b = new Builder(128);
|
||||||
|
const t = b.createString(token);
|
||||||
|
fb.EmailConfirmLinkRequest.startEmailConfirmLinkRequest(b);
|
||||||
|
fb.EmailConfirmLinkRequest.addToken(b, t);
|
||||||
|
return finish(b, fb.EmailConfirmLinkRequest.endEmailConfirmLinkRequest(b));
|
||||||
|
}
|
||||||
|
|
||||||
// --- response decoders ---
|
// --- response decoders ---
|
||||||
|
|
||||||
function s(v: string | null): string {
|
function s(v: string | null): string {
|
||||||
@@ -730,6 +741,16 @@ export function decodeLinkResult(buf: Uint8Array): LinkResult {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function decodeConfirmLinkResult(buf: Uint8Array): ConfirmLinkResult {
|
||||||
|
const r = fb.EmailConfirmLinkResult.getRootAsEmailConfirmLinkResult(new ByteBuffer(buf));
|
||||||
|
const sess = r.session();
|
||||||
|
return {
|
||||||
|
purpose: (s(r.purpose()) || 'link') as ConfirmLinkResult['purpose'],
|
||||||
|
status: (s(r.status()) || 'confirmed') as ConfirmLinkResult['status'],
|
||||||
|
session: sess ? sessionFromTable(sess) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// --- social decoders ---
|
// --- social decoders ---
|
||||||
|
|
||||||
function decodeAccountRef(r: fb.AccountRef): AccountRef {
|
function decodeAccountRef(r: fb.AccountRef): AccountRef {
|
||||||
|
|||||||
@@ -36,6 +36,12 @@ 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.action': 'Confirm',
|
||||||
|
'confirm.linked': 'Email confirmed.',
|
||||||
|
'confirm.merge': 'Almost done — finish the merge in the app.',
|
||||||
|
'confirm.close': 'You can close this window and return to the game.',
|
||||||
|
'confirm.expired': 'This link is invalid or has expired. Request a new code.',
|
||||||
'login.codeSent': 'We sent a code to {email}.',
|
'login.codeSent': 'We sent a code to {email}.',
|
||||||
|
|
||||||
'lobby.activeGames': 'Active games',
|
'lobby.activeGames': 'Active games',
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'login.sendCode': 'Отправить код',
|
'login.sendCode': 'Отправить код',
|
||||||
'login.codePlaceholder': 'Код из 6 цифр',
|
'login.codePlaceholder': 'Код из 6 цифр',
|
||||||
'login.signIn': 'Войти',
|
'login.signIn': 'Войти',
|
||||||
|
'confirm.prompt': 'Подтвердите почту, чтобы завершить.',
|
||||||
|
'confirm.action': 'Подтвердить',
|
||||||
|
'confirm.linked': 'Почта подтверждена.',
|
||||||
|
'confirm.merge': 'Почти готово — завершите объединение в приложении.',
|
||||||
|
'confirm.close': 'Можно закрыть это окно и вернуться в игру.',
|
||||||
|
'confirm.expired': 'Ссылка недействительна или истекла. Запросите новый код.',
|
||||||
'login.codeSent': 'Мы отправили код на {email}.',
|
'login.codeSent': 'Мы отправили код на {email}.',
|
||||||
|
|
||||||
'lobby.activeGames': 'Активные игры',
|
'lobby.activeGames': 'Активные игры',
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import type {
|
|||||||
HintResult,
|
HintResult,
|
||||||
Invitation,
|
Invitation,
|
||||||
InvitationSettings,
|
InvitationSettings,
|
||||||
|
ConfirmLinkResult,
|
||||||
LinkResult,
|
LinkResult,
|
||||||
MatchResult,
|
MatchResult,
|
||||||
MoveResult,
|
MoveResult,
|
||||||
@@ -149,7 +150,10 @@ export class MockGateway implements GatewayClient {
|
|||||||
async authGuest(): Promise<Session> {
|
async authGuest(): Promise<Session> {
|
||||||
return { ...SESSION };
|
return { ...SESSION };
|
||||||
}
|
}
|
||||||
async authEmailRequest(): Promise<void> {}
|
async authEmailRequest(_email: string, _language: string): Promise<void> {}
|
||||||
|
async confirmEmailLink(_token: string): Promise<ConfirmLinkResult> {
|
||||||
|
return { purpose: 'link', status: 'confirmed', session: null };
|
||||||
|
}
|
||||||
async authEmailLogin(): Promise<Session> {
|
async authEmailLogin(): Promise<Session> {
|
||||||
return { ...SESSION, isGuest: false };
|
return { ...SESSION, isGuest: false };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -355,6 +355,15 @@ export interface LinkResult {
|
|||||||
session: Session | null;
|
session: Session | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ConfirmLinkResult is the outcome of a one-tap deeplink confirmation. purpose is
|
||||||
|
// 'login' (session carries the minted credential to sign in) or 'link' (status is
|
||||||
|
// 'confirmed' or 'merge_required' — the app finishes any merge from its manual flow).
|
||||||
|
export interface ConfirmLinkResult {
|
||||||
|
purpose: 'login' | 'link';
|
||||||
|
status: 'confirmed' | 'merge_required';
|
||||||
|
session: Session | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MatchResult {
|
export interface MatchResult {
|
||||||
matched: boolean;
|
matched: boolean;
|
||||||
game?: GameView;
|
game?: GameView;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export type RouteName =
|
|||||||
| 'friends'
|
| 'friends'
|
||||||
| 'feedback'
|
| 'feedback'
|
||||||
| 'stats'
|
| 'stats'
|
||||||
|
| 'confirm'
|
||||||
| 'notfound';
|
| 'notfound';
|
||||||
|
|
||||||
export interface Route {
|
export interface Route {
|
||||||
@@ -58,6 +59,11 @@ export function parse(hash: string): Route {
|
|||||||
return { name: 'feedback', params: {} };
|
return { name: 'feedback', params: {} };
|
||||||
case 'stats':
|
case 'stats':
|
||||||
return { name: 'stats', params: {} };
|
return { name: 'stats', params: {} };
|
||||||
|
case 'confirm':
|
||||||
|
// The one-tap email deeplink: /confirm/<token>. The token is the confirmation
|
||||||
|
// authorization; the screen POSTs it (prefetch-safe), so a mail scanner's GET
|
||||||
|
// does not consume it.
|
||||||
|
return { name: 'confirm', params: { token: seg[1] ?? '' } };
|
||||||
default:
|
default:
|
||||||
return { name: 'notfound', params: {} };
|
return { name: 'notfound', params: {} };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,8 +101,11 @@ export function createTransport(baseUrl: string): GatewayClient {
|
|||||||
async authGuest(locale) {
|
async authGuest(locale) {
|
||||||
return codec.decodeSession(await exec('auth.guest', codec.encodeGuestLogin(locale ?? '', browserOffset())));
|
return codec.decodeSession(await exec('auth.guest', codec.encodeGuestLogin(locale ?? '', browserOffset())));
|
||||||
},
|
},
|
||||||
async authEmailRequest(email) {
|
async authEmailRequest(email, language) {
|
||||||
await exec('auth.email.request', codec.encodeEmailRequest(email, browserOffset()));
|
await exec('auth.email.request', codec.encodeEmailRequest(email, browserOffset(), language));
|
||||||
|
},
|
||||||
|
async confirmEmailLink(token) {
|
||||||
|
return codec.decodeConfirmLinkResult(await exec('auth.email.confirm_link', codec.encodeEmailConfirmLink(token)));
|
||||||
},
|
},
|
||||||
async authEmailLogin(email, code) {
|
async authEmailLogin(email, code) {
|
||||||
return codec.decodeSession(await exec('auth.email.login', codec.encodeEmailLogin(email, code)));
|
return codec.decodeSession(await exec('auth.email.login', codec.encodeEmailLogin(email, code)));
|
||||||
|
|||||||
@@ -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