mirror of
https://github.com/appwrite/console.git
synced 2026-06-06 19:27:48 +00:00
refactor: improve MFA challenge form logic and fix account page issues
This commit is contained in:
@@ -1,21 +1,31 @@
|
||||
<script context="module" lang="ts">
|
||||
let inputDigitFields: InputDigits;
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { invalidate } from '$app/navigation';
|
||||
import { Dependencies } from '$lib/constants';
|
||||
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
|
||||
import { AuthenticationFactor, type Models } from '@appwrite.io/console';
|
||||
import { InputDigits } from '$lib/elements/forms';
|
||||
|
||||
export async function verify(challenge: Models.MfaChallenge, code: string) {
|
||||
export async function verify(
|
||||
challenge: Models.MfaChallenge,
|
||||
code: string,
|
||||
challengeType: AuthenticationFactor = AuthenticationFactor.Totp
|
||||
) {
|
||||
try {
|
||||
if (challenge === null) {
|
||||
if (!challenge) {
|
||||
challenge = await sdk.forConsole.account.createMFAChallenge({
|
||||
factor: AuthenticationFactor.Totp
|
||||
factor: challengeType
|
||||
});
|
||||
}
|
||||
|
||||
await sdk.forConsole.account.updateMFAChallenge({
|
||||
challengeId: challenge.$id,
|
||||
otp: code
|
||||
});
|
||||
|
||||
await invalidate(Dependencies.ACCOUNT);
|
||||
trackEvent(Submit.AccountLogin, { mfa_used: true });
|
||||
} catch (error) {
|
||||
inputDigitFields?.clearInputsAndRefocus();
|
||||
trackError(error, Submit.AccountLogin);
|
||||
throw error;
|
||||
}
|
||||
@@ -24,18 +34,12 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { invalidate } from '$app/navigation';
|
||||
import { Button, InputDigits, InputText } from '$lib/elements/forms';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { Dependencies } from '$lib/constants';
|
||||
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
|
||||
import { AuthenticationFactor, type Models } from '@appwrite.io/console';
|
||||
import { Button, InputText } from '$lib/elements/forms';
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import { Icon, Typography } from '@appwrite.io/pink-svelte';
|
||||
import { IconChatAlt, IconDeviceMobile, IconMail } from '@appwrite.io/pink-icons-svelte';
|
||||
|
||||
export let factors: Models.MfaFactors & { recoveryCode: boolean };
|
||||
/** If true, the form will be submitted automatically when the code is entered. */
|
||||
export let showVerifyButton: boolean = true;
|
||||
export let disabled: boolean = false;
|
||||
export let challenge: Models.MfaChallenge;
|
||||
@@ -43,13 +47,24 @@
|
||||
|
||||
let challengeType: AuthenticationFactor;
|
||||
|
||||
const enabledFactors = Object.entries(factors).filter(([_, enabled]) => enabled);
|
||||
// Get enabled non-recovery factors for main factors
|
||||
$: enabledMainFactors = [
|
||||
factors.totp && 'totp',
|
||||
factors.email && 'email',
|
||||
factors.phone && 'phone'
|
||||
].filter(Boolean);
|
||||
|
||||
async function createChallenge(factor: AuthenticationFactor) {
|
||||
disabled = true;
|
||||
challengeType = factor;
|
||||
code = '';
|
||||
try {
|
||||
challenge = await sdk.forConsole.account.createMFAChallenge({ factor });
|
||||
if (
|
||||
factor !== AuthenticationFactor.Totp &&
|
||||
factor !== AuthenticationFactor.Recoverycode
|
||||
) {
|
||||
challenge = await sdk.forConsole.account.createMFAChallenge({ factor });
|
||||
}
|
||||
} catch (error) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
@@ -60,96 +75,109 @@
|
||||
}
|
||||
}
|
||||
|
||||
function switchToFactor(factor: AuthenticationFactor) {
|
||||
createChallenge(factor);
|
||||
}
|
||||
|
||||
export async function verifyCurrent() {
|
||||
return verify(challenge, code, challengeType);
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
const enabledNonRecoveryFactors = enabledFactors.filter(
|
||||
([factor, _]) => factor !== 'recoveryCode'
|
||||
);
|
||||
if (enabledNonRecoveryFactors.length === 1) {
|
||||
if (factors.phone) {
|
||||
await createChallenge(AuthenticationFactor.Phone);
|
||||
} else if (factors.email) {
|
||||
await createChallenge(AuthenticationFactor.Email);
|
||||
} else if (factors.totp) {
|
||||
challengeType = AuthenticationFactor.Totp;
|
||||
}
|
||||
if (enabledMainFactors.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (factors.totp) {
|
||||
challengeType = AuthenticationFactor.Totp;
|
||||
} else if (factors.email) {
|
||||
await createChallenge(AuthenticationFactor.Email);
|
||||
} else if (factors.phone) {
|
||||
await createChallenge(AuthenticationFactor.Phone);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if challengeType === AuthenticationFactor.Recoverycode}
|
||||
{#if enabledMainFactors.length === 0}
|
||||
<!-- Skip MFA completely if no factors enabled -->
|
||||
{:else if challengeType === AuthenticationFactor.Recoverycode}
|
||||
<Typography.Text align="center">
|
||||
Enter below one of the recovery codes you received when enabling MFA for this account.
|
||||
Enter one of the recovery codes you received when enabling MFA.
|
||||
</Typography.Text>
|
||||
<InputText id="recovery-code" bind:value={code} required autofocus />
|
||||
{:else}
|
||||
{#if factors.totp && (challengeType === AuthenticationFactor.Totp || challengeType === null)}
|
||||
<Typography.Text align="center"
|
||||
>Enter below a 6-digit one-time code generated by your authentication app.</Typography.Text>
|
||||
{:else if challengeType === AuthenticationFactor.Email}
|
||||
<Typography.Text align="center"
|
||||
>A 6-digit verification code was sent to your email, enter it below.</Typography.Text>
|
||||
{:else if challengeType === AuthenticationFactor.Phone}
|
||||
<Typography.Text align="center"
|
||||
>A 6-digit verification code was sent to your phone, enter it below.</Typography.Text>
|
||||
{/if}
|
||||
{:else if challengeType === AuthenticationFactor.Totp}
|
||||
<Typography.Text align="center">
|
||||
Enter a 6-digit one-time code from your authenticator app.
|
||||
</Typography.Text>
|
||||
<InputDigits bind:value={code} required autofocus />
|
||||
{:else if challengeType === AuthenticationFactor.Email}
|
||||
<Typography.Text align="center">
|
||||
A 6-digit verification code was sent to your email. Enter it below.
|
||||
</Typography.Text>
|
||||
<InputDigits bind:value={code} required autofocus />
|
||||
{:else if challengeType === AuthenticationFactor.Phone}
|
||||
<Typography.Text align="center">
|
||||
A 6-digit verification code was sent to your phone. Enter it below.
|
||||
</Typography.Text>
|
||||
<InputDigits bind:value={code} required autofocus />
|
||||
{/if}
|
||||
{#if showVerifyButton}
|
||||
|
||||
{#if showVerifyButton && enabledMainFactors.length > 0}
|
||||
<Button fullWidth submit {disabled}>Verify</Button>
|
||||
{/if}
|
||||
{#if enabledFactors.length > 1}
|
||||
<span class="with-separators eyebrow-heading-3">or</span>
|
||||
<div class="u-flex-vertical u-gap-8">
|
||||
{#if factors.totp}
|
||||
{#if (challengeType === null || challengeType === AuthenticationFactor.Totp || challengeType === AuthenticationFactor.Recoverycode) && factors.email}
|
||||
|
||||
{#if enabledMainFactors.length > 0}
|
||||
{@const hasSecondaryOptions =
|
||||
enabledMainFactors.length > 1 ||
|
||||
(factors.recoveryCode && challengeType !== AuthenticationFactor.Recoverycode) ||
|
||||
(enabledMainFactors.length === 1 && factors.recoveryCode)}
|
||||
|
||||
{#if hasSecondaryOptions}
|
||||
<span class="with-separators eyebrow-heading-3">or</span>
|
||||
<div class="u-flex-vertical u-gap-8">
|
||||
<!-- Show non-active main factors -->
|
||||
{#if factors.totp && challengeType !== AuthenticationFactor.Totp}
|
||||
<Button
|
||||
secondary
|
||||
fullWidth
|
||||
{disabled}
|
||||
on:click={() => createChallenge(AuthenticationFactor.Email)}>
|
||||
<Icon icon={IconMail} slot="start" size="s" />
|
||||
Email verification
|
||||
</Button>
|
||||
{:else if (challengeType === AuthenticationFactor.Recoverycode && !factors.email) || challengeType === AuthenticationFactor.Email}
|
||||
<Button
|
||||
secondary
|
||||
fullWidth
|
||||
{disabled}
|
||||
on:click={() => createChallenge(AuthenticationFactor.Totp)}>
|
||||
on:click={() => switchToFactor(AuthenticationFactor.Totp)}>
|
||||
<Icon icon={IconDeviceMobile} slot="start" size="s" />
|
||||
Authenticator app
|
||||
</Button>
|
||||
{/if}
|
||||
{:else}
|
||||
|
||||
{#if factors.email && challengeType !== AuthenticationFactor.Email}
|
||||
<Button
|
||||
secondary
|
||||
fullWidth
|
||||
{disabled}
|
||||
on:click={() => createChallenge(AuthenticationFactor.Email)}>
|
||||
on:click={() => switchToFactor(AuthenticationFactor.Email)}>
|
||||
<Icon icon={IconMail} slot="start" size="s" />
|
||||
Email verification
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
{#if factors.phone && challengeType !== AuthenticationFactor.Phone}
|
||||
<Button
|
||||
secondary
|
||||
fullWidth
|
||||
{disabled}
|
||||
on:click={() => createChallenge(AuthenticationFactor.Phone)}>
|
||||
on:click={() => switchToFactor(AuthenticationFactor.Phone)}>
|
||||
<Icon icon={IconChatAlt} slot="start" size="s" />
|
||||
Phone verification
|
||||
</Button>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if factors.recoveryCode && challengeType !== AuthenticationFactor.Recoverycode}
|
||||
<Button
|
||||
text
|
||||
fullWidth
|
||||
{disabled}
|
||||
on:click={() => createChallenge(AuthenticationFactor.Recoverycode)}>
|
||||
<span class="text">Use recovery code</span>
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if factors.recoveryCode && challengeType !== AuthenticationFactor.Recoverycode}
|
||||
<Button
|
||||
text
|
||||
fullWidth
|
||||
{disabled}
|
||||
on:click={() => switchToFactor(AuthenticationFactor.Recoverycode)}>
|
||||
<span class="text">Use recovery code</span>
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -280,6 +280,7 @@
|
||||
<Button
|
||||
secondary
|
||||
class="recovery-codes-button"
|
||||
disabled={enabledMethods.length === 0}
|
||||
on:click={() => (showRegenerateRecoveryCodes = true)}>
|
||||
Regenerate
|
||||
</Button>
|
||||
@@ -287,6 +288,7 @@
|
||||
<Button
|
||||
secondary
|
||||
class="recovery-codes-button"
|
||||
disabled={enabledMethods.length === 0}
|
||||
on:click={createRecoveryCodes}>View</Button>
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
|
||||
Reference in New Issue
Block a user