Add Optional 2FA Support (#61)
* Add TOTP MFA Support * Add Passkey MFA Support It's not impossible I missed some minor cleanup, but most things make sense and there isn't a lot of obvious duplication anymore. --------- Co-authored-by: Bruno Bernardino <me@brunobernardino.com>
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import { convertObjectToFormData } from '/lib/utils/misc.ts';
|
||||
import { FormField, generateFieldHtml, getFormDataField } from '/lib/form-utils.tsx';
|
||||
import { currencyMap, SupportedCurrencySymbol } from '/lib/types.ts';
|
||||
import { convertObjectToFormData } from '/lib/utils/misc.ts';
|
||||
import { currencyMap, SupportedCurrencySymbol, User } from '/lib/types.ts';
|
||||
import MultiFactorAuthSettings from '/islands/auth/MultiFactorAuthSettings.tsx';
|
||||
import { getEnabledMultiFactorAuthMethodsFromUser } from '/lib/utils/multi-factor-auth.ts';
|
||||
|
||||
interface SettingsProps {
|
||||
formData: Record<string, any>;
|
||||
@@ -14,7 +16,11 @@ interface SettingsProps {
|
||||
};
|
||||
currency?: SupportedCurrencySymbol;
|
||||
isExpensesAppEnabled: boolean;
|
||||
isMultiFactorAuthEnabled: boolean;
|
||||
helpEmail: string;
|
||||
user: {
|
||||
extra: Pick<User['extra'], 'multi_factor_auth_methods'>;
|
||||
};
|
||||
}
|
||||
|
||||
export type Action =
|
||||
@@ -121,11 +127,20 @@ function formFields(action: Action, formData: FormData, currency?: SupportedCurr
|
||||
}
|
||||
|
||||
export default function Settings(
|
||||
{ formData: formDataObject, error, notice, currency, isExpensesAppEnabled, helpEmail }: SettingsProps,
|
||||
{
|
||||
formData: formDataObject,
|
||||
error,
|
||||
notice,
|
||||
currency,
|
||||
isExpensesAppEnabled,
|
||||
isMultiFactorAuthEnabled,
|
||||
helpEmail,
|
||||
user,
|
||||
}: SettingsProps,
|
||||
) {
|
||||
const formData = convertObjectToFormData(formDataObject);
|
||||
|
||||
const action = getFormDataField(formData, 'action') as Action;
|
||||
const multiFactorAuthMethods = getEnabledMultiFactorAuthMethodsFromUser(user);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -151,7 +166,7 @@ export default function Settings(
|
||||
|
||||
<form method='POST' class='mb-12'>
|
||||
{formFields(
|
||||
action === 'change-email' && notice?.message.includes('verify') ? 'verify-change-email' : 'change-email',
|
||||
'change-email',
|
||||
formData,
|
||||
).map((field) => generateFieldHtml(field, formData))}
|
||||
<section class='flex justify-end mt-8 mb-4'>
|
||||
@@ -195,6 +210,20 @@ export default function Settings(
|
||||
)
|
||||
: null}
|
||||
|
||||
{isMultiFactorAuthEnabled
|
||||
? (
|
||||
<MultiFactorAuthSettings
|
||||
methods={multiFactorAuthMethods.map((method) => ({
|
||||
type: method.type,
|
||||
id: method.id,
|
||||
name: method.name,
|
||||
enabled: method.enabled,
|
||||
backupCodesCount: method.metadata.totp?.hashed_backup_codes?.length,
|
||||
}))}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
|
||||
<h2 class='text-2xl mb-4 text-left px-4 max-w-screen-md mx-auto lg:min-w-96'>Delete your account</h2>
|
||||
<p class='text-left mt-2 mb-6 px-4 max-w-screen-md mx-auto lg:min-w-96'>
|
||||
Deleting your account is instant and deletes all your data. {helpEmail !== ''
|
||||
|
||||
530
islands/auth/MultiFactorAuthSettings.tsx
Normal file
530
islands/auth/MultiFactorAuthSettings.tsx
Normal file
@@ -0,0 +1,530 @@
|
||||
import { useSignal } from '@preact/signals';
|
||||
import { startRegistration } from '@simplewebauthn/browser';
|
||||
|
||||
import { MultiFactorAuthMethodType } from '/lib/types.ts';
|
||||
import {
|
||||
RequestBody as PasskeySetupBeginRequestBody,
|
||||
ResponseBody as PasskeySetupBeginResponseBody,
|
||||
} from '/routes/api/auth/multi-factor/passkey/setup-begin.ts';
|
||||
import {
|
||||
RequestBody as PasskeySetupCompleteRequestBody,
|
||||
ResponseBody as PasskeySetupCompleteResponseBody,
|
||||
} from '/routes/api/auth/multi-factor/passkey/setup-complete.ts';
|
||||
import {
|
||||
RequestBody as TOTPSetupRequestBody,
|
||||
ResponseBody as TOTPSetupResponseBody,
|
||||
} from '/routes/api/auth/multi-factor/totp/setup.ts';
|
||||
import {
|
||||
RequestBody as MultiFactorAuthEnableRequestBody,
|
||||
ResponseBody as MultiFactorAuthEnableResponseBody,
|
||||
} from '/routes/api/auth/multi-factor/enable.ts';
|
||||
import {
|
||||
RequestBody as MultiFactorAuthDisableRequestBody,
|
||||
ResponseBody as MultiFactorAuthDisableResponseBody,
|
||||
} from '/routes/api/auth/multi-factor/disable.ts';
|
||||
|
||||
interface MultiFactorAuthMethod {
|
||||
type: MultiFactorAuthMethodType;
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
backupCodesCount?: number;
|
||||
}
|
||||
|
||||
interface MultiFactorAuthSettingsProps {
|
||||
methods: MultiFactorAuthMethod[];
|
||||
}
|
||||
|
||||
interface TOTPSetupData {
|
||||
type: 'totp';
|
||||
secret: string;
|
||||
qrCodeUrl: string;
|
||||
backupCodes: string[];
|
||||
methodId: string;
|
||||
}
|
||||
|
||||
interface PasskeySetupData {
|
||||
methodId: string;
|
||||
type: 'passkey';
|
||||
}
|
||||
|
||||
const methodTypeLabels: Record<MultiFactorAuthMethodType, string> = {
|
||||
totp: 'Authenticator App',
|
||||
passkey: 'Passkey',
|
||||
};
|
||||
|
||||
const methodTypeDescriptions: Record<MultiFactorAuthMethodType, string> = {
|
||||
totp: 'Use an authenticator app like Aegis Authenticator or Google Authenticator to generate codes',
|
||||
passkey: 'Use biometric authentication or security keys',
|
||||
};
|
||||
|
||||
const availableMethodTypes = ['totp', 'passkey'] as MultiFactorAuthMethodType[];
|
||||
|
||||
export default function MultiFactorAuthSettings({ methods }: MultiFactorAuthSettingsProps) {
|
||||
const setupData = useSignal<TOTPSetupData | PasskeySetupData | null>(null);
|
||||
const isLoading = useSignal(false);
|
||||
const error = useSignal<string | null>(null);
|
||||
const success = useSignal<string | null>(null);
|
||||
const showDisableForm = useSignal<'all' | string | null>(null);
|
||||
const verificationToken = useSignal('');
|
||||
const disablePassword = useSignal('');
|
||||
|
||||
const enabledMethods = methods.filter((method) => method.enabled);
|
||||
const hasMultiFactorAuthEnabled = enabledMethods.length > 0;
|
||||
|
||||
const setupPasskey = async () => {
|
||||
const beginRequestBody: PasskeySetupBeginRequestBody = {};
|
||||
|
||||
const beginResponse = await fetch('/api/auth/multi-factor/passkey/setup-begin', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(beginRequestBody),
|
||||
});
|
||||
|
||||
if (!beginResponse.ok) {
|
||||
throw new Error(
|
||||
`Failed to begin passkey registration! ${beginResponse.statusText} ${await beginResponse.text()}`,
|
||||
);
|
||||
}
|
||||
|
||||
const beginData = await beginResponse.json() as PasskeySetupBeginResponseBody;
|
||||
|
||||
if (!beginData.success) {
|
||||
throw new Error(beginData.error || 'Failed to begin passkey registration');
|
||||
}
|
||||
|
||||
const registrationResponse = await startRegistration({ optionsJSON: beginData.options! });
|
||||
|
||||
const completeRequestBody: PasskeySetupCompleteRequestBody = {
|
||||
methodId: beginData.sessionData!.methodId,
|
||||
challenge: beginData.sessionData!.challenge,
|
||||
registrationResponse,
|
||||
};
|
||||
|
||||
const completeResponse = await fetch('/api/auth/multi-factor/passkey/setup-complete', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(completeRequestBody),
|
||||
});
|
||||
|
||||
if (!completeResponse.ok) {
|
||||
throw new Error(
|
||||
`Failed to complete passkey registration! ${completeResponse.statusText} ${await completeResponse.text()}`,
|
||||
);
|
||||
}
|
||||
|
||||
const completeData = await completeResponse.json() as PasskeySetupCompleteResponseBody;
|
||||
|
||||
if (!completeData.success) {
|
||||
throw new Error(completeData.error || 'Failed to complete passkey registration');
|
||||
}
|
||||
|
||||
setupData.value = {
|
||||
methodId: beginData.sessionData!.methodId,
|
||||
type: 'passkey',
|
||||
};
|
||||
};
|
||||
|
||||
const setupTOTP = async () => {
|
||||
const requestBody: TOTPSetupRequestBody = {};
|
||||
|
||||
const response = await fetch('/api/auth/multi-factor/totp/setup', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
const data = await response.json() as TOTPSetupResponseBody;
|
||||
|
||||
if (!data.success || !data.data) {
|
||||
throw new Error(data.error || 'Failed to setup TOTP multi-factor authentication');
|
||||
}
|
||||
|
||||
setupData.value = {
|
||||
type: 'totp',
|
||||
secret: data.data.secret!,
|
||||
qrCodeUrl: data.data.qrCodeUrl!,
|
||||
backupCodes: data.data.backupCodes!,
|
||||
methodId: data.data.methodId!,
|
||||
};
|
||||
};
|
||||
|
||||
const setupMultiFactorAuth = async (type: MultiFactorAuthMethodType) => {
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
if (type === 'totp') {
|
||||
await setupTOTP();
|
||||
} else if (type === 'passkey') {
|
||||
await setupPasskey();
|
||||
}
|
||||
} catch (setupError) {
|
||||
error.value = (setupError as Error).message;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const enableMultiFactorAuth = async () => {
|
||||
if (!setupData.value) {
|
||||
error.value = 'No setup data available';
|
||||
return;
|
||||
}
|
||||
|
||||
if (setupData.value.type !== 'passkey' && !verificationToken.value) {
|
||||
error.value = 'Please enter a verification token';
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const requestBody: MultiFactorAuthEnableRequestBody = {
|
||||
methodId: setupData.value.methodId,
|
||||
code: setupData.value.type === 'passkey' ? 'passkey-verified' : verificationToken.value,
|
||||
};
|
||||
|
||||
const response = await fetch('/api/auth/multi-factor/enable', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
const data = await response.json() as MultiFactorAuthEnableResponseBody;
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Failed to enable multi-factor authentication');
|
||||
}
|
||||
|
||||
success.value = 'Multi-factor authentication has been enabled successfully! Reloading...';
|
||||
setupData.value = null;
|
||||
verificationToken.value = '';
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 2000);
|
||||
} catch (enableError) {
|
||||
error.value = (enableError as Error).message;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const disableMultiFactorAuth = async (methodId?: string, disableAll = false) => {
|
||||
if (!disablePassword.value) {
|
||||
error.value = 'Please enter your password';
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const requestBody: MultiFactorAuthDisableRequestBody = {
|
||||
methodId,
|
||||
password: disablePassword.value,
|
||||
disableAll,
|
||||
};
|
||||
|
||||
const response = await fetch('/api/auth/multi-factor/disable', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
const data = await response.json() as MultiFactorAuthDisableResponseBody;
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Failed to disable multi-factor authentication');
|
||||
}
|
||||
|
||||
success.value = 'Multi-factor authentication has been disabled successfully! Reloading...';
|
||||
showDisableForm.value = null;
|
||||
disablePassword.value = '';
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 2000);
|
||||
} catch (disableError) {
|
||||
error.value = (disableError as Error).message;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const cancelSetup = () => {
|
||||
setupData.value = null;
|
||||
verificationToken.value = '';
|
||||
error.value = null;
|
||||
};
|
||||
|
||||
const cancelDisable = () => {
|
||||
showDisableForm.value = null;
|
||||
disablePassword.value = '';
|
||||
error.value = null;
|
||||
};
|
||||
|
||||
return (
|
||||
<section class='mb-16'>
|
||||
<h2 class='text-2xl mb-4 text-left px-4 max-w-screen-md mx-auto lg:min-w-96'>
|
||||
Multi-Factor Authentication (MFA)
|
||||
</h2>
|
||||
|
||||
<section class='px-4 max-w-screen-md mx-auto lg:min-w-96'>
|
||||
{error.value
|
||||
? (
|
||||
<section class='notification-error mb-4'>
|
||||
<p>{error.value}</p>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
|
||||
{success.value
|
||||
? (
|
||||
<section class='notification-success mb-4'>
|
||||
<p>{success.value}</p>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
|
||||
<p class='mb-6'>
|
||||
Multi-factor authentication adds an extra layer of security to your account by requiring additional
|
||||
verification beyond your password.
|
||||
</p>
|
||||
|
||||
{availableMethodTypes
|
||||
.filter((type) => !enabledMethods.some((method) => method.type === type)).length > 0
|
||||
? (
|
||||
<section class='mb-6 mt-4'>
|
||||
<h3 class='text-lg font-semibold mb-4'>
|
||||
Available Authentication Methods
|
||||
</h3>
|
||||
<section class='space-y-4'>
|
||||
{availableMethodTypes
|
||||
.filter((type) =>
|
||||
!enabledMethods.some((method) => method.type === type) && setupData.value?.type !== type
|
||||
)
|
||||
.map((type) => (
|
||||
<section key={type} class='border rounded-lg p-4'>
|
||||
<section class='flex items-center justify-between'>
|
||||
<section>
|
||||
<h4 class='font-medium'>{methodTypeLabels[type]}</h4>
|
||||
<p class='text-sm text-gray-400'>{methodTypeDescriptions[type]}</p>
|
||||
</section>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setupMultiFactorAuth(type)}
|
||||
disabled={isLoading.value}
|
||||
class='button-secondary'
|
||||
>
|
||||
{isLoading.value ? '...' : 'Add'}
|
||||
</button>
|
||||
</section>
|
||||
</section>
|
||||
))}
|
||||
</section>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
|
||||
{setupData.value && setupData.value.type !== 'passkey'
|
||||
? (
|
||||
<section class='mb-6'>
|
||||
<h3 class='text-lg font-semibold mb-4'>Setup Authenticator App</h3>
|
||||
|
||||
<section class='mb-6'>
|
||||
<p class='mb-4'>
|
||||
1. Scan this QR code with your authenticator app (Aegis Authenticator, Google Authenticator, etc.):
|
||||
</p>
|
||||
<section class='flex justify-center mb-4'>
|
||||
<img src={setupData.value.qrCodeUrl} alt='TOTP QR Code' class='border' />
|
||||
</section>
|
||||
<p class='text-sm text-gray-400 mb-4'>
|
||||
Or manually enter this secret:{' '}
|
||||
<code class='bg-gray-200 px-2 py-1 rounded text-gray-900'>{setupData.value.secret}</code>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class='mb-6'>
|
||||
<p class='mb-4'>
|
||||
2. Save these backup codes <strong class='font-bold text-sky-500'>NOW</strong> in a safe place:
|
||||
</p>
|
||||
<section class='bg-gray-200 border rounded p-4 font-mono text-sm text-gray-900'>
|
||||
{setupData.value.backupCodes.map((code, index) => <section key={index} class='mb-1'>{code}</section>)}
|
||||
</section>
|
||||
<p class='text-sm text-gray-400 mt-2'>
|
||||
These codes can be used to access your account if you lose your authenticator device.{' '}
|
||||
<strong class='font-bold text-sky-500'>They won't be visible again</strong>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<fieldset class='block mb-6'>
|
||||
<label class='text-slate-300 block pb-1'>
|
||||
3. Enter the 6-digit code from your authenticator app:
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
value={verificationToken.value}
|
||||
onInput={(event) => verificationToken.value = (event.target as HTMLInputElement).value}
|
||||
placeholder='123456'
|
||||
class='mt-1 input-field'
|
||||
maxLength={6}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<section class='flex justify-end gap-2 mt-8 mb-4'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={cancelSetup}
|
||||
disabled={isLoading.value}
|
||||
class='button-secondary'
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
onClick={enableMultiFactorAuth}
|
||||
disabled={isLoading.value || !verificationToken.value}
|
||||
class='button'
|
||||
>
|
||||
{isLoading.value ? 'Enabling...' : 'Enable TOTP MFA'}
|
||||
</button>
|
||||
</section>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
|
||||
{setupData.value && setupData.value.type === 'passkey'
|
||||
? (
|
||||
<section class='mb-6'>
|
||||
<h3 class='text-lg font-semibold mb-4'>Passkey Setup Complete</h3>
|
||||
<p class='mb-4'>
|
||||
Your passkey has been successfully registered! You can now enable it for multi-factor authentication.
|
||||
</p>
|
||||
|
||||
<section class='flex justify-end gap-2 mt-8 mb-4'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={cancelSetup}
|
||||
disabled={isLoading.value}
|
||||
class='button-secondary'
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
onClick={enableMultiFactorAuth}
|
||||
disabled={isLoading.value}
|
||||
class='button'
|
||||
>
|
||||
{isLoading.value ? 'Enabling...' : 'Enable Passkey MFA'}
|
||||
</button>
|
||||
</section>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
|
||||
{hasMultiFactorAuthEnabled && !showDisableForm.value
|
||||
? (
|
||||
<section>
|
||||
<section class='mb-6'>
|
||||
<h3 class='text-lg font-semibold mb-4'>Active Authentication Methods</h3>
|
||||
|
||||
{enabledMethods.map((method) => (
|
||||
<section key={method.id} class='border rounded-lg p-4 mb-4'>
|
||||
<section class='flex items-center justify-between'>
|
||||
<section>
|
||||
<section
|
||||
class={`flex items-center ${
|
||||
method.type === 'totp' && typeof method.backupCodesCount !== 'undefined' ? 'mb-2' : ''
|
||||
}`}
|
||||
>
|
||||
<span class='inline-block w-3 h-3 bg-green-500 rounded-full mr-2'></span>
|
||||
<span class='font-medium'>{method.name}</span>
|
||||
</section>
|
||||
{method.type === 'totp' && typeof method.backupCodesCount !== 'undefined'
|
||||
? (
|
||||
<p class='text-sm text-gray-600'>
|
||||
{method.backupCodesCount > 0
|
||||
? `${method.backupCodesCount} backup codes remaining`
|
||||
: 'No backup codes remaining'}
|
||||
</p>
|
||||
)
|
||||
: null}
|
||||
</section>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => showDisableForm.value = method.id}
|
||||
class='button-secondary'
|
||||
>
|
||||
Disable
|
||||
</button>
|
||||
</section>
|
||||
</section>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section class='flex justify-end mt-8 mb-4'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => showDisableForm.value = 'all'}
|
||||
class='button-danger'
|
||||
>
|
||||
Disable All MFA
|
||||
</button>
|
||||
</section>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
|
||||
{showDisableForm.value
|
||||
? (
|
||||
<section class='mb-6'>
|
||||
<h3 class='text-lg font-semibold mb-4'>
|
||||
{showDisableForm.value === 'all'
|
||||
? 'Disable All Multi-Factor Authentication'
|
||||
: 'Disable Authentication Method'}
|
||||
</h3>
|
||||
<p class='mb-4'>
|
||||
{showDisableForm.value === 'all'
|
||||
? 'This will disable all multi-factor authentication methods and make your account less secure.'
|
||||
: 'This will disable this authentication method.'} Please enter your password to confirm.
|
||||
</p>
|
||||
|
||||
<fieldset class='block mb-4'>
|
||||
<label class='text-slate-300 block pb-1'>Password</label>
|
||||
<input
|
||||
type='password'
|
||||
value={disablePassword.value}
|
||||
onInput={(event) => disablePassword.value = (event.target as HTMLInputElement).value}
|
||||
placeholder='Enter your password'
|
||||
class='mt-1 input-field'
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<section class='flex justify-end gap-2 mt-8 mb-4'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={cancelDisable}
|
||||
disabled={isLoading.value}
|
||||
class='button-secondary'
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() =>
|
||||
disableMultiFactorAuth(
|
||||
showDisableForm.value === 'all' ? undefined : showDisableForm.value || undefined,
|
||||
showDisableForm.value === 'all',
|
||||
)}
|
||||
disabled={isLoading.value || !disablePassword.value}
|
||||
class='button-danger'
|
||||
>
|
||||
{isLoading.value ? 'Disabling...' : 'Disable'}
|
||||
</button>
|
||||
</section>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
116
islands/auth/PasswordlessPasskeyLogin.tsx
Normal file
116
islands/auth/PasswordlessPasskeyLogin.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useSignal } from '@preact/signals';
|
||||
import { startAuthentication } from '@simplewebauthn/browser';
|
||||
|
||||
import {
|
||||
RequestBody as PasskeyBeginRequestBody,
|
||||
ResponseBody as PasskeyBeginResponseBody,
|
||||
} from '/routes/api/auth/multi-factor/passkey/begin.ts';
|
||||
|
||||
import {
|
||||
RequestBody as PasskeyVerifyRequestBody,
|
||||
ResponseBody as PasskeyVerifyResponseBody,
|
||||
} from '/routes/api/auth/multi-factor/passkey/verify.ts';
|
||||
|
||||
interface PasswordlessPasskeyLoginProps {
|
||||
email?: string;
|
||||
redirectUrl?: string;
|
||||
}
|
||||
|
||||
export default function PasswordlessPasskeyLogin({ email: providedEmail, redirectUrl }: PasswordlessPasskeyLoginProps) {
|
||||
const isLoading = useSignal(false);
|
||||
const email = useSignal<string | null>(providedEmail || null);
|
||||
const error = useSignal<string | null>(null);
|
||||
|
||||
const handlePasswordlessLogin = async () => {
|
||||
if (isLoading.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!email.value) {
|
||||
const promptEmail = prompt('Please enter your email');
|
||||
if (!promptEmail) {
|
||||
throw new Error('Email is required to login with Passkey');
|
||||
}
|
||||
email.value = promptEmail;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const beginRequestBody: PasskeyBeginRequestBody = {
|
||||
email: email.value,
|
||||
};
|
||||
|
||||
const beginResponse = await fetch('/api/auth/multi-factor/passkey/begin', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(beginRequestBody),
|
||||
});
|
||||
|
||||
if (!beginResponse.ok) {
|
||||
throw new Error(
|
||||
`Failed to begin passwordless login! ${beginResponse.statusText} ${await beginResponse.text()}`,
|
||||
);
|
||||
}
|
||||
|
||||
const beginData = await beginResponse.json() as PasskeyBeginResponseBody;
|
||||
|
||||
if (!beginData.success) {
|
||||
throw new Error(beginData.error || 'Failed to begin passwordless login');
|
||||
}
|
||||
|
||||
const authenticationResponse = await startAuthentication({
|
||||
optionsJSON: beginData.options!,
|
||||
});
|
||||
|
||||
const verifyRequestBody: PasskeyVerifyRequestBody = {
|
||||
email: email.value,
|
||||
challenge: beginData.sessionData!.challenge,
|
||||
authenticationResponse,
|
||||
redirectUrl: redirectUrl || '/',
|
||||
};
|
||||
|
||||
const verifyResponse = await fetch('/api/auth/multi-factor/passkey/verify', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(verifyRequestBody),
|
||||
});
|
||||
|
||||
if (verifyResponse.ok) {
|
||||
window.location.href = redirectUrl || '/';
|
||||
return;
|
||||
}
|
||||
|
||||
const verifyData = await verifyResponse.json() as PasskeyVerifyResponseBody;
|
||||
throw new Error(
|
||||
verifyData.error || `Authentication failed! ${verifyResponse.statusText} ${await verifyResponse.text()}`,
|
||||
);
|
||||
} catch (handleError) {
|
||||
console.error('Passwordless passkey login failed:', handleError);
|
||||
error.value = (handleError as Error).message;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section class='space-y-4'>
|
||||
<section class='flex justify-center mt-2 mb-4'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handlePasswordlessLogin}
|
||||
class='button-secondary'
|
||||
>
|
||||
{isLoading.value ? 'Authenticating...' : 'Login with Passkey'}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{error.value
|
||||
? (
|
||||
<section class='notification-error'>
|
||||
<p>{error.value}</p>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user