feat(mfa): show more MFA methods & recovery options for console account

This commit is contained in:
Steven Nguyen
2024-04-22 15:41:34 -07:00
parent 125632728b
commit c5ed79b0bb
7 changed files with 320 additions and 168 deletions
+5
View File
@@ -151,7 +151,12 @@ export enum Submit {
AccountDelete = 'submit_account_delete',
AccountDeleteSession = 'submit_account_delete_session',
AccountDeleteAllSessions = 'submit_account_delete_all_sessions',
AccountUpdateMfa = 'submit_account_update_mfa',
AccountAuthenticatorCreate = 'submit_account_authenticator_create',
AccountAuthenticatorUpdate = 'submit_account_authenticator_update',
AccountAuthenticatorDelete = 'submit_account_authenticator_delete',
AccountRecoveryCodesCreate = 'submit_account_recovery_codes_create',
AccountRecoveryCodesUpdate = 'submit_account_recovery_codes_update',
UserCreate = 'submit_user_create',
UserDelete = 'submit_user_delete',
UserUpdateEmail = 'submit_user_update_email',
+3 -7
View File
@@ -15,7 +15,7 @@
let code: string;
let error: string;
async function deleteProvider() {
async function deleteAuthenticator() {
try {
await sdk.forConsole.account.deleteMfaAuthenticator(AuthenticatorType.Totp, code);
await invalidate(Dependencies.ACCOUNT);
@@ -41,18 +41,14 @@
<Modal
title="Delete authentication method"
bind:show={showDelete}
onSubmit={deleteProvider}
onSubmit={deleteAuthenticator}
icon="exclamation"
state="warning"
bind:error
headerDivider={false}>
<p class="u-bold">
Deleting the authentication method will disable multi-factor authentication for your
account. To re-enable it, you'll need to add a new authentication method.
</p>
<p>Enter the 6-digit verification code generated by your authenticator app to continue.</p>
<FormList>
<InputDigits autofocus required bind:value={code} />
<InputDigits autofocus required bind:value={code} autoSubmit={false} />
</FormList>
<svelte:fragment slot="footer">
+23 -105
View File
@@ -1,25 +1,24 @@
<script lang="ts">
import { invalidate } from '$app/navigation';
import { Submit, trackError } from '$lib/actions/analytics';
import { Modal, Output, Copy, Alert } from '$lib/components';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { Modal, CopyInput } from '$lib/components';
import { Dependencies } from '$lib/constants';
import { Button, FormList, InputDigits } from '$lib/elements/forms';
import { Table, TableBody, TableCell, TableRow } from '$lib/elements/table';
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import { AuthenticatorType, type Models } from '@appwrite.io/console';
import QrFrame from '$lib/images/qr2.svg';
export let showSetup = false;
export let showRecoveryCodes = false;
let copyParent: HTMLElement;
let code: string;
let type: Models.MfaType = null;
let step = 1;
let codes: Models.MfaRecoveryCodes = null;
let error = '';
async function addAuthenticator(): Promise<URL> {
type = await sdk.forConsole.account.createMfaAuthenticator(AuthenticatorType.Totp);
trackEvent(Submit.AccountAuthenticatorCreate);
return sdk.forConsole.avatars.getQR(type.uri, 192 * 2);
}
@@ -27,32 +26,27 @@
async function verifyAuthenticator() {
try {
await sdk.forConsole.account.updateMfaAuthenticator(AuthenticatorType.Totp, code);
codes = await sdk.forConsole.account.createMfaRecoveryCodes();
await invalidate(Dependencies.ACCOUNT);
await invalidate(Dependencies.FACTORS);
await Promise.all([invalidate(Dependencies.ACCOUNT), invalidate(Dependencies.FACTORS)]);
showSetup = false;
showRecoveryCodes = true;
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.AccountDelete);
trackEvent(Submit.AccountAuthenticatorUpdate);
} catch (e) {
error = e.message;
trackError(e, Submit.AccountAuthenticatorUpdate);
}
}
</script>
<Modal
title="Scan QR code"
description="Open your authentication app and scan the QR code."
title={step == 1 ? 'Scan QR code' : 'Enter verification code'}
bind:show={showSetup}
onSubmit={verifyAuthenticator}>
onSubmit={verifyAuthenticator}
{error}>
{#key showSetup}
<p>
Install an authenticator app on your mobile device, open it and scan the provided QR
code or enter it manually.
</p>
{#if step === 1}
<p>
Install an authenticator app on your mobile device, open it and scan the provided QR
code or enter it manually.
</p>
{#await addAuthenticator()}
<div class="loading">
<div class="loader"></div>
@@ -69,26 +63,12 @@
<span class="with-separators eyebrow-heading-3">or</span>
<div bind:this={copyParent}>
<label class="label" for="manual-code">Manual entry code</label>
<button class="tooltip" aria-label="sec retinfo">
<span class="icon-info" aria-hidden="true"></span>
<span class="tooltip-popup" role="tooltip">
<p class="text u-margin-block-start-8">
Manually enter the following code into the authenticator app
</p>
</span>
</button>
<div class="input-text-wrapper" style="--amount-of-buttons:1">
<input id="manual-code" type="text" value={type.secret} readonly={true} />
<div class="options-list">
{#key copyParent}
<Copy value={type.secret} appendTo={copyParent}>
<span class="icon-duplicate" aria-hidden="true" />
</Copy>
{/key}
</div>
</div>
<CopyInput
showLabel={true}
label="Manual entry code"
labelTooltip="Manually enter the following code into the authenticator app"
value={type.secret}
appendTo={copyParent} />
</div>
{/await}
{:else}
@@ -108,68 +88,6 @@
</svelte:fragment>
</Modal>
<Modal
title="Save recovery codes"
description="Learn more about multi-factor authentication in our documentation."
bind:show={showRecoveryCodes}
onSubmit={verifyAuthenticator}>
{#if type && codes}
{@const formattedBackupCodes = codes.recoveryCodes.join('\n')}
<Alert type="info">
<span slot="title">
It is highly recommended to securely store your recovery codes
</span>
<p>
Use security codes for emergency sign-ins in case you've lost access to your mobile
device. Each recovery code can only be used once, but you can re-generate a new set
of 6 codes anytime.
</p>
</Alert>
<div
style:flex-direction="row-reverse"
class="u-flex u-flex-vertical-mobile u-main-space-between u-gap-16">
<ul class="buttons-list">
<li class="buttons-list-item">
<Button
download="backups.txt"
href={`data:application/octet-stream;charset=utf-8,${formattedBackupCodes}`}
text>
<span class="icon-download" />
<span class="text">Download</span>
</Button>
</li>
<li class="buttons-list-item">
<Copy value={formattedBackupCodes} appendTo="parent">
<Button text>
<span class="icon-duplicate" />
<span class="text">Copy all</span>
</Button>
</Copy>
</li>
</ul>
</div>
<Table noMargin noStyles>
<TableBody>
{#each codes.recoveryCodes as code}
<TableRow>
<TableCell title="code">
<Output value={code} hideCopyIcon>{code}</Output>
</TableCell>
<TableCell title="actions" width={24}>
<Copy value={code} appendTo="parent">
<span class="icon-duplicate" aria-hidden="true" />
</Copy>
</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
{/if}
<svelte:fragment slot="footer">
<Button secondary on:click={() => (showRecoveryCodes = false)}>Close</Button>
</svelte:fragment>
</Modal>
<style lang="scss">
.loading {
display: flex;
@@ -0,0 +1,64 @@
<script lang="ts">
import { Modal, Output, Copy, Alert } from '$lib/components';
import { Button } from '$lib/elements/forms';
import { Table, TableBody, TableCell, TableRow } from '$lib/elements/table';
import { type Models } from '@appwrite.io/console';
export let showRecoveryCodes = false;
export let codes: Models.MfaRecoveryCodes = null;
</script>
<Modal title="Recovery codes" bind:show={showRecoveryCodes}>
{#if codes}
{@const formattedBackupCodes = codes.recoveryCodes.join('\n')}
<Alert type="warning">
<span slot="title">
Securely store your recovery codes as they won't be visible again for security
purposes
</span>
<p>
Use security codes for emergency sign-ins in case you've lost access to your mobile
device. Each recovery code can only be used once.
</p>
</Alert>
<div
style:flex-direction="row-reverse"
class="u-flex u-flex-vertical-mobile u-main-space-between u-gap-16">
<ul class="buttons-list">
<li class="buttons-list-item">
<Button
download="backups.txt"
href={`data:application/octet-stream;charset=utf-8,${formattedBackupCodes}`}
text>
<span class="icon-download u-font-size-20" aria-hidden="true" />
<span class="text">Download .txt</span>
</Button>
</li>
<li class="buttons-list-item">
<Copy value={formattedBackupCodes} appendTo="parent">
<Button text>
<span class="icon-duplicate" />
<span class="text">Copy all</span>
</Button>
</Copy>
</li>
</ul>
</div>
<Table noMargin noStyles>
<TableBody>
{#each codes.recoveryCodes as code}
<TableRow>
<TableCell title="code">
<Output value={code} hideCopyIcon>{code}</Output>
</TableCell>
<TableCell title="actions" width={24}>
<Copy value={code} appendTo="parent">
<span class="icon-duplicate" aria-hidden="true" />
</Copy>
</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
{/if}
</Modal>
@@ -0,0 +1,48 @@
<script lang="ts">
import { Modal } from '$lib/components';
import { Button, FormList, InputDigits } from '$lib/elements/forms';
import { sdk } from '$lib/stores/sdk';
import { AuthenticationFactor } from '@appwrite.io/console';
export let show = false;
export let regenerateRecoveryCodes: () => Promise<void>;
let code = '';
let error = '';
async function verify() {
try {
const challenge = await sdk.forConsole.account.createMfaChallenge(
AuthenticationFactor.Totp
);
await sdk.forConsole.account.updateMfaChallenge(challenge.$id, code);
show = false;
regenerateRecoveryCodes();
} catch (e) {
error = e.message;
}
}
</script>
<Modal
title="Regenerate recovery codes"
icon="exclamation"
state="warning"
headerDivider={false}
{error}
onSubmit={verify}
bind:show>
<p>
Are you sure you want to regenerate all recovery codes? All <b
>previously generated recovery codes will become invalid.</b>
</p>
<p>Enter the 6-digit verification code generated by your authenticator app to continue.</p>
<FormList>
<InputDigits bind:value={code} required autofocus autoSubmit={false} />
</FormList>
<svelte:fragment slot="footer">
<Button text on:click={() => (show = false)}>Cancel</Button>
<Button secondary submit>Regenerate</Button>
</svelte:fragment>
</Modal>
+4 -1
View File
@@ -2,4 +2,7 @@ import { page } from '$app/stores';
import { derived } from 'svelte/store';
import type { Models } from '@appwrite.io/console';
export const factors = derived(page, ($page) => $page.data.factors as Models.MfaFactors);
export const factors = derived(
page,
($page) => $page.data.factors as Models.MfaFactors & { recoveryCode: boolean }
);
+173 -55
View File
@@ -1,39 +1,66 @@
<script lang="ts">
import { invalidate } from '$app/navigation';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { CardGrid, Heading, Empty } from '$lib/components';
import { CardGrid, EyebrowHeading, Heading } from '$lib/components';
import { Dependencies } from '$lib/constants';
import { Button, FormList, InputChoice } from '$lib/elements/forms';
import {
Table,
TableBody,
TableCell,
TableCellHead,
TableCellText,
TableHeader,
TableRow
} from '$lib/elements/table';
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import { user } from '$lib/stores/user';
import Mfa from './mfa.svelte';
import DeleteMfa from './deleteMfa.svelte';
import { factors } from './store';
import MfaRecoveryCodes from './mfaRecoveryCodes.svelte';
import type { Models } from '@appwrite.io/console';
import MfaRegenerateCodes from './mfaRegenerateCodes.svelte';
let showSetup: boolean = false;
let showDelete: boolean = false;
let showRecoveryCodes = false;
let showRegenerateRecoveryCodes = false;
let codes: Models.MfaRecoveryCodes = null;
async function updateMfa() {
try {
await sdk.forConsole.account.updateMFA(!$user.mfa);
await invalidate(Dependencies.ACCOUNT);
trackEvent(Submit.AccountUpdateEmail);
trackEvent(Submit.AccountUpdateMfa, { mfa: !$user.mfa });
} catch (error) {
addNotification({
message: error.message,
type: 'error'
});
trackError(error, Submit.AccountUpdateEmail);
trackError(error, Submit.AccountUpdateMfa);
}
}
async function createRecoveryCodes() {
try {
codes = await sdk.forConsole.account.createMfaRecoveryCodes();
showRecoveryCodes = true;
Promise.all([invalidate(Dependencies.ACCOUNT), invalidate(Dependencies.FACTORS)]);
trackEvent(Submit.AccountRecoveryCodesCreate);
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.AccountRecoveryCodesCreate);
}
}
async function regenerateRecoveryCodes() {
try {
codes = await sdk.forConsole.account.updateMfaRecoveryCodes();
showRecoveryCodes = true;
Promise.all([invalidate(Dependencies.ACCOUNT), invalidate(Dependencies.FACTORS)]);
trackEvent(Submit.AccountRecoveryCodesUpdate);
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.AccountRecoveryCodesUpdate);
}
}
</script>
@@ -42,49 +69,118 @@
<Heading tag="h6" size="7">Multi-factor authentication</Heading>
<svelte:fragment slot="aside">
<FormList>
<InputChoice
on:change={updateMfa}
type="switchbox"
id="mfa"
label="Multi-factor authentication"
value={$user.mfa} />
</FormList>
<p>Enhance the security of your account by adding authentication factors.</p>
{#if $user.mfa}
{#if $factors.totp}
<Table noMargin noStyles transparent>
<TableHeader>
<TableCellHead>Authenticator</TableCellHead>
<TableCellHead width={40} />
</TableHeader>
<TableBody>
<TableRow>
<TableCellText title="Authenticator">
TOTP (One-time code)
</TableCellText>
<TableCell>
<Button
on:click={() => (showDelete = true)}
round
text
ariaLabel="Delete authenticator">
<span class="icon-trash" aria-hidden="true" />
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
<Button disabled text noMargin>
<span class="icon-plus" />
<span class="text">Add authentication factor</span>
</Button>
{:else}
<Empty on:click={() => (showSetup = true)}>
<p class="text">Add authentication factor</p>
</Empty>
{/if}
{/if}
<div class="u-flex u-flex-vertical u-gap-32">
<div class="u-flex u-flex-vertical u-gap-16">
<FormList>
<InputChoice
on:change={updateMfa}
type="switchbox"
id="mfa"
label="Multi-factor authentication"
value={$user.mfa} />
</FormList>
<p>
Two-factor authentication enhances your account's security by requiring a second
sign-in method. <Button
link
external
href="https://appwrite.io/docs/products/auth/2fa">Learn more.</Button>
</p>
</div>
<div class="u-flex-vertical u-gap-16">
<div class="u-sep-block-end" style="padding-block-end: 8px;">
<EyebrowHeading tag="h6" size={3} class="u-normal">Methods</EyebrowHeading>
</div>
<div
class="method u-flex u-flex-vertical-mobile u-gap-16 u-main-space-between u-sep-block-end"
style="padding-block-end: 16px">
<div class="u-flex u-gap-8">
<div class="avatar is-size-x-small">
<span class="icon-device-mobile" aria-hidden="true" />
</div>
<div class="u-flex-vertical u-gap-4 body-text-2">
<span class="u-bold">Authenticator app</span>
<span
>Use an authentication app to generate two-factor authentication
codes.</span>
</div>
</div>
<div class="method-button">
{#if $factors.totp}
<Button text secondary on:click={() => (showDelete = true)}
>Delete</Button>
{:else}
<Button secondary on:click={() => (showSetup = true)}>Add</Button>
{/if}
</div>
</div>
{#if $factors.email}
<div
class="u-flex u-main-space-between u-sep-block-end"
style="padding-block-end: 16px">
<div class="u-flex u-gap-8">
<div class="avatar is-size-x-small">
<span class="icon-mail" aria-hidden="true" />
</div>
<div class="u-flex-vertical u-gap-4 body-text-2">
<span class="u-bold">Email verification</span>
<span>One-time codes will be sent to: {$user.email}</span>
</div>
</div>
</div>
{/if}
{#if $factors.phone}
<div
class="u-flex u-main-space-between u-sep-block-end"
style="padding-block-end: 16px">
<div class="u-flex u-gap-8">
<div class="avatar is-size-x-small">
<span class="icon-send" aria-hidden="true" />
</div>
<div class="u-flex-vertical u-gap-4 body-text-2">
<span class="u-bold">SMS verification</span>
<span>One-time codes will be sent to: {$user.phone}</span>
</div>
</div>
</div>
{/if}
</div>
<div class="u-flex-vertical u-gap-16">
<div class="u-sep-block-end" style="padding-block-end: 8px;">
<EyebrowHeading tag="h6" size={3} class="u-normal">Recovery</EyebrowHeading>
</div>
<div
class="method u-flex u-flex-vertical-mobile u-gap-16 u-main-space-between u-sep-block-end"
style="padding-block-end: 16px">
<div class="u-flex u-gap-8">
<div class="avatar is-size-x-small">
<span class="icon-lock-open" aria-hidden="true" />
</div>
<div class="u-flex-vertical u-gap-4 body-text-2">
<span class="u-bold">Recovery codes</span>
<span
>Use in case you can't receive two-factor authentication codes.</span>
</div>
</div>
<div class="method-button">
{#if $factors.recoveryCode}
<Button
class="method-button"
text
secondary
on:click={() => (showRegenerateRecoveryCodes = true)}
>Regenerate</Button>
{:else}
<Button class="method-button" secondary on:click={createRecoveryCodes}
>View</Button>
{/if}
</div>
</div>
</div>
</div>
</svelte:fragment>
</CardGrid>
@@ -92,3 +188,25 @@
<Mfa bind:showSetup />
{/if}
<DeleteMfa bind:showDelete />
<MfaRecoveryCodes bind:showRecoveryCodes {codes} />
<MfaRegenerateCodes bind:show={showRegenerateRecoveryCodes} {regenerateRecoveryCodes} />
<style lang="scss">
@import '@appwrite.io/pink/src/abstract/variables/_devices.scss';
/* Default (including mobile) */
.method {
align-items: start;
.method-button {
margin-inline-start: 2rem;
}
}
/* for smaller screens */
@media #{$break2open} {
.method {
align-items: center;
}
}
</style>