feat: improve account security
Support TOTP 2fa, allow for changing password, add session management and account removal.
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
<script setup lang="ts">
|
||||
import { Err, Ok, type Result } from '~~/types/result';
|
||||
import { base64ToUint8Array, decrypt, deriveKey, encryptData, uint8ArrayToBase64 } from '~/utils/crypto';
|
||||
|
||||
const emit = defineEmits(['confirm', 'cancel']);
|
||||
|
||||
const { user } = useAuth();
|
||||
const { providers } = await useModels();
|
||||
|
||||
const INPUT_CLASS = 'w-full h-9 px-2.5 text-sm rounded-md bg-[var(--bg-container)] placeholder:text-[var(--text-tertiary)]';
|
||||
|
||||
type StepState = 'pending' | 'active' | 'done' | 'failed';
|
||||
|
||||
const form = reactive({
|
||||
current: '',
|
||||
next: '',
|
||||
confirm: '',
|
||||
});
|
||||
const busy = ref(false);
|
||||
const done = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
const savedKeyCount = computed(() => providers.value.filter(p => !!p.config.apiKey).length);
|
||||
|
||||
const steps = reactive<{ label: string; hint?: string; state: StepState }[]>([
|
||||
{ label: 'Unlock your saved API keys in this browser', state: 'pending' },
|
||||
{ label: 'Re-encrypt them with the new password', state: 'pending' },
|
||||
{ label: 'Save the new password and keys together', state: 'pending' },
|
||||
{ label: 'Sign out your other devices', state: 'pending' },
|
||||
]);
|
||||
|
||||
const started = computed(() => steps.some(s => s.state !== 'pending'));
|
||||
|
||||
const fail = (step: number, message: string) => {
|
||||
steps[step]!.state = 'failed';
|
||||
error.value = message;
|
||||
busy.value = false;
|
||||
};
|
||||
|
||||
const importKey = (jwk: JsonWebKey) =>
|
||||
crypto.subtle.importKey('jwk', jwk, 'AES-GCM', false, ['encrypt', 'decrypt']);
|
||||
|
||||
// API keys are stored encrypted with a key derived from the password, so they
|
||||
// have to be decrypted with the old password and encrypted again with the new
|
||||
// one. The server never sees the plaintext keys.
|
||||
const decryptProviderKeys = async (
|
||||
oldJwk: JsonWebKey,
|
||||
): Promise<Result<{ id: string; plaintext: string }[], string[]>> => {
|
||||
const oldKey = await importKey(oldJwk);
|
||||
|
||||
const decrypted: { id: string; plaintext: string }[] = [];
|
||||
const failed: string[] = [];
|
||||
|
||||
for (const provider of providers.value) {
|
||||
if (!provider.config.apiKey) continue;
|
||||
|
||||
try {
|
||||
const plaintext = await decrypt(oldKey, base64ToUint8Array(provider.config.apiKey));
|
||||
decrypted.push({ id: provider.id, plaintext });
|
||||
} catch {
|
||||
failed.push(provider.name);
|
||||
}
|
||||
}
|
||||
|
||||
if (failed.length > 0) {
|
||||
return Err(failed);
|
||||
}
|
||||
|
||||
return Ok(decrypted);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (busy.value || done.value || !user.value) return;
|
||||
|
||||
error.value = '';
|
||||
for (const step of steps) step.state = 'pending';
|
||||
|
||||
if (form.next !== form.confirm) {
|
||||
error.value = 'The new passwords do not match.';
|
||||
return;
|
||||
}
|
||||
|
||||
if (form.next === form.current) {
|
||||
error.value = 'The new password is the same as the current one.';
|
||||
return;
|
||||
}
|
||||
|
||||
busy.value = true;
|
||||
|
||||
const userId = user.value.id;
|
||||
|
||||
steps[0]!.state = 'active';
|
||||
const oldJwk = await deriveKey(form.current, userId);
|
||||
const decrypted = await decryptProviderKeys(oldJwk);
|
||||
if (decrypted.ok === false) {
|
||||
fail(0, `Your saved API keys could not be decrypted with that password. Check your current password. If it is right, re-enter the key for ${decrypted.error.join(', ')} under AI Providers first.`);
|
||||
return;
|
||||
}
|
||||
steps[0]!.state = 'done';
|
||||
|
||||
steps[1]!.state = 'active';
|
||||
const newJwk = await deriveKey(form.next, userId);
|
||||
const newKey = await importKey(newJwk);
|
||||
const providerKeys: { id: string; apiKey: string }[] = [];
|
||||
for (const { id, plaintext } of decrypted.data) {
|
||||
providerKeys.push({ id, apiKey: uint8ArrayToBase64(await encryptData(newKey, plaintext)) });
|
||||
}
|
||||
steps[1]!.state = 'done';
|
||||
|
||||
steps[2]!.state = 'active';
|
||||
try {
|
||||
await $fetch('/api/account/password', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
currentPassword: form.current,
|
||||
newPassword: form.next,
|
||||
providerKeys,
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
const code = err?.data?.data?.code as string | undefined;
|
||||
|
||||
switch (code) {
|
||||
case 'INVALID_PASSWORD':
|
||||
fail(2, 'Your current password is not right. Nothing was changed.');
|
||||
break;
|
||||
case 'PASSWORD_TOO_SHORT':
|
||||
case 'PASSWORD_TOO_LONG':
|
||||
fail(2, 'The new password has to be between 8 and 128 characters.');
|
||||
break;
|
||||
case 'PROVIDER_KEYS_OUT_OF_DATE':
|
||||
fail(2, 'Your providers changed in another tab. Reload the page and try again.');
|
||||
break;
|
||||
default:
|
||||
fail(2, 'Your password could not be changed. Nothing was saved, try again.');
|
||||
break;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// from here on this browser has to use the new key
|
||||
localStorage.setItem('encryptionKey', JSON.stringify(newJwk));
|
||||
|
||||
const newKeys = new Map(providerKeys.map(p => [p.id, p.apiKey]));
|
||||
providers.value = providers.value.map(p =>
|
||||
newKeys.has(p.id) ? { ...p, config: { ...p.config, apiKey: newKeys.get(p.id)! } } : p
|
||||
);
|
||||
|
||||
// the server signs the other devices out as part of the same request
|
||||
steps[2]!.state = 'done';
|
||||
steps[3]!.state = 'done';
|
||||
|
||||
form.current = '';
|
||||
form.next = '';
|
||||
form.confirm = '';
|
||||
busy.value = false;
|
||||
done.value = true;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="flex flex-col gap-5 p-6 w-screen md:w-130 max-w-full h-full md:h-auto overflow-y-auto"
|
||||
aria-labelledby="change-password-title" @submit.prevent="submit">
|
||||
<header class="flex flex-col gap-1">
|
||||
<h2 id="change-password-title" class="text-lg font-semibold">Change password</h2>
|
||||
<p class="text-sm text-[var(--text-secondary)]">
|
||||
<template v-if="savedKeyCount > 0">
|
||||
Your {{ savedKeyCount }} saved API {{ savedKeyCount === 1 ? 'key is' : 'keys are' }}
|
||||
re-encrypted with the new password and kept. You will automatically be logged out of
|
||||
all other devices.
|
||||
</template>
|
||||
<template v-else>
|
||||
Your password also encrypts the API keys you save in this browser.
|
||||
</template>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- hidden while it runs, and brought back with what was typed if a step fails -->
|
||||
<div v-if="!busy && !done" class="flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-sm" for="current-password">Current password</label>
|
||||
<input id="current-password" v-model="form.current" type="password" required :disabled="busy"
|
||||
autocomplete="current-password" :class="INPUT_CLASS" />
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<div class="flex flex-col gap-1 flex-1 min-w-40">
|
||||
<label class="text-sm" for="new-password">New password</label>
|
||||
<input id="new-password" v-model="form.next" type="password" required minlength="8" maxlength="128"
|
||||
:disabled="busy" autocomplete="new-password" :class="INPUT_CLASS" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 flex-1 min-w-40">
|
||||
<label class="text-sm" for="confirm-password">Confirm</label>
|
||||
<input id="confirm-password" v-model="form.confirm" type="password" required minlength="8"
|
||||
maxlength="128" :disabled="busy" autocomplete="new-password" :class="INPUT_CLASS" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ol v-if="started" aria-label="Progress" class="flex flex-col gap-2.5 text-sm">
|
||||
<li v-for="step in steps" :key="step.label" class="flex items-start gap-2.5"
|
||||
:class="step.state === 'pending' ? 'text-[var(--text-tertiary)]' : ''">
|
||||
<span class="text-4.5 mt-0.25 flex-shrink-0" :class="{
|
||||
'i-mynaui-circle': step.state === 'pending',
|
||||
'i-svg-spinners-180-ring-with-bg': step.state === 'active',
|
||||
'i-mynaui-check-circle text-[var(--color-accent)]': step.state === 'done',
|
||||
'i-mynaui-x-circle text-red-400': step.state === 'failed',
|
||||
}"></span>
|
||||
<span class="flex flex-col">
|
||||
<span>{{ step.label }}</span>
|
||||
<span v-if="step.hint && step.state === 'active'" class="text-xs text-[var(--text-secondary)]">
|
||||
{{ step.hint }}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<p v-if="error" role="alert" class="text-sm text-red-400">{{ error }}</p>
|
||||
<p v-else-if="done" role="status" class="text-sm text-[var(--text-secondary)]">
|
||||
Your password is changed. Use the new one the next time you sign in.
|
||||
</p>
|
||||
<p v-else-if="busy" class="text-xs text-[var(--text-secondary)]">
|
||||
Keep this tab open until it finishes. Plain-text keys never leave this browser.
|
||||
</p>
|
||||
|
||||
<footer class="flex justify-end gap-2 mt-auto md:mt-0">
|
||||
<button v-if="done" type="button"
|
||||
class="flex items-center justify-center h-9 px-3 text-sm font-medium rounded-md bg-[var(--color-accent)] text-[var(--color-accent-text)] @hover:bg-[var(--color-accent-hover)] transition-[background-color] duration-200"
|
||||
@click="emit('confirm', true)">
|
||||
Done
|
||||
</button>
|
||||
<template v-else>
|
||||
<button type="button" :disabled="busy"
|
||||
class="flex items-center justify-center h-9 px-3 text-sm 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"
|
||||
@click="emit('cancel')">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" :disabled="busy"
|
||||
class="flex items-center justify-center h-9 px-3 text-sm font-medium rounded-md 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">
|
||||
{{ busy ? 'Changing password…' : 'Change password' }}
|
||||
</button>
|
||||
</template>
|
||||
</footer>
|
||||
</form>
|
||||
</template>
|
||||
@@ -0,0 +1,152 @@
|
||||
<script setup lang="ts">
|
||||
import CodeInput from '~/components/CodeInput.vue';
|
||||
|
||||
const emit = defineEmits(['confirm', 'cancel']);
|
||||
|
||||
const { user, session } = useAuth();
|
||||
|
||||
const password = ref('');
|
||||
|
||||
// accounts with two-factor also have to prove the second factor
|
||||
const needsCode = ref(!!user.value?.twoFactorEnabled);
|
||||
const code = ref('');
|
||||
const useBackupCode = ref(false);
|
||||
|
||||
const codeReady = computed(() => {
|
||||
if (!needsCode.value) return true;
|
||||
|
||||
return useBackupCode.value ? code.value.trim() !== '' : code.value.length === 6;
|
||||
});
|
||||
|
||||
const toggleBackupCode = () => {
|
||||
useBackupCode.value = !useBackupCode.value;
|
||||
code.value = '';
|
||||
error.value = '';
|
||||
};
|
||||
const busy = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
const passwordInputEl = ref<HTMLInputElement | null>(null);
|
||||
|
||||
onMounted(() => {
|
||||
passwordInputEl.value?.focus();
|
||||
});
|
||||
|
||||
const submit = async () => {
|
||||
if (busy.value || password.value === '' || !codeReady.value) return;
|
||||
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
|
||||
try {
|
||||
await $fetch('/api/account', {
|
||||
method: 'DELETE',
|
||||
body: {
|
||||
password: password.value,
|
||||
code: needsCode.value ? code.value.trim() : undefined,
|
||||
backupCode: needsCode.value ? useBackupCode.value : undefined,
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
switch (err?.data?.data?.code as string | undefined) {
|
||||
case 'INVALID_PASSWORD':
|
||||
error.value = 'That password is not right. Nothing was deleted.';
|
||||
password.value = '';
|
||||
break;
|
||||
case 'INVALID_CODE':
|
||||
error.value = 'That code is not right. Check your authenticator app. Nothing was deleted.';
|
||||
code.value = '';
|
||||
break;
|
||||
case 'INVALID_BACKUP_CODE':
|
||||
error.value = 'That backup code is not valid, or it was already used. Nothing was deleted.';
|
||||
code.value = '';
|
||||
break;
|
||||
case 'TWO_FACTOR_REQUIRED':
|
||||
// two-factor was turned on somewhere else since this page loaded
|
||||
needsCode.value = true;
|
||||
error.value = 'This account has two-factor sign-in on. Enter a code to continue.';
|
||||
break;
|
||||
default:
|
||||
error.value = 'Your account could not be deleted. Nothing was deleted, try again.';
|
||||
break;
|
||||
}
|
||||
|
||||
busy.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// nothing of this account is left, so drop every local trace of it too
|
||||
localStorage.removeItem('encryptionKey');
|
||||
user.value = null;
|
||||
session.value = null;
|
||||
clearNuxtData();
|
||||
|
||||
emit('confirm', true);
|
||||
|
||||
await navigateTo('/auth/login');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form 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="delete-account-title" @submit.prevent="submit">
|
||||
<header class="flex flex-col gap-1">
|
||||
<h2 id="delete-account-title" class="text-lg font-semibold">Delete your account</h2>
|
||||
<p class="text-sm text-[var(--text-secondary)]">
|
||||
This can't be undone.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<p class="text-sm leading-normal">
|
||||
Deleting <strong class="font-medium">{{ user?.email }}</strong> removes your agents, topics and
|
||||
messages, your providers with their saved API keys, your file list, and your settings from this
|
||||
instance. You are signed out everywhere.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="delete-account-password"
|
||||
class="text-[13px] font-medium uppercase tracking-wider text-[var(--text-secondary)]">
|
||||
Password
|
||||
</label>
|
||||
<input id="delete-account-password" ref="passwordInputEl" v-model="password" type="password" required
|
||||
:disabled="busy" autocomplete="current-password"
|
||||
class="w-full h-9 px-2.5 text-sm rounded-md bg-[var(--bg-container)]" />
|
||||
</div>
|
||||
|
||||
<div v-if="needsCode && useBackupCode" class="flex flex-col gap-1.5">
|
||||
<label for="delete-account-backup-code"
|
||||
class="text-[13px] font-medium uppercase tracking-wider text-[var(--text-secondary)]">
|
||||
Backup code
|
||||
</label>
|
||||
<input id="delete-account-backup-code" v-model="code" type="text" required :disabled="busy"
|
||||
autocomplete="off" autocapitalize="off" spellcheck="false" placeholder="xxxxx-xxxxx"
|
||||
class="w-full h-9 px-2.5 text-sm font-mono rounded-md bg-[var(--bg-container)] placeholder:text-[var(--text-tertiary)]" />
|
||||
</div>
|
||||
<fieldset v-else-if="needsCode" class="flex flex-col">
|
||||
<legend class="mb-2 text-[13px] font-medium uppercase tracking-wider text-[var(--text-secondary)]">
|
||||
6-digit code from your authenticator app
|
||||
</legend>
|
||||
<CodeInput v-model="code" :disabled="busy" />
|
||||
</fieldset>
|
||||
|
||||
<button v-if="needsCode" type="button" :disabled="busy" @click="toggleBackupCode"
|
||||
class="self-start -mt-2 text-sm text-[var(--color-accent)] font-medium @hover:underline">
|
||||
{{ useBackupCode ? 'Use your authenticator app instead' : 'Use a backup code instead' }}
|
||||
</button>
|
||||
|
||||
<p v-if="error" role="alert" class="text-sm text-red-400">{{ error }}</p>
|
||||
|
||||
<footer class="flex justify-between gap-2 mt-auto">
|
||||
<button type="button" :disabled="busy"
|
||||
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"
|
||||
@click="emit('cancel')">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" :disabled="busy || password === '' || !codeReady"
|
||||
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">
|
||||
<span v-if="busy" class="i-svg-spinners-180-ring-with-bg text-4"></span>
|
||||
Delete account
|
||||
</button>
|
||||
</footer>
|
||||
</form>
|
||||
</template>
|
||||
@@ -0,0 +1,362 @@
|
||||
<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) {
|
||||
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;
|
||||
|
||||
// the boxes are disabled while busy and can't take focus until now
|
||||
await nextTick();
|
||||
codeInputEl.value?.focus();
|
||||
} else {
|
||||
// set here rather than by whoever opened the dialog, so it is right
|
||||
// even if the dialog gets dismissed on the last step
|
||||
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, so going back to peek at one is not enough
|
||||
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>
|
||||
@@ -1,6 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import Textbox from './Textbox.vue';
|
||||
import QuickSwitcher from './QuickSwitcher.vue';
|
||||
import ChangePassword from './ChangePassword.vue';
|
||||
import TwoFactor from './TwoFactor.vue';
|
||||
import DeleteAccount from './DeleteAccount.vue';
|
||||
|
||||
import { DialogType } from '~/composables/useDialog';
|
||||
|
||||
@@ -60,6 +63,12 @@ onUnmounted(() => {
|
||||
|
||||
<QuickSwitcher v-else-if="page === DialogType.QuickSwitcher" @close="close" />
|
||||
</KeepAlive>
|
||||
|
||||
<!-- kept out of KeepAlive so passwords never linger after it closes -->
|
||||
<ChangePassword v-if="page === DialogType.ChangePassword" @confirm="confirm" @cancel="close" />
|
||||
<TwoFactor v-else-if="page === DialogType.TwoFactor" :action="data.action" @confirm="confirm"
|
||||
@cancel="close" />
|
||||
<DeleteAccount v-else-if="page === DialogType.DeleteAccount" @confirm="confirm" @cancel="close" />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
Reference in New Issue
Block a user