feat(auth): add username sign-in mode and recovery kits (#3215)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-05 14:10:07 +02:00
1 parent bf3d73a5f7
commit e1eecc3b6c
355 files changed
+52138 -24097

No files matched your search

@@ -104,6 +104,13 @@ export function isRegistrationPendingApprovalResponse(
export type ResetPasswordResponse = AuthTokenResponse | MfaLoginResponse;
interface RecoveryKitIssued {
recovery_key: string;
recovery_kit_created_at: string;
}
export type RecoverAccountResponse = (AuthTokenResponse | MfaLoginResponse) & RecoveryKitIssued;
interface DesktopHandoffInitiateResponse {
code: string;
expires_at: string;
@@ -132,22 +139,27 @@ export interface DesktopHandoffInfoResponse {
client_info?: DesktopHandoffInfoClientInfo | null;
}
interface LoginParams {
email: string;
export type LoginIdentifier = {email: string; login?: undefined} | {login: string; email?: undefined};
type LoginParams = LoginIdentifier & {
password: string;
inviteCode?: string;
}
};
function withInviteCode<T extends object>(body: T, inviteCode?: string): T & {invite_code?: string} {
return inviteCode ? {...body, invite_code: inviteCode} : body;
}
function loginBody({email, password, inviteCode}: Pick<LoginParams, 'email' | 'password' | 'inviteCode'>): {
email: string;
function loginBody(params: LoginParams): {
email?: string;
login?: string;
password: string;
invite_code?: string;
} {
return withInviteCode({email, password}, inviteCode);
if (params.login !== undefined) {
return withInviteCode({login: params.login, password: params.password}, params.inviteCode);
}
return withInviteCode({email: params.email, password: params.password}, params.inviteCode);
}
function mfaTotpBody(
@@ -218,14 +230,10 @@ function verificationResultFromError(
return responseErr.status === invalidStatus ? invalidResult : VerificationResult.SERVER_ERROR;
}
export async function login({
email,
password,
inviteCode,
}: LoginParams): Promise<LoginResponse | IpAuthorizationRequiredResponse> {
export async function login(params: LoginParams): Promise<LoginResponse | IpAuthorizationRequiredResponse> {
try {
const response = await http.post<LoginResponse>(Endpoints.AUTH_LOGIN, {
body: loginBody({email, password, inviteCode}),
body: loginBody(params),
headers: withAuthLocaleHeader(),
});
logger.debug('Login successful', {mfa: response.body?.mfa});
@@ -234,7 +242,7 @@ export async function login({
if (error instanceof HttpError) {
const ipAuthorization = loginIpAuthorizationResponse(error);
if (ipAuthorization) {
logger.info('Login requires IP authorization', {email});
logger.info('Login requires IP authorization', {email: params.email});
return ipAuthorization;
}
}
@@ -360,6 +368,19 @@ export async function getUsernameSuggestions(globalName: string): Promise<Array<
}
}
interface UsernameAvailabilityResponse {
available: boolean;
}
export async function checkUsernameAvailability(username: string, signal?: AbortSignal): Promise<boolean> {
const response = await http.get<UsernameAvailabilityResponse>(Endpoints.AUTH_USERNAME_AVAILABILITY, {
query: {username},
headers: withAuthLocaleHeader(),
signal,
});
return response.body.available;
}
export async function forgotPassword(email: string): Promise<void> {
try {
await http.post(Endpoints.AUTH_FORGOT_PASSWORD, {
@@ -401,6 +422,28 @@ export async function resetPassword(token: string, password: string): Promise<Re
}
}
export async function recoverAccount({
login,
recoveryKey,
password,
}: {
login: string;
recoveryKey: string;
password: string;
}): Promise<RecoverAccountResponse> {
try {
const response = await http.post<RecoverAccountResponse>(Endpoints.AUTH_RECOVER, {
body: {login, recovery_key: recoveryKey, password},
headers: withAuthLocaleHeader(),
});
logger.info('Account recovery successful');
return response.body;
} catch (error) {
logger.error('Account recovery failed', error);
throw error;
}
}
export async function revertEmailChange(token: string, password: string): Promise<TokenResponse> {
try {
const response = await http.post<TokenResponse>(Endpoints.AUTH_EMAIL_REVERT, {
@@ -7,6 +7,7 @@ import {MockAvatar} from '@app/features/ui/components/MockAvatar';
import * as AvatarUtils from '@app/features/user/utils/AvatarUtils';
import {getCurrentLocale} from '@app/features/user/utils/LocaleUtils';
import * as NicknameUtils from '@app/features/user/utils/NicknameUtils';
import {formatUserTag} from '@app/features/user/utils/UserTagUtils';
import {formatLastActive} from '@fluxer/date_utils/src/DateFormatting';
import {msg} from '@lingui/core/macro';
import {Trans, useLingui} from '@lingui/react/macro';
@@ -43,6 +44,9 @@ export const getAccountAvatarUrl = (account: Account): string | undefined => {
export const getAccountDisplayName = (account: Account, fallback: string): string => {
return account.userData ? NicknameUtils.getDisplayName(account.userData) : fallback;
};
export const getAccountTag = (account: Account): string | null => {
return account.userData ? formatUserTag(account.userData) : null;
};
export const AccountListItem = ({
account,
disabled = false,
@@ -57,17 +61,21 @@ export const AccountListItem = ({
const avatarUrl = getAccountAvatarUrl(account);
const avatarSize = variant === 'compact' ? 32 : 40;
const emailUnavailableLabel = i18n._(EMAIL_UNAVAILABLE_DESCRIPTOR);
const accountSubtitle =
RuntimeConfig.usesUsernameSignIn && account.userData
? formatUserTag(account.userData)
: (account.userData?.email ?? emailUnavailableLabel);
const defaultMeta =
variant === 'compact' ? (
isCurrent ? (
(account.userData?.email ?? emailUnavailableLabel)
accountSubtitle
) : (
<Trans comment="Account switcher metadata showing when a saved account was last used.">
Last active {{lastActive: formatLastActive(account.lastActive, getCurrentLocale())}}
</Trans>
)
) : (
(account.userData?.email ?? emailUnavailableLabel)
accountSubtitle
);
return (
<button
@@ -1,7 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {useContextMenuHoverState} from '@app/features/app/hooks/useContextMenuHoverState';
import {getAccountAvatarUrl, getAccountDisplayName} from '@app/features/auth/components/accounts/AccountListItem';
import {
getAccountAvatarUrl,
getAccountDisplayName,
getAccountTag,
} from '@app/features/auth/components/accounts/AccountListItem';
import styles from '@app/features/auth/components/accounts/AccountRow.module.css';
import type {Account} from '@app/features/platform/state/AuthSession';
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
@@ -53,10 +57,8 @@ export const AccountRow = observer(
const {i18n} = useLingui();
const avatarUrl = getAccountAvatarUrl(account);
const displayName = getAccountDisplayName(account, '???');
const userData = account.userData;
const accountTag = userData
? NicknameUtils.formatTagForStreamerMode(`${userData.username}#${userData.discriminator}`)
: displayName;
const rawAccountTag = getAccountTag(account);
const accountTag = rawAccountTag ? NicknameUtils.formatTagForStreamerMode(rawAccountTag) : displayName;
const menuButtonRef = useRef<HTMLButtonElement | null>(null);
const isContextMenuOpen = useContextMenuHoverState(menuButtonRef, Boolean(onMenuClick));
const handleMenuClick = useCallback(
@@ -76,7 +76,7 @@ export const BackupCodesModal = observer(({backupCodes, user, challenge}: Backup
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `fluxer_${user.email}_backup_codes.txt`;
a.download = `fluxer_${user.email ?? user.username}_backup_codes.txt`;
a.click();
URL.revokeObjectURL(url);
}}
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import * as Modal from '@app/features/app/components/dialogs/Modal';
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import * as MfaCommands from '@app/features/auth/commands/MfaCommands';
import {BackupCodesModal} from '@app/features/auth/components/modals/BackupCodesModal';
import {VERIFICATION_CODE_DESCRIPTOR, VERIFY_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
@@ -32,6 +33,10 @@ const UNABLE_TO_RESEND_CODE_RIGHT_NOW_DESCRIPTOR = msg({
message: 'Unable to resend code right now',
comment: 'Error message in the authentication backup codes view modal. Keep the tone plain and specific.',
});
const UNABLE_TO_LOAD_BACKUP_CODES_DESCRIPTOR = msg({
message: 'Unable to load backup codes',
comment: 'Error title shown when backup codes could not be loaded after confirming identity.',
});
const CHALLENGE_EXPIRED_DESCRIPTOR = msg({
message: 'Verification expired',
comment: 'Error title shown when the backup codes email verification expired and must be started again.',
@@ -79,6 +84,28 @@ export const BackupCodesViewModal = observer(({user}: BackupCodesViewModalProps)
setResendAt(null);
setStage('intro');
}, []);
const usesSudo = RuntimeConfig.usesUsernameSignIn;
const showWithSudo = useCallback(async () => {
setSubmitting(true);
try {
const backupCodes = await MfaCommands.getBackupCodes(false);
ModalCommands.popByType(BackupCodesViewModal);
ModalCommands.pushWithKey(
modal(() => (
<BackupCodesModal
backupCodes={backupCodes}
user={user}
data-flx="auth.backup-codes-view-modal.show-with-sudo.backup-codes-modal"
/>
)),
'backup-codes',
);
} catch (error: unknown) {
FormUtils.pushApiErrorModal(i18n, error, i18n._(UNABLE_TO_LOAD_BACKUP_CODES_DESCRIPTOR));
} finally {
setSubmitting(false);
}
}, [user, i18n]);
const startChallenge = useCallback(async () => {
setSubmitting(true);
try {
@@ -143,7 +170,11 @@ export const BackupCodesViewModal = observer(({user}: BackupCodesViewModalProps)
}, [ticket, canResend, i18n, resetToIntro]);
const renderIntroStage = () => (
<Modal.Description data-flx="auth.backup-codes-view-modal.modal-description">
<Trans>We'll send a verification code to your email before you can view your backup codes.</Trans>
{usesSudo ? (
<Trans>Confirm it's you before you view your backup codes.</Trans>
) : (
<Trans>We'll send a verification code to your email before you can view your backup codes.</Trans>
)}
</Modal.Description>
);
const renderVerifyStage = () => (
@@ -174,7 +205,7 @@ export const BackupCodesViewModal = observer(({user}: BackupCodesViewModalProps)
<Trans>Cancel</Trans>
</Button>
<Button
onClick={startChallenge}
onClick={usesSudo ? showWithSudo : startChallenge}
submitting={submitting}
data-flx="auth.backup-codes-view-modal.button.start-challenge"
>
@@ -3,10 +3,12 @@
import * as Modal from '@app/features/app/components/dialogs/Modal';
import {EXAMPLE_PERSONAL_EMAIL} from '@app/features/app/config/I18nDisplayConstants';
import {useFormSubmit} from '@app/features/app/hooks/useFormSubmit';
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import {
CLAIM_ACCOUNT_DESCRIPTOR,
EMAIL_DESCRIPTOR,
PASSWORD_DESCRIPTOR,
USERNAME_DESCRIPTOR,
VERIFICATION_CODE_DESCRIPTOR,
} from '@app/features/i18n/utils/CommonMessageDescriptors';
import type {HttpError} from '@app/features/platform/types/EndpointError';
@@ -18,7 +20,9 @@ import {Form} from '@app/features/ui/components/form/Form';
import {Input} from '@app/features/ui/components/form/FormInput';
import ModalState from '@app/features/ui/state/Modal';
import {SteppedCarousel} from '@app/features/ui/stepped_carousel/SteppedCarousel';
import {createRecoveryKitWithPasswordAndOpen} from '@app/features/user/commands/RecoveryKitCommands';
import * as UserCommands from '@app/features/user/commands/UserCommands';
import Users from '@app/features/user/state/Users';
import * as FormUtils from '@app/lib/forms';
import {msg} from '@lingui/core/macro';
import {Trans, useLingui} from '@lingui/react/macro';
@@ -42,6 +46,14 @@ const CLAIM_YOUR_ACCOUNT_DESCRIPTOR = msg({
message: 'Claim your account',
comment: 'Short label in the authentication claim account modal. Keep the tone plain and specific.',
});
const CONFIRM_PASSWORD_DESCRIPTOR = msg({
message: 'Confirm password',
comment: 'Label for the password confirmation field in the claim account modal.',
});
const PASSWORDS_DO_NOT_MATCH_DESCRIPTOR = msg({
message: 'Passwords do not match',
comment: 'Claim account modal error when the password and its confirmation differ.',
});
interface FormInputs {
email: string;
@@ -52,7 +64,118 @@ interface FormInputs {
type Stage = 'collect' | 'verify';
const STAGE_ORDER: ReadonlyArray<Stage> = ['collect', 'verify'];
export const ClaimAccountModal = observer(() => {
const CLAIM_ACCOUNT_MODAL_KEY = 'claim-account-modal';
interface UsernameClaimInputs {
username: string;
newPassword: string;
confirmPassword: string;
}
const USERNAME_CLAIM_PATH_MAP = {new_password: 'newPassword'} as const;
const UsernameClaimAccountModal = observer(() => {
const {i18n} = useLingui();
const form = useForm<UsernameClaimInputs>({
defaultValues: {username: Users.currentUser?.username ?? '', newPassword: '', confirmPassword: ''},
});
const handleClaim = async (data: UsernameClaimInputs) => {
if (data.newPassword !== data.confirmPassword) {
form.setError('confirmPassword', {type: 'validate', message: i18n._(PASSWORDS_DO_NOT_MATCH_DESCRIPTOR)});
return;
}
const updated = await UserCommands.update({username: data.username.trim(), new_password: data.newPassword});
ModalCommands.popWithKey(CLAIM_ACCOUNT_MODAL_KEY);
ToastCommands.createToast({type: 'success', children: i18n._(ACCOUNT_CLAIMED_SUCCESSFULLY_DESCRIPTOR)});
void createRecoveryKitWithPasswordAndOpen({
userId: updated.id,
password: data.newPassword,
username: updated.username,
discriminator: updated.discriminator,
});
};
const {handleSubmit, isSubmitting} = useFormSubmit({
form,
onSubmit: handleClaim,
defaultErrorField: 'username',
pathMap: USERNAME_CLAIM_PATH_MAP,
});
return (
<Modal.Root size="small" centered data-flx="auth.claim-account-modal.username.modal-root">
<Modal.Header
title={i18n._(CLAIM_YOUR_ACCOUNT_DESCRIPTOR)}
data-flx="auth.claim-account-modal.username.modal-header"
/>
<Form form={form} onSubmit={handleSubmit} data-flx="auth.claim-account-modal.username.form.submit">
<Modal.Content data-flx="auth.claim-account-modal.username.modal-content">
<Modal.ContentLayout data-flx="auth.claim-account-modal.username.modal-content-layout">
<Modal.Description data-flx="auth.claim-account-modal.username.modal-description">
<Trans>
Claim your account by choosing a username and password. You sign in with them, so pick ones you will
remember.
</Trans>
</Modal.Description>
<Modal.InputGroup data-flx="auth.claim-account-modal.username.modal-input-group">
<Input
data-flx="auth.claim-account-modal.username.input.username"
{...form.register('username')}
autoCapitalize="none"
autoComplete="username"
autoCorrect="off"
autoFocus={true}
error={form.formState.errors.username?.message}
label={i18n._(USERNAME_DESCRIPTOR)}
maxLength={32}
minLength={1}
required={true}
spellCheck={false}
/>
<Input
data-flx="auth.claim-account-modal.username.input.password"
{...form.register('newPassword')}
autoComplete="new-password"
error={form.formState.errors.newPassword?.message}
label={i18n._(PASSWORD_DESCRIPTOR)}
maxLength={128}
minLength={8}
placeholder={'•'.repeat(32)}
required={true}
type="password"
/>
<Input
data-flx="auth.claim-account-modal.username.input.confirm-password"
{...form.register('confirmPassword')}
autoComplete="new-password"
error={form.formState.errors.confirmPassword?.message}
label={i18n._(CONFIRM_PASSWORD_DESCRIPTOR)}
maxLength={128}
minLength={8}
placeholder={'•'.repeat(32)}
required={true}
type="password"
/>
</Modal.InputGroup>
</Modal.ContentLayout>
</Modal.Content>
<Modal.Footer data-flx="auth.claim-account-modal.username.footer">
<Button
onClick={ModalCommands.pop}
variant="secondary"
type="button"
data-flx="auth.claim-account-modal.username.button.pop"
>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" submitting={isSubmitting} data-flx="auth.claim-account-modal.username.button.submit">
{i18n._(CLAIM_ACCOUNT_DESCRIPTOR)}
</Button>
</Modal.Footer>
</Form>
</Modal.Root>
);
});
const EmailClaimAccountModal = observer(() => {
const {i18n} = useLingui();
const form = useForm<FormInputs>({
defaultValues: {email: '', newPassword: '', verificationCode: ''},
@@ -265,7 +388,13 @@ export const ClaimAccountModal = observer(() => {
</Modal.Root>
);
});
const CLAIM_ACCOUNT_MODAL_KEY = 'claim-account-modal';
export const ClaimAccountModal = observer(() =>
RuntimeConfig.usesUsernameSignIn ? (
<UsernameClaimAccountModal data-flx="auth.claim-account-modal.username-claim-account-modal" />
) : (
<EmailClaimAccountModal data-flx="auth.claim-account-modal.email-claim-account-modal" />
),
);
let hasShownClaimAccountModalThisSession = false;
@@ -114,7 +114,7 @@ export const MfaTotpEnableModal = observer(({user}: MfaTotpEnableModalProps) =>
{!isMobileExperience && (
<div className={styles.qrCode} data-flx="auth.mfa-totp-enable-modal.qr-code">
<QRCodeCanvas
data={MfaUtils.encodeTotpSecretAsURL(user.email!, secret)}
data={MfaUtils.encodeTotpSecretAsURL(user.email ?? user.tag, secret)}
data-flx="auth.mfa-totp-enable-modal.qr-code-canvas"
/>
</div>
@@ -0,0 +1,159 @@
/* SPDX-License-Identifier: AGPL-3.0-or-later */
.keyGrid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.5rem 0.75rem;
border-radius: 0.5rem;
border: 0.0625rem solid var(--background-modifier-accent);
background: var(--background-secondary);
padding: 1rem;
user-select: text;
-webkit-user-select: text;
}
.keyGroup {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 1.25rem;
font-weight: 600;
letter-spacing: 0.08em;
line-height: 1.75rem;
text-align: center;
color: var(--text-primary);
}
.warning {
font-size: 0.875rem;
line-height: 1.25rem;
color: var(--text-tertiary);
}
.buttonRow {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
@media (min-width: 640px) {
.buttonRow {
flex-direction: row;
flex-wrap: wrap;
}
}
.buttonIcon {
height: 1.25rem;
width: 1.25rem;
}
.options {
display: flex;
flex-direction: column;
gap: 0.75rem;
border-top: 0.0625rem solid var(--background-modifier-accent);
padding-top: 1rem;
}
.optionLabel {
font-size: 0.875rem;
line-height: 1.25rem;
color: var(--text-primary);
}
.printSheet {
display: none;
}
@media print {
.printing > *:not(.printSheet) {
display: none !important;
}
.printing .printSheet {
display: block;
background: #ffffff;
color: #111827;
font-family: var(--font-sans, sans-serif);
font-size: 11pt;
line-height: 1.4;
}
}
.printTitle {
margin: 0 0 0.5rem;
font-size: 20pt;
font-weight: 700;
}
.printIntro {
margin: 0 0 1.25rem;
color: #4b5563;
}
.printTop {
display: flex;
justify-content: space-between;
gap: 1.5rem;
}
.printDetails {
margin: 0;
}
.printDetailLabel {
font-size: 9pt;
font-weight: 700;
color: #4b5563;
}
.printDetailValue {
margin: 0 0 0.6rem;
font-size: 12pt;
word-break: break-all;
}
.printQr {
display: flex;
width: 40mm;
flex-shrink: 0;
flex-direction: column;
gap: 0.25rem;
font-size: 8pt;
color: #4b5563;
}
.printQrImage {
width: 40mm;
height: 40mm;
}
.printSectionTitle {
margin: 1.25rem 0 0.4rem;
font-size: 13pt;
font-weight: 700;
}
.printKey {
border: 1pt solid #d1d5db;
border-radius: 4pt;
background: #f3f4f6;
padding: 0.75rem;
text-align: center;
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 16pt;
font-weight: 700;
letter-spacing: 0.05em;
}
.printSteps {
margin: 0;
padding-left: 1.25rem;
}
.printCodes {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.3rem 1.5rem;
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 12pt;
}
@@ -0,0 +1,464 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Routes} from '@app/app/Routes';
import * as Modal from '@app/features/app/components/dialogs/Modal';
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import * as MfaCommands from '@app/features/auth/commands/MfaCommands';
import styles from '@app/features/auth/components/modals/RecoveryKitModal.module.css';
import {isAbortError} from '@app/features/auth/state/SudoPrompt';
import {
buildRecoveryKitPdf,
createRecoveryKitQrDataUrl,
type RecoveryKitSheet,
} from '@app/features/auth/utils/RecoveryKitSheet';
import {USERNAME_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import {TimestampStyle} from '@app/features/messaging/utils/markdown/parser/Enums';
import {Logger} from '@app/features/platform/utils/AppLogger';
import {downloadBlob} from '@app/features/platform/utils/DownloadFile';
import {Button} from '@app/features/ui/button/Button';
import {Checkbox} from '@app/features/ui/checkbox/Checkbox';
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
import {modal} from '@app/features/ui/commands/ModalCommands';
import * as TextCopyCommands from '@app/features/ui/commands/TextCopyCommands';
import * as ToastCommands from '@app/features/ui/commands/ToastCommands';
import UserSettings from '@app/features/user/state/UserSettings';
import Users from '@app/features/user/state/Users';
import {shouldUse12HourFormat} from '@app/features/user/utils/DateFormatting';
import {getCurrentLocale} from '@app/features/user/utils/LocaleUtils';
import {formatUserTag} from '@app/features/user/utils/UserTagUtils';
import {RECOVERY_KEY_SEPARATOR} from '@fluxer/constants/src/RecoveryKeyUtils';
import {formatTimestampWithStyle} from '@fluxer/date_utils/src/DateTimestampStyle';
import {msg} from '@lingui/core/macro';
import {Trans, useLingui} from '@lingui/react/macro';
import {ClipboardIcon, DownloadIcon, PrinterIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import {useCallback, useEffect, useMemo, useState} from 'react';
import {createPortal} from 'react-dom';
const logger = new Logger('RecoveryKitModal');
const RECOVERY_KIT_MODAL_KEY = 'recovery-kit-modal';
const YOUR_RECOVERY_KIT_DESCRIPTOR = msg({
message: 'Your recovery kit',
comment: 'Title of the modal that shows a newly created account recovery kit.',
});
const RECOVERY_KIT_CREATED_DESCRIPTOR = msg({
message:
'If you forget your password, this kit is the only way back into your account. Store it somewhere safe, like a password manager or a printed copy.',
comment: 'Recovery kit modal description shown after a new recovery kit is created.',
});
const RECOVERY_KIT_REPLACED_DESCRIPTOR = msg({
message: 'Your old recovery kit no longer works. Store this new one somewhere safe.',
comment: 'Recovery kit modal description shown after a recovery kit replaces an older one.',
});
const RECOVERY_KIT_RECOVERED_DESCRIPTOR = msg({
message: 'Your password is reset. Your old recovery kit no longer works. Store this new one somewhere safe.',
comment: 'Recovery kit modal description shown after someone recovers their account with a recovery key.',
});
const RECOVERY_KIT_WARNING_DESCRIPTOR = msg({
message: 'Anyone with this key can reset your password. Never share it.',
comment: 'Warning under the recovery key in the recovery kit modal.',
});
const RECOVERY_KEY_DESCRIPTOR = msg({
message: 'Recovery key',
comment: 'Label for the account recovery key in the recovery kit modal and the printed kit.',
});
const RECOVERY_KIT_DOCUMENT_TITLE_DESCRIPTOR = msg({
message: '{productName} recovery kit',
comment: 'Title of the printed or downloaded recovery kit. productName is the app name.',
});
const RECOVERY_KIT_DOCUMENT_INTRO_DESCRIPTOR = msg({
message:
'Keep this page somewhere safe. If you forget your password, you can use it to get back into your account. Anyone with this recovery key can reset your password, so never share it.',
comment: 'Introduction at the top of the printed or downloaded recovery kit.',
});
const RECOVERY_KIT_INSTANCE_DESCRIPTOR = msg({
message: 'Instance',
comment: 'Label for the server address in the printed or downloaded recovery kit.',
});
const RECOVERY_KIT_CREATED_AT_DESCRIPTOR = msg({
message: 'Created',
comment: 'Label for the creation date in the printed or downloaded recovery kit.',
});
const RECOVERY_KIT_QR_CAPTION_DESCRIPTOR = msg({
message: 'Scan to open the recovery page with your details filled in.',
comment: 'Caption under the QR code in the printed or downloaded recovery kit.',
});
const RECOVERY_KIT_STEPS_TITLE_DESCRIPTOR = msg({
message: 'How to recover your account',
comment: 'Heading above the recovery steps in the printed or downloaded recovery kit.',
});
const RECOVERY_KIT_STEP_OPEN_DESCRIPTOR = msg({
message: 'Go to {recoverUrl} or scan the QR code.',
comment: 'First recovery step in the printed recovery kit. recoverUrl is the address of the recovery page.',
});
const RECOVERY_KIT_STEP_ENTER_DESCRIPTOR = msg({
message: 'Enter your username and this recovery key.',
comment: 'Second recovery step in the printed recovery kit.',
});
const RECOVERY_KIT_STEP_PASSWORD_DESCRIPTOR = msg({
message: 'Choose a new password. You get a new recovery kit and this one stops working.',
comment: 'Third recovery step in the printed recovery kit.',
});
const RECOVERY_KIT_BACKUP_CODES_TITLE_DESCRIPTOR = msg({
message: 'Two-factor backup codes',
comment: 'Heading above the two-factor backup codes in the printed or downloaded recovery kit.',
});
const RECOVERY_KIT_BACKUP_CODES_NOTE_DESCRIPTOR = msg({
message: 'Each code works once in place of your authenticator app.',
comment: 'Note under the two-factor backup codes heading in the printed or downloaded recovery kit.',
});
const RECOVERY_KIT_PDF_FAILED_DESCRIPTOR = msg({
message: "Couldn't create the PDF. Try again or print the kit instead.",
comment: 'Toast shown when building the recovery kit PDF fails.',
});
const RECOVERY_KIT_BACKUP_CODES_FAILED_DESCRIPTOR = msg({
message: "Couldn't load your backup codes.",
comment: 'Toast shown when the two-factor backup codes for the recovery kit cannot be loaded.',
});
export type RecoveryKitModalReason = 'created' | 'replaced' | 'recovered';
export interface RecoveryKitModalProps {
recoveryKey: string;
createdAt: string;
username?: string;
discriminator?: string;
reason?: RecoveryKitModalReason;
onDone?: () => void;
}
function fileSlug(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
const RecoveryKitPrintView = ({sheet, qrDataUrl}: {sheet: RecoveryKitSheet; qrDataUrl: string | null}) =>
createPortal(
<div className={styles.printSheet} data-flx="auth.recovery-kit-modal.print-sheet">
<h1 className={styles.printTitle} data-flx="auth.recovery-kit-modal.print-view.print-title">
{sheet.title}
</h1>
<p className={styles.printIntro} data-flx="auth.recovery-kit-modal.print-view.print-intro">
{sheet.intro}
</p>
<div className={styles.printTop} data-flx="auth.recovery-kit-modal.print-view.print-top">
<dl className={styles.printDetails} data-flx="auth.recovery-kit-modal.print-view.print-details">
{sheet.details.map((detail) => (
<div key={detail.label} data-flx="auth.recovery-kit-modal.print-view.div">
<dt className={styles.printDetailLabel} data-flx="auth.recovery-kit-modal.print-view.print-detail-label">
{detail.label}
</dt>
<dd className={styles.printDetailValue} data-flx="auth.recovery-kit-modal.print-view.print-detail-value">
{detail.value}
</dd>
</div>
))}
</dl>
{qrDataUrl ? (
<div className={styles.printQr} data-flx="auth.recovery-kit-modal.print-view.print-qr">
<img
className={styles.printQrImage}
src={qrDataUrl}
alt=""
data-flx="auth.recovery-kit-modal.print-view.print-qr-image"
/>
<span data-flx="auth.recovery-kit-modal.print-view.span">{sheet.qrCaption}</span>
</div>
) : null}
</div>
<h2 className={styles.printSectionTitle} data-flx="auth.recovery-kit-modal.print-view.print-section-title">
{sheet.keyLabel}
</h2>
<div className={styles.printKey} data-flx="auth.recovery-kit-modal.print-view.print-key">
{sheet.recoveryKey}
</div>
<h2 className={styles.printSectionTitle} data-flx="auth.recovery-kit-modal.print-view.print-section-title--2">
{sheet.stepsTitle}
</h2>
<ol className={styles.printSteps} data-flx="auth.recovery-kit-modal.print-view.print-steps">
{sheet.steps.map((step) => (
<li key={step} data-flx="auth.recovery-kit-modal.print-view.li">
{step}
</li>
))}
</ol>
{sheet.backupCodes && sheet.backupCodes.codes.length > 0 ? (
<>
<h2 className={styles.printSectionTitle} data-flx="auth.recovery-kit-modal.print-view.print-section-title--3">
{sheet.backupCodes.title}
</h2>
<p className={styles.printIntro} data-flx="auth.recovery-kit-modal.print-view.print-intro--2">
{sheet.backupCodes.note}
</p>
<div className={styles.printCodes} data-flx="auth.recovery-kit-modal.print-view.print-codes">
{sheet.backupCodes.codes.map((code) => (
<span key={code} data-flx="auth.recovery-kit-modal.print-view.span--2">
{code}
</span>
))}
</div>
</>
) : null}
</div>,
document.body,
);
export const RecoveryKitModal = observer(
({recoveryKey, createdAt, username, discriminator, reason = 'created', onDone}: RecoveryKitModalProps) => {
const {i18n} = useLingui();
const currentUser = Users.currentUser;
const accountUsername = username ?? currentUser?.username ?? '';
const accountDiscriminator = username ? discriminator : (discriminator ?? currentUser?.discriminator);
const accountTag = accountDiscriminator
? formatUserTag({username: accountUsername, discriminator: accountDiscriminator})
: accountUsername;
const canIncludeBackupCodes = Boolean(currentUser?.mfaEnabled) && (!username || username === currentUser?.username);
const [acknowledged, setAcknowledged] = useState(false);
const [includeBackupCodes, setIncludeBackupCodes] = useState(false);
const [backupCodes, setBackupCodes] = useState<ReadonlyArray<string> | null>(null);
const [loadingBackupCodes, setLoadingBackupCodes] = useState(false);
const [buildingPdf, setBuildingPdf] = useState(false);
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [qrFailed, setQrFailed] = useState(false);
const [printing, setPrinting] = useState(false);
const keyGroups = useMemo(() => recoveryKey.split(RECOVERY_KEY_SEPARATOR), [recoveryKey]);
const recoverPageUrl = `${RuntimeConfig.webAppBaseUrl}${Routes.RECOVER_ACCOUNT}`;
const recoverUrl = `${recoverPageUrl}#username=${encodeURIComponent(accountTag)}&key=${encodeURIComponent(recoveryKey)}`;
const locale = getCurrentLocale();
const timeFormat = UserSettings.getTimeFormat();
const sheet = useMemo<RecoveryKitSheet>(() => {
const productName = RuntimeConfig.productName;
const createdDate = new Date(createdAt);
const createdLabel = Number.isNaN(createdDate.getTime())
? createdAt
: formatTimestampWithStyle(
Math.floor(createdDate.getTime() / 1000),
TimestampStyle.ShortDateTime,
locale,
shouldUse12HourFormat(locale),
);
return {
productName,
title: i18n._(RECOVERY_KIT_DOCUMENT_TITLE_DESCRIPTOR, {productName}),
intro: i18n._(RECOVERY_KIT_DOCUMENT_INTRO_DESCRIPTOR),
details: [
{label: i18n._(RECOVERY_KIT_INSTANCE_DESCRIPTOR), value: RuntimeConfig.webAppBaseUrl},
{label: i18n._(USERNAME_DESCRIPTOR), value: accountTag},
{label: i18n._(RECOVERY_KIT_CREATED_AT_DESCRIPTOR), value: createdLabel},
],
keyLabel: i18n._(RECOVERY_KEY_DESCRIPTOR),
recoveryKey,
recoverUrl,
qrCaption: i18n._(RECOVERY_KIT_QR_CAPTION_DESCRIPTOR),
stepsTitle: i18n._(RECOVERY_KIT_STEPS_TITLE_DESCRIPTOR),
steps: [
i18n._(RECOVERY_KIT_STEP_OPEN_DESCRIPTOR, {recoverUrl: recoverPageUrl}),
i18n._(RECOVERY_KIT_STEP_ENTER_DESCRIPTOR),
i18n._(RECOVERY_KIT_STEP_PASSWORD_DESCRIPTOR),
],
backupCodes:
includeBackupCodes && backupCodes
? {
title: i18n._(RECOVERY_KIT_BACKUP_CODES_TITLE_DESCRIPTOR),
note: i18n._(RECOVERY_KIT_BACKUP_CODES_NOTE_DESCRIPTOR),
codes: backupCodes,
}
: null,
};
}, [
accountTag,
backupCodes,
createdAt,
i18n,
i18n.locale,
includeBackupCodes,
locale,
recoverPageUrl,
recoverUrl,
recoveryKey,
timeFormat,
]);
useEffect(() => {
let cancelled = false;
createRecoveryKitQrDataUrl(recoverUrl)
.then((dataUrl) => {
if (!cancelled) setQrDataUrl(dataUrl);
})
.catch((error) => {
logger.error('Failed to render recovery kit QR code', error);
if (!cancelled) setQrFailed(true);
});
return () => {
cancelled = true;
};
}, [recoverUrl]);
useEffect(() => {
if (!printing) return;
const printingClass = styles.printing;
document.body.classList.add(printingClass);
const finish = () => setPrinting(false);
window.addEventListener('afterprint', finish);
const frame = window.requestAnimationFrame(() => window.print());
return () => {
window.cancelAnimationFrame(frame);
window.removeEventListener('afterprint', finish);
document.body.classList.remove(printingClass);
};
}, [printing]);
const handleIncludeBackupCodes = useCallback(
async (checked: boolean) => {
setIncludeBackupCodes(checked);
if (!checked || backupCodes) return;
setLoadingBackupCodes(true);
try {
const codes = await MfaCommands.getBackupCodes(false);
setBackupCodes(codes.filter(({consumed}) => !consumed).map(({code}) => code));
} catch (error) {
setIncludeBackupCodes(false);
if (!isAbortError(error)) {
ToastCommands.error(i18n._(RECOVERY_KIT_BACKUP_CODES_FAILED_DESCRIPTOR));
}
} finally {
setLoadingBackupCodes(false);
}
},
[backupCodes, i18n],
);
const handleDownload = useCallback(async () => {
setBuildingPdf(true);
try {
const bytes = await buildRecoveryKitPdf(sheet);
const fileName = [fileSlug(sheet.productName), 'recovery-kit', fileSlug(accountUsername)]
.filter(Boolean)
.join('-');
downloadBlob(new Blob([bytes.slice().buffer], {type: 'application/pdf'}), `${fileName}.pdf`);
} catch (error) {
logger.error('Failed to build recovery kit PDF', error);
ToastCommands.error(i18n._(RECOVERY_KIT_PDF_FAILED_DESCRIPTOR));
} finally {
setBuildingPdf(false);
}
}, [accountUsername, i18n, sheet]);
const handleDone = useCallback(() => {
if (!acknowledged) return;
ModalCommands.popWithKey(RECOVERY_KIT_MODAL_KEY);
onDone?.();
}, [acknowledged, onDone]);
const description =
reason === 'recovered'
? i18n._(RECOVERY_KIT_RECOVERED_DESCRIPTOR)
: reason === 'replaced'
? i18n._(RECOVERY_KIT_REPLACED_DESCRIPTOR)
: i18n._(RECOVERY_KIT_CREATED_DESCRIPTOR);
const optionsBusy = loadingBackupCodes || buildingPdf;
return (
<Modal.Root size="small" centered onClose={handleDone} data-flx="auth.recovery-kit-modal.modal-root">
<Modal.Header
title={i18n._(YOUR_RECOVERY_KIT_DESCRIPTOR)}
hideCloseButton
data-flx="auth.recovery-kit-modal.modal-header"
/>
<Modal.Content data-flx="auth.recovery-kit-modal.modal-content">
<Modal.ContentLayout data-flx="auth.recovery-kit-modal.modal-content-layout">
<Modal.Description data-flx="auth.recovery-kit-modal.description">{description}</Modal.Description>
<div
className={styles.keyGrid}
role="group"
aria-label={i18n._(RECOVERY_KEY_DESCRIPTOR)}
data-flx="auth.recovery-kit-modal.key-grid"
>
{keyGroups.map((group, index) => (
<span
key={`${index}-${group}`}
className={styles.keyGroup}
data-flx="auth.recovery-kit-modal.key-group"
>
{group}
</span>
))}
</div>
<p className={styles.warning} data-flx="auth.recovery-kit-modal.warning">
{i18n._(RECOVERY_KIT_WARNING_DESCRIPTOR)}
</p>
<div className={styles.buttonRow} data-flx="auth.recovery-kit-modal.button-row">
<Button
small={true}
leftIcon={
<DownloadIcon className={styles.buttonIcon} data-flx="auth.recovery-kit-modal.download-icon" />
}
submitting={buildingPdf}
disabled={loadingBackupCodes}
onClick={handleDownload}
data-flx="auth.recovery-kit-modal.button.download"
>
<Trans comment="Button that downloads the account recovery kit as a PDF file.">Download PDF</Trans>
</Button>
<Button
variant="secondary"
small={true}
leftIcon={<PrinterIcon className={styles.buttonIcon} data-flx="auth.recovery-kit-modal.print-icon" />}
disabled={optionsBusy || (qrDataUrl === null && !qrFailed)}
onClick={() => setPrinting(true)}
data-flx="auth.recovery-kit-modal.button.print"
>
<Trans comment="Button that prints the account recovery kit.">Print</Trans>
</Button>
<Button
variant="secondary"
small={true}
leftIcon={<ClipboardIcon className={styles.buttonIcon} data-flx="auth.recovery-kit-modal.copy-icon" />}
onClick={() => TextCopyCommands.copy(i18n, recoveryKey)}
data-flx="auth.recovery-kit-modal.button.copy"
>
<Trans comment="Button that copies the account recovery key to the clipboard.">Copy</Trans>
</Button>
</div>
<div className={styles.options} data-flx="auth.recovery-kit-modal.options">
{canIncludeBackupCodes ? (
<Checkbox
checked={includeBackupCodes}
disabled={optionsBusy}
onChange={handleIncludeBackupCodes}
data-flx="auth.recovery-kit-modal.checkbox.include-backup-codes"
>
<span className={styles.optionLabel} data-flx="auth.recovery-kit-modal.include-backup-codes-label">
<Trans>Include my two-factor backup codes in the PDF and printout</Trans>
</span>
</Checkbox>
) : null}
<Checkbox
checked={acknowledged}
onChange={setAcknowledged}
data-flx="auth.recovery-kit-modal.checkbox.acknowledge"
>
<span className={styles.optionLabel} data-flx="auth.recovery-kit-modal.acknowledge-label">
<Trans>I've stored my recovery kit somewhere safe</Trans>
</span>
</Checkbox>
</div>
</Modal.ContentLayout>
</Modal.Content>
<Modal.Footer data-flx="auth.recovery-kit-modal.footer">
<Button disabled={!acknowledged} onClick={handleDone} data-flx="auth.recovery-kit-modal.button.done">
<Trans comment="Button that closes the recovery kit modal once the kit is stored.">Done</Trans>
</Button>
</Modal.Footer>
{printing ? (
<RecoveryKitPrintView sheet={sheet} qrDataUrl={qrDataUrl} data-flx="auth.recovery-kit-modal.print-view" />
) : null}
</Modal.Root>
);
},
);
export function openRecoveryKitModal(props: RecoveryKitModalProps): void {
ModalCommands.popWithKey(RECOVERY_KIT_MODAL_KEY);
ModalCommands.pushWithKey(
modal(() => <RecoveryKitModal {...props} data-flx="auth.recovery-kit-modal.open-recovery-kit-modal" />),
RECOVERY_KIT_MODAL_KEY,
);
}
@@ -2,6 +2,7 @@
import * as Modal from '@app/features/app/components/dialogs/Modal';
import {Endpoints} from '@app/features/app/constants/Endpoints';
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import styles from '@app/features/auth/components/modals/SudoVerificationModal.module.css';
import {
isPasskeyCeremonyDismissed,
@@ -31,6 +32,7 @@ import FocusRing from '@app/features/ui/focus_ring/FocusRing';
import WebAuthnCredentials from '@app/features/user/state/WebAuthnCredentials';
import * as FormUtils from '@app/lib/forms';
import {PASSKEY_MIGRATION_RP_ID} from '@fluxer/constants/src/PasskeyConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {msg} from '@lingui/core/macro';
import {Trans, useLingui} from '@lingui/react/macro';
import {clsx} from 'clsx';
@@ -80,6 +82,25 @@ const BACKUP_CODE_DESCRIPTOR = msg({
'Label and placeholder for the code field in the authentication sudo verification modal when the only code the account can use is a backup code.',
});
const VERIFICATION_FAILED_DESCRIPTOR = msg({message: 'Verification failed'});
const INCORRECT_PASSWORD_DESCRIPTOR = msg({
message: 'Incorrect password.',
comment:
'Error under the password field when confirming identity on an instance where people sign in with a username.',
});
function isInvalidPasswordError(body: unknown): boolean {
if (typeof body !== 'object' || body === null) return false;
const errors = (body as {errors?: unknown}).errors;
return (
Array.isArray(errors) &&
errors.some(
(entry) =>
typeof entry === 'object' &&
entry !== null &&
(entry as {code?: unknown}).code === ValidationErrorCodes.INVALID_PASSWORD,
)
);
}
const FINISH_IN_THE_NEW_TAB_DESCRIPTOR = msg({
message: 'Finish in the new tab. If you closed it, press Continue with passkey again.',
comment:
@@ -131,6 +152,9 @@ const SudoVerificationModal: React.FC = observer(() => {
const fallback: keyof FormInputs = showCode ? 'totp' : 'password';
if (rawError) {
FormUtils.handleError(i18n, form, rawError, fallback);
if (RuntimeConfig.usesUsernameSignIn && isInvalidPasswordError(rawError.body)) {
form.setError('password', {type: 'server', message: i18n._(INCORRECT_PASSWORD_DESCRIPTOR)});
}
} else if (verificationFailed) {
form.setError(fallback, {type: 'server', message: i18n._(VERIFICATION_FAILED_DESCRIPTOR)});
}
@@ -381,6 +405,7 @@ const SudoVerificationModal: React.FC = observer(() => {
{...form.register('password')}
label={i18n._(PASSWORD_DESCRIPTOR)}
type="password"
autoComplete={RuntimeConfig.usesUsernameSignIn ? 'current-password' : undefined}
autoFocus={!showPasskey && !showCode}
error={form.formState.errors.password?.message}
/>
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import * as AuthenticationCommands from '@app/features/auth/commands/AuthenticationCommands';
import {AuthLoginLayout} from '@app/features/auth/flow/AuthLoginLayout';
import {AuthRouterLink} from '@app/features/auth/flow/AuthRouterLink';
@@ -31,7 +32,7 @@ const LoginPage = observer(function LoginPage() {
const rawRedirect = params['get']('redirect_to');
const safeRedirect = safeRedirectTarget(rawRedirect);
const isHandoff = isHandoffRequest(params);
const initialEmail = params['get']('email') ?? undefined;
const initialIdentifier = params['get'](RuntimeConfig.usesUsernameSignIn ? 'login' : 'email') ?? undefined;
const registerSearch = safeRedirect ? {redirect_to: safeRedirect} : undefined;
const redirectPath = isHandoff ? undefined : (safeRedirect ?? '/');
return (
@@ -39,7 +40,7 @@ const LoginPage = observer(function LoginPage() {
redirectPath={redirectPath}
desktopHandoff={isHandoff}
excludeCurrentUser={false}
initialEmail={initialEmail}
initialIdentifier={initialIdentifier}
registerLink={
<AuthRouterLink to="/register" search={registerSearch} data-flx="auth.login-page.auth-router-link">
{i18n._(REGISTER_DESCRIPTOR)}
@@ -0,0 +1,78 @@
/* SPDX-License-Identifier: AGPL-3.0-or-later */
.title {
margin-bottom: 0.5rem;
text-align: center;
font-size: 1.25rem;
line-height: 1.75rem;
font-weight: 600;
letter-spacing: 0.025em;
color: var(--text-primary);
}
.description {
margin-bottom: 2rem;
text-align: center;
font-size: 0.875rem;
line-height: 1.625;
color: var(--text-tertiary);
}
.form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.footer {
margin-top: 1.5rem;
text-align: center;
}
.formError {
margin-bottom: 1rem;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
background-color: color-mix(in srgb, var(--accent-danger) 12%, transparent);
border: 0.0625rem solid var(--accent-danger, rgba(220, 38, 38, 0.3));
color: var(--accent-danger, #dc2626);
font-size: 0.875rem;
line-height: 1.4;
text-align: center;
}
.keyInput {
font-family: var(--font-mono, ui-monospace, monospace);
letter-spacing: 0.05em;
text-transform: uppercase;
}
@media (max-width: 480px) {
.form .keyInput {
font-size: 0.75rem;
letter-spacing: 0;
}
}
.hint {
margin-bottom: 1rem;
font-size: 0.875rem;
line-height: 1.4;
color: var(--text-tertiary);
}
.link {
font-size: 0.875rem;
line-height: 1.25rem;
color: var(--text-tertiary);
text-decoration: none;
transition-property: color;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
cursor: pointer;
}
.link:hover {
color: var(--text-primary);
text-decoration: underline;
}
@@ -0,0 +1,269 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Routes} from '@app/app/Routes';
import * as AuthenticationCommands from '@app/features/auth/commands/AuthenticationCommands';
import {openRecoveryKitModal} from '@app/features/auth/components/modals/RecoveryKitModal';
import styles from '@app/features/auth/components/pages/RecoverAccountPage.module.css';
import FormField from '@app/features/auth/flow/AuthFormField';
import {AuthRouterLink} from '@app/features/auth/flow/AuthRouterLink';
import MfaScreen from '@app/features/auth/flow/MfaScreen';
import {useAuthForm} from '@app/features/auth/hooks/useAuthForm';
import {type LoginSuccessPayload, type MfaChallenge, recoverAccount} from '@app/features/auth/state/AuthFlow';
import {BACK_TO_SIGN_IN_DESCRIPTOR, USERNAME_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import {Button} from '@app/features/ui/button/Button';
import {rememberRecoveryKit} from '@app/features/user/commands/RecoveryKitCommands';
import {useFluxerDocumentTitle} from '@app/features/window/hooks/useFluxerDocumentTitle';
import {formatRecoveryKeyInput} from '@fluxer/constants/src/RecoveryKeyUtils';
import {msg} from '@lingui/core/macro';
import {Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import {useCallback, useEffect, useId, useState} from 'react';
const RECOVER_ACCOUNT_DESCRIPTOR = msg({
message: 'Recover your account',
comment: 'Title of the page where someone resets their password with a recovery kit.',
});
const RECOVERY_KEY_DESCRIPTOR = msg({
message: 'Recovery key',
comment: 'Label for the recovery key field on the account recovery page.',
});
const NEW_PASSWORD_DESCRIPTOR = msg({
message: 'New password',
comment: 'Label for the new password field on the account recovery page.',
});
const CONFIRM_NEW_PASSWORD_DESCRIPTOR = msg({
message: 'Confirm new password',
comment: 'Label for the new password confirmation field on the account recovery page.',
});
const PASSWORDS_DO_NOT_MATCH_DESCRIPTOR = msg({
message: 'Passwords do not match',
comment: 'Account recovery page error when the new password and its confirmation differ.',
});
const API_RENDERED_FIELDS = new Set(['login', 'recovery_key', 'password']);
interface RecoveryFragment {
username: string;
key: string;
}
function readRecoveryFragment(): RecoveryFragment {
const params = new URLSearchParams(window.location.hash.replace(/^#/, ''));
return {username: params.get('username') ?? '', key: params.get('key') ?? ''};
}
function clearRecoveryFragment(): void {
if (!window.location.hash) return;
window.history.replaceState(window.history.state, '', `${window.location.pathname}${window.location.search}`);
}
function resolveBannerError(
error: string | null,
fieldErrors: ReadonlyMap<string, string> | null | undefined,
): string | null {
if (error != null) {
return error;
}
if (fieldErrors == null) {
return null;
}
for (const [fieldName, message] of fieldErrors) {
if (!API_RENDERED_FIELDS.has(fieldName)) {
return message;
}
}
return null;
}
function usernameFromLogin(login: string): {username: string; discriminator?: string} {
const [username, discriminator] = login.split('#');
return discriminator ? {username, discriminator} : {username};
}
interface PendingRecoveryKit {
recoveryKey: string;
createdAt: string;
login: string;
}
function showRecoveredKit(payload: LoginSuccessPayload, kit: PendingRecoveryKit): void {
const account = payload.userData
? {username: payload.userData.username, discriminator: payload.userData.discriminator}
: usernameFromLogin(kit.login);
openRecoveryKitModal({
recoveryKey: kit.recoveryKey,
createdAt: kit.createdAt,
username: account.username,
discriminator: account.discriminator,
reason: 'recovered',
});
rememberRecoveryKit(payload.userId, kit.createdAt);
}
const RecoverAccountPage = observer(function RecoverAccountPage() {
const {i18n} = useLingui();
const loginId = useId();
const keyId = useId();
const passwordId = useId();
const confirmPasswordId = useId();
useFluxerDocumentTitle(i18n._(RECOVER_ACCOUNT_DESCRIPTOR));
const [fragment] = useState(readRecoveryFragment);
const [mfaChallenge, setMfaChallenge] = useState<MfaChallenge | null>(null);
const [pendingKit, setPendingKit] = useState<PendingRecoveryKit | null>(null);
useEffect(() => {
clearRecoveryFragment();
}, []);
const {form, isLoading, error, fieldErrors} = useAuthForm({
initialValues: {
login: fragment.username,
recovery_key: formatRecoveryKeyInput(fragment.key),
password: '',
confirm_password: '',
},
onSubmit: async (values) => {
if (values.password !== values.confirm_password) {
form.setError('confirm_password', i18n._(PASSWORDS_DO_NOT_MATCH_DESCRIPTOR));
return false;
}
const login = values.login.trim();
const result = await recoverAccount({login, recoveryKey: values.recovery_key, password: values.password});
if (result.type === 'mfa') {
setPendingKit({...result.kit, login});
setMfaChallenge(result.challenge);
return false;
}
showRecoveredKit(result.payload, {...result.kit, login});
await AuthenticationCommands.completeLogin(result.payload);
return undefined;
},
firstFieldName: 'login',
});
const handleMfaSuccess = useCallback(
async (payload: LoginSuccessPayload) => {
if (pendingKit) {
showRecoveredKit(payload, pendingKit);
}
await AuthenticationCommands.completeLogin(payload);
},
[pendingKit],
);
const handleMfaCancel = useCallback(() => {
setMfaChallenge(null);
setPendingKit(null);
}, []);
if (mfaChallenge) {
return (
<MfaScreen
challenge={mfaChallenge}
onSuccess={handleMfaSuccess}
onCancel={handleMfaCancel}
data-flx="auth.recover-account-page.mfa-screen"
/>
);
}
const bannerError = resolveBannerError(error, fieldErrors);
return (
<>
<h1 className={styles.title} data-flx="auth.recover-account-page.title">
{i18n._(RECOVER_ACCOUNT_DESCRIPTOR)}
</h1>
<p className={styles.description} data-flx="auth.recover-account-page.description">
<Trans>Enter your username and the recovery key from your recovery kit, then choose a new password.</Trans>
</p>
{bannerError ? (
<div className={styles.formError} role="alert" data-flx="auth.recover-account-page.form-error">
{bannerError}
</div>
) : null}
<form className={styles.form} onSubmit={form.handleSubmit} data-flx="auth.recover-account-page.form.submit">
<FormField
id={loginId}
name="login"
type="text"
autoComplete="username"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
required
label={i18n._(USERNAME_DESCRIPTOR)}
value={form.getValue('login')}
onChange={(value) => form.setValue('login', value)}
error={form.getError('login') || fieldErrors?.get('login')}
data-flx="auth.recover-account-page.form-field.set-value.login"
/>
<FormField
id={keyId}
name="recovery_key"
type="text"
autoComplete="off"
autoCapitalize="characters"
autoCorrect="off"
spellCheck={false}
required
className={styles.keyInput}
placeholder="XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX"
label={i18n._(RECOVERY_KEY_DESCRIPTOR)}
value={form.getValue('recovery_key')}
onChange={(value) => form.setValue('recovery_key', value)}
onBlur={() => form.setValue('recovery_key', formatRecoveryKeyInput(form.getValue('recovery_key')))}
onPaste={(event) => {
const input = event.currentTarget;
const current = input.value;
const start = input.selectionStart ?? current.length;
const end = input.selectionEnd ?? current.length;
const pasted = event.clipboardData.getData('text');
event.preventDefault();
form.setValue(
'recovery_key',
formatRecoveryKeyInput(`${current.slice(0, start)}${pasted}${current.slice(end)}`),
);
}}
error={form.getError('recovery_key') || fieldErrors?.get('recovery_key')}
data-flx="auth.recover-account-page.form-field.set-value.recovery-key"
/>
<FormField
id={passwordId}
name="password"
type="password"
autoComplete="new-password"
required
label={i18n._(NEW_PASSWORD_DESCRIPTOR)}
value={form.getValue('password')}
onChange={(value) => form.setValue('password', value)}
error={form.getError('password') || fieldErrors?.get('password')}
data-flx="auth.recover-account-page.form-field.set-value.password"
/>
<FormField
id={confirmPasswordId}
name="confirm_password"
type="password"
autoComplete="new-password"
required
label={i18n._(CONFIRM_NEW_PASSWORD_DESCRIPTOR)}
value={form.getValue('confirm_password')}
onChange={(value) => form.setValue('confirm_password', value)}
error={form.getError('confirm_password')}
data-flx="auth.recover-account-page.form-field.set-value.confirm-password"
/>
<Button
type="submit"
fitContainer
disabled={isLoading || form.isSubmitting}
data-flx="auth.recover-account-page.button.submit"
>
<Trans>Reset password</Trans>
</Button>
</form>
<div className={styles.footer} data-flx="auth.recover-account-page.footer">
<p className={styles.hint} data-flx="auth.recover-account-page.no-kit-hint">
<Trans>No recovery kit? Ask an admin of this instance for a password reset link.</Trans>
</p>
<AuthRouterLink to={Routes.LOGIN} className={styles.link} data-flx="auth.recover-account-page.link">
{i18n._(BACK_TO_SIGN_IN_DESCRIPTOR)}
</AuthRouterLink>
</div>
</>
);
});
export default RecoverAccountPage;
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import {AuthBottomLink} from '@app/features/auth/flow/AuthBottomLink';
import sharedStyles from '@app/features/auth/flow/AuthPageStyles.module.css';
import {AuthRegisterFormCore} from '@app/features/auth/flow/AuthRegisterFormCore';
@@ -19,6 +20,7 @@ const RegisterPageContent = observer(function RegisterPageContent() {
const safeRedirect = safeRedirectTarget(rawRedirect);
const redirectTo = safeRedirect ?? '/';
const loginPath = safeRedirect ? setPathQueryParams('/login', {redirect_to: safeRedirect}) : '/login';
const usesUsernameSignIn = RuntimeConfig.usesUsernameSignIn;
if (isRuntimeSsoEnforced()) {
return (
<div className={sharedStyles.container} data-flx="auth.register-page.register-page-content.sso-container">
@@ -38,13 +40,15 @@ const RegisterPageContent = observer(function RegisterPageContent() {
<div className={sharedStyles.container} data-flx="auth.register-page.register-page-content.div">
<AuthRegisterFormCore
fields={{
showEmail: true,
showEmail: !usesUsernameSignIn,
showPassword: true,
showPasswordConfirmation: true,
showUsernameValidation: true,
requireUsername: usesUsernameSignIn,
}}
submitLabel={<Trans>Create account</Trans>}
redirectPath={redirectTo}
offerRecoveryKit={usesUsernameSignIn}
data-flx="auth.register-page.register-page-content.auth-register-form-core"
/>
<AuthBottomLink
@@ -1,6 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Routes} from '@app/app/Routes';
import {useHashParam} from '@app/features/app/hooks/useHashParam';
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import * as AuthenticationCommands from '@app/features/auth/commands/AuthenticationCommands';
import styles from '@app/features/auth/components/pages/ResetPasswordPage.module.css';
import FormField from '@app/features/auth/flow/AuthFormField';
@@ -89,9 +91,10 @@ const ResetPasswordPage = observer(function ResetPasswordPage() {
await AuthenticationCommands.completeLogin(response.payload);
},
});
const usesUsernameSignIn = RuntimeConfig.usesUsernameSignIn;
useEffect(() => {
if (!token) {
RouterUtils.replaceWith('/forgot');
RouterUtils.replaceWith(usesUsernameSignIn ? Routes.RECOVER_ACCOUNT : '/forgot');
return;
}
let cancelled = false;
@@ -108,7 +111,7 @@ const ResetPasswordPage = observer(function ResetPasswordPage() {
return () => {
cancelled = true;
};
}, [token]);
}, [token, usesUsernameSignIn]);
const bannerError = resolveBannerError(error, fieldErrors);
if (tokenStatus === 'validating') {
return (
@@ -128,14 +131,35 @@ const ResetPasswordPage = observer(function ResetPasswordPage() {
<h1 className={styles.title} data-flx="auth.reset-password-page.title--2">
<Trans>Reset link invalid or expired</Trans>
</h1>
<p className={styles.description} data-flx="auth.reset-password-page.description">
<Trans>This reset link has expired. Reset links last 1 hour. Please request a new one.</Trans>
</p>
<div className={styles.footer} data-flx="auth.reset-password-page.footer">
<AuthRouterLink to="/forgot" className={styles.link} data-flx="auth.reset-password-page.link">
<Trans>Request a new reset link</Trans>
</AuthRouterLink>
</div>
{usesUsernameSignIn ? (
<>
<p className={styles.description} data-flx="auth.reset-password-page.description--username">
<Trans>
This reset link has expired or was already used. Ask an admin for a new one, or use your recovery kit.
</Trans>
</p>
<div className={styles.footer} data-flx="auth.reset-password-page.footer--username">
<AuthRouterLink
to={Routes.RECOVER_ACCOUNT}
className={styles.link}
data-flx="auth.reset-password-page.link--recover"
>
<Trans>Use your recovery kit</Trans>
</AuthRouterLink>
</div>
</>
) : (
<>
<p className={styles.description} data-flx="auth.reset-password-page.description">
<Trans>This reset link has expired. Reset links last 1 hour. Please request a new one.</Trans>
</p>
<div className={styles.footer} data-flx="auth.reset-password-page.footer">
<AuthRouterLink to="/forgot" className={styles.link} data-flx="auth.reset-password-page.link">
<Trans>Request a new reset link</Trans>
</AuthRouterLink>
</div>
</>
)}
</>
);
}
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Routes} from '@app/app/Routes';
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
import {detectDomainMigrationInstallKind} from '@app/features/app/domain_migration/DomainMigrationBrowser';
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
@@ -48,6 +49,7 @@ import {IS_DEV} from '@app/features/platform/types/Env';
import {Button} from '@app/features/ui/button/Button';
import {SteppedCarousel} from '@app/features/ui/stepped_carousel/SteppedCarousel';
import {isDesktop} from '@app/features/ui/utils/NativeUtils';
import {formatUserTag} from '@app/features/user/utils/UserTagUtils';
import * as FormUtils from '@app/lib/forms';
import {msg} from '@lingui/core/macro';
import {Trans, useLingui} from '@lingui/react/macro';
@@ -113,7 +115,7 @@ interface AuthLoginLayoutProps {
title?: ReactNode;
registerLink: ReactElement<Record<string, unknown>>;
onLoginComplete?: (payload: LoginSuccessPayload) => Promise<void> | void;
initialEmail?: string;
initialIdentifier?: string;
}
export const AuthLoginLayout = observer(function AuthLoginLayout({
@@ -126,7 +128,7 @@ export const AuthLoginLayout = observer(function AuthLoginLayout({
title,
registerLink,
onLoginComplete,
initialEmail,
initialIdentifier,
}: AuthLoginLayoutProps) {
const {i18n} = useLingui();
const location = useLocation();
@@ -146,19 +148,25 @@ export const AuthLoginLayout = observer(function AuthLoginLayout({
initialMode: desktopHandoff && hasHandoffAccounts ? 'selecting' : 'login',
});
const [ipAuthChallenge, setIpAuthChallenge] = useState<IpAuthorizationChallenge | null>(null);
const [showAccountSelector, setShowAccountSelector] = useState(!desktopHandoff && hasStoredAccounts && !initialEmail);
const [showAccountSelector, setShowAccountSelector] = useState(
!desktopHandoff && hasStoredAccounts && !initialIdentifier,
);
const [isSwitching, setIsSwitching] = useState(false);
const [switchError, setSwitchError] = useState<string | null>(null);
const [prefillEmail, setPrefillEmail] = useState<string | null>(() => initialEmail ?? null);
const [prefillIdentifier, setPrefillIdentifier] = useState<string | null>(() => initialIdentifier ?? null);
const ssoRedirectPath = desktopHandoff ? `${location.pathname}${location.search}` : redirectPath;
const usesUsernameSignIn = RuntimeConfig.usesUsernameSignIn;
const showExpiredLoginForm = useCallback(
(account: Account) => {
const identifier = account.userData?.email ?? account.userData?.username ?? account.userId;
const signInIdentifier = usesUsernameSignIn
? account.userData && formatUserTag(account.userData)
: account.userData?.email;
const identifier = signInIdentifier ?? account.userData?.username ?? account.userId;
setShowAccountSelector(false);
setSwitchError(i18n._(SESSION_EXPIRED_SIGN_IN_AGAIN_DESCRIPTOR, {identifier}));
setPrefillEmail(account.userData?.email ?? null);
setPrefillIdentifier(signInIdentifier ?? null);
},
[i18n],
[i18n, usesUsernameSignIn],
);
const handleLoginSuccess = useCallback(
async (payload: LoginSuccessPayload) => {
@@ -172,18 +180,25 @@ export const AuthLoginLayout = observer(function AuthLoginLayout({
},
[desktopHandoff, handoff, onLoginComplete],
);
const {form, isLoading, fieldErrors, handlePasskeyLogin, handlePasskeyBrowserLogin, isPasskeyLoading} =
useLoginFormController({
redirectPath,
inviteCode,
onLoginSuccess: handleLoginSuccess,
onRequireMfa: (challenge) => {
AuthenticationCommands.setMfaTicket(challenge);
},
onRequireIpAuthorization: (challenge) => {
setIpAuthChallenge(challenge);
},
});
const {
form,
identifierField,
isLoading,
fieldErrors,
handlePasskeyLogin,
handlePasskeyBrowserLogin,
isPasskeyLoading,
} = useLoginFormController({
redirectPath,
inviteCode,
onLoginSuccess: handleLoginSuccess,
onRequireMfa: (challenge) => {
AuthenticationCommands.setMfaTicket(challenge);
},
onRequireIpAuthorization: (challenge) => {
setIpAuthChallenge(challenge);
},
});
const isPasskeyBridgeRedeeming = usePasskeyBridgeReturn({
redirectPath,
onLoginSuccess: handleLoginSuccess,
@@ -228,16 +243,16 @@ export const AuthLoginLayout = observer(function AuthLoginLayout({
[handleLoginSuccess, redirectPath],
);
useEffect(() => {
setPrefillEmail(initialEmail ?? null);
if (initialEmail) {
setPrefillIdentifier(initialIdentifier ?? null);
if (initialIdentifier) {
setShowAccountSelector(false);
}
}, [initialEmail]);
}, [initialIdentifier]);
useEffect(() => {
if (prefillEmail !== null) {
form.setValue('email', prefillEmail);
if (prefillIdentifier !== null) {
form.setValue(identifierField, prefillIdentifier);
}
}, [form.setValue, prefillEmail]);
}, [form.setValue, identifierField, prefillIdentifier]);
const handleSelectExistingAccount = useCallback(
async (account: Account) => {
if (account.isValid === false) {
@@ -283,18 +298,18 @@ export const AuthLoginLayout = observer(function AuthLoginLayout({
const canChooseSavedAccount = desktopHandoff ? hasHandoffAccounts : hasStoredAccounts;
const handleChooseSavedAccount = useCallback(() => {
setSwitchError(null);
setPrefillEmail(initialEmail ?? null);
form.setValue('email', initialEmail ?? '');
setPrefillIdentifier(initialIdentifier ?? null);
form.setValue(identifierField, initialIdentifier ?? '');
if (desktopHandoff) {
handoff.setMode('selecting');
} else {
setShowAccountSelector(true);
}
}, [desktopHandoff, form.setValue, handoff, initialEmail]);
}, [desktopHandoff, form.setValue, handoff, identifierField, initialIdentifier]);
const handleAddAnotherAccount = useCallback(() => {
setShowAccountSelector(false);
setSwitchError(null);
setPrefillEmail(null);
setPrefillIdentifier(null);
}, []);
const handleStartSso = useCallback(async () => {
if (!ssoConfig?.enabled) return;
@@ -337,7 +352,7 @@ export const AuthLoginLayout = observer(function AuthLoginLayout({
showAccountSelector,
]);
const hasExtraTopContent = extraTopContent !== undefined && extraTopContent !== null;
const startedFromAccountSelector = hasStoredAccounts && !desktopHandoff && !initialEmail;
const startedFromAccountSelector = hasStoredAccounts && !desktopHandoff && !initialIdentifier;
const showSplitLogo =
authLoginStep === 'credentials' && !desktopHandoff && !hasExtraTopContent && !startedFromAccountSelector;
const cardVariant = useMemo(() => {
@@ -451,8 +466,17 @@ export const AuthLoginLayout = observer(function AuthLoginLayout({
submitLabel={i18n._(SIGN_IN_DESCRIPTOR)}
classes={{form: styles.form}}
linksWrapperClassName={styles.formLinks}
identifierField={identifierField}
links={
RuntimeConfig.emailsEnabled ? (
usesUsernameSignIn ? (
<AuthRouterLink
to={Routes.RECOVER_ACCOUNT}
className={styles.link}
data-flx="auth.flow.auth-login-layout.link.recover"
>
{i18n._(FORGOT_PASSWORD_DESCRIPTOR)}
</AuthRouterLink>
) : RuntimeConfig.emailsEnabled ? (
<AuthRouterLink to="/forgot" className={styles.link} data-flx="auth.flow.auth-login-layout.link">
{i18n._(FORGOT_PASSWORD_DESCRIPTOR)}
</AuthRouterLink>
@@ -12,14 +12,20 @@ import {
} from '@app/features/auth/flow/RegistrationLegalConsent';
import {type MissingField, SubmitTooltip, shouldDisableSubmit} from '@app/features/auth/flow/SubmitTooltip';
import {useAuthForm} from '@app/features/auth/hooks/useAuthForm';
import {useUsernameAvailability} from '@app/features/auth/hooks/useUsernameAvailability';
import {
type AuthRegisterFormDraft,
EMPTY_AUTH_REGISTER_FORM_DRAFT,
useAuthRegisterDraftContext,
} from '@app/features/auth/state/AuthRegisterDraftContext';
import {EMAIL_DESCRIPTOR, PASSWORD_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import {
EMAIL_DESCRIPTOR,
PASSWORD_DESCRIPTOR,
USERNAME_DESCRIPTOR,
} from '@app/features/i18n/utils/CommonMessageDescriptors';
import {useLocation} from '@app/features/platform/components/router/RouterReact';
import {Button} from '@app/features/ui/button/Button';
import {createRecoveryKitWithPasswordAndOpen} from '@app/features/user/commands/RecoveryKitCommands';
import {useUsernameSuggestions} from '@app/features/user/hooks/useUsernameSuggestions';
import type {ThemeType} from '@fluxer/constants/src/UserConstants';
import {msg} from '@lingui/core/macro';
@@ -64,13 +70,27 @@ const LEAVE_BLANK_FOR_A_RANDOM_USERNAME_DESCRIPTOR = msg({
message: 'Leave blank for a random username',
comment: 'Short label in the authentication auth register form core. Keep the tone plain and specific.',
});
const USERNAME_SIGN_IN_HINT_DESCRIPTOR = msg({
message: 'You sign in with this username. Pick one you will remember.',
comment: 'Helper text under the required username field when the instance uses usernames to sign in.',
});
const USERNAME_TAKEN_DESCRIPTOR = msg({
message: 'This username is already taken',
comment: 'Registration form message when the chosen username belongs to someone else.',
});
const USERNAME_AVAILABLE_DESCRIPTOR = msg({
message: 'This username is available',
comment: 'Registration form message when the chosen username is free to use.',
});
const MAX_USERNAME_LENGTH = 32;
const USERNAME_PATTERN = /^[a-zA-Z0-9_]+$/;
interface FieldConfig {
showEmail?: boolean;
showPassword?: boolean;
showPasswordConfirmation?: boolean;
showUsernameValidation?: boolean;
requireUsername?: boolean;
}
interface AuthRegisterFormCoreProps {
@@ -82,6 +102,7 @@ interface AuthRegisterFormCoreProps {
extraContent?: React.ReactNode;
showLegalConsent?: boolean;
theme?: ThemeType;
offerRecoveryKit?: boolean;
}
export const AuthRegisterFormCore = observer(function AuthRegisterFormCore({
@@ -93,6 +114,7 @@ export const AuthRegisterFormCore = observer(function AuthRegisterFormCore({
extraContent,
showLegalConsent = true,
theme,
offerRecoveryKit = false,
}: AuthRegisterFormCoreProps) {
const {i18n} = useLingui();
const {
@@ -100,6 +122,7 @@ export const AuthRegisterFormCore = observer(function AuthRegisterFormCore({
showPassword = false,
showPasswordConfirmation = false,
showUsernameValidation = false,
requireUsername = false,
} = fields;
const location = useLocation();
const draftKey = `register:${location.pathname}${location.search}`;
@@ -201,7 +224,7 @@ export const AuthRegisterFormCore = observer(function AuthRegisterFormCore({
: undefined;
const response = await AuthenticationCommands.register({
global_name: values.global_name || undefined,
username: values.username || undefined,
username: (requireUsername ? values.username.trim() : values.username) || undefined,
email: showEmail ? values.email : undefined,
password: showPassword ? values.password : undefined,
date_of_birth: dateOfBirth,
@@ -224,6 +247,14 @@ export const AuthRegisterFormCore = observer(function AuthRegisterFormCore({
userId: response.user_id,
...(userData ? {userData} : {}),
});
if (offerRecoveryKit && showPassword && values.password) {
void createRecoveryKitWithPasswordAndOpen({
userId: response.user_id,
password: values.password,
username: response.user?.username,
discriminator: response.user?.discriminator,
});
}
}
clearRegisterFormDraft(draftKey);
return undefined;
@@ -232,7 +263,7 @@ export const AuthRegisterFormCore = observer(function AuthRegisterFormCore({
initialValues,
onSubmit: handleRegisterSubmit,
redirectPath,
firstFieldName: showEmail ? 'email' : 'global_name',
firstFieldName: showEmail ? 'email' : requireUsername ? 'username' : 'global_name',
});
const setDraftedFormValue = useCallback(
(fieldName: string, value: string) => {
@@ -254,6 +285,9 @@ export const AuthRegisterFormCore = observer(function AuthRegisterFormCore({
if (showEmail && !form.getValue('email')) {
missing.push({key: 'email', label: i18n._(EMAIL_DESCRIPTOR)});
}
if (requireUsername && !form.getValue('username')?.trim()) {
missing.push({key: 'username', label: i18n._(USERNAME_DESCRIPTOR)});
}
if (showPassword && !form.getValue('password')) {
missing.push({key: 'password', label: i18n._(PASSWORD_DESCRIPTOR)});
}
@@ -270,36 +304,66 @@ export const AuthRegisterFormCore = observer(function AuthRegisterFormCore({
selectedDay,
selectedYear,
showEmail,
requireUsername,
showPassword,
showPasswordConfirmation,
collectDateOfBirth,
i18n.locale,
]);
type HelperTextState = {type: 'error'; message: string} | {type: 'suggestion'; username: string} | null;
type HelperTextState =
| {type: 'error'; message: string}
| {type: 'hint'; message: string}
| {type: 'suggestion'; username: string}
| null;
const usernameValue = form.getValue('username');
const trimmedUsername = usernameValue?.trim() || '';
const usernameFormatValid = trimmedUsername.length <= MAX_USERNAME_LENGTH && USERNAME_PATTERN.test(trimmedUsername);
const uniqueUsernames = RuntimeConfig.usesUniqueUsernames;
const usernameAvailability = useUsernameAvailability(
trimmedUsername,
uniqueUsernames && trimmedUsername.length > 0 && usernameFormatValid,
);
const helperTextState = useMemo<HelperTextState>(() => {
const trimmed = usernameValue?.trim() || '';
if (showUsernameValidation && trimmed.length > 0) {
if (trimmed.length > MAX_USERNAME_LENGTH) {
if ((showUsernameValidation || requireUsername) && trimmedUsername.length > 0) {
if (trimmedUsername.length > MAX_USERNAME_LENGTH) {
return {
type: 'error',
message: i18n._(USERNAME_MUST_BE_CHARACTERS_OR_LESS_DESCRIPTOR, {maxUsernameLength: MAX_USERNAME_LENGTH}),
};
}
if (!/^[a-zA-Z0-9_]+$/.test(trimmed)) {
if (!USERNAME_PATTERN.test(trimmedUsername)) {
return {type: 'error', message: i18n._(ONLY_LETTERS_NUMBERS_AND_UNDERSCORES_DESCRIPTOR)};
}
}
if (trimmed.length === 0 && suggestions.length === 1) {
if (uniqueUsernames && usernameAvailability === 'taken') {
return {type: 'error', message: i18n._(USERNAME_TAKEN_DESCRIPTOR)};
}
if (uniqueUsernames && usernameAvailability === 'available') {
return {type: 'hint', message: i18n._(USERNAME_AVAILABLE_DESCRIPTOR)};
}
if (trimmedUsername.length === 0 && suggestions.length === 1) {
return {type: 'suggestion', username: suggestions[0]};
}
if (requireUsername) {
return {type: 'hint', message: i18n._(USERNAME_SIGN_IN_HINT_DESCRIPTOR)};
}
return null;
}, [usernameValue, suggestions, showUsernameValidation, i18n.locale]);
}, [
trimmedUsername,
suggestions,
showUsernameValidation,
requireUsername,
uniqueUsernames,
usernameAvailability,
i18n.locale,
]);
const submitDisabled =
isLoading ||
form.isSubmitting ||
isPublicRegistrationClosed ||
pendingApprovalUserId !== null ||
(requireUsername && !usernameFormatValid) ||
(uniqueUsernames && usernameAvailability === 'taken') ||
shouldDisableSubmit(effectiveConsent, missingFields);
return (
<form className={styles.form} onSubmit={form.handleSubmit} data-flx="auth.flow.auth-register-form-core.form.submit">
@@ -352,8 +416,12 @@ export const AuthRegisterFormCore = observer(function AuthRegisterFormCore({
name="username"
type="text"
autoComplete="username"
label={i18n._(USERNAME_OPTIONAL_DESCRIPTOR)}
placeholder={i18n._(LEAVE_BLANK_FOR_A_RANDOM_USERNAME_DESCRIPTOR)}
autoCapitalize={requireUsername ? 'none' : undefined}
autoCorrect={requireUsername ? 'off' : undefined}
spellCheck={requireUsername ? false : undefined}
required={requireUsername}
label={i18n._(requireUsername ? USERNAME_DESCRIPTOR : USERNAME_OPTIONAL_DESCRIPTOR)}
placeholder={requireUsername ? undefined : i18n._(LEAVE_BLANK_FOR_A_RANDOM_USERNAME_DESCRIPTOR)}
value={usernameValue}
onChange={(value) => setDraftedFormValue('username', value)}
error={form.getError('username') || fieldErrors?.get('username')}
@@ -373,6 +441,19 @@ export const AuthRegisterFormCore = observer(function AuthRegisterFormCore({
{helperTextState.message}
</motion.span>
)}
{helperTextState?.type === 'hint' && (
<motion.span
key={`hint-${helperTextState.message}`}
className={styles.usernameHint}
initial={Accessibility.useReducedMotion ? {opacity: 1, y: 0} : {opacity: 0, y: -5}}
animate={{opacity: 1, y: 0}}
exit={Accessibility.useReducedMotion ? {opacity: 1, y: 0} : {opacity: 0, y: 5}}
transition={{duration: Accessibility.useReducedMotion ? 0 : 0.2}}
data-flx="auth.flow.auth-register-form-core.username-sign-in-hint"
>
{helperTextState.message}
</motion.span>
)}
{helperTextState?.type === 'suggestion' && (
<motion.span
key="suggestion"
@@ -34,13 +34,14 @@ export type BrowserLoginHandoffVariant = 'browser' | 'old_app';
interface BrowserLoginHandoffModalProps {
onSuccess: (payload: LoginSuccessPayload) => Promise<void>;
prefillEmail?: string;
prefillLogin?: string;
variant?: BrowserLoginHandoffVariant;
}
const POLL_INTERVAL_MS = 2000;
const BrowserLoginHandoffModal = observer(({onSuccess, prefillEmail, variant}: BrowserLoginHandoffModalProps) => {
const BrowserLoginHandoffModal = observer(({onSuccess, prefillLogin, variant}: BrowserLoginHandoffModalProps) => {
const prefillEmail = prefillLogin;
const {i18n} = useLingui();
const isOldAppVariant = variant === 'old_app';
const currentWebAppUrl = RuntimeConfig.webAppBaseUrl;
@@ -102,11 +103,11 @@ const BrowserLoginHandoffModal = observer(({onSuccess, prefillEmail, variant}: B
const handleOpenBrowser = useCallback(async () => {
const loginUrl = new URL('/login', currentWebAppUrl);
loginUrl.searchParams.set('handoff', '1');
if (prefillEmail) {
loginUrl.searchParams.set('email', prefillEmail);
if (prefillLogin) {
loginUrl.searchParams.set(RuntimeConfig.usesUsernameSignIn ? 'login' : 'email', prefillLogin);
}
await openExternalUrl(loginUrl.toString());
}, [currentWebAppUrl, prefillEmail]);
}, [currentWebAppUrl, prefillLogin]);
return (
<Modal.Root
size="small"
@@ -144,7 +145,7 @@ const BrowserLoginHandoffModal = observer(({onSuccess, prefillEmail, variant}: B
}
data-flx="auth.flow.browser-login-handoff-modal.handoff-code-display"
/>
{prefillEmail && !isOldAppVariant ? (
{prefillLogin && !isOldAppVariant ? (
<Modal.Description
className={styles.prefillHint}
data-flx="auth.flow.browser-login-handoff-modal.prefill-hint"
@@ -185,7 +186,7 @@ const BrowserLoginHandoffModal = observer(({onSuccess, prefillEmail, variant}: B
export function showBrowserLoginHandoffModal(
onSuccess: (payload: LoginSuccessPayload) => Promise<void>,
prefillEmail?: string,
prefillLogin?: string,
variant: BrowserLoginHandoffVariant = 'browser',
): void {
ModalCommands.push(
@@ -194,7 +195,7 @@ export function showBrowserLoginHandoffModal(
onSuccess={async (payload) => {
await onSuccess(payload);
}}
prefillEmail={prefillEmail}
prefillLogin={prefillLogin}
variant={variant}
data-flx="auth.flow.browser-login-handoff-modal.show-browser-login-handoff-modal.browser-login-handoff-modal"
/>
@@ -1,7 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import FormField from '@app/features/auth/flow/AuthFormField';
import {EMAIL_DESCRIPTOR, PASSWORD_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import {
EMAIL_DESCRIPTOR,
PASSWORD_DESCRIPTOR,
USERNAME_DESCRIPTOR,
} from '@app/features/i18n/utils/CommonMessageDescriptors';
import {Button} from '@app/features/ui/button/Button';
import {useLingui} from '@lingui/react/macro';
import type React from 'react';
@@ -31,6 +35,7 @@ interface Props {
links?: React.ReactNode;
linksWrapperClassName?: string;
disableSubmit?: boolean;
identifierField?: 'email' | 'login';
}
export default function AuthLoginEmailPasswordForm({
@@ -43,6 +48,7 @@ export default function AuthLoginEmailPasswordForm({
links,
linksWrapperClassName,
disableSubmit,
identifierField = 'email',
}: Props) {
const {i18n} = useLingui();
const emailId = useId();
@@ -59,8 +65,8 @@ export default function AuthLoginEmailPasswordForm({
>
<FormField
id={emailId}
name="email"
type="email"
name={identifierField}
type={identifierField === 'email' ? 'email' : 'text'}
autoComplete="username"
autoCapitalize="none"
autoCorrect="off"
@@ -68,10 +74,10 @@ export default function AuthLoginEmailPasswordForm({
spellCheck={false}
data-step-focus="true"
required
label={i18n._(EMAIL_DESCRIPTOR)}
value={form.getValue('email')}
onChange={(value) => form.setValue('email', value)}
error={form.getError('email') || fieldErrors?.get('email')}
label={i18n._(identifierField === 'email' ? EMAIL_DESCRIPTOR : USERNAME_DESCRIPTOR)}
value={form.getValue(identifierField)}
onChange={(value) => form.setValue(identifierField, value)}
error={form.getError(identifierField) || fieldErrors?.get(identifierField)}
data-flx="auth.flow.auth-login-core.auth-login-email-password-form.form-field.set-value.email"
/>
<FormField
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import {showBrowserLoginHandoffModal} from '@app/features/auth/flow/BrowserLoginHandoffModal';
import {useAuthForm} from '@app/features/auth/hooks/useAuthForm';
import {
@@ -94,6 +95,12 @@ const handleLoginOutcome = async (
}
};
type LoginIdentifierField = 'email' | 'login';
function loginIdentifierField(): LoginIdentifierField {
return RuntimeConfig.usesUsernameSignIn ? 'login' : 'email';
}
interface LoginFormControllerOptions {
inviteCode?: string;
redirectPath?: string;
@@ -110,17 +117,19 @@ export function useLoginFormController({
onRequireIpAuthorization,
}: LoginFormControllerOptions) {
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false);
const identifierField = loginIdentifierField();
const {form, isLoading, fieldErrors, error} = useAuthForm({
initialValues: {email: '', password: ''},
initialValues: {[identifierField]: '', password: ''},
onSubmit: async (values) => {
const identifier = values[identifierField];
const result = await loginWithPassword({
email: values.email,
...(identifierField === 'login' ? {login: identifier.trim()} : {email: identifier}),
password: values.password,
inviteCode,
});
handleLoginOutcome(result, onLoginSuccess, onRequireMfa, onRequireIpAuthorization, redirectPath);
},
firstFieldName: 'email',
firstFieldName: identifierField,
redirectPath: undefined,
});
const handleDesktopPasskeyHandoff = useCallback(() => {
@@ -187,6 +196,7 @@ export function useLoginFormController({
}, [inviteCode, onLoginSuccess, redirectPath, handleDesktopPasskeyHandoff]);
return {
form,
identifierField,
isLoading,
fieldErrors,
error,
@@ -0,0 +1,41 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import * as AuthenticationCommands from '@app/features/auth/commands/AuthenticationCommands';
import {useEffect, useState} from 'react';
export type UsernameAvailability = 'idle' | 'checking' | 'available' | 'taken';
const CHECK_DELAY_MS = 350;
export function useUsernameAvailability(username: string, enabled: boolean): UsernameAvailability {
const [result, setResult] = useState<{username: string; availability: UsernameAvailability}>({
username: '',
availability: 'idle',
});
useEffect(() => {
if (!enabled || username.length === 0) {
return;
}
const controller = new AbortController();
const timer = window.setTimeout(() => {
setResult({username, availability: 'checking'});
AuthenticationCommands.checkUsernameAvailability(username, controller.signal)
.then((available) => {
if (controller.signal.aborted) return;
setResult({username, availability: available ? 'available' : 'taken'});
})
.catch(() => {
if (controller.signal.aborted) return;
setResult({username, availability: 'idle'});
});
}, CHECK_DELAY_MS);
return () => {
window.clearTimeout(timer);
controller.abort();
};
}, [enabled, username]);
if (!enabled || username.length === 0 || result.username !== username) {
return 'idle';
}
return result.availability;
}
+40 -14
View File
@@ -40,20 +40,13 @@ export function toLoginSuccessPayload(response: AuthenticationCommands.AuthToken
};
}
export async function loginWithPassword({
email,
password,
inviteCode,
}: {
email: string;
password: string;
inviteCode?: string;
}): Promise<LoginResult> {
const response = await AuthenticationCommands.login({
email,
password,
inviteCode,
});
export async function loginWithPassword(
params: AuthenticationCommands.LoginIdentifier & {
password: string;
inviteCode?: string;
},
): Promise<LoginResult> {
const response = await AuthenticationCommands.login(params);
if (AuthenticationCommands.isIpAuthorizationRequiredResponse(response)) {
return {
type: 'ip_authorization',
@@ -271,6 +264,39 @@ export async function resetPassword(token: string, password: string): Promise<Pa
};
}
export interface IssuedRecoveryKit {
recoveryKey: string;
createdAt: string;
}
export type AccountRecoveryResult = PasswordResetResult & {kit: IssuedRecoveryKit};
export async function recoverAccount({
login,
recoveryKey,
password,
}: {
login: string;
recoveryKey: string;
password: string;
}): Promise<AccountRecoveryResult> {
const response = await AuthenticationCommands.recoverAccount({login, recoveryKey, password});
const kit = {recoveryKey: response.recovery_key, createdAt: response.recovery_kit_created_at};
if ('token' in response) {
return {type: 'success', payload: toLoginSuccessPayload(response), kit};
}
return {
type: 'mfa',
challenge: {
ticket: response.ticket,
totp: response.totp,
webauthn: response.webauthn,
backupCodes: response.backup_codes ?? false,
},
kit,
};
}
export async function verifyEmail(token: string): Promise<AuthenticationCommands.VerificationResult> {
return AuthenticationCommands.verifyEmail(token);
}
@@ -4,6 +4,7 @@ import i18n from '@app/app/I18n';
import {showGenericErrorModal} from '@app/features/app/components/alerts/GenericErrorModalCommands';
import {ConfirmModal} from '@app/features/app/components/dialogs/ConfirmModal';
import {Endpoints} from '@app/features/app/constants/Endpoints';
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import * as AuthenticationCommands from '@app/features/auth/commands/AuthenticationCommands';
import {getAccountAvatarUrl, getAccountDisplayName} from '@app/features/auth/components/accounts/AccountListItem';
import {showBrowserLoginHandoffModal} from '@app/features/auth/flow/BrowserLoginHandoffModal';
@@ -18,6 +19,7 @@ import {MenuItem} from '@app/features/ui/action_menu/MenuItem';
import * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuCommands';
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
import {modal} from '@app/features/ui/commands/ModalCommands';
import {formatUserTag} from '@app/features/user/utils/UserTagUtils';
import type {MessageDescriptor} from '@lingui/core';
import {msg} from '@lingui/core/macro';
import {Trans} from '@lingui/react/macro';
@@ -81,8 +83,10 @@ export function useAccountSwitcherLogic(options: AccountSwitcherLogicOptions = {
showAccountSwitcherErrorModal(WE_COULDN_T_SWITCH_ACCOUNTS_PLEASE_TRY_AGAIN_DESCRIPTOR);
return;
}
const email = account.userData?.email ?? undefined;
showBrowserLoginHandoffModal(handleLoginSuccess, email);
const prefillLogin = RuntimeConfig.usesUsernameSignIn
? account.userData && formatUserTag(account.userData)
: (account.userData?.email ?? undefined);
showBrowserLoginHandoffModal(handleLoginSuccess, prefillLogin);
};
const handleSwitchAccount = async (userId: string): Promise<void> => {
if (isBusy) {
@@ -0,0 +1,288 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {PDFDocument, PDFFont, PDFPage, RGB} from 'pdf-lib';
import QRCode from 'qrcode';
export interface RecoveryKitSheetDetail {
label: string;
value: string;
}
export interface RecoveryKitSheet {
productName: string;
title: string;
intro: string;
details: ReadonlyArray<RecoveryKitSheetDetail>;
keyLabel: string;
recoveryKey: string;
recoverUrl: string;
qrCaption: string;
stepsTitle: string;
steps: ReadonlyArray<string>;
backupCodes: {
title: string;
note: string;
codes: ReadonlyArray<string>;
} | null;
}
export function createRecoveryKitQrDataUrl(url: string): Promise<string> {
return QRCode.toDataURL(url, {
errorCorrectionLevel: 'M',
margin: 0,
width: 480,
color: {dark: '#000000', light: '#ffffff'},
});
}
const PAGE_WIDTH = 595.28;
const PAGE_HEIGHT = 841.89;
const MARGIN = 56;
const CONTENT_WIDTH = PAGE_WIDTH - MARGIN * 2;
const QR_SIZE = 112;
const QR_GAP = 24;
const KEY_SIZE = 17;
const KEY_BOX_HEIGHT = 44;
const LINE_HEIGHT_RATIO = 1.35;
const RASTER_SCALE = 4;
type FontKind = 'regular' | 'bold' | 'mono';
interface TextOptions {
size: number;
font: FontKind;
color: RGB;
x?: number;
width?: number;
after?: number;
}
function canEncode(font: PDFFont, text: string): boolean {
try {
font.encodeText(text);
return true;
} catch {
return false;
}
}
function wrapText(text: string, maxWidth: number, measure: (value: string) => number): Array<string> {
const lines: Array<string> = [];
let current = '';
const pushWord = (word: string) => {
if (measure(word) <= maxWidth) {
current = word;
return;
}
let chunk = '';
for (const char of Array.from(word)) {
if (chunk && measure(chunk + char) > maxWidth) {
lines.push(chunk);
chunk = char;
} else {
chunk += char;
}
}
current = chunk;
};
for (const word of text.split(/\s+/).filter(Boolean)) {
if (!current) {
pushWord(word);
continue;
}
const candidate = `${current} ${word}`;
if (measure(candidate) <= maxWidth) {
current = candidate;
} else {
lines.push(current);
pushWord(word);
}
}
if (current) {
lines.push(current);
}
return lines;
}
function rgbToCss(color: RGB): string {
return `rgb(${Math.round(color.red * 255)}, ${Math.round(color.green * 255)}, ${Math.round(color.blue * 255)})`;
}
function rasteriseLines(text: string, options: Required<Pick<TextOptions, 'size' | 'font' | 'color' | 'width'>>) {
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
if (!context) {
return [];
}
const family =
options.font === 'mono'
? 'ui-monospace, monospace'
: window.getComputedStyle(document.body).fontFamily || 'sans-serif';
const weight = options.font === 'regular' ? 400 : 700;
const fontSpec = `${weight} ${options.size * RASTER_SCALE}px ${family}`;
context.font = fontSpec;
const maxWidth = options.width * RASTER_SCALE;
const lines = wrapText(text, maxWidth, (value) => context.measureText(value).width);
const lineHeight = Math.ceil(options.size * LINE_HEIGHT_RATIO * RASTER_SCALE);
const rtl = document.documentElement.dir === 'rtl';
return lines.map((line) => {
const lineCanvas = document.createElement('canvas');
const lineContext = lineCanvas.getContext('2d');
lineCanvas.width = Math.max(1, Math.ceil(maxWidth));
lineCanvas.height = lineHeight;
if (lineContext) {
lineContext.font = fontSpec;
lineContext.fillStyle = rgbToCss(options.color);
lineContext.textBaseline = 'middle';
lineContext.direction = rtl ? 'rtl' : 'ltr';
lineContext.textAlign = rtl ? 'right' : 'left';
lineContext.fillText(line, rtl ? lineCanvas.width : 0, lineHeight / 2);
}
return {dataUrl: lineCanvas.toDataURL('image/png'), height: lineHeight / RASTER_SCALE};
});
}
class SheetWriter {
private page: PDFPage;
y = PAGE_HEIGHT - MARGIN;
constructor(
private readonly pdf: PDFDocument,
private readonly fonts: Record<FontKind, PDFFont>,
) {
this.page = pdf.addPage([PAGE_WIDTH, PAGE_HEIGHT]);
}
get currentPage(): PDFPage {
return this.page;
}
ensureSpace(height: number): void {
if (this.y - height < MARGIN) {
this.page = this.pdf.addPage([PAGE_WIDTH, PAGE_HEIGHT]);
this.y = PAGE_HEIGHT - MARGIN;
}
}
async text(text: string, {size, font, color, x = MARGIN, width = CONTENT_WIDTH, after = 0}: TextOptions) {
const pdfFont = this.fonts[font];
const lineHeight = size * LINE_HEIGHT_RATIO;
if (canEncode(pdfFont, text)) {
for (const line of wrapText(text, width, (value) => pdfFont.widthOfTextAtSize(value, size))) {
this.ensureSpace(lineHeight);
this.page.drawText(line, {x, y: this.y - size, size, font: pdfFont, color});
this.y -= lineHeight;
}
} else {
for (const line of rasteriseLines(text, {size, font, color, width})) {
this.ensureSpace(line.height);
const image = await this.pdf.embedPng(line.dataUrl);
this.page.drawImage(image, {x, y: this.y - line.height, width, height: line.height});
this.y -= line.height;
}
}
this.y -= after;
}
}
export async function buildRecoveryKitPdf(sheet: RecoveryKitSheet): Promise<Uint8Array> {
const {PDFDocument, StandardFonts, rgb} = await import('pdf-lib');
const pdf = await PDFDocument.create();
pdf.setTitle(sheet.title);
pdf.setCreator(sheet.productName);
pdf.setProducer(sheet.productName);
const fonts: Record<FontKind, PDFFont> = {
regular: await pdf.embedFont(StandardFonts.Helvetica),
bold: await pdf.embedFont(StandardFonts.HelveticaBold),
mono: await pdf.embedFont(StandardFonts.CourierBold),
};
const colours = {
text: rgb(0.07, 0.09, 0.15),
muted: rgb(0.36, 0.39, 0.45),
rule: rgb(0.82, 0.84, 0.87),
panel: rgb(0.95, 0.96, 0.97),
};
const writer = new SheetWriter(pdf, fonts);
await writer.text(sheet.title, {size: 22, font: 'bold', color: colours.text, after: 8});
await writer.text(sheet.intro, {size: 11, font: 'regular', color: colours.muted, after: 20});
const top = writer.y;
const qrX = PAGE_WIDTH - MARGIN - QR_SIZE;
const qrImage = await pdf.embedPng(await createRecoveryKitQrDataUrl(sheet.recoverUrl));
writer.currentPage.drawImage(qrImage, {x: qrX, y: top - QR_SIZE, width: QR_SIZE, height: QR_SIZE});
writer.y = top - QR_SIZE - 6;
await writer.text(sheet.qrCaption, {size: 8, font: 'regular', color: colours.muted, x: qrX, width: QR_SIZE});
const qrBottom = writer.y;
writer.y = top;
const detailsWidth = CONTENT_WIDTH - QR_SIZE - QR_GAP;
for (const detail of sheet.details) {
await writer.text(detail.label, {size: 9, font: 'bold', color: colours.muted, width: detailsWidth, after: 2});
await writer.text(detail.value, {size: 12, font: 'regular', color: colours.text, width: detailsWidth, after: 10});
}
writer.y = Math.min(writer.y, qrBottom) - 14;
await writer.text(sheet.keyLabel, {size: 11, font: 'bold', color: colours.text, after: 6});
writer.ensureSpace(KEY_BOX_HEIGHT);
writer.currentPage.drawRectangle({
x: MARGIN,
y: writer.y - KEY_BOX_HEIGHT,
width: CONTENT_WIDTH,
height: KEY_BOX_HEIGHT,
color: colours.panel,
borderColor: colours.rule,
borderWidth: 1,
});
const keyWidth = fonts.mono.widthOfTextAtSize(sheet.recoveryKey, KEY_SIZE);
writer.currentPage.drawText(sheet.recoveryKey, {
x: MARGIN + (CONTENT_WIDTH - keyWidth) / 2,
y: writer.y - KEY_BOX_HEIGHT / 2 - KEY_SIZE * 0.3,
size: KEY_SIZE,
font: fonts.mono,
color: colours.text,
});
writer.y -= KEY_BOX_HEIGHT + 24;
await writer.text(sheet.stepsTitle, {size: 13, font: 'bold', color: colours.text, after: 6});
for (const [index, step] of sheet.steps.entries()) {
writer.ensureSpace(11 * LINE_HEIGHT_RATIO);
writer.currentPage.drawText(`${index + 1}.`, {
x: MARGIN,
y: writer.y - 11,
size: 11,
font: fonts.bold,
color: colours.text,
});
await writer.text(step, {
size: 11,
font: 'regular',
color: colours.text,
x: MARGIN + 18,
width: CONTENT_WIDTH - 18,
after: 4,
});
}
if (sheet.backupCodes && sheet.backupCodes.codes.length > 0) {
writer.y -= 16;
await writer.text(sheet.backupCodes.title, {size: 13, font: 'bold', color: colours.text, after: 4});
await writer.text(sheet.backupCodes.note, {size: 10, font: 'regular', color: colours.muted, after: 8});
const codes = sheet.backupCodes.codes;
const columnWidth = CONTENT_WIDTH / 2;
for (let index = 0; index < codes.length; index += 2) {
writer.ensureSpace(18);
for (const [column, code] of codes.slice(index, index + 2).entries()) {
writer.currentPage.drawText(code, {
x: MARGIN + column * columnWidth,
y: writer.y - 12,
size: 12,
font: fonts.mono,
color: colours.text,
});
}
writer.y -= 18;
}
}
return pdf.save();
}