4bba529ba8
Support TOTP 2fa, allow for changing password, add session management and account removal.
246 lines
10 KiB
Vue
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>
|