fix(settings): group account security into clear cards (#3359)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-10 00:59:33 +02:00
1 parent c8755885d4
commit 72f9f9c396
43 files changed
+9982 -9367

No files matched your search

@@ -0,0 +1,134 @@
/* SPDX-License-Identifier: AGPL-3.0-or-later */
.card {
display: flex;
flex-direction: column;
width: 100%;
border: 0.0625rem solid var(--background-modifier-accent);
border-radius: 0.75rem;
color: var(--text-primary);
}
.headerless .rows > :first-child {
border-top: none;
}
.header {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
padding: var(--spacing-4);
}
@media (min-width: 640px) {
.header {
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: var(--spacing-4);
}
}
.headerText {
display: flex;
flex: 1;
flex-direction: column;
gap: var(--spacing-1);
min-width: 0;
}
.titleRow {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-2);
}
.title {
margin: 0;
font-size: 0.9375rem;
font-weight: 600;
line-height: 1.3;
color: var(--text-primary);
}
.description {
margin: 0;
font-size: 0.8125rem;
line-height: 1.4;
color: var(--text-tertiary);
}
.headerAction {
display: flex;
flex-shrink: 0;
gap: var(--spacing-2);
}
.rows {
display: flex;
flex-direction: column;
}
.row {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
border-top: 0.0625rem solid var(--background-modifier-accent);
padding: var(--spacing-3) var(--spacing-4);
}
@media (min-width: 640px) {
.row {
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: var(--spacing-4);
}
}
.rowText {
display: flex;
flex: 1;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.rowLabel {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-primary);
overflow-wrap: anywhere;
}
.rowDescription {
font-size: 0.8125rem;
line-height: 1.4;
color: var(--text-primary-muted);
}
.rowWarning {
font-size: 0.8125rem;
line-height: 1.4;
color: var(--alert-warning-color);
}
.rowActions {
display: flex;
flex-shrink: 0;
flex-wrap: wrap;
gap: var(--spacing-2);
}
.emptyRow {
border-top: 0.0625rem solid var(--background-modifier-accent);
padding: var(--spacing-3) var(--spacing-4);
font-size: 0.8125rem;
color: var(--text-primary-muted);
}
.switchRow {
border-top: 0.0625rem solid var(--background-modifier-accent);
padding: var(--spacing-3) var(--spacing-4);
}
@@ -0,0 +1,125 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import statusStyles from '@app/features/app/components/dialogs/components/plutonium/PurchaseHistoryStatus.module.css';
import styles from '@app/features/user/components/modals/tabs/account_security_tab/AccountSecurityCard.module.css';
import {clsx} from 'clsx';
import type React from 'react';
import {useId} from 'react';
type AccountSecurityStatusTone = 'success' | 'muted' | 'neutral' | 'pending';
interface AccountSecurityCardProps {
title?: React.ReactNode;
'aria-label'?: string;
description?: React.ReactNode;
status?: {label: React.ReactNode; tone: AccountSecurityStatusTone};
action?: React.ReactNode;
children?: React.ReactNode;
'data-flx'?: string;
}
export const AccountSecurityCard: React.FC<AccountSecurityCardProps> = ({
title,
description,
status,
action,
children,
'aria-label': ariaLabel,
}) => {
const titleId = useId();
const hasHeader = title != null || action != null;
return (
<section
className={clsx(styles.card, !hasHeader && styles.headerless)}
aria-labelledby={title != null ? titleId : undefined}
aria-label={title != null ? undefined : ariaLabel}
data-flx="user.account-security-card.card"
>
{hasHeader ? (
<div className={styles.header} data-flx="user.account-security-card.header">
<div className={styles.headerText} data-flx="user.account-security-card.header-text">
<div className={styles.titleRow} data-flx="user.account-security-card.title-row">
{title != null ? (
<h4 id={titleId} className={styles.title} data-flx="user.account-security-card.title">
{title}
</h4>
) : null}
{status ? (
<span className={statusStyles[status.tone]} data-flx="user.account-security-card.status">
{status.label}
</span>
) : null}
</div>
{description ? (
<p className={styles.description} data-flx="user.account-security-card.description">
{description}
</p>
) : null}
</div>
{action ? (
<div className={styles.headerAction} data-flx="user.account-security-card.header-action">
{action}
</div>
) : null}
</div>
) : null}
{children ? (
<div className={styles.rows} data-flx="user.account-security-card.rows">
{children}
</div>
) : null}
</section>
);
};
interface AccountSecurityRowProps {
label: React.ReactNode;
description?: React.ReactNode;
warning?: React.ReactNode;
labelClassName?: string;
children?: React.ReactNode;
'data-flx'?: string;
}
export const AccountSecurityRow: React.FC<AccountSecurityRowProps> = ({
label,
description,
warning,
labelClassName,
children,
}) => (
<div className={styles.row} data-flx="user.account-security-card.row">
<div className={styles.rowText} data-flx="user.account-security-card.row-text">
<span className={labelClassName ?? styles.rowLabel} data-flx="user.account-security-card.row-label">
{label}
</span>
{description ? (
<span className={styles.rowDescription} data-flx="user.account-security-card.row-description">
{description}
</span>
) : null}
{warning ? (
<span className={styles.rowWarning} data-flx="user.account-security-card.row-warning">
{warning}
</span>
) : null}
</div>
{children ? (
<div className={styles.rowActions} data-flx="user.account-security-card.row-actions">
{children}
</div>
) : null}
</div>
);
export const AccountSecurityEmptyRow: React.FC<{children: React.ReactNode; 'data-flx'?: string}> = ({children}) => (
<div className={styles.emptyRow} data-flx="user.account-security-card.empty-row">
{children}
</div>
);
export const AccountSecuritySwitchRow: React.FC<{children: React.ReactNode; 'data-flx'?: string}> = ({children}) => (
<div className={styles.switchRow} data-flx="user.account-security-card.switch-row">
{children}
</div>
);
@@ -1,60 +1,15 @@
/* SPDX-License-Identifier: AGPL-3.0-or-later */
.row {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.accountRows {
display: flex;
flex-direction: column;
gap: 1rem;
}
@media (min-width: 640px) {
.row {
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 1.5rem;
}
}
.rowContent {
flex: 1;
}
.label {
display: block;
margin-bottom: 0.25rem;
font-weight: 500;
color: var(--text-primary);
font-size: 0.875rem;
}
.description {
color: var(--text-primary-muted);
font-size: 0.875rem;
}
.emailRow {
display: flex;
flex-direction: column;
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
@media (min-width: 640px) {
.emailRow {
flex-direction: row;
align-items: center;
gap: 0.5rem;
}
}
.emailText {
color: var(--text-primary-muted);
font-size: 0.875rem;
font-size: 0.8125rem;
}
.emailTextSelectable {
@@ -63,33 +18,11 @@
}
.toggleButton {
margin-top: 0.1em;
text-align: left;
color: var(--text-link);
font-size: 0.875rem;
font-size: 0.8125rem;
cursor: pointer;
}
.toggleButton:hover {
text-decoration: underline;
}
@media (min-width: 640px) {
.toggleButton {
text-align: center;
}
}
.warningText {
color: var(--alert-warning-color);
font-size: 0.875rem;
}
.divider {
border-top: 0.0625rem solid var(--background-header-secondary);
padding-top: 1rem;
}
.claimButton {
align-self: flex-start;
}
@@ -9,6 +9,10 @@ import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
import {modal} from '@app/features/ui/commands/ModalCommands';
import {EmailChangeModal} from '@app/features/user/components/modals/EmailChangeModal';
import {PasswordChangeModal} from '@app/features/user/components/modals/PasswordChangeModal';
import {
AccountSecurityCard,
AccountSecurityRow,
} from '@app/features/user/components/modals/tabs/account_security_tab/AccountSecurityCard';
import styles from '@app/features/user/components/modals/tabs/account_security_tab/AccountTab.module.css';
import {
RecoveryKitReminderAlert,
@@ -29,6 +33,10 @@ const REVEAL_EMAIL_TOGGLE_DESCRIPTOR = msg({
message: 'Reveal',
comment: 'Account settings: button that reveals the masked user email address. Verb, sentence case.',
});
const SIGN_IN_METHODS_DESCRIPTOR = msg({
message: 'Sign-in methods',
comment: 'Accessible name for the group of email, password and recovery kit rows in account settings.',
});
const maskEmail = (email: string): string => {
const [username, domain] = email.split('@');
const maskedUsername = username.replace(/./g, '*');
@@ -47,166 +55,107 @@ export const AccountTabContent: React.FC<AccountTabProps> = observer(
const {i18n} = useLingui();
const usesUsernameSignIn = RuntimeConfig.usesUsernameSignIn;
const emailRow = usesUsernameSignIn ? null : isClaimed ? (
<>
<div className={styles.row} data-flx="user.account-security-tab.account-tab.account-tab-content.email-row">
<div
className={styles.rowContent}
data-flx="user.account-security-tab.account-tab.account-tab-content.email-row-content"
<AccountSecurityRow
label={<Trans>Email address</Trans>}
description={
<span
className={styles.emailRow}
data-flx="user.account-security-tab.account-tab.account-tab-content.email-value"
>
<div
className={styles.label}
data-flx="user.account-security-tab.account-tab.account-tab-content.email-label"
<span
className={`${styles.emailText} ${showMaskedEmail ? styles.emailTextSelectable : ''}`}
data-flx="user.account-security-tab.account-tab.account-tab-content.email-text"
>
<Trans>Email address</Trans>
</div>
<div
className={styles.emailRow}
data-flx="user.account-security-tab.account-tab.account-tab-content.email-value-row"
{showMaskedEmail ? user.email : maskEmail(user.email!)}
</span>
<button
type="button"
className={styles.toggleButton}
onClick={() => setShowMaskedEmail(!showMaskedEmail)}
data-flx="user.account-security-tab.account-tab.account-tab-content.toggle-button"
>
<span
className={`${styles.emailText} ${showMaskedEmail ? styles.emailTextSelectable : ''}`}
data-flx="user.account-security-tab.account-tab.account-tab-content.email-text"
>
{showMaskedEmail ? user.email : maskEmail(user.email!)}
</span>
<button
type="button"
className={styles.toggleButton}
onClick={() => setShowMaskedEmail(!showMaskedEmail)}
data-flx="user.account-security-tab.account-tab.account-tab-content.toggle-email-visibility"
>
{showMaskedEmail ? i18n._(HIDE_EMAIL_TOGGLE_DESCRIPTOR) : i18n._(REVEAL_EMAIL_TOGGLE_DESCRIPTOR)}
</button>
</div>
</div>
{RuntimeConfig.emailsEnabled && (
<Button
small={true}
onClick={() =>
ModalCommands.push(
modal(() => (
<EmailChangeModal
user={user}
data-flx="user.account-security-tab.account-tab.account-tab-content.email-change-modal"
/>
)),
)
}
data-flx="user.account-security-tab.account-tab.account-tab-content.change-email-button"
>
<Trans>Change email</Trans>
</Button>
)}
</div>
{RuntimeConfig.emailsEnabled && user.email && !user.verified && (
<EmailVerificationAlert data-flx="user.account-security-tab.account-tab.account-tab-content.email-verification-alert" />
{showMaskedEmail ? i18n._(HIDE_EMAIL_TOGGLE_DESCRIPTOR) : i18n._(REVEAL_EMAIL_TOGGLE_DESCRIPTOR)}
</button>
</span>
}
data-flx="user.account-security-tab.account-tab.account-tab-content.email-row"
>
{RuntimeConfig.emailsEnabled && (
<Button
variant="secondary"
small={true}
onClick={() =>
ModalCommands.push(
modal(() => (
<EmailChangeModal
user={user}
data-flx="user.account-security-tab.account-tab.account-tab-content.email-change-modal"
/>
)),
)
}
data-flx="user.account-security-tab.account-tab.account-tab-content.button.change-email"
>
<Trans>Change email</Trans>
</Button>
)}
</>
</AccountSecurityRow>
) : (
<div className={styles.row} data-flx="user.account-security-tab.account-tab.account-tab-content.email-row">
<div
className={styles.rowContent}
data-flx="user.account-security-tab.account-tab.account-tab-content.email-row-content"
>
<div
className={styles.label}
data-flx="user.account-security-tab.account-tab.account-tab-content.email-label"
>
<Trans>Email address</Trans>
</div>
<div
className={styles.warningText}
data-flx="user.account-security-tab.account-tab.account-tab-content.email-warning"
>
<Trans>No email address set</Trans>
</div>
</div>
<AccountSecurityRow
label={<Trans>Email address</Trans>}
warning={<Trans>No email address set</Trans>}
data-flx="user.account-security-tab.account-tab.account-tab-content.email-row--unclaimed"
>
<Button
small={true}
className={styles.claimButton}
fitContent
onClick={() => openClaimAccountModal()}
data-flx="user.account-security-tab.account-tab.account-tab-content.add-email-button"
data-flx="user.account-security-tab.account-tab.account-tab-content.button.add-email"
>
<Trans>Add email</Trans>
</Button>
</div>
</AccountSecurityRow>
);
const passwordRow = (
<div
className={emailRow ? styles.divider : undefined}
data-flx="user.account-security-tab.account-tab.account-tab-content.password-block"
>
<div className={styles.row} data-flx="user.account-security-tab.account-tab.account-tab-content.password-row">
{isClaimed ? (
<>
<div
className={styles.rowContent}
data-flx="user.account-security-tab.account-tab.account-tab-content.password-row-content"
>
<div
className={styles.label}
data-flx="user.account-security-tab.account-tab.account-tab-content.password-label"
>
<Trans>Password</Trans>
</div>
<div
className={styles.description}
data-flx="user.account-security-tab.account-tab.account-tab-content.password-description"
>
{user.passwordLastChangedAt ? (
<Trans>Last changed: {DateUtils.getRelativeDateString(user.passwordLastChangedAt, i18n)}</Trans>
) : (
<Trans>Last changed: never</Trans>
)}
</div>
</div>
<Button
small={true}
onClick={() =>
ModalCommands.push(
modal(() => (
<PasswordChangeModal data-flx="user.account-security-tab.account-tab.account-tab-content.password-change-modal" />
)),
)
}
data-flx="user.account-security-tab.account-tab.account-tab-content.change-password-button"
>
<Trans>Change password</Trans>
</Button>
</>
const passwordRow = isClaimed ? (
<AccountSecurityRow
label={<Trans>Password</Trans>}
description={
user.passwordLastChangedAt ? (
<Trans>Last changed: {DateUtils.getRelativeDateString(user.passwordLastChangedAt, i18n)}</Trans>
) : (
<>
<div
className={styles.rowContent}
data-flx="user.account-security-tab.account-tab.account-tab-content.password-row-content"
>
<div
className={styles.label}
data-flx="user.account-security-tab.account-tab.account-tab-content.password-label"
>
<Trans>Password</Trans>
</div>
<div
className={styles.warningText}
data-flx="user.account-security-tab.account-tab.account-tab-content.password-warning"
>
<Trans>No password set</Trans>
</div>
</div>
<Button
small={true}
className={styles.claimButton}
fitContent
onClick={() => openClaimAccountModal()}
data-flx="user.account-security-tab.account-tab.account-tab-content.set-password-button"
>
<Trans>Set password</Trans>
</Button>
</>
)}
</div>
</div>
<Trans>Last changed: never</Trans>
)
}
data-flx="user.account-security-tab.account-tab.account-tab-content.password-row"
>
<Button
variant="secondary"
small={true}
onClick={() =>
ModalCommands.push(
modal(() => (
<PasswordChangeModal data-flx="user.account-security-tab.account-tab.account-tab-content.password-change-modal" />
)),
)
}
data-flx="user.account-security-tab.account-tab.account-tab-content.button.change-password"
>
<Trans>Change password</Trans>
</Button>
</AccountSecurityRow>
) : (
<AccountSecurityRow
label={<Trans>Password</Trans>}
warning={<Trans>No password set</Trans>}
data-flx="user.account-security-tab.account-tab.account-tab-content.password-row--unclaimed"
>
<Button
small={true}
onClick={() => openClaimAccountModal()}
data-flx="user.account-security-tab.account-tab.account-tab-content.button.set-password"
>
<Trans>Set password</Trans>
</Button>
</AccountSecurityRow>
);
return (
<>
@@ -216,10 +165,13 @@ export const AccountTabContent: React.FC<AccountTabProps> = observer(
{usesUsernameSignIn && isClaimed && (
<RecoveryKitReminderAlert
user={user}
data-flx="user.account-security-tab.account-tab.account-tab-content.recovery-kit-reminder"
data-flx="user.account-security-tab.account-tab.account-tab-content.recovery-kit-reminder-alert"
/>
)}
<div className={styles.accountRows} data-flx="user.account-security-tab.account-tab.account-tab-content.rows">
<AccountSecurityCard
aria-label={i18n._(SIGN_IN_METHODS_DESCRIPTOR)}
data-flx="user.account-security-tab.account-tab.account-tab-content.rows"
>
{emailRow}
{passwordRow}
{usesUsernameSignIn && isClaimed && (
@@ -228,7 +180,10 @@ export const AccountTabContent: React.FC<AccountTabProps> = observer(
data-flx="user.account-security-tab.account-tab.account-tab-content.recovery-kit-row"
/>
)}
</div>
</AccountSecurityCard>
{RuntimeConfig.emailsEnabled && isClaimed && !usesUsernameSignIn && user.email && !user.verified && (
<EmailVerificationAlert data-flx="user.account-security-tab.account-tab.account-tab-content.email-verification-alert" />
)}
</>
);
},
@@ -1,6 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {SettingsTabSection} from '@app/features/app/components/dialogs/shared/SettingsTabLayout';
import {AccountDeleteModal} from '@app/features/auth/components/modals/AccountDeleteModal';
import {AccountDisableModal} from '@app/features/auth/components/modals/AccountDisableModal';
import {GuildOwnershipWarningModal} from '@app/features/guild/components/modals/GuildOwnershipWarningModal';
@@ -9,12 +8,21 @@ import {DELETE_ACCOUNT_DESCRIPTOR, DISABLE_ACCOUNT_DESCRIPTOR} from '@app/featur
import {Button} from '@app/features/ui/button/Button';
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
import {modal} from '@app/features/ui/commands/ModalCommands';
import styles from '@app/features/user/components/modals/tabs/account_security_tab/AccountTab.module.css';
import {
AccountSecurityCard,
AccountSecurityRow,
} from '@app/features/user/components/modals/tabs/account_security_tab/AccountSecurityCard';
import type {User} from '@app/features/user/models/User';
import {msg} from '@lingui/core/macro';
import {Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import type React from 'react';
const DANGER_ZONE_ACTIONS_DESCRIPTOR = msg({
message: 'Disable or delete your account',
comment: 'Accessible name for the group of disable and delete account actions in account settings.',
});
interface DangerZoneTabProps {
user: User;
isClaimed: boolean;
@@ -49,41 +57,42 @@ export const DangerZoneTabContent: React.FC<DangerZoneTabProps> = observer(({use
}
};
return (
<>
<AccountSecurityCard
aria-label={i18n._(DANGER_ZONE_ACTIONS_DESCRIPTOR)}
data-flx="user.account-security-tab.danger-zone-tab.card"
>
{isClaimed && (
<SettingsTabSection
title={i18n._(DISABLE_ACCOUNT_DESCRIPTOR)}
<AccountSecurityRow
label={i18n._(DISABLE_ACCOUNT_DESCRIPTOR)}
description={<Trans>Temporarily disable your account. You can reactivate it later by signing back in.</Trans>}
data-flx="user.account-security-tab.danger-zone-tab.danger-zone-tab-content.settings-tab-section"
data-flx="user.account-security-tab.danger-zone-tab.disable-row"
>
<Button
variant="primary"
className={styles.claimButton}
variant="secondary"
small={true}
onClick={handleDisableAccount}
data-flx="user.account-security-tab.danger-zone-tab.danger-zone-tab-content.claim-button.disable-account"
data-flx="user.account-security-tab.danger-zone-tab.button.disable-account"
>
{i18n._(DISABLE_ACCOUNT_DESCRIPTOR)}
</Button>
</SettingsTabSection>
</AccountSecurityRow>
)}
<SettingsTabSection
title={i18n._(DELETE_ACCOUNT_DESCRIPTOR)}
<AccountSecurityRow
label={i18n._(DELETE_ACCOUNT_DESCRIPTOR)}
description={
<Trans>Permanently delete your account and all associated data. This action cannot be undone.</Trans>
}
data-flx="user.account-security-tab.danger-zone-tab.danger-zone-tab-content.settings-tab-section--2"
data-flx="user.account-security-tab.danger-zone-tab.delete-row"
>
<Button
variant="danger"
className={styles.claimButton}
small={true}
onClick={handleDeleteAccount}
data-flx="user.account-security-tab.danger-zone-tab.danger-zone-tab-content.claim-button.delete-account"
data-flx="user.account-security-tab.danger-zone-tab.button.delete-account"
>
{i18n._(DELETE_ACCOUNT_DESCRIPTOR)}
</Button>
</SettingsTabSection>
</>
</AccountSecurityRow>
</AccountSecurityCard>
);
});
@@ -6,7 +6,7 @@ import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
import {modal} from '@app/features/ui/commands/ModalCommands';
import {WarningAlert} from '@app/features/ui/warning_alert/WarningAlert';
import * as RecoveryKitCommands from '@app/features/user/commands/RecoveryKitCommands';
import styles from '@app/features/user/components/modals/tabs/account_security_tab/AccountTab.module.css';
import {AccountSecurityRow} from '@app/features/user/components/modals/tabs/account_security_tab/AccountSecurityCard';
import type {User} from '@app/features/user/models/User';
import RecoveryKitStatus from '@app/features/user/state/RecoveryKitStatus';
import * as DateUtils from '@app/features/user/utils/DateFormatting';
@@ -79,37 +79,30 @@ export const RecoveryKitRow = observer(({user}: {user: User}) => {
const status = useRecoveryKitStatus(user);
const {creating, create} = useCreateRecoveryKit();
const createdAt = status?.createdAt ?? null;
const missing = status != null && !status.hasRecoveryKit;
return (
<div className={styles.divider} data-flx="user.account-security-tab.recovery-kit-settings.recovery-kit-block">
<div className={styles.row} data-flx="user.account-security-tab.recovery-kit-settings.recovery-kit-row">
<div className={styles.rowContent} data-flx="user.account-security-tab.recovery-kit-settings.row-content">
<div className={styles.label} data-flx="user.account-security-tab.recovery-kit-settings.label">
<Trans>Recovery kit</Trans>
</div>
{status && !status.hasRecoveryKit ? (
<div className={styles.warningText} data-flx="user.account-security-tab.recovery-kit-settings.missing">
<Trans>You don't have a recovery kit yet</Trans>
</div>
) : (
<div className={styles.description} data-flx="user.account-security-tab.recovery-kit-settings.description">
{createdAt ? (
<Trans>Created {DateUtils.getRelativeDateString(createdAt, i18n)}</Trans>
) : (
<Trans>Lets you reset your password if you forget it.</Trans>
)}
</div>
)}
</div>
<Button
small={true}
submitting={creating}
onClick={status?.hasRecoveryKit === false ? create : () => confirmNewRecoveryKit(i18n, create)}
data-flx="user.account-security-tab.recovery-kit-settings.button.create"
>
{status?.hasRecoveryKit === false ? <Trans>Create recovery kit</Trans> : <Trans>Create a new kit</Trans>}
</Button>
</div>
</div>
<AccountSecurityRow
label={<Trans>Recovery kit</Trans>}
description={
missing ? null : createdAt ? (
<Trans>Created {DateUtils.getRelativeDateString(createdAt, i18n)}</Trans>
) : (
<Trans>Lets you reset your password if you forget it.</Trans>
)
}
warning={missing ? <Trans>You don't have a recovery kit yet</Trans> : null}
data-flx="user.account-security-tab.recovery-kit-settings.recovery-kit-row"
>
<Button
small={true}
variant={missing ? 'primary' : 'secondary'}
submitting={creating}
onClick={status?.hasRecoveryKit === false ? create : () => confirmNewRecoveryKit(i18n, create)}
data-flx="user.account-security-tab.recovery-kit-settings.button.create"
>
{status?.hasRecoveryKit === false ? <Trans>Create recovery kit</Trans> : <Trans>Create a new kit</Trans>}
</Button>
</AccountSecurityRow>
);
});
@@ -1,91 +1,11 @@
/* SPDX-License-Identifier: AGPL-3.0-or-later */
.row {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
@media (min-width: 640px) {
.row {
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 1.5rem;
}
}
.rowContent {
flex: 1;
}
.label {
display: block;
margin-bottom: 0.25rem;
font-weight: 500;
color: var(--text-primary);
font-size: 0.875rem;
}
.description {
color: var(--text-primary-muted);
font-size: 0.875rem;
}
.warningText {
margin-top: 0.25rem;
color: var(--alert-warning-color);
font-size: 0.875rem;
}
.divider {
border-top: 0.0625rem solid var(--background-modifier-accent);
padding-top: 1rem;
}
.passkeyList {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.passkeyItem {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
@media (min-width: 640px) {
.passkeyItem {
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 1.5rem;
}
}
.passkeyInfo {
flex: 1;
}
.passkeyName {
display: block;
margin-bottom: 0.25rem;
font-weight: 500;
color: var(--text-primary);
font-size: 0.875rem;
}
.passkeyDetails {
color: var(--text-primary-muted);
font-size: 0.75rem;
}
.passkeyActions {
display: flex;
gap: 0.5rem;
}
.claimButton {
align-self: flex-start;
}
@@ -19,6 +19,12 @@ import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
import {modal} from '@app/features/ui/commands/ModalCommands';
import {Switch} from '@app/features/ui/components/form/FormSwitch';
import * as UserCommands from '@app/features/user/commands/UserCommands';
import {
AccountSecurityCard,
AccountSecurityEmptyRow,
AccountSecurityRow,
AccountSecuritySwitchRow,
} from '@app/features/user/components/modals/tabs/account_security_tab/AccountSecurityCard';
import styles from '@app/features/user/components/modals/tabs/account_security_tab/SecurityTab.module.css';
import type {User} from '@app/features/user/models/User';
import type {WebAuthnCredential} from '@app/features/user/state/WebAuthnCredentials';
@@ -85,9 +91,54 @@ const COULD_NOT_UPDATE_PASSKEY_TWO_FACTOR_DESCRIPTOR = msg({
message: "Couldn't update passkey two-factor authentication",
comment: 'Title of the error modal shown when the passkey two-factor setting could not be saved.',
});
const ACCOUNT_ACCESS_DESCRIPTOR = msg({
message: 'Account access',
comment: 'Security settings section for third-party app access and signed-in devices.',
const APPS_AND_DEVICES_DESCRIPTOR = msg({
message: 'Apps and devices',
comment: 'Security settings card title for third-party app access and signed-in devices.',
});
const TWO_FACTOR_CARD_DESCRIPTION_DESCRIPTOR = msg({
message: 'Ask for a second step after your password when you sign in.',
comment: 'Security settings: description under the two-factor authentication card title.',
});
const STATUS_ON_DESCRIPTOR = msg({
message: 'On',
comment:
'Security settings: status badge next to the two-factor authentication title when it is turned on. One word.',
});
const STATUS_OFF_DESCRIPTOR = msg({
message: 'Off',
comment:
'Security settings: status badge next to the two-factor authentication title when it is turned off. One word.',
});
const PASSKEY_COUNT_DESCRIPTOR = msg({
message: '{count, plural, one {# passkey} other {# passkeys}}',
comment: 'Security settings: badge next to the Passkeys title showing how many passkeys are registered.',
});
const TURN_OFF_AUTHENTICATOR_APP_DESCRIPTOR = msg({
message: 'Turn off authenticator app',
comment:
'Security settings: accessible name for the button that removes authenticator-app two-factor authentication.',
});
const SET_UP_AUTHENTICATOR_APP_DESCRIPTOR = msg({
message: 'Set up authenticator app',
comment: 'Security settings: accessible name for the button that starts authenticator-app setup.',
});
const RENAME_PASSKEY_NAMED_DESCRIPTOR = msg({
message: 'Rename passkey {passkeyName}',
comment:
'Security settings: accessible name for the rename button on a passkey row. {passkeyName} is the name the user gave the passkey.',
});
const DELETE_PASSKEY_NAMED_DESCRIPTOR = msg({
message: 'Delete passkey {passkeyName}',
comment:
'Security settings: accessible name for the delete button on a passkey row. {passkeyName} is the name the user gave the passkey.',
});
const MANAGE_AUTHORIZED_APPS_DESCRIPTOR = msg({
message: 'Manage authorized apps',
comment: 'Security settings: accessible name for the button that opens the authorized apps list.',
});
const MANAGE_DEVICES_DESCRIPTOR = msg({
message: 'Manage devices',
comment: 'Security settings: accessible name for the button that opens the signed-in devices list.',
});
const MANAGE_APPS_AND_DEVICES_WITH_ACCESS_TO_YOUR_DESCRIPTOR = msg({
message: 'Manage apps and devices with access to your account',
@@ -289,47 +340,36 @@ export const SecurityTabContent: React.FC<SecurityTabProps> = observer(
</SettingsTabSection>
);
}
const totpDescription = hasTotpMfa ? (
<Trans>Codes from your authenticator app are your second step when you sign in.</Trans>
) : (
<Trans>Use an authenticator app to generate codes for two-factor authentication</Trans>
);
return (
<>
<SettingsTabSection
<AccountSecurityCard
title={i18n._(TWO_FACTOR_AUTHENTICATION_DESCRIPTOR)}
description={<Trans>Add an extra layer of security to your account</Trans>}
data-flx="user.account-security-tab.security-tab.security-tab-content.settings-tab-section--2"
description={i18n._(TWO_FACTOR_CARD_DESCRIPTION_DESCRIPTOR)}
status={
hasAnyMfa
? {label: i18n._(STATUS_ON_DESCRIPTOR), tone: 'success'}
: {label: i18n._(STATUS_OFF_DESCRIPTOR), tone: 'muted'}
}
data-flx="user.account-security-tab.security-tab.two-factor-card"
>
<div className={styles.row} data-flx="user.account-security-tab.security-tab.security-tab-content.row">
<div
className={styles.rowContent}
data-flx="user.account-security-tab.security-tab.security-tab-content.row-content"
>
<div
className={styles.label}
data-flx="user.account-security-tab.security-tab.security-tab-content.label"
>
<Trans>Authenticator app</Trans>
</div>
<div
className={styles.description}
data-flx="user.account-security-tab.security-tab.security-tab-content.description"
>
{hasTotpMfa ? (
<Trans>Two-factor authentication is enabled</Trans>
) : (
<Trans>Use an authenticator app to generate codes for two-factor authentication</Trans>
)}
</div>
{needsEmailVerification && !hasTotpMfa && (
<div
className={styles.warningText}
data-flx="user.account-security-tab.security-tab.security-tab-content.warning-text"
>
{i18n._(VERIFY_EMAIL_BEFORE_AUTHENTICATOR_APP_DESCRIPTOR)}
</div>
)}
</div>
<AccountSecurityRow
label={<Trans>Authenticator app</Trans>}
description={totpDescription}
warning={
needsEmailVerification && !hasTotpMfa ? i18n._(VERIFY_EMAIL_BEFORE_AUTHENTICATOR_APP_DESCRIPTOR) : null
}
data-flx="user.account-security-tab.security-tab.authenticator-app-row"
>
{hasTotpMfa ? (
<Button
variant="danger"
variant="secondary"
small={true}
aria-label={i18n._(TURN_OFF_AUTHENTICATOR_APP_DESCRIPTOR)}
onClick={() =>
ModalCommands.push(
modal(() => (
@@ -339,12 +379,13 @@ export const SecurityTabContent: React.FC<SecurityTabProps> = observer(
}
data-flx="user.account-security-tab.security-tab.security-tab-content.button.push"
>
<Trans>Disable</Trans>
<Trans>Turn off</Trans>
</Button>
) : (
<Button
small={true}
disabled={!canAddSecurityCredential}
aria-label={i18n._(SET_UP_AUTHENTICATOR_APP_DESCRIPTOR)}
onClick={() =>
ModalCommands.push(
modal(() => (
@@ -357,93 +398,114 @@ export const SecurityTabContent: React.FC<SecurityTabProps> = observer(
}
data-flx="user.account-security-tab.security-tab.security-tab-content.button.push--2"
>
<Trans>Enable</Trans>
<Trans>Set up</Trans>
</Button>
)}
</div>
</AccountSecurityRow>
{hasAnyMfa && (
<div
className={styles.divider}
data-flx="user.account-security-tab.security-tab.security-tab-content.divider"
<AccountSecurityRow
label={<Trans>Backup codes</Trans>}
description={<Trans>View and manage your backup codes for account recovery</Trans>}
data-flx="user.account-security-tab.security-tab.backup-codes-row"
>
<div className={styles.row} data-flx="user.account-security-tab.security-tab.security-tab-content.row--2">
<div
className={styles.rowContent}
data-flx="user.account-security-tab.security-tab.security-tab-content.row-content--2"
>
<div
className={styles.label}
data-flx="user.account-security-tab.security-tab.security-tab-content.label--2"
>
<Trans>Backup codes</Trans>
</div>
<div
className={styles.description}
data-flx="user.account-security-tab.security-tab.security-tab-content.description--2"
>
<Trans>View and manage your backup codes for account recovery</Trans>
</div>
</div>
<Button
variant="secondary"
small={true}
onClick={() =>
ModalCommands.push(
modal(() => (
<BackupCodesViewModal
user={user}
data-flx="user.account-security-tab.security-tab.security-tab-content.backup-codes-view-modal"
/>
)),
)
}
data-flx="user.account-security-tab.security-tab.security-tab-content.button.push--3"
>
<Trans>View codes</Trans>
</Button>
</div>
</div>
<Button
variant="secondary"
small={true}
onClick={() =>
ModalCommands.push(
modal(() => (
<BackupCodesViewModal
user={user}
data-flx="user.account-security-tab.security-tab.security-tab-content.backup-codes-view-modal"
/>
)),
)
}
data-flx="user.account-security-tab.security-tab.security-tab-content.button.push--3"
>
<Trans>View codes</Trans>
</Button>
</AccountSecurityRow>
)}
</SettingsTabSection>
<SettingsTabSection
</AccountSecurityCard>
<AccountSecurityCard
title={<Trans>Passkeys</Trans>}
description={<Trans>Use passkeys to sign in without a password</Trans>}
data-flx="user.account-security-tab.security-tab.security-tab-content.settings-tab-section--3"
>
<div className={styles.row} data-flx="user.account-security-tab.security-tab.security-tab-content.row--3">
<div
className={styles.rowContent}
data-flx="user.account-security-tab.security-tab.security-tab-content.row-content--3"
>
<div
className={styles.label}
data-flx="user.account-security-tab.security-tab.security-tab-content.label--3"
>
<Trans>Registered passkeys</Trans>
</div>
{needsEmailVerification && (
<div
className={styles.warningText}
data-flx="user.account-security-tab.security-tab.security-tab-content.warning-text--2"
>
{i18n._(VERIFY_EMAIL_BEFORE_PASSKEY_DESCRIPTOR)}
</div>
)}
</div>
status={
passkeys.length > 0
? {
label: i18n._(PASSKEY_COUNT_DESCRIPTOR, {count: passkeys.length}),
tone: 'neutral',
}
: undefined
}
action={
<Button
small={true}
variant={passkeys.length > 0 ? 'secondary' : 'primary'}
disabled={!canAddPasskey}
onClick={handleAddPasskey}
data-flx="user.account-security-tab.security-tab.security-tab-content.button.add-passkey"
>
<Trans>Add passkey</Trans>
</Button>
</div>
}
data-flx="user.account-security-tab.security-tab.passkeys-card"
>
{needsEmailVerification && (
<AccountSecurityEmptyRow data-flx="user.account-security-tab.security-tab.passkeys-verify-email">
{i18n._(VERIFY_EMAIL_BEFORE_PASSKEY_DESCRIPTOR)}
</AccountSecurityEmptyRow>
)}
{passkeys.length === 0 && !needsEmailVerification && (
<AccountSecurityEmptyRow data-flx="user.account-security-tab.security-tab.passkeys-empty">
<Trans>No passkeys yet. Add one to sign in with your fingerprint, face or a security key.</Trans>
</AccountSecurityEmptyRow>
)}
{passkeys.map((passkey) => {
const createdDate = DateUtils.getRelativeDateString(new Date(passkey.created_at), i18n);
const lastUsedDate = passkey.last_used_at
? DateUtils.getRelativeDateString(new Date(passkey.last_used_at), i18n)
: null;
const passkeyName = passkey.name;
return (
<AccountSecurityRow
key={passkey.id}
label={passkeyName}
description={
lastUsedDate ? (
<Trans>
Added {createdDate}, last used {lastUsedDate}
</Trans>
) : (
<Trans>Added {createdDate}, never used</Trans>
)
}
data-flx="user.account-security-tab.security-tab.security-tab-content.passkey-item"
>
<Button
variant="secondary"
small={true}
aria-label={i18n._(RENAME_PASSKEY_NAMED_DESCRIPTOR, {passkeyName})}
onClick={() => handleRenamePasskey(passkey.id)}
data-flx="user.account-security-tab.security-tab.security-tab-content.button.rename-passkey"
>
<Trans>Rename</Trans>
</Button>
<Button
variant="ghost"
small={true}
aria-label={i18n._(DELETE_PASSKEY_NAMED_DESCRIPTOR, {passkeyName})}
onClick={() => handleDeletePasskey(passkey.id)}
data-flx="user.account-security-tab.security-tab.security-tab-content.button.delete-passkey"
>
<Trans>Delete</Trans>
</Button>
</AccountSecurityRow>
);
})}
{passkeys.length > 0 && (
<div
className={styles.divider}
data-flx="user.account-security-tab.security-tab.security-tab-content.passkey-two-factor-divider"
>
<AccountSecuritySwitchRow data-flx="user.account-security-tab.security-tab.passkey-two-factor-row">
<Switch
label={<Trans>Require a passkey as your second factor</Trans>}
description={<Trans>Ask for a passkey after your password when you sign in</Trans>}
@@ -451,154 +513,51 @@ export const SecurityTabContent: React.FC<SecurityTabProps> = observer(
onChange={handleTogglePasskeyTwoFactor}
data-flx="user.account-security-tab.security-tab.security-tab-content.switch.passkey-two-factor"
/>
</div>
</AccountSecuritySwitchRow>
)}
{passkeys.length > 0 && (
<div
className={styles.divider}
data-flx="user.account-security-tab.security-tab.security-tab-content.divider--2"
>
<div
className={styles.passkeyList}
data-flx="user.account-security-tab.security-tab.security-tab-content.passkey-list"
>
{passkeys.map((passkey) => {
const createdDate = DateUtils.getRelativeDateString(new Date(passkey.created_at), i18n);
const lastUsedDate = passkey.last_used_at
? DateUtils.getRelativeDateString(new Date(passkey.last_used_at), i18n)
: null;
return (
<div
key={passkey.id}
className={styles.passkeyItem}
data-flx="user.account-security-tab.security-tab.security-tab-content.passkey-item"
>
<div
className={styles.passkeyInfo}
data-flx="user.account-security-tab.security-tab.security-tab-content.passkey-info"
>
<div
className={styles.passkeyName}
data-flx="user.account-security-tab.security-tab.security-tab-content.passkey-name"
>
{passkey.name}
</div>
<div
className={styles.passkeyDetails}
data-flx="user.account-security-tab.security-tab.security-tab-content.passkey-details"
>
{lastUsedDate ? (
<Trans>
Added: {createdDate} • last used: {lastUsedDate}
</Trans>
) : (
<Trans>Added: {createdDate}</Trans>
)}
</div>
</div>
<div
className={styles.passkeyActions}
data-flx="user.account-security-tab.security-tab.security-tab-content.passkey-actions"
>
<Button
variant="secondary"
small={true}
onClick={() => handleRenamePasskey(passkey.id)}
data-flx="user.account-security-tab.security-tab.security-tab-content.button.rename-passkey"
>
<Trans>Rename</Trans>
</Button>
<Button
variant="danger"
small={true}
onClick={() => handleDeletePasskey(passkey.id)}
data-flx="user.account-security-tab.security-tab.security-tab-content.button.delete-passkey"
>
<Trans>Delete</Trans>
</Button>
</div>
</div>
);
})}
</div>
</div>
)}
</SettingsTabSection>
</AccountSecurityCard>
{(onManageAuthorizedApps || onManageLinkedDevices) && (
<SettingsTabSection
title={i18n._(ACCOUNT_ACCESS_DESCRIPTOR)}
<AccountSecurityCard
title={i18n._(APPS_AND_DEVICES_DESCRIPTOR)}
description={i18n._(MANAGE_APPS_AND_DEVICES_WITH_ACCESS_TO_YOUR_DESCRIPTOR)}
data-flx="user.account-security-tab.security-tab.security-tab-content.account-access"
>
{onManageAuthorizedApps && (
<div
className={styles.row}
<AccountSecurityRow
label={i18n._(AUTHORIZED_APPS_DESCRIPTOR)}
description={i18n._(REVIEW_APPS_THAT_CAN_ACCESS_YOUR_ACCOUNT_DESCRIPTOR)}
data-flx="user.account-security-tab.security-tab.security-tab-content.account-access.authorized-apps-row"
>
<div
className={styles.rowContent}
data-flx="user.account-security-tab.security-tab.security-tab-content.account-access.authorized-apps-row-content"
>
<div
className={styles.label}
data-flx="user.account-security-tab.security-tab.security-tab-content.account-access.authorized-apps-label"
>
{i18n._(AUTHORIZED_APPS_DESCRIPTOR)}
</div>
<div
className={styles.description}
data-flx="user.account-security-tab.security-tab.security-tab-content.account-access.authorized-apps-description"
>
{i18n._(REVIEW_APPS_THAT_CAN_ACCESS_YOUR_ACCOUNT_DESCRIPTOR)}
</div>
</div>
<Button
variant="secondary"
small={true}
submitting={authorizedAppsSubmitting}
aria-label={i18n._(MANAGE_AUTHORIZED_APPS_DESCRIPTOR)}
onClick={onManageAuthorizedApps}
data-flx="user.account-security-tab.security-tab.security-tab-content.account-access.button.manage-authorized-apps"
>
<Trans>Manage</Trans>
</Button>
</div>
</AccountSecurityRow>
)}
{onManageLinkedDevices && (
<div
className={styles.divider}
data-flx="user.account-security-tab.security-tab.security-tab-content.account-access.devices-divider"
<AccountSecurityRow
label={i18n._(LINKED_DEVICES_DESCRIPTOR)}
description={i18n._(REVIEW_SIGNED_IN_DEVICES_DESCRIPTOR)}
data-flx="user.account-security-tab.security-tab.security-tab-content.account-access.devices-row"
>
<div
className={styles.row}
data-flx="user.account-security-tab.security-tab.security-tab-content.account-access.devices-row"
<Button
variant="secondary"
small={true}
aria-label={i18n._(MANAGE_DEVICES_DESCRIPTOR)}
onClick={onManageLinkedDevices}
data-flx="user.account-security-tab.security-tab.security-tab-content.account-access.button.manage-linked-devices"
>
<div
className={styles.rowContent}
data-flx="user.account-security-tab.security-tab.security-tab-content.account-access.devices-row-content"
>
<div
className={styles.label}
data-flx="user.account-security-tab.security-tab.security-tab-content.account-access.devices-label"
>
{i18n._(LINKED_DEVICES_DESCRIPTOR)}
</div>
<div
className={styles.description}
data-flx="user.account-security-tab.security-tab.security-tab-content.account-access.devices-description"
>
{i18n._(REVIEW_SIGNED_IN_DEVICES_DESCRIPTOR)}
</div>
</div>
<Button
small={true}
onClick={onManageLinkedDevices}
data-flx="user.account-security-tab.security-tab.security-tab-content.account-access.button.manage-linked-devices"
>
<Trans>Manage</Trans>
</Button>
</div>
</div>
<Trans>Manage</Trans>
</Button>
</AccountSecurityRow>
)}
</SettingsTabSection>
</AccountSecurityCard>
)}
</>
);