diff --git a/src/backend/controllers/auth/AuthController.test.ts b/src/backend/controllers/auth/AuthController.test.ts index fd3b454b5..95ff5981c 100644 --- a/src/backend/controllers/auth/AuthController.test.ts +++ b/src/backend/controllers/auth/AuthController.test.ts @@ -5132,6 +5132,26 @@ describe('AuthController phone verification — staging & reuse', () => { expect(staged).toBe('+14155550123'); }); + it('makes an email-gate-last account verify its card before its phone', async () => { + const { user, actor } = await makeUserAndActor({ + requires_phone_verification: 1, + requires_card_verification: 1, + }); + await server.stores.user.updateMetadata(user.id, { + emailGateLast: true, + }); + const prelude = stubPrelude(); + await withClients({ prelude }, async () => { + await expect( + controller.handleSendConfirmPhone( + makeReq({ phone: '+14155550123' }, { actor }), + makeRes(), + ), + ).rejects.toMatchObject({ statusCode: 409 }); + }); + expect(prelude.createVerification).not.toHaveBeenCalled(); + }); + it('forwards an abuse-extension veto as 429 with the opaque reason, and sends nothing', async () => { const { user, actor } = await makeUserAndActor(); const emitAndWait = vi.fn( diff --git a/src/backend/controllers/auth/AuthController.ts b/src/backend/controllers/auth/AuthController.ts index 23b7aa037..0af720971 100644 --- a/src/backend/controllers/auth/AuthController.ts +++ b/src/backend/controllers/auth/AuthController.ts @@ -66,6 +66,7 @@ import { isCardFallbackEnabled, phoneAttemptsKey, } from '../../util/cardFallback.js'; +import { isEmailGateLast } from '../../util/verificationOrder.js'; import { sessionCookieFlags } from '../../util/cookieFlags.js'; import { cleanEmail, isStorableEmail } from '../../util/email.js'; import { isGodmodeApp } from '../../util/godmodeApps.js'; @@ -1220,6 +1221,18 @@ export class AuthController extends PuterController { } } + /** The email-gate-last confirmation email; a failure never fails the gate. */ + private async sendDeferredConfirmation(userId: number): Promise { + try { + await this.services.signup.sendDeferredConfirmationEmail(userId); + } catch (e) { + console.warn( + '[verification] deferred confirmation email failed:', + e, + ); + } + } + private async isCardFallbackEligible(user: { id: number; requires_phone_verification?: boolean | number | null; @@ -1265,6 +1278,12 @@ export class AuthController extends PuterController { throw new HttpError(403, 'Account suspended.', { legacyCode: 'account_suspended', }); + if (isEmailGateLast(user) && user.requires_card_verification) + throw new HttpError( + 409, + 'Card verification must be completed first.', + { legacyCode: 'conflict' }, + ); if (!this.clients.prelude?.isConfigured()) throw await this.smsSendError( 503, @@ -1574,6 +1593,7 @@ export class AuthController extends PuterController { requires_phone_verification: 0, phone: pendingPhone, }); + await this.sendDeferredConfirmation(user.id); // Run the verified-event listeners synchronously (emitAndWait, not // fire-and-forget emit) so a carrier-based card-verification waiver in @@ -1654,7 +1674,11 @@ export class AuthController extends PuterController { res.json({ card_verified: true }); return; } - if (user.requires_phone_verification && !fallbackEligible) + if ( + user.requires_phone_verification && + !fallbackEligible && + !isEmailGateLast(user) + ) throw new HttpError( 409, 'Phone verification must be completed first.', @@ -1717,6 +1741,7 @@ export class AuthController extends PuterController { await this.stores.user.update(user.id, { requires_card_verification: 0, }); + await this.sendDeferredConfirmation(user.id); res.json({ card_verified: true, disabled: true }); return; } @@ -1776,7 +1801,11 @@ export class AuthController extends PuterController { res.json({ card_verified: true }); return; } - if (user.requires_phone_verification && !fallbackEligible) + if ( + user.requires_phone_verification && + !fallbackEligible && + !isEmailGateLast(user) + ) throw new HttpError( 409, 'Phone verification must be completed first.', @@ -1817,6 +1846,7 @@ export class AuthController extends PuterController { await this.stores.user.update(user.id, { requires_card_verification: 0, }); + await this.sendDeferredConfirmation(user.id); res.json({ card_verified: true, disabled: true }); return; } @@ -1841,6 +1871,7 @@ export class AuthController extends PuterController { requires_card_verification: 0, ...(clearedPhoneGate ? { requires_phone_verification: 0 } : {}), }); + await this.sendDeferredConfirmation(user.id); // Awaited like `user.phone-verified`, so whatever a listener grants on // verification is in place before the client refreshes. emitAndWait diff --git a/src/backend/core/http/middleware/gates.test.ts b/src/backend/core/http/middleware/gates.test.ts index ec339c8b4..53bfa6b08 100644 --- a/src/backend/core/http/middleware/gates.test.ts +++ b/src/backend/core/http/middleware/gates.test.ts @@ -836,6 +836,34 @@ describe('requireVerifiedAccount', () => { expectHttpError(got, 403, 'card_verification_required'); }); + it('asks an email-gate-last account for card, then phone, then email', () => { + const pending = { + uuid: 'u-1', + requires_email_confirmation: true, + email_confirmed: false, + requires_phone_verification: true, + requires_card_verification: true, + metadata: { emailGateLast: true }, + }; + const gateFor = (user: Record) => + runGate(requireVerifiedAccount(), { actor: { user } }); + expectHttpError(gateFor(pending), 403, 'card_verification_required'); + expectHttpError( + gateFor({ ...pending, requires_card_verification: false }), + 403, + 'phone_verification_required', + ); + expectHttpError( + gateFor({ + ...pending, + requires_card_verification: false, + requires_phone_verification: false, + }), + 403, + 'email_confirmation_required', + ); + }); + it('returns 403 two_factor_required for a seat whose team requires 2FA', async () => { const teams = { getOrgSeat: async () => ({ require_2fa: 1 }) }; const got = await runGateAsync(requireTeam2fa(teams), { diff --git a/src/backend/core/http/middleware/gates.ts b/src/backend/core/http/middleware/gates.ts index aae513c23..79c36d9d0 100644 --- a/src/backend/core/http/middleware/gates.ts +++ b/src/backend/core/http/middleware/gates.ts @@ -32,6 +32,7 @@ import { type CardFallbackDeps, } from '../../../util/cardFallback'; import { reserveUsernames } from '../../../util/reservedUsernames'; +import { isEmailGateLast } from '../../../util/verificationOrder'; import { isAccountContext, isAppActor, @@ -309,11 +310,25 @@ export const requireVerifiedAccount = (): RequestHandler => export const assertVerifiedAccount = ( user: AccountGateUser | undefined, ): void => { - if (user?.requires_email_confirmation && !user?.email_confirmed) { - throw new HttpError(403, 'Please confirm your email to continue', { - legacyCode: 'email_confirmation_required', - }); - } + const assertEmail = () => { + if (user?.requires_email_confirmation && !user?.email_confirmed) { + throw new HttpError(403, 'Please confirm your email to continue', { + legacyCode: 'email_confirmation_required', + }); + } + }; + const assertCard = () => { + if (user?.requires_card_verification) { + throw new HttpError(403, 'Please verify your card to continue', { + legacyCode: 'card_verification_required', + }); + } + }; + // Email → phone → card, or card → phone → email for an email-gate-last + // account, so the client is sent to the gate it has to clear next. + const emailLast = isEmailGateLast(user); + if (emailLast) assertCard(); + else assertEmail(); if (user?.requires_phone_verification) { throw new HttpError( 403, @@ -323,11 +338,8 @@ export const assertVerifiedAccount = ( }, ); } - if (user?.requires_card_verification) { - throw new HttpError(403, 'Please verify your card to continue', { - legacyCode: 'card_verification_required', - }); - } + if (emailLast) assertEmail(); + else assertCard(); // A team seat signs in on a password its administrator still holds, // so it reaches nothing until it has replaced that password. if (user?.requires_password_change) { diff --git a/src/backend/core/http/types.ts b/src/backend/core/http/types.ts index 26707cfa6..3f84f6bd2 100644 --- a/src/backend/core/http/types.ts +++ b/src/backend/core/http/types.ts @@ -76,6 +76,7 @@ export type AccountGateUser = Partial< | 'phone' | 'card_fingerprint' | 'otp_enabled' + | 'metadata' > > & { id?: number }; diff --git a/src/backend/services/user/SignupService.test.ts b/src/backend/services/user/SignupService.test.ts index ecaea89db..5f0f5fffa 100644 --- a/src/backend/services/user/SignupService.test.ts +++ b/src/backend/services/user/SignupService.test.ts @@ -172,6 +172,87 @@ describe('SignupService.signup', () => { }); }); +describe('SignupService email gate last', () => { + /** Signs up with the validate hook asking for `gates`; returns sent emails. */ + const signupWith = async (gates: Record) => { + const send = vi.fn(async () => true); + const clients = server.clients as unknown as { email: unknown }; + const realEmail = clients.email; + clients.email = { send }; + const require = (_k: string, data: unknown) => { + Object.assign(data as Record, gates); + }; + server.clients.event.on('puter.signup.validate', require); + const username = `egl_${slug()}`; + try { + const user = await server.services.signup.signup({ + req, + body: { + username, + email: `${username}@example.com`, + password: 'correct horse battery', + }, + isTemp: false, + fingerprint: null, + bonusCode: null, + }); + return { user, send, restore: () => (clients.email = realEmail) }; + } finally { + server.clients.event.off('puter.signup.validate', require); + } + }; + + it('holds the confirmation email until card and phone are cleared, then sends it once', async () => { + const { user, send, restore } = await signupWith({ + requires_phone_verification: true, + requires_card_verification: true, + email_gate_last: true, + }); + try { + expect(send).not.toHaveBeenCalled(); + const stored = await server.stores.user.getById(user.id, { + force: true, + }); + expect(stored!.metadata?.emailGateLast).toBe(true); + + await server.stores.user.update(user.id, { + requires_card_verification: 0, + }); + await server.services.signup.sendDeferredConfirmationEmail(user.id); + expect(send).not.toHaveBeenCalled(); + + await server.stores.user.update(user.id, { + requires_phone_verification: 0, + }); + await server.services.signup.sendDeferredConfirmationEmail(user.id); + await server.services.signup.sendDeferredConfirmationEmail(user.id); + expect(send).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledWith( + `${user.username}@example.com`, + 'email_verification_code', + { code: stored!.email_confirm_code }, + ); + } finally { + restore(); + } + }); + + it('ignores the flag when there is no phone or card gate', async () => { + const { user, send, restore } = await signupWith({ + email_gate_last: true, + }); + try { + expect(send).toHaveBeenCalledTimes(1); + const stored = await server.stores.user.getById(user.id, { + force: true, + }); + expect(stored!.metadata?.emailGateLast).toBeUndefined(); + } finally { + restore(); + } + }); +}); + describe('SignupService.announceSignup', () => { it('leaves out the form-only fields for a source that has none', async () => { const username = `an_${slug()}`; diff --git a/src/backend/services/user/SignupService.ts b/src/backend/services/user/SignupService.ts index c7ab6332d..d5c429eab 100644 --- a/src/backend/services/user/SignupService.ts +++ b/src/backend/services/user/SignupService.ts @@ -32,6 +32,7 @@ import { validateSignupBonus, } from '../../util/signupBonus.js'; import { isUsernameTaken } from '../../util/username.js'; +import { isEmailGateLast } from '../../util/verificationOrder.js'; import { generateDefaultFsentries, promoteToVerifiedGroup, @@ -47,6 +48,8 @@ export type SignupGateOutcome = requiresEmailConfirmation: boolean; requiresPhoneVerification: boolean; requiresCardVerification: boolean; + /** Card and phone come before email (see `isEmailGateLast`). */ + emailGateLast: boolean; } | { verdict: 'bonus_code_invalid' } | { @@ -175,6 +178,9 @@ export class SignupService extends PuterService { requires_email_confirmation: false, requires_phone_verification: false, requires_card_verification: false, + // Clear card and phone before email, and send the confirmation + // email only then. Meaningless without a phone or card gate. + email_gate_last: false, message: null as string | null, code: null as string | null, ...(input.fingerprint !== undefined ? { fingerprint } : {}), @@ -250,6 +256,9 @@ export class SignupService extends PuterService { ), requiresPhoneVerification, requiresCardVerification, + emailGateLast: + Boolean(validateEvent.email_gate_last) && + (requiresPhoneVerification || requiresCardVerification), }; } @@ -507,7 +516,10 @@ export class SignupService extends PuterService { } } - if (!isTemp && user.requires_email_confirmation) { + const emailGateLast = !isTemp && gate.emailGateLast; + if (emailGateLast) user = await this.#markEmailGateLast(user); + + if (!isTemp && user.requires_email_confirmation && !emailGateLast) { await this.sendConfirmationEmail( user, 'signup', @@ -582,13 +594,46 @@ export class SignupService extends PuterService { } } + /** Record that this account clears card and phone before email. */ + async #markEmailGateLast(user: UserRow): Promise { + await this.stores.user.updateMetadata(user.id, { emailGateLast: true }); + return { ...user, metadata: { ...user.metadata, emailGateLast: true } }; + } + + /** + * Send an email-gate-last account its confirmation email once card and + * phone are cleared. Once per account; `/send-confirm-email` resends. + */ + async sendDeferredConfirmationEmail(userId: number): Promise { + const user = await this.stores.user.getById(userId, { force: true }); + if (!user || !isEmailGateLast(user)) return; + if (user.metadata?.emailGateLastSent === true) return; + if (user.email_confirmed || !user.requires_email_confirmation) return; + if (user.requires_phone_verification || user.requires_card_verification) + return; + await this.stores.user.updateMetadata(user.id, { + emailGateLastSent: true, + }); + let code = + typeof user.email_confirm_code === 'string' + ? user.email_confirm_code + : null; + if (!code) { + code = String(crypto.randomInt(100000, 1000000)); + await this.stores.user.update(user.id, { + email_confirm_code: code, + }); + } + await this.sendConfirmationEmail(user, 'deferred', { code }); + } + /** * Send the confirmation code (or link) for the account's address. A lost * message is alarmed rather than thrown: the account exists either way. */ async sendConfirmationEmail( user: UserRow, - stage: 'signup' | 'resend', + stage: 'signup' | 'resend' | 'deferred', message: { code: string } | { token: string }, ): Promise { if (!this.clients.email || !user.email) return; @@ -762,7 +807,7 @@ export class SignupService extends PuterService { * phone/card gate is outstanding either. */ #confirmationEmailFailed( - stage: 'signup' | 'resend', + stage: 'signup' | 'resend' | 'deferred', user: UserRow, cause: unknown, ): void { @@ -774,7 +819,7 @@ export class SignupService extends PuterService { ? 'no transport configured (message dropped)' : String(cause); console.warn( - `[${stage === 'signup' ? 'signup' : 'send-confirm-email'}] ` + + `[${stage === 'resend' ? 'send-confirm-email' : 'signup'}] ` + `confirmation email not delivered: ${detail}`, ); // Best-effort: failing to alarm must not fail the signup. diff --git a/src/backend/util/userDetails.ts b/src/backend/util/userDetails.ts index 351c38e44..b123fe4c1 100644 --- a/src/backend/util/userDetails.ts +++ b/src/backend/util/userDetails.ts @@ -28,6 +28,7 @@ import { type CardFallbackClients, } from './cardFallback.js'; import { getTaskbarItems } from './taskbarItems.js'; +import { isEmailGateLast } from './verificationOrder.js'; type TaskbarDeps = Parameters[1]; @@ -153,6 +154,8 @@ export async function buildUserDetails( // client reads it. Only the verification flag ships. requires_phone_verification: user.requires_phone_verification, requires_card_verification: user.requires_card_verification, + // Card and phone come before email for this account. + email_gate_last: isEmailGateLast(user), // A seat reaches nothing until it replaces its admin's password. requires_password_change: user.requires_password_change, // The SMS-to-card escape hatch. It ships here because by the time the diff --git a/src/backend/util/verificationOrder.ts b/src/backend/util/verificationOrder.ts new file mode 100644 index 000000000..3c9671be3 --- /dev/null +++ b/src/backend/util/verificationOrder.ts @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/** + * Whether the account clears card and phone verification before confirming its + * email, and is only sent the confirmation email once it gets there. Decided by + * the signup gate and recorded in the account's metadata. + */ +export const isEmailGateLast = ( + user: { metadata?: Record | null } | null | undefined, +): boolean => user?.metadata?.emailGateLast === true; diff --git a/src/gui/src/UI/UIWindowSignup.js b/src/gui/src/UI/UIWindowSignup.js index 660393b4b..000e76ab4 100644 --- a/src/gui/src/UI/UIWindowSignup.js +++ b/src/gui/src/UI/UIWindowSignup.js @@ -596,26 +596,31 @@ function UIWindowSignup(options) { show_email_dialog ) { $(el_window).close(); - // Verification gates run in order: email → - // phone (SMS) → card, matching the server-side - // order in assertVerifiedAccount. - if (show_email_dialog) { - email_verified = - await UIWindowEmailConfirmationRequired({ - stay_on_top: true, - has_head: true, - reload_on_success: - options.reload_on_success, - window_options: - options.window_options ?? {}, - }); - } + // Verification gates run in the server's order + // (see assertVerifiedAccount): email → phone + // (SMS) → card, or card → phone → email when the + // account clears email last. + const emailGate = async () => { + if (show_email_dialog) { + email_verified = + await UIWindowEmailConfirmationRequired({ + stay_on_top: true, + has_head: true, + reload_on_success: + options.reload_on_success, + window_options: + options.window_options ?? {}, + }); + } + }; // A user the SMS path keeps failing may take the // card fallback instead; that resolves 'card' and // clears the card gate along with the phone gate // (see UIWindowPhoneVerificationRequired). let card_gate_cleared_by_fallback = false; - if (data.user?.requires_phone_verification) { + const phoneGate = async () => { + if (!data.user?.requires_phone_verification) + return; let phone_ok = false; do { phone_ok = @@ -632,12 +637,13 @@ function UIWindowSignup(options) { } while (!phone_ok); card_gate_cleared_by_fallback = phone_ok === 'card'; - } - // Card verification is the last gate. - if ( - data.user?.requires_card_verification && - !card_gate_cleared_by_fallback - ) { + }; + const cardGate = async () => { + if ( + !data.user?.requires_card_verification || + card_gate_cleared_by_fallback + ) + return; let card_ok = false; do { card_ok = @@ -649,6 +655,15 @@ function UIWindowSignup(options) { options.window_options ?? {}, }); } while (!card_ok); + }; + if (data.user?.email_gate_last) { + await cardGate(); + await phoneGate(); + await emailGate(); + } else { + await emailGate(); + await phoneGate(); + await cardGate(); } } diff --git a/src/gui/src/initgui.js b/src/gui/src/initgui.js index 725997571..9e8222e56 100644 --- a/src/gui/src/initgui.js +++ b/src/gui/src/initgui.js @@ -2041,61 +2041,75 @@ window.initgui = async function (options) { } if (whoami) { - // Verification gates run in order: email → phone (SMS) → card, - // matching the server-side order in assertVerifiedAccount. - if (whoami.requires_email_confirmation) { - let is_verified; - do { - is_verified = await UIWindowEmailConfirmationRequired({ - show_close_button: false, - stay_on_top: true, - has_head: false, - window_options: { - is_draggable: false, - }, - }); - } while (!is_verified); - } - // is phone verification required? (hard gate for low-rep signups) - // A user the SMS path keeps failing may be offered the card - // fallback instead; that resolves 'card' and clears the card gate - // too (see UIWindowPhoneVerificationRequired). + // Verification gates run in the server's order (see + // assertVerifiedAccount): email → phone (SMS) → card, or card → + // phone → email when the account clears email last. let card_gate_cleared_by_fallback = false; - if (whoami.requires_phone_verification) { - let is_verified; - do { - is_verified = await UIWindowPhoneVerificationRequired({ - show_close_button: false, - stay_on_top: true, - has_head: false, - // Already out of SMS attempts (the offer outlives the - // send window, and no send can report it any more). - card_fallback_available: - whoami.card_fallback_available, - window_options: { - is_draggable: false, - }, - }); - } while (!is_verified); - card_gate_cleared_by_fallback = is_verified === 'card'; - } - // Card verification is the last gate: only show it once the email and - // phone (SMS) gates are cleared, since those show up first. - if ( - whoami.requires_card_verification && - !card_gate_cleared_by_fallback - ) { - let is_verified; - do { - is_verified = await UIWindowCardVerificationRequired({ - show_close_button: false, - stay_on_top: true, - has_head: false, - window_options: { - is_draggable: false, - }, - }); - } while (!is_verified); + const emailGate = async () => { + if (whoami.requires_email_confirmation) { + let is_verified; + do { + is_verified = await UIWindowEmailConfirmationRequired({ + show_close_button: false, + stay_on_top: true, + has_head: false, + window_options: { + is_draggable: false, + }, + }); + } while (!is_verified); + } + }; + const phoneGate = async () => { + // is phone verification required? (hard gate for low-rep signups) + // A user the SMS path keeps failing may be offered the card + // fallback instead; that resolves 'card' and clears the card gate + // too (see UIWindowPhoneVerificationRequired). + if (whoami.requires_phone_verification) { + let is_verified; + do { + is_verified = await UIWindowPhoneVerificationRequired({ + show_close_button: false, + stay_on_top: true, + has_head: false, + // Already out of SMS attempts (the offer outlives the + // send window, and no send can report it any more). + card_fallback_available: + whoami.card_fallback_available, + window_options: { + is_draggable: false, + }, + }); + } while (!is_verified); + card_gate_cleared_by_fallback = is_verified === 'card'; + } + }; + const cardGate = async () => { + if ( + whoami.requires_card_verification && + !card_gate_cleared_by_fallback + ) { + let is_verified; + do { + is_verified = await UIWindowCardVerificationRequired({ + show_close_button: false, + stay_on_top: true, + has_head: false, + window_options: { + is_draggable: false, + }, + }); + } while (!is_verified); + } + }; + if (whoami.email_gate_last) { + await cardGate(); + await phoneGate(); + await emailGate(); + } else { + await emailGate(); + await phoneGate(); + await cardGate(); } // Last, matching assertVerifiedAccount's order. if (whoami.requires_password_change) { @@ -2319,67 +2333,81 @@ window.initgui = async function (options) { // verification gates below: the landing's max z-index would cover // them. reveal_anonymous_markup(false); - // Verification gates run in order: email → phone (SMS) → card, - // matching the server-side order in assertVerifiedAccount. - if (whoami.requires_email_confirmation) { - let is_verified; - do { - is_verified = await UIWindowEmailConfirmationRequired({ - show_close_button: false, - stay_on_top: true, - has_head: false, - logout_in_footer: true, - window_options: { - is_draggable: false, - cover_page: window.is_embedded, - }, - }); - } while (!is_verified); - } - // is phone verification required? (hard gate for low-rep signups) - // A user the SMS path keeps failing may be offered the card - // fallback instead; that resolves 'card' and clears the card gate - // too (see UIWindowPhoneVerificationRequired). + // Verification gates run in the server's order (see + // assertVerifiedAccount): email → phone (SMS) → card, or card → + // phone → email when the account clears email last. let card_gate_cleared_by_fallback = false; - if (whoami.requires_phone_verification) { - let is_verified; - do { - is_verified = await UIWindowPhoneVerificationRequired({ - show_close_button: false, - stay_on_top: true, - has_head: false, - logout_in_footer: true, - // Already out of SMS attempts (the offer outlives the - // send window, and no send can report it any more). - card_fallback_available: - whoami.card_fallback_available, - window_options: { - is_draggable: false, - cover_page: window.is_embedded, - }, - }); - } while (!is_verified); - card_gate_cleared_by_fallback = is_verified === 'card'; - } - // Card verification is the last gate: only show it once the email and - // phone (SMS) gates are cleared, since those show up first. - if ( - whoami.requires_card_verification && - !card_gate_cleared_by_fallback - ) { - let is_verified; - do { - is_verified = await UIWindowCardVerificationRequired({ - show_close_button: false, - stay_on_top: true, - has_head: false, - logout_in_footer: true, - window_options: { - is_draggable: false, - cover_page: window.is_embedded, - }, - }); - } while (!is_verified); + const emailGate = async () => { + if (whoami.requires_email_confirmation) { + let is_verified; + do { + is_verified = await UIWindowEmailConfirmationRequired({ + show_close_button: false, + stay_on_top: true, + has_head: false, + logout_in_footer: true, + window_options: { + is_draggable: false, + cover_page: window.is_embedded, + }, + }); + } while (!is_verified); + } + }; + const phoneGate = async () => { + // is phone verification required? (hard gate for low-rep signups) + // A user the SMS path keeps failing may be offered the card + // fallback instead; that resolves 'card' and clears the card gate + // too (see UIWindowPhoneVerificationRequired). + if (whoami.requires_phone_verification) { + let is_verified; + do { + is_verified = await UIWindowPhoneVerificationRequired({ + show_close_button: false, + stay_on_top: true, + has_head: false, + logout_in_footer: true, + // Already out of SMS attempts (the offer outlives the + // send window, and no send can report it any more). + card_fallback_available: + whoami.card_fallback_available, + window_options: { + is_draggable: false, + cover_page: window.is_embedded, + }, + }); + } while (!is_verified); + card_gate_cleared_by_fallback = is_verified === 'card'; + } + }; + const cardGate = async () => { + if ( + whoami.requires_card_verification && + !card_gate_cleared_by_fallback + ) { + let is_verified; + do { + is_verified = await UIWindowCardVerificationRequired({ + show_close_button: false, + stay_on_top: true, + has_head: false, + logout_in_footer: true, + window_options: { + is_draggable: false, + cover_page: window.is_embedded, + }, + }); + } while (!is_verified); + } + }; + if (whoami.email_gate_last) { + await cardGate(); + await phoneGate(); + await emailGate(); + } else { + await emailGate(); + await phoneGate(); + await cardGate(); } // Last, matching assertVerifiedAccount's order. if (whoami.requires_password_change) {