Files
veridian/app/components/Dialog/TwoFactor.vue
T
2026-10-15 10:22:06 -05:00

360 lines
16 KiB
Vue

<script setup lang="ts">
import { renderSVG } from 'uqr';
import CodeInput from '~/components/CodeInput.vue';
type Action = 'enable' | 'disable' | 'regenerate';
type Step = 'password' | 'verify' | 'codes' | 'check';
const props = defineProps<{
action?: Action;
}>();
const emit = defineEmits(['confirm', 'cancel']);
const { client: authClient, user } = useAuth();
const action = computed<Action>(() => props.action ?? 'enable');
const FLOWS: Record<Action, { title: string; steps: Step[] }> = {
enable: { title: 'Set up two-factor sign-in', steps: ['password', 'verify', 'codes', 'check'] },
regenerate: { title: 'New backup codes', steps: ['password', 'codes', 'check'] },
disable: { title: 'Turn off two-factor sign-in', steps: ['password'] },
};
const STEP_LABELS: Record<Step, string> = {
password: 'Confirm your password',
verify: 'Scan and confirm',
codes: 'Save your backup codes',
check: 'Check what you saved',
};
const INPUT_CLASS = 'w-full h-9 px-2.5 text-sm rounded-md bg-[var(--bg-container)] placeholder:text-[var(--text-tertiary)]';
const CANCEL_CLASS = 'flex items-center h-10 px-4 rounded-md border border-[var(--color-border)] text-[var(--text-secondary)] @hover:bg-[var(--color-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-[background-color] duration-200';
const PRIMARY_CLASS = 'flex items-center gap-2 h-10 px-4 rounded-md font-semibold bg-[var(--color-accent)] text-[var(--color-accent-text)] @hover:bg-[var(--color-accent-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-[background-color] duration-200';
const DANGER_CLASS = 'flex items-center gap-2 h-10 px-4 rounded-md font-semibold bg-red-600 text-white disabled:opacity-50 disabled:cursor-not-allowed';
const SECONDARY_CLASS = 'flex items-center gap-2 h-8.5 px-3 text-sm rounded-md bg-[var(--bg-container)] @hover:bg-[var(--color-hover)] transition-colors duration-200';
const flow = computed(() => FLOWS[action.value]);
const step = ref<Step>('password');
const stepIndex = computed(() => flow.value.steps.indexOf(step.value));
const password = ref('');
const code = ref('');
const busy = ref(false);
const error = ref('');
const totpUri = ref('');
const backupCodes = ref<string[]>([]);
const copied = ref<'secret' | 'codes' | null>(null);
// last step: ask for one of the codes back, to make sure they were really saved
const checkIndex = ref(0);
const checkInput = ref('');
const checkInputEl = ref<HTMLInputElement | null>(null);
const passwordInputEl = ref<HTMLInputElement | null>(null);
const codeInputEl = ref<InstanceType<typeof CodeInput> | null>(null);
onMounted(() => {
passwordInputEl.value?.focus();
});
const totpSecret = computed(() => {
if (!totpUri.value) return '';
return new URL(totpUri.value).searchParams.get('secret') ?? '';
});
const qrCode = computed(() => totpUri.value ? renderSVG(totpUri.value, { border: 2 }) : '');
type AuthClientError = { code?: string; message?: string; status?: number } | null;
const describeError = (err: AuthClientError, fallback: string): string => {
if (err?.status === 429) return 'Too many attempts. Wait a few seconds and try again.';
if (err?.code === 'INVALID_PASSWORD') return 'That password is not right.';
if (err?.code === 'ACCOUNT_TEMPORARILY_LOCKED') return 'Too many wrong codes. Try again later.';
if (err?.code === 'INVALID_CODE') return 'That code is not right. Check your authenticator app and try again.';
return fallback;
};
const submitPassword = async () => {
if (busy.value || password.value === '') return;
busy.value = true;
error.value = '';
const body = { password: password.value };
if (action.value === 'enable') {
const { data, error: err } = await authClient.twoFactor.enable(body);
if (err || !data || data.method !== 'totp') {
error.value = describeError(err, 'Two-factor could not be set up. Try again.');
} else {
// nothing is turned on until a code from the app is verified
totpUri.value = data.totpURI;
backupCodes.value = data.backupCodes;
step.value = 'verify';
await nextTick();
codeInputEl.value?.focus();
}
} else if (action.value === 'regenerate') {
const { data, error: err } = await authClient.twoFactor.generateBackupCodes(body);
if (err || !data) {
error.value = describeError(err, 'New backup codes could not be made. Try again.');
} else {
backupCodes.value = data.backupCodes;
step.value = 'codes';
}
} else {
const { error: err } = await authClient.twoFactor.disable(body);
if (err) {
error.value = describeError(err, 'Two-factor could not be turned off. Try again.');
} else {
if (user.value) user.value = { ...user.value, twoFactorEnabled: false };
emit('confirm', true);
}
}
password.value = '';
busy.value = false;
};
const verifyCode = async () => {
if (busy.value || code.value.length !== 6) return;
busy.value = true;
error.value = '';
const { error: err } = await authClient.twoFactor.verifyTotp({ code: code.value });
if (err) {
error.value = describeError(err, 'That code could not be verified. Try again.');
code.value = '';
busy.value = false;
await nextTick();
codeInputEl.value?.focus();
} else {
if (user.value) user.value = { ...user.value, twoFactorEnabled: true };
step.value = 'codes';
}
busy.value = false;
};
// the numbers are part of what gets saved, the last step asks for a code by number
const numberedBackupCodes = computed(() =>
backupCodes.value.map((backupCode, index) => `${index + 1}. ${backupCode}`).join('\n')
);
const startCheck = async () => {
// a different code every time
checkIndex.value = Math.floor(Math.random() * backupCodes.value.length);
checkInput.value = '';
error.value = '';
step.value = 'check';
await nextTick();
checkInputEl.value?.focus();
};
const backToCodes = () => {
error.value = '';
step.value = 'codes';
};
// forgiving about spacing and the dash, since the code may be read off paper
const normalizeBackupCode = (value: string) => value.replace(/[\s-]/g, '');
const submitCheck = () => {
const expected = backupCodes.value[checkIndex.value] ?? '';
if (normalizeBackupCode(checkInput.value) !== normalizeBackupCode(expected)) {
error.value = `That is not code ${checkIndex.value + 1}. Capital letters matter. Go back and check what you saved.`;
return;
}
emit('confirm', true);
};
const copyText = async (text: string, what: 'secret' | 'codes') => {
try {
await navigator.clipboard.writeText(text);
copied.value = what;
setTimeout(() => {
if (copied.value === what) copied.value = null;
}, 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
};
const downloadBackupCodes = () => {
const text = `Veridian backup codes for ${user.value?.email ?? 'your account'}\nEach code works once.\n\n${numberedBackupCodes.value}\n`;
const url = URL.createObjectURL(new Blob([text], { type: 'text/plain' }));
const link = document.createElement('a');
link.href = url;
link.download = 'veridian-backup-codes.txt';
link.click();
URL.revokeObjectURL(url);
};
</script>
<template>
<section class="flex flex-col gap-4.5 p-6 w-screen md:w-130 max-w-full h-full md:h-auto overflow-y-auto"
aria-labelledby="two-factor-title">
<header class="flex items-start justify-between gap-3">
<div class="flex flex-col gap-1">
<h2 id="two-factor-title" class="text-lg font-semibold">{{ flow.title }}</h2>
<p class="text-sm text-[var(--text-secondary)]">
<template v-if="flow.steps.length > 1">
Step {{ stepIndex + 1 }} of {{ flow.steps.length }} ·
</template>
{{ STEP_LABELS[step] }}
</p>
</div>
<div v-if="flow.steps.length > 1" aria-hidden="true" class="flex gap-1 pt-2">
<span v-for="(_, index) in flow.steps" :key="index" class="w-5 h-1 rounded-sm"
:class="index <= stepIndex ? 'bg-[var(--color-accent)]' : 'bg-[var(--color-border-active)]'"></span>
</div>
</header>
<!-- STEP: PASSWORD -->
<form v-if="step === 'password'" class="flex flex-col gap-4.5 flex-1" @submit.prevent="submitPassword">
<div class="flex flex-col gap-1.5">
<label for="two-factor-password"
class="text-[13px] font-medium uppercase tracking-wider text-[var(--text-secondary)]">
Password
</label>
<input id="two-factor-password" ref="passwordInputEl" v-model="password" type="password" required
:disabled="busy" autocomplete="current-password" :class="INPUT_CLASS" />
<p v-if="error" role="alert" class="text-sm text-red-400">{{ error }}</p>
</div>
<ol class="flex flex-col gap-1.5 text-[13px] text-[var(--text-secondary)]">
<li v-if="action === 'regenerate'">
Your current backup codes stop working as soon as the new ones are made.
</li>
</ol>
<footer class="flex justify-between gap-2 mt-auto">
<button type="button" :disabled="busy" :class="CANCEL_CLASS" @click="emit('cancel')">Cancel</button>
<button type="submit" :disabled="busy" :class="action === 'disable' ? DANGER_CLASS : PRIMARY_CLASS">
<span v-if="busy" class="i-svg-spinners-180-ring-with-bg text-4"></span>
{{ action === 'disable' ? 'Turn off' : 'Continue' }}
</button>
</footer>
</form>
<!-- STEP: SCAN AND CONFIRM -->
<form v-else-if="step === 'verify'" class="flex flex-col gap-4.5 flex-1" @submit.prevent="verifyCode">
<div class="flex flex-wrap gap-4 p-4 rounded-lg bg-[var(--bg-surface)] border border-[var(--color-border)]">
<div class="w-37 h-37 flex-shrink-0 rounded-lg overflow-hidden bg-white" role="img"
aria-label="QR code for your authenticator app" v-html="qrCode"></div>
<div class="flex flex-col gap-2.5 flex-1 min-w-40">
<p class="text-sm leading-normal">
Scan this with an authenticator app such as Ente, KeepassXC, or Authy.
</p>
<div class="flex flex-col gap-1">
<span class="text-xs text-[var(--text-secondary)]">Can't scan? Enter this key by hand</span>
<div class="flex items-center gap-1 pl-2.5 rounded-md bg-[var(--bg-container)]">
<span
class="flex-1 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap font-mono text-[13px] select-all">
{{ totpSecret }}
</span>
<button type="button" :aria-label="copied === 'secret' ? 'Copied' : 'Copy setup key'"
class="flex items-center justify-center w-8.5 h-8.5 flex-shrink-0 text-[var(--text-secondary)] @hover:text-[var(--text-primary)] transition-colors"
@click="copyText(totpSecret, 'secret')">
<span class="text-4"
:class="copied === 'secret' ? 'i-mynaui-check' : 'i-mynaui-copy'"></span>
</button>
</div>
</div>
</div>
</div>
<fieldset class="flex flex-col gap-2">
<legend class="mb-2 text-[13px] font-medium uppercase tracking-wider text-[var(--text-secondary)]">
Enter the 6-digit code to verify that enrollment was successful
</legend>
<CodeInput ref="codeInputEl" v-model="code" :disabled="busy" @complete="verifyCode" />
<p v-if="error" role="alert" class="text-sm text-red-400">{{ error }}</p>
</fieldset>
<footer class="flex justify-between gap-2 mt-auto">
<button type="button" :disabled="busy" :class="CANCEL_CLASS" @click="emit('cancel')">Cancel</button>
<button type="submit" :disabled="busy || code.length !== 6" :class="PRIMARY_CLASS">
<span v-if="busy" class="i-svg-spinners-180-ring-with-bg text-4"></span>
Verify code
</button>
</footer>
</form>
<!-- STEP: BACKUP CODES -->
<div v-else-if="step === 'codes'" class="flex flex-col gap-4.5 flex-1">
<p class="text-sm leading-normal">
<template v-if="action === 'enable'">Two-factor sign-in is on. </template>
Save these codes somewhere safe. Each one signs you in once if you lose your authenticator app.
They are not shown again.
</p>
<ul
class="grid grid-cols-2 gap-x-6 gap-y-1.5 p-4 rounded-lg bg-[var(--bg-surface)] border border-[var(--color-border)] font-mono text-sm [list-style-type:none]">
<li v-for="(backupCode, index) in backupCodes" :key="backupCode" class="flex gap-2">
<span class="w-5 text-right text-[var(--text-tertiary)] select-none">{{ index + 1 }}.</span>
<span>{{ backupCode }}</span>
</li>
</ul>
<div class="flex flex-wrap gap-2">
<button type="button" :class="SECONDARY_CLASS" @click="copyText(numberedBackupCodes, 'codes')">
<span class="text-4" :class="copied === 'codes' ? 'i-mynaui-check' : 'i-mynaui-copy'"></span>
{{ copied === 'codes' ? 'Copied' : 'Copy' }}
</button>
<button type="button" :class="SECONDARY_CLASS" @click="downloadBackupCodes">
<span class="i-mynaui-download text-4"></span>
Download
</button>
</div>
<footer class="flex justify-end gap-2 mt-auto">
<button type="button" :class="PRIMARY_CLASS" @click="startCheck">I saved them</button>
</footer>
</div>
<!-- STEP: CHECK A SAVED CODE -->
<form v-else class="flex flex-col gap-4.5 flex-1" @submit.prevent="submitCheck">
<p class="text-sm leading-normal">
One last check that your codes made it somewhere safe. Find them where you saved them and enter
code {{ checkIndex + 1 }} of {{ backupCodes.length }}.
</p>
<div class="flex flex-col gap-1.5">
<label for="two-factor-check"
class="text-[13px] font-medium tracking-wider text-[var(--text-secondary)]">
Backup code {{ checkIndex + 1 }} of {{ backupCodes.length }}
</label>
<input id="two-factor-check" ref="checkInputEl" v-model="checkInput" type="text" required
autocomplete="off" autocapitalize="off" spellcheck="false" placeholder="xxxxx-xxxxx"
:class="[INPUT_CLASS, 'font-mono']" />
<p v-if="error" role="alert" class="text-sm text-red-400">{{ error }}</p>
</div>
<ol class="flex flex-col gap-1.5 text-[13px] text-[var(--text-secondary)] [list-style-type:none]">
<li>Entering it here does not use the code up.</li>
</ol>
<footer class="flex justify-between gap-2 mt-auto">
<button type="button" :class="CANCEL_CLASS" @click="backToCodes">Back to the codes</button>
<button type="submit" :class="PRIMARY_CLASS">Finish</button>
</footer>
</form>
</section>
</template>