diff --git a/ui/src/App.svelte b/ui/src/App.svelte index e337352..215f47b 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -15,6 +15,7 @@ import NewGame from './screens/NewGame.svelte'; import SettingsHub from './screens/SettingsHub.svelte'; import Stats from './screens/Stats.svelte'; + import Confirm from './screens/Confirm.svelte'; import Game from './game/Game.svelte'; import CommsHub from './game/CommsHub.svelte'; import Feedback from './screens/Feedback.svelte'; @@ -110,6 +111,8 @@ {:else if router.route.name === 'stats'} + {:else if router.route.name === 'confirm'} + {:else} {/if} diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 80deed6..2cce2a4 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -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 { 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 { export async function requestEmailCode(email: string): Promise { 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 { } } +// 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 { closeStream(); resetConnection(); diff --git a/ui/src/lib/client.ts b/ui/src/lib/client.ts index b91bcb2..b1767ff 100644 --- a/ui/src/lib/client.ts +++ b/ui/src/lib/client.ts @@ -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; authGuest(locale?: string): Promise; - authEmailRequest(email: string): Promise; + authEmailRequest(email: string, language: string): Promise; authEmailLogin(email: string, code: string): Promise; + /** 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; // --- profile / lists --- profileGet(): Promise; diff --git a/ui/src/lib/codec.test.ts b/ui/src/lib/codec.test.ts index 429fcdd..21b37ae 100644 --- a/ui/src/lib/codec.test.ts +++ b/ui/src/lib/codec.test.ts @@ -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'); diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index 4665bd0..e617ee2 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -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 { diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 4f6cbc0..10f4634 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -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', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index dafa2df..9cd928c 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -37,6 +37,12 @@ export const ru: Record = { 'login.sendCode': 'Отправить код', 'login.codePlaceholder': 'Код из 6 цифр', 'login.signIn': 'Войти', + 'confirm.prompt': 'Подтвердите почту, чтобы завершить.', + 'confirm.action': 'Подтвердить', + 'confirm.linked': 'Почта подтверждена.', + 'confirm.merge': 'Почти готово — завершите объединение в приложении.', + 'confirm.close': 'Можно закрыть это окно и вернуться в игру.', + 'confirm.expired': 'Ссылка недействительна или истекла. Запросите новый код.', 'login.codeSent': 'Мы отправили код на {email}.', 'lobby.activeGames': 'Активные игры', diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index 1658c02..2aca5d8 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -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 { return { ...SESSION }; } - async authEmailRequest(): Promise {} + async authEmailRequest(_email: string, _language: string): Promise {} + async confirmEmailLink(_token: string): Promise { + return { purpose: 'link', status: 'confirmed', session: null }; + } async authEmailLogin(): Promise { return { ...SESSION, isGuest: false }; } diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts index 60fa825..28a4510 100644 --- a/ui/src/lib/model.ts +++ b/ui/src/lib/model.ts @@ -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; diff --git a/ui/src/lib/routeparse.ts b/ui/src/lib/routeparse.ts index 434c902..b91191d 100644 --- a/ui/src/lib/routeparse.ts +++ b/ui/src/lib/routeparse.ts @@ -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/. 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: {} }; } diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index cc06486..62b07a6 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -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))); diff --git a/ui/src/screens/Confirm.svelte b/ui/src/screens/Confirm.svelte new file mode 100644 index 0000000..5af9f62 --- /dev/null +++ b/ui/src/screens/Confirm.svelte @@ -0,0 +1,95 @@ + + +
+
+
{t('app.title')}
+ {#if stage === 'idle' || stage === 'busy'} +

{t('confirm.prompt')}

+ + {:else if stage === 'linked'} +

{t('confirm.linked')}

+

{t('confirm.close')}

+ {:else if stage === 'merge'} +

{t('confirm.merge')}

+

{t('confirm.close')}

+ {:else} +

{t('confirm.expired')}

+ {/if} +
+
+ +