mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-07 14:33:57 +00:00
fix: signup gate order
This commit is contained in:
1 parent
abb70c14a6
commit
8a50c5ebbc
11 files changed
+442
-151
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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<void> {
|
||||
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
|
||||
|
||||
@@ -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<string, unknown>) =>
|
||||
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), {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -76,6 +76,7 @@ export type AccountGateUser = Partial<
|
||||
| 'phone'
|
||||
| 'card_fingerprint'
|
||||
| 'otp_enabled'
|
||||
| 'metadata'
|
||||
>
|
||||
> & { id?: number };
|
||||
|
||||
|
||||
@@ -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<string, boolean>) => {
|
||||
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<string, unknown>, 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()}`;
|
||||
|
||||
@@ -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<UserRow> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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.
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
type CardFallbackClients,
|
||||
} from './cardFallback.js';
|
||||
import { getTaskbarItems } from './taskbarItems.js';
|
||||
import { isEmailGateLast } from './verificationOrder.js';
|
||||
|
||||
type TaskbarDeps = Parameters<typeof getTaskbarItems>[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
|
||||
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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<string, unknown> | null } | null | undefined,
|
||||
): boolean => user?.metadata?.emailGateLast === true;
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+142
-114
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user