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:
Ilia Denisov
2026-07-03 04:30:16 +02:00
parent 762155a55e
commit 409462fc09
12 changed files with 184 additions and 8 deletions
+21 -2
View File
@@ -409,6 +409,11 @@ function openStream(): void {
if (e.sub === 'banner') {
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();
}
},
@@ -738,7 +743,7 @@ export async function bootstrap(): Promise<void> {
if (saved) {
await adoptSession(saved);
if (router.route.name === 'login') navigate('/');
} else if (router.route.name !== 'login') {
} else if (router.route.name !== 'login' && router.route.name !== 'confirm') {
navigate('/login');
}
app.ready = true;
@@ -915,7 +920,7 @@ export async function loginGuest(): Promise<void> {
export async function requestEmailCode(email: string): Promise<boolean> {
try {
await gateway.authEmailRequest(email);
await gateway.authEmailRequest(email, app.locale);
return true;
} catch (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> {
closeStream();
resetConnection();
+5 -1
View File
@@ -20,6 +20,7 @@ import type {
HintResult,
Invitation,
InvitationSettings,
ConfirmLinkResult,
LinkResult,
MatchResult,
MoveResult,
@@ -64,8 +65,11 @@ export interface GatewayClient {
* cosmetic seed for a brand-new account). */
authVK(params: string, displayName: 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>;
/** 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 ---
profileGet(): Promise<Profile>;
+1 -1
View File
@@ -117,7 +117,7 @@ describe('codec', () => {
expect(guest.browserTz()).toBe('-05:30');
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.browserTz()).toBe('+00:00');
+22 -1
View File
@@ -31,6 +31,7 @@ import type {
Invitation,
InvitationInvitee,
InvitationSettings,
ConfirmLinkResult,
LinkResult,
MatchResult,
MoveRecord,
@@ -212,13 +213,15 @@ export function encodeGuestLogin(locale: string, browserTz: string): Uint8Array
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 e = b.createString(email);
const tz = b.createString(browserTz);
const l = b.createString(language);
fb.EmailRequestRequest.startEmailRequestRequest(b);
fb.EmailRequestRequest.addEmail(b, e);
fb.EmailRequestRequest.addBrowserTz(b, tz);
fb.EmailRequestRequest.addLanguage(b, l);
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));
}
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 ---
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 ---
function decodeAccountRef(r: fb.AccountRef): AccountRef {
+6
View File
@@ -36,6 +36,12 @@ export const en = {
'login.sendCode': 'Send code',
'login.codePlaceholder': '6-digit code',
'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}.',
'lobby.activeGames': 'Active games',
+6
View File
@@ -37,6 +37,12 @@ export const ru: Record<MessageKey, string> = {
'login.sendCode': 'Отправить код',
'login.codePlaceholder': 'Код из 6 цифр',
'login.signIn': 'Войти',
'confirm.prompt': 'Подтвердите почту, чтобы завершить.',
'confirm.action': 'Подтвердить',
'confirm.linked': 'Почта подтверждена.',
'confirm.merge': 'Почти готово — завершите объединение в приложении.',
'confirm.close': 'Можно закрыть это окно и вернуться в игру.',
'confirm.expired': 'Ссылка недействительна или истекла. Запросите новый код.',
'login.codeSent': 'Мы отправили код на {email}.',
'lobby.activeGames': 'Активные игры',
+5 -1
View File
@@ -27,6 +27,7 @@ import type {
HintResult,
Invitation,
InvitationSettings,
ConfirmLinkResult,
LinkResult,
MatchResult,
MoveResult,
@@ -149,7 +150,10 @@ export class MockGateway implements GatewayClient {
async authGuest(): Promise<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> {
return { ...SESSION, isGuest: false };
}
+9
View File
@@ -355,6 +355,15 @@ export interface LinkResult {
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 {
matched: boolean;
game?: GameView;
+6
View File
@@ -15,6 +15,7 @@ export type RouteName =
| 'friends'
| 'feedback'
| 'stats'
| 'confirm'
| 'notfound';
export interface Route {
@@ -58,6 +59,11 @@ export function parse(hash: string): Route {
return { name: 'feedback', params: {} };
case 'stats':
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:
return { name: 'notfound', params: {} };
}
+5 -2
View File
@@ -101,8 +101,11 @@ export function createTransport(baseUrl: string): GatewayClient {
async authGuest(locale) {
return codec.decodeSession(await exec('auth.guest', codec.encodeGuestLogin(locale ?? '', browserOffset())));
},
async authEmailRequest(email) {
await exec('auth.email.request', codec.encodeEmailRequest(email, browserOffset()));
async authEmailRequest(email, language) {
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) {
return codec.decodeSession(await exec('auth.email.login', codec.encodeEmailLogin(email, code)));