mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-18 14:56:50 +02:00
feat(core): Multi-credential UX + last-used tracking + method-aware reverify
- Track lastUsedAt per webauthn credential (new column + migration) and surface it as "Last used X ago" in the credentials list. - Personal settings restructured to one card per method with a nested credentials list, device-aware label pre-fill on the setup wizard, and a dashed "Add another …" button. - Login MFA prompt picks the method the user signed in with last on this device (localStorage) and shows a switcher link when both TOTP and a webauthn credential are registered. - Credential removal re-verifies with the same kind of authenticator being removed (passkey → passkey, security key → security key) via a narrowed allowCredentials, falling back to the TOTP / recovery code prompt when the device is unavailable. - Refuse to delete the last MFA credential while 2FA is enforced.
This commit is contained in:
parent
00bce46e5f
commit
0cc659eb99
|
|
@ -7,4 +7,5 @@ export interface WebAuthnCredentialResponse {
|
|||
transports: string[] | null;
|
||||
aaguid: string | null;
|
||||
createdAt: string;
|
||||
lastUsedAt: string | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,4 +39,7 @@ export class WebauthnCredential extends WithTimestampsAndStringId {
|
|||
|
||||
@Column({ type: String, length: 255 })
|
||||
label: string;
|
||||
|
||||
@Column({ type: 'datetime', nullable: true })
|
||||
lastUsedAt: Date | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||
|
||||
export class AddLastUsedAtToWebauthnCredential1783300000000 implements ReversibleMigration {
|
||||
async up({ schemaBuilder: { addColumns, column } }: MigrationContext) {
|
||||
await addColumns('webauthn_credential', [column('lastUsedAt').timestamp()]);
|
||||
}
|
||||
|
||||
async down({ schemaBuilder: { dropColumns } }: MigrationContext) {
|
||||
await dropColumns('webauthn_credential', ['lastUsedAt']);
|
||||
}
|
||||
}
|
||||
|
|
@ -175,6 +175,7 @@ import { AddEvaluationConfigColumnsToTestRun1778100002000 } from '../common/1778
|
|||
import { CreateAgentTables1783000000000 } from '../common/1783000000000-CreateAgentTables';
|
||||
import { CreateAgentExecutionTables1783000000001 } from '../common/1783000000001-CreateAgentExecutionTables';
|
||||
import { AddTokensValidAfterToUser1783200000000 } from '../common/1783200000000-AddTokensValidAfterToUser';
|
||||
import { AddLastUsedAtToWebauthnCredential1783300000000 } from '../common/1783300000000-AddLastUsedAtToWebauthnCredential';
|
||||
import type { Migration } from '../migration-types';
|
||||
|
||||
export const postgresMigrations: Migration[] = [
|
||||
|
|
@ -355,4 +356,5 @@ export const postgresMigrations: Migration[] = [
|
|||
CreateAgentTables1783000000000,
|
||||
CreateAgentExecutionTables1783000000001,
|
||||
AddTokensValidAfterToUser1783200000000,
|
||||
AddLastUsedAtToWebauthnCredential1783300000000,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -168,6 +168,7 @@ import { AddEvaluationConfigColumnsToTestRun1778100002000 } from '../common/1778
|
|||
import { CreateAgentTables1783000000000 } from '../common/1783000000000-CreateAgentTables';
|
||||
import { CreateAgentExecutionTables1783000000001 } from '../common/1783000000001-CreateAgentExecutionTables';
|
||||
import { AddTokensValidAfterToUser1783200000000 } from '../common/1783200000000-AddTokensValidAfterToUser';
|
||||
import { AddLastUsedAtToWebauthnCredential1783300000000 } from '../common/1783300000000-AddLastUsedAtToWebauthnCredential';
|
||||
import type { Migration } from '../migration-types';
|
||||
|
||||
const sqliteMigrations: Migration[] = [
|
||||
|
|
@ -341,6 +342,7 @@ const sqliteMigrations: Migration[] = [
|
|||
CreateAgentTables1783000000000,
|
||||
CreateAgentExecutionTables1783000000001,
|
||||
AddTokensValidAfterToUser1783200000000,
|
||||
AddLastUsedAtToWebauthnCredential1783300000000,
|
||||
];
|
||||
|
||||
export { sqliteMigrations };
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { InstanceSettingsLoaderConfig } from '@n8n/config';
|
|||
import { AuthenticatedRequest, UserRepository } from '@n8n/db';
|
||||
import {
|
||||
createUserKeyedRateLimiter,
|
||||
Delete,
|
||||
Get,
|
||||
GlobalScope,
|
||||
Patch,
|
||||
|
|
@ -317,8 +316,10 @@ export class MFAController {
|
|||
@Post('/webauthn/authentication-options', {
|
||||
skipAuth: true,
|
||||
})
|
||||
async getWebAuthnAuthenticationOptions(req: AuthenticatedRequest<{}, {}, { email: string }>) {
|
||||
const { email } = req.body;
|
||||
async getWebAuthnAuthenticationOptions(
|
||||
req: AuthenticatedRequest<{}, {}, { email: string; kind?: 'passkey' | 'security_key' }>,
|
||||
) {
|
||||
const { email, kind } = req.body;
|
||||
if (!email) {
|
||||
throw new BadRequestError('Email is required');
|
||||
}
|
||||
|
|
@ -329,7 +330,8 @@ export class MFAController {
|
|||
throw new BadRequestError('MFA Error', 998);
|
||||
}
|
||||
|
||||
return await this.mfaService.webauthn.generateAuthenticationOptions(user.id);
|
||||
const narrowedKind = kind === 'passkey' || kind === 'security_key' ? kind : undefined;
|
||||
return await this.mfaService.webauthn.generateAuthenticationOptions(user.id, narrowedKind);
|
||||
}
|
||||
|
||||
@Get('/webauthn/credentials')
|
||||
|
|
@ -344,25 +346,73 @@ export class MFAController {
|
|||
transports: c.transports,
|
||||
aaguid: c.aaguid ?? null,
|
||||
createdAt: c.createdAt.toISOString(),
|
||||
lastUsedAt: c.lastUsedAt ? c.lastUsedAt.toISOString() : null,
|
||||
}));
|
||||
}
|
||||
|
||||
@Delete('/webauthn/credentials/:id')
|
||||
async deleteWebAuthnCredential(req: AuthenticatedRequest<{ id: string }>, res: Response) {
|
||||
// Use POST (not DELETE) so the proof body reliably reaches the server —
|
||||
// our rest-api-client routes DELETE data through query params, which
|
||||
// can't carry a structured webauthn assertion.
|
||||
@Post('/webauthn/credentials/:id/remove')
|
||||
async deleteWebAuthnCredential(
|
||||
req: AuthenticatedRequest<
|
||||
{ id: string },
|
||||
{},
|
||||
{ mfaCode?: string; mfaRecoveryCode?: string; webauthnResponse?: unknown }
|
||||
>,
|
||||
res: Response,
|
||||
) {
|
||||
const { id } = req.params;
|
||||
const { mfaCode, mfaRecoveryCode, webauthnResponse } = req.body;
|
||||
const userId = req.user.id;
|
||||
|
||||
// Removing a credential is security-sensitive — re-verify the user's
|
||||
// active 2FA method (TOTP code, recovery code, or a fresh webauthn
|
||||
// assertion) before we touch the credential row.
|
||||
const provided = [mfaCode, mfaRecoveryCode, webauthnResponse].filter(
|
||||
(v) => v !== undefined && v !== null && v !== '',
|
||||
);
|
||||
if (provided.length !== 1) {
|
||||
throw new BadRequestError(
|
||||
'Exactly one of mfaCode, mfaRecoveryCode, or webauthnResponse is required to remove this credential',
|
||||
);
|
||||
}
|
||||
|
||||
let proofValid: boolean;
|
||||
if (webauthnResponse !== undefined && webauthnResponse !== null) {
|
||||
proofValid = await this.mfaService.validateWebAuthn(userId, webauthnResponse);
|
||||
} else {
|
||||
proofValid = await this.mfaService.validateMfa(userId, mfaCode, mfaRecoveryCode);
|
||||
}
|
||||
if (!proofValid) {
|
||||
throw new BadRequestError('Invalid two-factor proof');
|
||||
}
|
||||
|
||||
const [credentialsBefore, isMFAEnforced, { decryptedSecret }] = await Promise.all([
|
||||
this.mfaService.webauthn.getUserCredentials(userId),
|
||||
this.mfaService.isMFAEnforced(),
|
||||
this.mfaService.getSecretAndRecoveryCodes(userId),
|
||||
]);
|
||||
const remainingWebauthn = credentialsBefore.filter((c) => c.id !== id).length;
|
||||
const hasTotpSecret = !!decryptedSecret;
|
||||
|
||||
// Refuse if removing this credential would leave the user with no
|
||||
// authentication factor on an instance that enforces MFA.
|
||||
if (isMFAEnforced && remainingWebauthn === 0 && !hasTotpSecret) {
|
||||
throw new BadRequestError(
|
||||
'You can’t remove your last MFA credential while two-factor authentication is required on this instance',
|
||||
);
|
||||
}
|
||||
|
||||
const deleted = await this.mfaService.webauthn.deleteCredential(id, userId);
|
||||
if (!deleted) {
|
||||
throw new BadRequestError('Credential not found');
|
||||
}
|
||||
|
||||
// Check if user has any remaining MFA methods
|
||||
const hasWebauthnCredentials = await this.mfaService.webauthn.hasCredentials(userId);
|
||||
const { decryptedSecret } = await this.mfaService.getSecretAndRecoveryCodes(userId);
|
||||
const hasTotpSecret = !!decryptedSecret;
|
||||
|
||||
if (!hasWebauthnCredentials && !hasTotpSecret) {
|
||||
// If this was the last MFA factor, mirror disable-MFA semantics so the
|
||||
// user's session reflects the new state. The pre-delete read above
|
||||
// already told us what's left — no need to re-query.
|
||||
if (remainingWebauthn === 0 && !hasTotpSecret) {
|
||||
await this.userRepository.update(userId, {
|
||||
mfaEnabled: false,
|
||||
mfaSecret: null,
|
||||
|
|
|
|||
|
|
@ -158,13 +158,24 @@ export class WebAuthnService {
|
|||
);
|
||||
}
|
||||
|
||||
async generateAuthenticationOptions(userId: string) {
|
||||
async generateAuthenticationOptions(userId: string, kind?: 'passkey' | 'security_key') {
|
||||
const { generateAuthenticationOptions } = await import('@simplewebauthn/server');
|
||||
|
||||
const credentials = await this.webauthnCredentialRepository.find({
|
||||
const allCredentials = await this.webauthnCredentialRepository.find({
|
||||
where: { userId },
|
||||
});
|
||||
|
||||
// When the caller asks for a specific kind (e.g. re-verify with a passkey
|
||||
// when removing one) we narrow `allowCredentials` so the browser/OS can
|
||||
// only satisfy the ceremony with a matching credential.
|
||||
const filterByKind = (c: (typeof allCredentials)[number]) => {
|
||||
if (!kind) return true;
|
||||
const isPlatform =
|
||||
(c.transports ?? []).includes('internal') || c.deviceType === 'multiDevice';
|
||||
return kind === 'passkey' ? isPlatform : !isPlatform;
|
||||
};
|
||||
const credentials = allCredentials.filter(filterByKind);
|
||||
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: this.getRpId(),
|
||||
allowCredentials: credentials.map((cred) => ({
|
||||
|
|
@ -231,6 +242,7 @@ export class WebAuthnService {
|
|||
if (verification.verified && verification.authenticationInfo) {
|
||||
await this.webauthnCredentialRepository.update(credential.id, {
|
||||
counter: verification.authenticationInfo.newCounter,
|
||||
lastUsedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -307,6 +319,7 @@ export class WebAuthnService {
|
|||
|
||||
await this.webauthnCredentialRepository.update(credential.id, {
|
||||
counter: verification.authenticationInfo.newCounter,
|
||||
lastUsedAt: new Date(),
|
||||
});
|
||||
await this.cacheService.delete(cacheKey);
|
||||
|
||||
|
|
|
|||
|
|
@ -4867,7 +4867,14 @@
|
|||
"settings.personal.method.button.remove": "Remove",
|
||||
"settings.personal.method.button.disable": "Disable",
|
||||
"settings.personal.method.button.addAnother": "Add another",
|
||||
"settings.personal.method.button.addAnother.passkey": "Add another passkey",
|
||||
"settings.personal.method.button.addAnother.security_key": "Add another security key",
|
||||
"settings.personal.method.badge.registeredOne": "1 registered",
|
||||
"settings.personal.method.badge.registeredMany": "{count} registered",
|
||||
"settings.personal.method.detail.setUpOn": "Set up {date}",
|
||||
"settings.personal.method.detail.addedOn": "Added {date}",
|
||||
"settings.personal.method.detail.lastUsed": "Last used {when}",
|
||||
"settings.personal.method.detail.neverUsed": "Never used",
|
||||
"settings.personal.twoFactor.section.title": "Two-factor authentication (2FA)",
|
||||
"settings.personal.twoFactor.description": "Add a second step after your password. You can enable multiple methods at the same time.",
|
||||
"settings.personal.twoFactor.disabled.description": "Two-factor authentication is currently disabled.",
|
||||
|
|
@ -4928,6 +4935,9 @@
|
|||
"mfa.login.security_key.waiting.title": "Touch your security key",
|
||||
"mfa.login.security_key.waiting.description": "The key should be blinking. Touch the button on it now to verify.",
|
||||
"mfa.login.cancel": "Cancel",
|
||||
"mfa.login.switcher.useTotp": "Use authenticator app instead",
|
||||
"mfa.login.switcher.useSecurityKey": "Use security key instead",
|
||||
"mfa.login.switcher.usePasskey": "Use passkey instead",
|
||||
"auth.changePassword.passkey.title": "Verify with your passkey",
|
||||
"auth.changePassword.passkey.description": "Confirm with Touch ID, Face ID, or your device PIN to set a new password.",
|
||||
"auth.changePassword.passkey.button": "Verify with passkey",
|
||||
|
|
|
|||
|
|
@ -30,9 +30,14 @@ export async function verifyRegistration(
|
|||
return await makeRestApiRequest(context, 'POST', '/mfa/webauthn/registration-verify', data);
|
||||
}
|
||||
|
||||
export async function getAuthenticationOptions(context: IRestApiContext, email: string) {
|
||||
export async function getAuthenticationOptions(
|
||||
context: IRestApiContext,
|
||||
email: string,
|
||||
kind?: 'passkey' | 'security_key',
|
||||
) {
|
||||
return await makeRestApiRequest(context, 'POST', '/mfa/webauthn/authentication-options', {
|
||||
email,
|
||||
...(kind ? { kind } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -42,8 +47,18 @@ export async function getCredentials(
|
|||
return await makeRestApiRequest(context, 'GET', '/mfa/webauthn/credentials');
|
||||
}
|
||||
|
||||
export async function deleteCredential(context: IRestApiContext, id: string): Promise<void> {
|
||||
return await makeRestApiRequest(context, 'DELETE', `/mfa/webauthn/credentials/${id}`);
|
||||
export type DeleteCredentialProof = {
|
||||
mfaCode?: string;
|
||||
mfaRecoveryCode?: string;
|
||||
webauthnResponse?: unknown;
|
||||
};
|
||||
|
||||
export async function deleteCredential(
|
||||
context: IRestApiContext,
|
||||
id: string,
|
||||
proof: DeleteCredentialProof,
|
||||
): Promise<void> {
|
||||
return await makeRestApiRequest(context, 'POST', `/mfa/webauthn/credentials/${id}/remove`, proof);
|
||||
}
|
||||
|
||||
export async function updateCredential(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
/**
|
||||
* Per-device localStorage key remembering which 2FA method the user used last
|
||||
* to sign in. Read on the MFA prompt to pick a sensible default and written
|
||||
* after a successful sign-in. Stored values are `MfaMethod` strings.
|
||||
*/
|
||||
export const LAST_2FA_METHOD_KEY = 'n8n.last2faMethod';
|
||||
|
|
@ -18,6 +18,7 @@ import { useUIStore } from '@/app/stores/ui.store';
|
|||
import { useToast } from '@/app/composables/useToast';
|
||||
import { useUsersStore } from '@/features/settings/users/users.store';
|
||||
import { twoFactorWizardBus, type TwoFactorMethod } from '../auth.eventBus';
|
||||
import { suggestCredentialLabel } from '../utils/suggest-label';
|
||||
import MfaWizardSteps from './MfaWizardSteps.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
|
|
@ -36,7 +37,7 @@ const attachment = computed<'platform' | 'cross-platform'>(() =>
|
|||
method.value === 'passkey' ? 'platform' : 'cross-platform',
|
||||
);
|
||||
|
||||
const label = ref('');
|
||||
const label = ref(suggestCredentialLabel(navigator.userAgent, props.data?.method ?? 'passkey'));
|
||||
const submitting = ref(false);
|
||||
const recoveryCodes = ref<string[]>([]);
|
||||
const showRecoveryCodes = computed(() => recoveryCodes.value.length > 0);
|
||||
|
|
|
|||
|
|
@ -14,53 +14,52 @@ export type MfaProof =
|
|||
* webauthn assertion ceremony. The caller forwards the returned proof to whichever
|
||||
* endpoint requires re-verification (e.g. disable MFA, change password, change email).
|
||||
*
|
||||
* Pass `preferKind: 'passkey' | 'security_key'` to force a webauthn ceremony with
|
||||
* a credential of that type (used when removing a credential — the user is asked
|
||||
* to re-verify with the same kind of authenticator they're removing). When the
|
||||
* user doesn't have any credentials of that kind, this falls back to the
|
||||
* code/recovery-code prompt.
|
||||
*
|
||||
* Resolves with `null` if the user cancels.
|
||||
*/
|
||||
export function useMfaReverify() {
|
||||
const usersStore = useUsersStore();
|
||||
const uiStore = useUIStore();
|
||||
|
||||
async function reverify(): Promise<MfaProof | null> {
|
||||
const promptCode = (): Promise<MfaProof | null> =>
|
||||
new Promise<MfaProof | null>((resolve) => {
|
||||
const handler = (payload: { mfaCode?: string; mfaRecoveryCode?: string } | undefined) => {
|
||||
promptMfaCodeBus.off('closed', handler);
|
||||
if (!payload) return resolve(null);
|
||||
if (payload.mfaCode) return resolve({ mfaCode: payload.mfaCode });
|
||||
if (payload.mfaRecoveryCode) return resolve({ mfaRecoveryCode: payload.mfaRecoveryCode });
|
||||
resolve(null);
|
||||
};
|
||||
promptMfaCodeBus.on('closed', handler);
|
||||
uiStore.openModal(PROMPT_MFA_CODE_MODAL_KEY);
|
||||
});
|
||||
|
||||
async function reverify(preferKind?: 'passkey' | 'security_key'): Promise<MfaProof | null> {
|
||||
const user = usersStore.currentUser;
|
||||
if (!user || !user.mfaEnabled) return null;
|
||||
|
||||
const methods = user.availableMfaMethods ?? [];
|
||||
const method = methods[0] ?? 'totp';
|
||||
const chosen: 'totp' | 'passkey' | 'security_key' =
|
||||
preferKind && methods.includes(preferKind) ? preferKind : (methods[0] ?? 'totp');
|
||||
|
||||
if (method === 'totp') {
|
||||
return await new Promise<MfaProof | null>((resolve) => {
|
||||
const handler = (payload: { mfaCode?: string; mfaRecoveryCode?: string } | undefined) => {
|
||||
promptMfaCodeBus.off('closed', handler);
|
||||
if (!payload) return resolve(null);
|
||||
if (payload.mfaCode) return resolve({ mfaCode: payload.mfaCode });
|
||||
if (payload.mfaRecoveryCode) return resolve({ mfaRecoveryCode: payload.mfaRecoveryCode });
|
||||
resolve(null);
|
||||
};
|
||||
promptMfaCodeBus.on('closed', handler);
|
||||
uiStore.openModal(PROMPT_MFA_CODE_MODAL_KEY);
|
||||
});
|
||||
if (chosen === 'totp') {
|
||||
return await promptCode();
|
||||
}
|
||||
|
||||
// webauthn — passkey or security_key
|
||||
if (!user.email) return null;
|
||||
try {
|
||||
const webauthnResponse = await usersStore.verifyWebAuthnAuthentication(user.email);
|
||||
const webauthnResponse = await usersStore.verifyWebAuthnAuthentication(user.email, chosen);
|
||||
return { webauthnResponse };
|
||||
} catch {
|
||||
// Authenticator unavailable (lost device, cancelled ceremony) —
|
||||
// fall back to the code/recovery-code prompt so users can still
|
||||
// manage their account with a recovery code.
|
||||
return await new Promise<MfaProof | null>((resolve) => {
|
||||
const handler = (payload: { mfaCode?: string; mfaRecoveryCode?: string } | undefined) => {
|
||||
promptMfaCodeBus.off('closed', handler);
|
||||
if (!payload) return resolve(null);
|
||||
if (payload.mfaCode) return resolve({ mfaCode: payload.mfaCode });
|
||||
if (payload.mfaRecoveryCode) return resolve({ mfaRecoveryCode: payload.mfaRecoveryCode });
|
||||
resolve(null);
|
||||
};
|
||||
promptMfaCodeBus.on('closed', handler);
|
||||
uiStore.openModal(PROMPT_MFA_CODE_MODAL_KEY);
|
||||
});
|
||||
// Authenticator unavailable (lost device, cancelled ceremony) — fall
|
||||
// back to the code/recovery-code prompt so users can still manage
|
||||
// their account with a recovery code.
|
||||
return await promptCode();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
import Bowser from 'bowser';
|
||||
|
||||
/**
|
||||
* Suggest a sensible default label for a freshly-registered passkey based on
|
||||
* the current device. Returns `''` for security keys (the user is more likely
|
||||
* to want a specific, inventory-style name like "YubiKey 5C (primary)") and
|
||||
* for unknown platforms. The result is purely a UX hint — users can edit it
|
||||
* freely before registration.
|
||||
*/
|
||||
export function suggestCredentialLabel(
|
||||
userAgent: string,
|
||||
method: 'passkey' | 'security_key',
|
||||
): string {
|
||||
if (method !== 'passkey') return '';
|
||||
|
||||
const { os } = Bowser.parse(userAgent);
|
||||
switch (os.name) {
|
||||
case 'iOS':
|
||||
return 'iPhone';
|
||||
case 'iPadOS':
|
||||
return 'iPad';
|
||||
case 'macOS':
|
||||
return 'Mac';
|
||||
case 'Android':
|
||||
return 'Android device';
|
||||
case 'Windows':
|
||||
return 'Windows PC';
|
||||
case 'Linux':
|
||||
case 'Chrome OS':
|
||||
return `${os.name} PC`;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
|
@ -8,7 +8,8 @@ import {
|
|||
MFA_FORM,
|
||||
} from '@/app/constants';
|
||||
import { mfaEventBus } from '../auth.eventBus';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { LAST_2FA_METHOD_KEY } from '../auth.constants';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { toRefs } from '@vueuse/core';
|
||||
import { useSettingsStore } from '@/app/stores/settings.store';
|
||||
|
|
@ -27,9 +28,55 @@ import {
|
|||
const props = defineProps<{
|
||||
reportError: boolean;
|
||||
email: string;
|
||||
mfaMethod: MfaMethod;
|
||||
availableMethods: MfaMethod[];
|
||||
}>();
|
||||
|
||||
const readLastMethod = (): MfaMethod | null => {
|
||||
try {
|
||||
const v = localStorage.getItem(LAST_2FA_METHOD_KEY);
|
||||
if (v === 'totp' || v === 'security_key' || v === 'passkey') return v;
|
||||
} catch {
|
||||
// localStorage unavailable
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Pick the method the user used last on this device, falling back to a
|
||||
// security key when registered (passkey/security_key share the same WebAuthn
|
||||
// ceremony) and finally to TOTP.
|
||||
const computeInitialMethod = (): MfaMethod => {
|
||||
const last = readLastMethod();
|
||||
if (last && props.availableMethods.includes(last)) return last;
|
||||
if (props.availableMethods.includes('security_key')) return 'security_key';
|
||||
if (props.availableMethods.includes('passkey')) return 'passkey';
|
||||
return 'totp';
|
||||
};
|
||||
|
||||
const webauthnMethod = computed<'passkey' | 'security_key'>(() =>
|
||||
props.availableMethods.includes('security_key') ? 'security_key' : 'passkey',
|
||||
);
|
||||
const currentMethod = ref<MfaMethod>(computeInitialMethod());
|
||||
const showSwitcher = computed(() => {
|
||||
const hasTotp = props.availableMethods.includes('totp');
|
||||
const hasWebauthn =
|
||||
props.availableMethods.includes('security_key') || props.availableMethods.includes('passkey');
|
||||
return hasTotp && hasWebauthn;
|
||||
});
|
||||
const switcherTarget = computed<MfaMethod>(() =>
|
||||
currentMethod.value === 'totp' ? webauthnMethod.value : 'totp',
|
||||
);
|
||||
const switcherLabelKey = computed(() =>
|
||||
switcherTarget.value === 'totp'
|
||||
? 'mfa.login.switcher.useTotp'
|
||||
: switcherTarget.value === 'passkey'
|
||||
? 'mfa.login.switcher.usePasskey'
|
||||
: 'mfa.login.switcher.useSecurityKey',
|
||||
);
|
||||
const switcherIcon = computed(() => (switcherTarget.value === 'totp' ? 'shield' : 'key-round'));
|
||||
const onSwitcherClick = () => {
|
||||
currentMethod.value = switcherTarget.value;
|
||||
};
|
||||
|
||||
const hasAnyChanges = ref(false);
|
||||
const formBus = ref(mfaEventBus);
|
||||
const formInputs = ref<null | IFormInputs>(null);
|
||||
|
|
@ -194,11 +241,22 @@ const {
|
|||
} = useSettingsStore();
|
||||
|
||||
onMounted(() => {
|
||||
if (props.mfaMethod === 'totp') {
|
||||
if (currentMethod.value === 'totp') {
|
||||
formInputs.value = [mfaCodeFieldWithDefaults()];
|
||||
focusMfaCodeAfterPasswordManager();
|
||||
}
|
||||
});
|
||||
|
||||
// When the user toggles between methods via the switcher, swap form inputs so
|
||||
// the new method's primary input is auto-focused.
|
||||
watch(currentMethod, (next) => {
|
||||
if (next === 'totp') {
|
||||
formInputs.value = [mfaCodeFieldWithDefaults()];
|
||||
focusMfaCodeAfterPasswordManager();
|
||||
} else {
|
||||
formInputs.value = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -207,7 +265,7 @@ onMounted(() => {
|
|||
<N8nCard>
|
||||
<div :class="$style.headerContainer">
|
||||
<N8nHeading size="xlarge" color="text-dark">
|
||||
<template v-if="mfaMethod === 'totp'">{{
|
||||
<template v-if="currentMethod === 'totp'">{{
|
||||
showRecoveryCodeForm
|
||||
? i18.baseText('mfa.recovery.modal.title')
|
||||
: i18.baseText('mfa.code.modal.title')
|
||||
|
|
@ -217,7 +275,7 @@ onMounted(() => {
|
|||
</div>
|
||||
|
||||
<!-- TOTP flow -->
|
||||
<template v-if="mfaMethod === 'totp'">
|
||||
<template v-if="currentMethod === 'totp'">
|
||||
<div :class="[$style.formContainer, reportError ? $style.formError : '']">
|
||||
<N8nFormInputs
|
||||
v-if="formInputs"
|
||||
|
|
@ -272,6 +330,16 @@ onMounted(() => {
|
|||
@click="onSaveClick"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="showSwitcher && !showRecoveryCodeForm"
|
||||
:class="$style.switcher"
|
||||
data-test-id="mfa-switcher"
|
||||
>
|
||||
<button type="button" :class="$style.switcherLink" @click="onSwitcherClick">
|
||||
<N8nIcon :icon="switcherIcon" size="xsmall" />
|
||||
{{ i18.baseText(switcherLabelKey as never) }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- WebAuthn flow (passkey or security key) -->
|
||||
|
|
@ -280,31 +348,31 @@ onMounted(() => {
|
|||
<div
|
||||
:class="[
|
||||
$style.bigIcon,
|
||||
$style[`tone_${mfaMethod}`],
|
||||
$style[`tone_${currentMethod}`],
|
||||
{ [$style.pulse]: webauthnWaiting },
|
||||
]"
|
||||
>
|
||||
<N8nIcon :icon="mfaMethod === 'passkey' ? 'fingerprint' : 'key-round'" :size="36" />
|
||||
<N8nIcon :icon="currentMethod === 'passkey' ? 'fingerprint' : 'key-round'" :size="36" />
|
||||
</div>
|
||||
<N8nHeading tag="h3" size="medium" color="text-dark" :class="$style.promptTitle">
|
||||
<template v-if="!webauthnWaiting">{{
|
||||
i18.baseText(`mfa.login.${mfaMethod}.title` as never)
|
||||
i18.baseText(`mfa.login.${currentMethod}.title` as never)
|
||||
}}</template>
|
||||
<template v-else>{{
|
||||
i18.baseText(`mfa.login.${mfaMethod}.waiting.title` as never)
|
||||
i18.baseText(`mfa.login.${currentMethod}.waiting.title` as never)
|
||||
}}</template>
|
||||
</N8nHeading>
|
||||
<N8nText size="small" color="text-base" :class="$style.promptDescription">
|
||||
<template v-if="!webauthnWaiting">{{
|
||||
i18.baseText(`mfa.login.${mfaMethod}.description` as never)
|
||||
i18.baseText(`mfa.login.${currentMethod}.description` as never)
|
||||
}}</template>
|
||||
<template v-else>{{
|
||||
i18.baseText(`mfa.login.${mfaMethod}.waiting.description` as never)
|
||||
i18.baseText(`mfa.login.${currentMethod}.waiting.description` as never)
|
||||
}}</template>
|
||||
</N8nText>
|
||||
</div>
|
||||
<N8nNotice
|
||||
v-if="mfaMethod === 'security_key' && !webauthnWaiting"
|
||||
v-if="currentMethod === 'security_key' && !webauthnWaiting"
|
||||
theme="info"
|
||||
:content="i18.baseText('mfa.login.security_key.info')"
|
||||
:class="$style.infoNotice"
|
||||
|
|
@ -314,7 +382,7 @@ onMounted(() => {
|
|||
</N8nText>
|
||||
<N8nButton
|
||||
v-if="!webauthnWaiting"
|
||||
:label="i18.baseText(`mfa.login.${mfaMethod}.button` as never)"
|
||||
:label="i18.baseText(`mfa.login.${currentMethod}.button` as never)"
|
||||
size="large"
|
||||
:class="$style.fullWidthButton"
|
||||
data-test-id="mfa-webauthn-button"
|
||||
|
|
@ -330,6 +398,16 @@ onMounted(() => {
|
|||
@click="onWebAuthnCancel"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="showSwitcher && !webauthnWaiting"
|
||||
:class="$style.switcher"
|
||||
data-test-id="mfa-switcher"
|
||||
>
|
||||
<button type="button" :class="$style.switcherLink" @click="onSwitcherClick">
|
||||
<N8nIcon :icon="switcherIcon" size="xsmall" />
|
||||
{{ i18.baseText(switcherLabelKey as never) }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</N8nCard>
|
||||
</div>
|
||||
|
|
@ -463,4 +541,24 @@ body {
|
|||
justify-content: flex-start;
|
||||
margin-top: var(--spacing--3xs);
|
||||
}
|
||||
|
||||
.switcher {
|
||||
margin-top: var(--spacing--xs);
|
||||
padding-top: var(--spacing--xs);
|
||||
border-top: var(--border-width) var(--border-style) var(--color--foreground);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.switcherLink {
|
||||
color: var(--color--primary);
|
||||
font-size: var(--font-size--xs);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--3xs);
|
||||
}
|
||||
|
||||
.switcherLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -148,14 +148,41 @@ const formatSetupDate = (iso: string) => {
|
|||
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(date);
|
||||
};
|
||||
|
||||
const formatCredentialDetail = (c: WebAuthnCredentialResponse) => {
|
||||
const relativeTimeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' });
|
||||
const formatRelativeTime = (iso: string | null) => {
|
||||
if (!iso) return null;
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
const diffSec = Math.round((date.getTime() - Date.now()) / 1000);
|
||||
const abs = Math.abs(diffSec);
|
||||
if (abs < 60) return relativeTimeFormatter.format(Math.round(diffSec), 'second');
|
||||
if (abs < 3600) return relativeTimeFormatter.format(Math.round(diffSec / 60), 'minute');
|
||||
if (abs < 86400) return relativeTimeFormatter.format(Math.round(diffSec / 3600), 'hour');
|
||||
if (abs < 86400 * 30) return relativeTimeFormatter.format(Math.round(diffSec / 86400), 'day');
|
||||
if (abs < 86400 * 365)
|
||||
return relativeTimeFormatter.format(Math.round(diffSec / (86400 * 30)), 'month');
|
||||
return relativeTimeFormatter.format(Math.round(diffSec / (86400 * 365)), 'year');
|
||||
};
|
||||
|
||||
const formatCredentialMeta = (c: WebAuthnCredentialResponse) => {
|
||||
const parts: string[] = [];
|
||||
const setupDate = formatSetupDate(c.createdAt);
|
||||
const setupSuffix = setupDate
|
||||
? i18n.baseText('settings.personal.method.detail.setUpOn', {
|
||||
if (setupDate) {
|
||||
parts.push(
|
||||
i18n.baseText('settings.personal.method.detail.addedOn', {
|
||||
interpolate: { date: setupDate },
|
||||
})
|
||||
: '';
|
||||
return [c.label, setupSuffix].filter(Boolean).join(' · ');
|
||||
}),
|
||||
);
|
||||
}
|
||||
const lastUsed = formatRelativeTime(c.lastUsedAt);
|
||||
parts.push(
|
||||
lastUsed
|
||||
? i18n.baseText('settings.personal.method.detail.lastUsed', {
|
||||
interpolate: { when: lastUsed },
|
||||
})
|
||||
: i18n.baseText('settings.personal.method.detail.neverUsed'),
|
||||
);
|
||||
return parts.join(' · ');
|
||||
};
|
||||
|
||||
async function refreshWebauthnCredentials() {
|
||||
|
|
@ -166,9 +193,16 @@ async function refreshWebauthnCredentials() {
|
|||
}
|
||||
}
|
||||
|
||||
async function removeWebAuthnCredential(credentialId: string) {
|
||||
async function removeWebAuthnCredential(credential: WebAuthnCredentialResponse) {
|
||||
// Re-verify with the same kind of authenticator the user is removing:
|
||||
// `reverify` filters `allowCredentials` server-side so the OS picker can
|
||||
// only satisfy the ceremony with a matching credential. Falls back to
|
||||
// TOTP / recovery-code when the device is unavailable.
|
||||
const kind = isPlatformCredential(credential) ? 'passkey' : 'security_key';
|
||||
const proof = await reverify(kind);
|
||||
if (!proof) return;
|
||||
try {
|
||||
await usersStore.deleteWebAuthnCredential(credentialId);
|
||||
await usersStore.deleteWebAuthnCredential(credential.id, proof);
|
||||
showToast({
|
||||
title: i18n.baseText('settings.personal.mfa.toast.disabledMfa.title'),
|
||||
message: i18n.baseText('settings.personal.mfa.toast.disabledMfa.message'),
|
||||
|
|
@ -488,38 +522,8 @@ onBeforeUnmount(() => {
|
|||
{{ i18n.baseText('settings.personal.passkey.description') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
<div :class="$style.methodCards">
|
||||
<div
|
||||
v-for="cred in passkeyCredentials"
|
||||
:key="cred.id"
|
||||
:class="[$style.methodCard]"
|
||||
:data-test-id="`passkey-card-${cred.id}`"
|
||||
>
|
||||
<div :class="[$style.activeMethodIcon, $style.tone_passkey]">
|
||||
<N8nIcon icon="fingerprint" />
|
||||
</div>
|
||||
<div :class="$style.methodCardContent">
|
||||
<div :class="$style.methodCardLabel">
|
||||
<N8nText :bold="true">{{ cred.label }}</N8nText>
|
||||
<N8nBadge theme="success">{{
|
||||
i18n.baseText('settings.personal.mfa.status.enabled')
|
||||
}}</N8nBadge>
|
||||
</div>
|
||||
<span :class="$style.methodDetail">{{ formatCredentialDetail(cred) }}</span>
|
||||
</div>
|
||||
<N8nButton
|
||||
variant="subtle"
|
||||
size="small"
|
||||
:label="i18n.baseText('settings.personal.method.button.remove')"
|
||||
:data-test-id="`remove-passkey-${cred.id}`"
|
||||
@click="removeWebAuthnCredential(cred.id)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="passkeyCredentials.length === 0"
|
||||
:class="[$style.methodCard]"
|
||||
data-test-id="passkey-card-disabled"
|
||||
>
|
||||
<div :class="$style.methodCard" data-test-id="passkey-card">
|
||||
<div :class="$style.methodCardHeader">
|
||||
<div :class="[$style.activeMethodIcon, $style.tone_passkey]">
|
||||
<N8nIcon icon="fingerprint" />
|
||||
</div>
|
||||
|
|
@ -528,7 +532,14 @@ onBeforeUnmount(() => {
|
|||
<N8nText :bold="true">{{
|
||||
i18n.baseText('settings.personal.twoFactor.method.passkey.name')
|
||||
}}</N8nText>
|
||||
<N8nBadge theme="default">{{
|
||||
<N8nBadge v-if="passkeyCredentials.length > 0" theme="success">{{
|
||||
passkeyCredentials.length === 1
|
||||
? i18n.baseText('settings.personal.method.badge.registeredOne')
|
||||
: i18n.baseText('settings.personal.method.badge.registeredMany', {
|
||||
interpolate: { count: String(passkeyCredentials.length) },
|
||||
})
|
||||
}}</N8nBadge>
|
||||
<N8nBadge v-else theme="default">{{
|
||||
i18n.baseText('settings.personal.method.status.notSetUp')
|
||||
}}</N8nBadge>
|
||||
</div>
|
||||
|
|
@ -537,6 +548,7 @@ onBeforeUnmount(() => {
|
|||
}}</span>
|
||||
</div>
|
||||
<N8nButton
|
||||
v-if="passkeyCredentials.length === 0"
|
||||
variant="subtle"
|
||||
size="small"
|
||||
:label="i18n.baseText('settings.personal.method.button.setUp')"
|
||||
|
|
@ -544,14 +556,40 @@ onBeforeUnmount(() => {
|
|||
@click="onSetupPasskeyClick"
|
||||
/>
|
||||
</div>
|
||||
<N8nLink
|
||||
v-if="passkeyCredentials.length > 0"
|
||||
size="small"
|
||||
data-test-id="add-passkey-button"
|
||||
@click="onSetupPasskeyClick"
|
||||
>
|
||||
+ {{ i18n.baseText('settings.personal.method.button.addAnother' as never) }}
|
||||
</N8nLink>
|
||||
<div v-if="passkeyCredentials.length > 0" :class="$style.credList">
|
||||
<div
|
||||
v-for="cred in passkeyCredentials"
|
||||
:key="cred.id"
|
||||
:class="$style.credItem"
|
||||
:data-test-id="`passkey-cred-${cred.id}`"
|
||||
>
|
||||
<div :class="$style.credIconSm">
|
||||
<N8nIcon icon="fingerprint" size="xsmall" />
|
||||
</div>
|
||||
<div :class="$style.credMeta">
|
||||
<div :class="$style.credName">{{ cred.label }}</div>
|
||||
<div :class="$style.credSub">{{ formatCredentialMeta(cred) }}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
:class="$style.credTrash"
|
||||
:aria-label="i18n.baseText('settings.personal.method.button.remove')"
|
||||
:data-test-id="`remove-passkey-${cred.id}`"
|
||||
@click="removeWebAuthnCredential(cred)"
|
||||
>
|
||||
<N8nIcon icon="trash-2" size="small" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
:class="$style.addAnotherBtn"
|
||||
data-test-id="add-passkey-button"
|
||||
@click="onSetupPasskeyClick"
|
||||
>
|
||||
<N8nIcon icon="plus" size="xsmall" />
|
||||
{{ i18n.baseText('settings.personal.method.button.addAnother.passkey') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -571,110 +609,116 @@ onBeforeUnmount(() => {
|
|||
/>
|
||||
<div :class="$style.methodCards">
|
||||
<!-- TOTP card -->
|
||||
<div :class="[$style.methodCard]" data-test-id="mfa-method-totp">
|
||||
<div :class="[$style.activeMethodIcon, $style.tone_security_key]">
|
||||
<N8nIcon icon="shield" />
|
||||
</div>
|
||||
<div :class="$style.methodCardContent">
|
||||
<div :class="$style.methodCardLabel">
|
||||
<N8nText :bold="true">{{
|
||||
i18n.baseText('settings.personal.twoFactor.method.totp.name')
|
||||
}}</N8nText>
|
||||
<N8nBadge v-if="isMethodActive('totp')" theme="success">{{
|
||||
i18n.baseText('settings.personal.mfa.status.enabled')
|
||||
}}</N8nBadge>
|
||||
<N8nBadge v-else theme="default">{{
|
||||
i18n.baseText('settings.personal.method.status.notSetUp')
|
||||
}}</N8nBadge>
|
||||
<div :class="$style.methodCard" data-test-id="mfa-method-totp">
|
||||
<div :class="$style.methodCardHeader">
|
||||
<div :class="[$style.activeMethodIcon, $style.tone_security_key]">
|
||||
<N8nIcon icon="shield" />
|
||||
</div>
|
||||
<span :class="$style.methodDetail">{{
|
||||
i18n.baseText('settings.personal.twoFactor.picker.totp.description')
|
||||
}}</span>
|
||||
<div :class="$style.methodCardContent">
|
||||
<div :class="$style.methodCardLabel">
|
||||
<N8nText :bold="true">{{
|
||||
i18n.baseText('settings.personal.twoFactor.method.totp.name')
|
||||
}}</N8nText>
|
||||
<N8nBadge v-if="isMethodActive('totp')" theme="success">{{
|
||||
i18n.baseText('settings.personal.mfa.status.enabled')
|
||||
}}</N8nBadge>
|
||||
<N8nBadge v-else theme="default">{{
|
||||
i18n.baseText('settings.personal.method.status.notSetUp')
|
||||
}}</N8nBadge>
|
||||
</div>
|
||||
<span :class="$style.methodDetail">{{
|
||||
i18n.baseText('settings.personal.twoFactor.picker.totp.description')
|
||||
}}</span>
|
||||
</div>
|
||||
<N8nButton
|
||||
v-if="isMethodActive('totp')"
|
||||
variant="destructive"
|
||||
size="small"
|
||||
:label="i18n.baseText('settings.personal.method.button.disable')"
|
||||
data-test-id="mfa-method-totp-disable"
|
||||
@click="onDisable2faClick"
|
||||
/>
|
||||
<N8nButton
|
||||
v-else
|
||||
variant="subtle"
|
||||
size="small"
|
||||
:label="i18n.baseText('settings.personal.method.button.setUp')"
|
||||
data-test-id="mfa-method-totp-setup"
|
||||
@click="onTwoFactorMethodClick('totp')"
|
||||
/>
|
||||
</div>
|
||||
<N8nButton
|
||||
v-if="isMethodActive('totp')"
|
||||
variant="destructive"
|
||||
size="small"
|
||||
:label="i18n.baseText('settings.personal.method.button.disable')"
|
||||
data-test-id="mfa-method-totp-disable"
|
||||
@click="onDisable2faClick"
|
||||
/>
|
||||
<N8nButton
|
||||
v-else
|
||||
variant="subtle"
|
||||
size="small"
|
||||
:label="i18n.baseText('settings.personal.method.button.setUp')"
|
||||
data-test-id="mfa-method-totp-setup"
|
||||
@click="onTwoFactorMethodClick('totp')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Security key credentials -->
|
||||
<div
|
||||
v-for="cred in securityKeyCredentials"
|
||||
:key="cred.id"
|
||||
:class="[$style.methodCard]"
|
||||
:data-test-id="`security-key-card-${cred.id}`"
|
||||
>
|
||||
<div :class="[$style.activeMethodIcon, $style.tone_security_key]">
|
||||
<N8nIcon icon="key-round" />
|
||||
</div>
|
||||
<div :class="$style.methodCardContent">
|
||||
<div :class="$style.methodCardLabel">
|
||||
<N8nText :bold="true">{{ cred.label }}</N8nText>
|
||||
<N8nBadge theme="success">{{
|
||||
i18n.baseText('settings.personal.mfa.status.enabled')
|
||||
}}</N8nBadge>
|
||||
<!-- Security key card with optional credential list -->
|
||||
<div :class="$style.methodCard" data-test-id="mfa-method-security_key">
|
||||
<div :class="$style.methodCardHeader">
|
||||
<div :class="[$style.activeMethodIcon, $style.tone_security_key]">
|
||||
<N8nIcon icon="key-round" />
|
||||
</div>
|
||||
<span :class="$style.methodDetail">{{ formatCredentialDetail(cred) }}</span>
|
||||
</div>
|
||||
<N8nButton
|
||||
variant="subtle"
|
||||
size="small"
|
||||
:label="i18n.baseText('settings.personal.method.button.remove')"
|
||||
:data-test-id="`remove-security-key-${cred.id}`"
|
||||
@click="removeWebAuthnCredential(cred.id)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Security key setup card (when none registered) -->
|
||||
<div
|
||||
v-if="securityKeyCredentials.length === 0"
|
||||
:class="[$style.methodCard]"
|
||||
data-test-id="mfa-method-security_key"
|
||||
>
|
||||
<div :class="[$style.activeMethodIcon, $style.tone_security_key]">
|
||||
<N8nIcon icon="key-round" />
|
||||
</div>
|
||||
<div :class="$style.methodCardContent">
|
||||
<div :class="$style.methodCardLabel">
|
||||
<N8nText :bold="true">{{
|
||||
i18n.baseText('settings.personal.twoFactor.method.security_key.name')
|
||||
}}</N8nText>
|
||||
<N8nBadge theme="default">{{
|
||||
i18n.baseText('settings.personal.method.status.notSetUp')
|
||||
}}</N8nBadge>
|
||||
<div :class="$style.methodCardContent">
|
||||
<div :class="$style.methodCardLabel">
|
||||
<N8nText :bold="true">{{
|
||||
i18n.baseText('settings.personal.twoFactor.method.security_key.name')
|
||||
}}</N8nText>
|
||||
<N8nBadge v-if="securityKeyCredentials.length > 0" theme="success">{{
|
||||
securityKeyCredentials.length === 1
|
||||
? i18n.baseText('settings.personal.method.badge.registeredOne')
|
||||
: i18n.baseText('settings.personal.method.badge.registeredMany', {
|
||||
interpolate: { count: String(securityKeyCredentials.length) },
|
||||
})
|
||||
}}</N8nBadge>
|
||||
<N8nBadge v-else theme="default">{{
|
||||
i18n.baseText('settings.personal.method.status.notSetUp')
|
||||
}}</N8nBadge>
|
||||
</div>
|
||||
<span :class="$style.methodDetail">{{
|
||||
i18n.baseText('settings.personal.twoFactor.picker.security_key.description')
|
||||
}}</span>
|
||||
</div>
|
||||
<span :class="$style.methodDetail">{{
|
||||
i18n.baseText('settings.personal.twoFactor.picker.security_key.description')
|
||||
}}</span>
|
||||
<N8nButton
|
||||
v-if="securityKeyCredentials.length === 0"
|
||||
variant="subtle"
|
||||
size="small"
|
||||
:label="i18n.baseText('settings.personal.method.button.setUp')"
|
||||
data-test-id="mfa-method-security_key-setup"
|
||||
@click="onTwoFactorMethodClick('security_key')"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="securityKeyCredentials.length > 0" :class="$style.credList">
|
||||
<div
|
||||
v-for="cred in securityKeyCredentials"
|
||||
:key="cred.id"
|
||||
:class="$style.credItem"
|
||||
:data-test-id="`security-key-cred-${cred.id}`"
|
||||
>
|
||||
<div :class="$style.credIconSm">
|
||||
<N8nIcon icon="key-round" size="xsmall" />
|
||||
</div>
|
||||
<div :class="$style.credMeta">
|
||||
<div :class="$style.credName">{{ cred.label }}</div>
|
||||
<div :class="$style.credSub">{{ formatCredentialMeta(cred) }}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
:class="$style.credTrash"
|
||||
:aria-label="i18n.baseText('settings.personal.method.button.remove')"
|
||||
:data-test-id="`remove-security-key-${cred.id}`"
|
||||
@click="removeWebAuthnCredential(cred)"
|
||||
>
|
||||
<N8nIcon icon="trash-2" size="small" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
:class="$style.addAnotherBtn"
|
||||
data-test-id="add-security-key-button"
|
||||
@click="onTwoFactorMethodClick('security_key')"
|
||||
>
|
||||
<N8nIcon icon="plus" size="xsmall" />
|
||||
{{ i18n.baseText('settings.personal.method.button.addAnother.security_key') }}
|
||||
</button>
|
||||
</div>
|
||||
<N8nButton
|
||||
variant="subtle"
|
||||
size="small"
|
||||
:label="i18n.baseText('settings.personal.method.button.setUp')"
|
||||
data-test-id="mfa-method-security_key-setup"
|
||||
@click="onTwoFactorMethodClick('security_key')"
|
||||
/>
|
||||
</div>
|
||||
<N8nLink
|
||||
v-if="securityKeyCredentials.length > 0"
|
||||
size="small"
|
||||
data-test-id="add-security-key-button"
|
||||
@click="onTwoFactorMethodClick('security_key')"
|
||||
>
|
||||
+ {{ i18n.baseText('settings.personal.method.button.addAnother' as never) }}
|
||||
</N8nLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -808,11 +852,16 @@ onBeforeUnmount(() => {
|
|||
|
||||
.methodCard {
|
||||
display: flex;
|
||||
gap: var(--spacing--xs);
|
||||
flex-direction: column;
|
||||
padding: var(--spacing--xs) var(--spacing--sm);
|
||||
background: var(--background--surface);
|
||||
border: var(--border-width) var(--border-style) var(--color--foreground);
|
||||
border-radius: var(--radius--md);
|
||||
}
|
||||
|
||||
.methodCardHeader {
|
||||
display: flex;
|
||||
gap: var(--spacing--xs);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
|
|
@ -820,6 +869,91 @@ onBeforeUnmount(() => {
|
|||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.credList {
|
||||
margin-top: var(--spacing--2xs);
|
||||
padding-top: var(--spacing--2xs);
|
||||
border-top: var(--border-width) var(--border-style) var(--color--foreground);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.credItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
padding: var(--spacing--3xs) 0;
|
||||
}
|
||||
|
||||
.credItem + .credItem {
|
||||
border-top: var(--border-width) var(--border-style) var(--color--foreground);
|
||||
}
|
||||
|
||||
.credIconSm {
|
||||
width: var(--spacing--m);
|
||||
height: var(--spacing--m);
|
||||
border-radius: var(--radius--xs);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-color--subtle);
|
||||
background: var(--color--background--light-2);
|
||||
border: var(--border-width) var(--border-style) var(--color--foreground);
|
||||
}
|
||||
|
||||
.credMeta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.credName {
|
||||
font-size: var(--font-size--xs);
|
||||
font-weight: var(--font-weight--medium);
|
||||
color: var(--text-color);
|
||||
margin-bottom: var(--spacing--5xs);
|
||||
}
|
||||
|
||||
.credSub {
|
||||
font-size: var(--font-size--2xs);
|
||||
color: var(--text-color--subtle);
|
||||
}
|
||||
|
||||
.credTrash {
|
||||
flex-shrink: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-color--subtle);
|
||||
cursor: pointer;
|
||||
padding: var(--spacing--3xs);
|
||||
border-radius: var(--radius--xs);
|
||||
}
|
||||
|
||||
.credTrash:hover {
|
||||
color: var(--color--danger);
|
||||
background: var(--color--background--light-2);
|
||||
}
|
||||
|
||||
.addAnotherBtn {
|
||||
margin-top: var(--spacing--2xs);
|
||||
padding: var(--spacing--2xs) var(--spacing--xs);
|
||||
font-size: var(--font-size--xs);
|
||||
border-radius: var(--radius--sm);
|
||||
border: 1px dashed var(--color--foreground);
|
||||
background: transparent;
|
||||
color: var(--text-color--subtle);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing--3xs);
|
||||
}
|
||||
|
||||
.addAnotherBtn:hover {
|
||||
border-style: solid;
|
||||
border-color: var(--color--primary);
|
||||
color: var(--color--primary);
|
||||
}
|
||||
|
||||
.methodCardContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { useSSOStore } from '@/features/settings/sso/sso.store';
|
|||
import type { IFormBoxConfig } from '@/Interface';
|
||||
import { MFA_AUTHENTICATION_REQUIRED_ERROR_CODE, VIEWS, MFA_FORM } from '@/app/constants';
|
||||
import type { LoginRequestDto, MfaMethod } from '@n8n/api-types';
|
||||
import { LAST_2FA_METHOD_KEY } from '../auth.constants';
|
||||
|
||||
export type EmailOrLdapLoginIdAndPassword = Pick<
|
||||
LoginRequestDto,
|
||||
|
|
@ -38,7 +39,6 @@ const telemetry = useTelemetry();
|
|||
const loading = ref(false);
|
||||
const showMfaView = ref(false);
|
||||
const availableMfaMethods = ref<MfaMethod[]>(['totp']);
|
||||
const mfaMethod = computed<MfaMethod>(() => availableMfaMethods.value[0] ?? 'totp');
|
||||
const emailOrLdapLoginId = ref('');
|
||||
const password = ref('');
|
||||
const reportError = ref(false);
|
||||
|
|
@ -134,6 +134,18 @@ const getRedirectQueryParameter = () => {
|
|||
return redirect;
|
||||
};
|
||||
|
||||
const persistLastMethodOnSuccess = (form: LoginRequestDto) => {
|
||||
let method: MfaMethod | null = null;
|
||||
if (form.mfaCode) method = 'totp';
|
||||
else if (form.webauthnResponse) method = 'security_key';
|
||||
if (!method) return;
|
||||
try {
|
||||
localStorage.setItem(LAST_2FA_METHOD_KEY, method);
|
||||
} catch {
|
||||
// localStorage unavailable
|
||||
}
|
||||
};
|
||||
|
||||
const login = async (form: LoginRequestDto) => {
|
||||
try {
|
||||
loading.value = true;
|
||||
|
|
@ -145,6 +157,7 @@ const login = async (form: LoginRequestDto) => {
|
|||
webauthnResponse: form.webauthnResponse,
|
||||
});
|
||||
loading.value = false;
|
||||
persistLastMethodOnSuccess(form);
|
||||
await settingsStore.getSettings();
|
||||
|
||||
toast.clearAllStickyNotifications();
|
||||
|
|
@ -277,7 +290,7 @@ onMounted(async () => {
|
|||
v-if="showMfaView"
|
||||
:report-error="reportError"
|
||||
:email="emailOrLdapLoginId"
|
||||
:mfa-method="mfaMethod"
|
||||
:available-methods="availableMfaMethods"
|
||||
@submit="onMFASubmitted"
|
||||
@webauthn-submit="onWebAuthnSubmitted"
|
||||
@on-back-click="onBackClick"
|
||||
|
|
|
|||
|
|
@ -461,17 +461,21 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
|
|||
return await webauthnApi.getCredentials(rootStore.restApiContext);
|
||||
};
|
||||
|
||||
const deleteWebAuthnCredential = async (id: string) => {
|
||||
await webauthnApi.deleteCredential(rootStore.restApiContext, id);
|
||||
const deleteWebAuthnCredential = async (id: string, proof: webauthnApi.DeleteCredentialProof) => {
|
||||
await webauthnApi.deleteCredential(rootStore.restApiContext, id, proof);
|
||||
};
|
||||
|
||||
const updateWebAuthnCredentialLabel = async (id: string, label: string) => {
|
||||
await webauthnApi.updateCredential(rootStore.restApiContext, id, { label });
|
||||
};
|
||||
|
||||
const verifyWebAuthnAuthentication = async (email: string) => {
|
||||
const verifyWebAuthnAuthentication = async (email: string, kind?: 'passkey' | 'security_key') => {
|
||||
const { startAuthentication } = await import('@simplewebauthn/browser');
|
||||
const options = await webauthnApi.getAuthenticationOptions(rootStore.restApiContext, email);
|
||||
const options = await webauthnApi.getAuthenticationOptions(
|
||||
rootStore.restApiContext,
|
||||
email,
|
||||
kind,
|
||||
);
|
||||
const assertionResponse = await startAuthentication({
|
||||
optionsJSON: options as Parameters<typeof startAuthentication>[0]['optionsJSON'],
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user