Files
veridian/app/components/Dialog/ChangePassword.vue
T
zoeissleeping 4bba529ba8 feat: improve account security
Support TOTP 2fa, allow for changing password, add session management
and account removal.
2026-10-08 12:43:22 -05:00

246 lines
10 KiB
Vue

<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>