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,106 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const LENGTH = 6;
|
||||||
|
|
||||||
|
// the code typed so far, always a run of digits with no gaps
|
||||||
|
const code = defineModel<string>({ default: '' });
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
large?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
complete: [code: string];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const inputs = ref<HTMLInputElement[]>([]);
|
||||||
|
|
||||||
|
const digits = computed(() => Array.from({ length: LENGTH }, (_, i) => code.value[i] ?? ''));
|
||||||
|
|
||||||
|
const focusAt = (index: number) => {
|
||||||
|
inputs.value[Math.max(0, Math.min(LENGTH - 1, index))]?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const setCode = (value: string) => {
|
||||||
|
const next = value.slice(0, LENGTH);
|
||||||
|
code.value = next;
|
||||||
|
|
||||||
|
// code.value still reads the old value until the parent re-renders, so
|
||||||
|
// this has to check what was just written
|
||||||
|
if (next.length === LENGTH) {
|
||||||
|
emit('complete', next);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInput = (index: number, event: Event) => {
|
||||||
|
const el = event.target as HTMLInputElement;
|
||||||
|
// more than one digit arrives at once when the browser autofills the code
|
||||||
|
const typed = el.value.replace(/\D/g, '');
|
||||||
|
|
||||||
|
if (typed !== '') {
|
||||||
|
const next = code.value.slice(0, index) + typed + code.value.slice(index + typed.length);
|
||||||
|
setCode(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
// the box only ever shows its own digit, whatever was typed into it
|
||||||
|
nextTick(() => {
|
||||||
|
el.value = digits.value[index]!;
|
||||||
|
if (typed !== '') focusAt(index + typed.length);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeydown = (index: number, event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Backspace') {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (digits.value[index] !== '') {
|
||||||
|
code.value = code.value.slice(0, index) + code.value.slice(index + 1);
|
||||||
|
} else if (index > 0) {
|
||||||
|
code.value = code.value.slice(0, index - 1) + code.value.slice(index);
|
||||||
|
focusAt(index - 1);
|
||||||
|
}
|
||||||
|
} else if (event.key === 'ArrowLeft') {
|
||||||
|
event.preventDefault();
|
||||||
|
focusAt(index - 1);
|
||||||
|
} else if (event.key === 'ArrowRight') {
|
||||||
|
event.preventDefault();
|
||||||
|
focusAt(index + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePaste = (event: ClipboardEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const pasted = (event.clipboardData?.getData('text') ?? '').replace(/\D/g, '');
|
||||||
|
if (pasted === '') return;
|
||||||
|
|
||||||
|
setCode(pasted);
|
||||||
|
focusAt(pasted.length);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFocus = (index: number, event: FocusEvent) => {
|
||||||
|
// boxes fill left to right, so never leave the cursor past the first empty one
|
||||||
|
if (index > code.value.length) {
|
||||||
|
focusAt(code.value.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
(event.target as HTMLInputElement).select();
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
focus: () => focusAt(code.value.length),
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input v-for="(digit, index) in digits" :key="index" :ref="(el) => { inputs[index] = el as HTMLInputElement; }"
|
||||||
|
type="text" inputmode="numeric" pattern="[0-9]*" :aria-label="`Digit ${index + 1}`"
|
||||||
|
:autocomplete="index === 0 ? 'one-time-code' : 'off'" :value="digit" :disabled="props.disabled" :class="[
|
||||||
|
'flex-1 min-w-0 w-full p-0 text-center font-mono rounded-lg bg-[var(--bg-container)] text-[var(--text-primary)] border border-[var(--color-border)] outline-none transition-[border-color,box-shadow] duration-150 focus:border-[var(--color-accent)] focus:ring-3 focus:ring-[var(--color-accent)]/20 disabled:opacity-50',
|
||||||
|
props.large ? 'h-14 text-2xl' : 'h-13 text-[22px]',
|
||||||
|
]" @input="handleInput(index, $event)" @keydown="handleKeydown(index, $event)" @paste="handlePaste"
|
||||||
|
@focus="handleFocus(index, $event)" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -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">
|
<script setup lang="ts">
|
||||||
import Textbox from './Textbox.vue';
|
import Textbox from './Textbox.vue';
|
||||||
import QuickSwitcher from './QuickSwitcher.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';
|
import { DialogType } from '~/composables/useDialog';
|
||||||
|
|
||||||
@@ -60,6 +63,12 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<QuickSwitcher v-else-if="page === DialogType.QuickSwitcher" @close="close" />
|
<QuickSwitcher v-else-if="page === DialogType.QuickSwitcher" @close="close" />
|
||||||
</KeepAlive>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const groups = [
|
|||||||
label: 'Account',
|
label: 'Account',
|
||||||
items: [
|
items: [
|
||||||
{ to: '/settings/profile', name: 'Profile', icon: 'i-mynaui-user' },
|
{ to: '/settings/profile', name: 'Profile', icon: 'i-mynaui-user' },
|
||||||
|
{ to: '/settings/security', name: 'Security', icon: 'i-mynaui-shield' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { BetterAuthClientOptions, InferSessionFromClient, InferUserFromClient, User } from 'better-auth/client';
|
import type { BetterAuthClientOptions, InferSessionFromClient, InferUserFromClient, User } from 'better-auth/client';
|
||||||
|
import { twoFactorClient } from 'better-auth/client/plugins';
|
||||||
import { createAuthClient } from 'better-auth/vue';
|
import { createAuthClient } from 'better-auth/vue';
|
||||||
import { Err, Ok, type Result } from '~~/types/result';
|
import { Err, Ok, type Result } from '~~/types/result';
|
||||||
import { assert } from '~~/utils/assert';
|
import { assert } from '~~/utils/assert';
|
||||||
@@ -12,15 +13,27 @@ export enum AuthError {
|
|||||||
NetworkError = 'NETWORK_ERROR',
|
NetworkError = 'NETWORK_ERROR',
|
||||||
}
|
}
|
||||||
|
|
||||||
type sessionData = {
|
export type AuthUser = InferUserFromClient<BetterAuthClientOptions> & {
|
||||||
session: InferSessionFromClient<BetterAuthClientOptions> | null;
|
twoFactorEnabled?: boolean | null;
|
||||||
user: InferUserFromClient<BetterAuthClientOptions> | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type sessionData = {
|
||||||
|
session: InferSessionFromClient<BetterAuthClientOptions> | null;
|
||||||
|
user: AuthUser | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Signing in either finishes right away, or stops at the second factor when
|
||||||
|
// the account has two-factor authentication turned on.
|
||||||
|
export type SignInResult =
|
||||||
|
| { twoFactorRequired: false; user: User; token: string }
|
||||||
|
| { twoFactorRequired: true };
|
||||||
|
|
||||||
export const useAuth = () => {
|
export const useAuth = () => {
|
||||||
const authClient = createAuthClient({});
|
const authClient = createAuthClient({
|
||||||
|
plugins: [twoFactorClient()],
|
||||||
|
});
|
||||||
const session = useState<InferSessionFromClient<BetterAuthClientOptions> | null>('auth:session', () => null);
|
const session = useState<InferSessionFromClient<BetterAuthClientOptions> | null>('auth:session', () => null);
|
||||||
const user = useState<InferUserFromClient<BetterAuthClientOptions> | null>('auth:user', () => null);
|
const user = useState<AuthUser | null>('auth:user', () => null);
|
||||||
const sessionFetching = import.meta.server ? ref(false) : useState('auth:sessionFetching', () => false);
|
const sessionFetching = import.meta.server ? ref(false) : useState('auth:sessionFetching', () => false);
|
||||||
|
|
||||||
let sessionPromise: Promise<Result<sessionData, AuthError>> | null = null;
|
let sessionPromise: Promise<Result<sessionData, AuthError>> | null = null;
|
||||||
@@ -36,7 +49,7 @@ export const useAuth = () => {
|
|||||||
sessionPromise = promise;
|
sessionPromise = promise;
|
||||||
let data: {
|
let data: {
|
||||||
session: InferSessionFromClient<BetterAuthClientOptions>;
|
session: InferSessionFromClient<BetterAuthClientOptions>;
|
||||||
user: InferUserFromClient<BetterAuthClientOptions>;
|
user: AuthUser;
|
||||||
} | null = null;
|
} | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -45,7 +58,7 @@ export const useAuth = () => {
|
|||||||
(
|
(
|
||||||
await useFetch<{
|
await useFetch<{
|
||||||
session: InferSessionFromClient<BetterAuthClientOptions>;
|
session: InferSessionFromClient<BetterAuthClientOptions>;
|
||||||
user: InferUserFromClient<BetterAuthClientOptions>;
|
user: AuthUser;
|
||||||
}>('/api/auth/get-session')
|
}>('/api/auth/get-session')
|
||||||
).data.value ?? null;
|
).data.value ?? null;
|
||||||
} else {
|
} else {
|
||||||
@@ -75,7 +88,7 @@ export const useAuth = () => {
|
|||||||
const signIn = async (
|
const signIn = async (
|
||||||
email: string,
|
email: string,
|
||||||
password: string
|
password: string
|
||||||
): Promise<Result<{ user: User; token: string }, { error: AuthError, data?: any }>> => {
|
): Promise<Result<SignInResult, { error: AuthError, data?: any }>> => {
|
||||||
sessionFetching.value = true;
|
sessionFetching.value = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -93,11 +106,16 @@ export const useAuth = () => {
|
|||||||
return Err({ error: AuthError.SignInFailed });
|
return Err({ error: AuthError.SignInFailed });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// no session exists yet, verifyTwoFactor() finishes the sign in
|
||||||
|
if ('twoFactorRedirect' in data && data.twoFactorRedirect) {
|
||||||
|
return Ok({ twoFactorRequired: true });
|
||||||
|
}
|
||||||
|
|
||||||
user.value = data.user;
|
user.value = data.user;
|
||||||
|
|
||||||
clearNuxtData();
|
clearNuxtData();
|
||||||
|
|
||||||
return Ok({ user: data.user, token: data.token });
|
return Ok({ twoFactorRequired: false, user: data.user, token: data.token });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Sign in error:', err);
|
console.error('Sign in error:', err);
|
||||||
return Err({ error: AuthError.NetworkError });
|
return Err({ error: AuthError.NetworkError });
|
||||||
@@ -106,6 +124,39 @@ export const useAuth = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const verifyTwoFactor = async (
|
||||||
|
code: string,
|
||||||
|
options: { backupCode?: boolean; trustDevice?: boolean } = {}
|
||||||
|
): Promise<Result<{ user: User; token: string }, { error: AuthError, data?: any }>> => {
|
||||||
|
sessionFetching.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = { code, trustDevice: options.trustDevice ?? false };
|
||||||
|
const { data, error } = options.backupCode
|
||||||
|
? await authClient.twoFactor.verifyBackupCode(body)
|
||||||
|
: await authClient.twoFactor.verifyTotp(body);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return Err({ error: AuthError.SignInFailed, data: error });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || !data.token) {
|
||||||
|
return Err({ error: AuthError.SignInFailed });
|
||||||
|
}
|
||||||
|
|
||||||
|
user.value = data.user as AuthUser;
|
||||||
|
|
||||||
|
clearNuxtData();
|
||||||
|
|
||||||
|
return Ok({ user: data.user as User, token: data.token });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Two-factor verification error:', err);
|
||||||
|
return Err({ error: AuthError.NetworkError });
|
||||||
|
} finally {
|
||||||
|
sessionFetching.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const signUp = async (
|
const signUp = async (
|
||||||
email: string,
|
email: string,
|
||||||
password: string,
|
password: string,
|
||||||
@@ -195,6 +246,7 @@ export const useAuth = () => {
|
|||||||
user,
|
user,
|
||||||
loggedIn: computed(() => !!session.value),
|
loggedIn: computed(() => !!session.value),
|
||||||
signIn,
|
signIn,
|
||||||
|
verifyTwoFactor,
|
||||||
signUp,
|
signUp,
|
||||||
signOut,
|
signOut,
|
||||||
updateProfile,
|
updateProfile,
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ export enum DialogType {
|
|||||||
Textbox = 'textbox',
|
Textbox = 'textbox',
|
||||||
QuickSwitcher = 'quick-switcher',
|
QuickSwitcher = 'quick-switcher',
|
||||||
Confirm = 'confirm',
|
Confirm = 'confirm',
|
||||||
|
ChangePassword = 'change-password',
|
||||||
|
TwoFactor = 'two-factor',
|
||||||
|
DeleteAccount = 'delete-account',
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DialogOptions {
|
interface DialogOptions {
|
||||||
|
|||||||
+162
-5
@@ -1,7 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import CodeInput from '~/components/CodeInput.vue';
|
||||||
import { deriveKey } from '~/utils/crypto';
|
import { deriveKey } from '~/utils/crypto';
|
||||||
|
|
||||||
const { client: authClient, signIn, session } = useAuth();
|
const { client: authClient, signIn, verifyTwoFactor, session } = useAuth();
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: 'auth',
|
layout: 'auth',
|
||||||
@@ -21,6 +22,27 @@ const form = reactive({
|
|||||||
});
|
});
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
|
||||||
|
// second step, only shown for accounts with two-factor authentication
|
||||||
|
const step = ref<'credentials' | 'twoFactor'>('credentials');
|
||||||
|
const twoFactor = reactive({
|
||||||
|
code: '',
|
||||||
|
useBackupCode: false,
|
||||||
|
trustDevice: false,
|
||||||
|
error: '',
|
||||||
|
});
|
||||||
|
const codeInputEl = ref<InstanceType<typeof CodeInput> | null>(null);
|
||||||
|
const backupCodeInputEl = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const focusCodeInput = async () => {
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
if (twoFactor.useBackupCode) {
|
||||||
|
backupCodeInputEl.value?.focus();
|
||||||
|
} else {
|
||||||
|
codeInputEl.value?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const emailInputEl = ref<HTMLInputElement | null>(null);
|
const emailInputEl = ref<HTMLInputElement | null>(null);
|
||||||
const passwordInputEl = ref<HTMLInputElement | null>(null);
|
const passwordInputEl = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
@@ -97,11 +119,94 @@ const submit = async () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = await deriveKey(form.password, res.data.user.id);
|
if (res.data.twoFactorRequired) {
|
||||||
|
twoFactor.code = '';
|
||||||
|
twoFactor.error = '';
|
||||||
|
twoFactor.useBackupCode = false;
|
||||||
|
step.value = 'twoFactor';
|
||||||
|
|
||||||
|
await focusCodeInput();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return finishSignIn(res.data.user.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
// The password is still in memory at this point, which is the only time we
|
||||||
|
// can derive the key that encrypts the user's API keys.
|
||||||
|
const finishSignIn = async (userId: string) => {
|
||||||
|
const key = await deriveKey(form.password, userId);
|
||||||
localStorage.setItem('encryptionKey', JSON.stringify(key));
|
localStorage.setItem('encryptionKey', JSON.stringify(key));
|
||||||
|
|
||||||
return navigateTo(to ?? '/');
|
return navigateTo(to ?? '/');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const backToCredentials = (error = '') => {
|
||||||
|
step.value = 'credentials';
|
||||||
|
twoFactor.code = '';
|
||||||
|
twoFactor.error = '';
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
passwordInputEl.value!.setCustomValidity(error);
|
||||||
|
passwordInputEl.value!.reportValidity();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleBackupCode = async () => {
|
||||||
|
twoFactor.useBackupCode = !twoFactor.useBackupCode;
|
||||||
|
twoFactor.code = '';
|
||||||
|
twoFactor.error = '';
|
||||||
|
|
||||||
|
await focusCodeInput();
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitTwoFactor = async () => {
|
||||||
|
const code = twoFactor.code.trim();
|
||||||
|
if (code === '' || loading.value) return;
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
twoFactor.error = '';
|
||||||
|
|
||||||
|
const res = await verifyTwoFactor(code, {
|
||||||
|
backupCode: twoFactor.useBackupCode,
|
||||||
|
trustDevice: twoFactor.trustDevice,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
loading.value = false;
|
||||||
|
|
||||||
|
if (res.error.data?.status === 429) {
|
||||||
|
twoFactor.error = 'Too many attempts. Wait a few seconds and try again.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (res.error.data?.code) {
|
||||||
|
case 'INVALID_CODE':
|
||||||
|
twoFactor.error = 'That code is not right. Check your authenticator app and try again.';
|
||||||
|
break;
|
||||||
|
case 'INVALID_BACKUP_CODE':
|
||||||
|
twoFactor.error = 'That backup code is not valid, or it was already used.';
|
||||||
|
break;
|
||||||
|
case 'ACCOUNT_TEMPORARILY_LOCKED':
|
||||||
|
twoFactor.error = 'Too many wrong codes. Two-factor sign-in is locked for a while, try again later.';
|
||||||
|
break;
|
||||||
|
case 'INVALID_TWO_FACTOR_COOKIE':
|
||||||
|
// the 10 minute window to enter a code ran out
|
||||||
|
backToCredentials('Sign in timed out. Enter your password again.');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
twoFactor.error = 'Something went wrong. Try again.';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
twoFactor.code = '';
|
||||||
|
await focusCodeInput();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await finishSignIn(res.data.user.id);
|
||||||
|
loading.value = false;
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -117,11 +222,17 @@ const submit = async () => {
|
|||||||
<div class="z-10 max-w-sm w-full">
|
<div class="z-10 max-w-sm w-full">
|
||||||
<div class="flex flex-col items-center mb-4 text-center">
|
<div class="flex flex-col items-center mb-4 text-center">
|
||||||
<h1 class="text-3xl font-bold tracking-tight">Veridian</h1>
|
<h1 class="text-3xl font-bold tracking-tight">Veridian</h1>
|
||||||
<p class="text-[var(--text-secondary)]">Sign in to your account</p>
|
<p class="text-[var(--text-secondary)]">
|
||||||
|
{{ step === 'credentials'
|
||||||
|
? 'Sign in to your account'
|
||||||
|
: twoFactor.useBackupCode
|
||||||
|
? 'Enter one of your backup codes'
|
||||||
|
: 'Enter the code from your authenticator app' }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-2xl">
|
<div class="rounded-2xl">
|
||||||
<form class="flex flex-col gap-2" @submit.prevent="submit">
|
<form v-show="step === 'credentials'" class="flex flex-col gap-2" @submit.prevent="submit">
|
||||||
<div class="flex flex-col gap-1.5">
|
<div class="flex flex-col gap-1.5">
|
||||||
<label for="email"
|
<label for="email"
|
||||||
class="text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)] ml-1">Email</label>
|
class="text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)] ml-1">Email</label>
|
||||||
@@ -144,9 +255,55 @@ const submit = async () => {
|
|||||||
<span v-else>Sign In</span>
|
<span v-else>Sign In</span>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<form v-if="step === 'twoFactor'" class="flex flex-col gap-3" @submit.prevent="submitTwoFactor">
|
||||||
|
<div v-if="twoFactor.useBackupCode" class="flex flex-col gap-1.5">
|
||||||
|
<label for="two-factor-code"
|
||||||
|
class="text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)] ml-1">
|
||||||
|
Backup code
|
||||||
|
</label>
|
||||||
|
<input required ref="backupCodeInputEl" type="text" autocomplete="off" autocapitalize="off"
|
||||||
|
spellcheck="false" id="two-factor-code" v-model="twoFactor.code"
|
||||||
|
placeholder="xxxxx-xxxxx"
|
||||||
|
class="font-mono focus:ring-2 focus:ring-[var(--color-accent)]/20 border border-[var(--color-border)] transition-all" />
|
||||||
|
</div>
|
||||||
|
<fieldset v-else>
|
||||||
|
<legend
|
||||||
|
class="mb-1.5 ml-1 text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)]">
|
||||||
|
6-digit code
|
||||||
|
</legend>
|
||||||
|
<CodeInput ref="codeInputEl" v-model="twoFactor.code" large :disabled="loading"
|
||||||
|
@complete="submitTwoFactor" />
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<p v-if="twoFactor.error" role="alert" class="text-sm text-red-400 ml-1">
|
||||||
|
{{ twoFactor.error }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-2.5 text-sm text-[var(--text-secondary)] cursor-pointer">
|
||||||
|
<input type="checkbox" v-model="twoFactor.trustDevice"
|
||||||
|
class="w-4 h-4 p-0 m-0 accent-[var(--color-accent)]" />
|
||||||
|
<span>Trust this browser for 30 days</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button class="accent w-full h-11 flex items-center justify-center font-semibold" type="submit">
|
||||||
|
<span v-if="loading" class="i-svg-spinners-90-ring-with-bg text-xl"></span>
|
||||||
|
<span v-else>Verify</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-center gap-2 mt-2 text-sm text-[var(--text-secondary)]">
|
||||||
|
<button type="button" @click="toggleBackupCode"
|
||||||
|
class="text-[var(--color-accent)] font-medium @hover:underline">
|
||||||
|
{{ twoFactor.useBackupCode ? 'Use your authenticator app instead' : 'Use a backup code instead' }}
|
||||||
|
</button>
|
||||||
|
<button type="button" @click="backToCredentials()" class="@hover:underline">
|
||||||
|
Back to log in
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="!config.public.disableSignup" class="text-sm text-center mt-3 text-[var(--text-secondary)]">
|
<p v-if="!config.public.disableSignup && step === 'credentials'" class="text-sm text-center mt-3 text-[var(--text-secondary)]">
|
||||||
New to Veridian?
|
New to Veridian?
|
||||||
<NuxtLink :to="to ? `/auth/register?to=${to}` : '/auth/register'"
|
<NuxtLink :to="to ? `/auth/register?to=${to}` : '/auth/register'"
|
||||||
class="text-[var(--color-accent)] font-medium @hover:underline">
|
class="text-[var(--color-accent)] font-medium @hover:underline">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const route = useRoute();
|
|||||||
|
|
||||||
const TITLES: Record<string, string> = {
|
const TITLES: Record<string, string> = {
|
||||||
profile: 'Profile',
|
profile: 'Profile',
|
||||||
|
security: 'Security',
|
||||||
general: 'General',
|
general: 'General',
|
||||||
appearance: 'Appearance',
|
appearance: 'Appearance',
|
||||||
providers: 'AI Providers',
|
providers: 'AI Providers',
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const updateHinting = (e: Event) => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="flex flex-col gap-6 py-4 overflow-auto [scrollbar-width:thin] [scrollbar-color:#888_transparent] [scrollbar-gutter:stable]">
|
class="flex flex-col gap-6 -mx-4 p-4 overflow-auto [scrollbar-width:thin] [scrollbar-color:#888_transparent] [scrollbar-gutter:stable]">
|
||||||
<div class="flex flex-row items-center justify-between gap-2">
|
<div class="flex flex-row items-center justify-between gap-2">
|
||||||
<h4 class="font-medium">Theme</h4>
|
<h4 class="font-medium">Theme</h4>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Err, Ok, type Result } from '~~/types/result';
|
import { Err, Ok, type Result } from '~~/types/result';
|
||||||
|
import { DialogType } from '~/composables/useDialog';
|
||||||
|
|
||||||
const AVATAR_SIZE = 256;
|
const AVATAR_SIZE = 256;
|
||||||
const MAX_SOURCE_SIZE = 10 * 1024 * 1024;
|
const MAX_SOURCE_SIZE = 10 * 1024 * 1024;
|
||||||
const ACCEPTED_TYPES = ['image/png', 'image/jpeg', 'image/webp'];
|
const ACCEPTED_TYPES = ['image/png', 'image/jpeg', 'image/webp'];
|
||||||
|
|
||||||
const { user, updateProfile } = useAuth();
|
const { user, updateProfile } = useAuth();
|
||||||
|
const { openDialog } = useDialog();
|
||||||
|
|
||||||
const fileInput = ref<HTMLInputElement | null>(null);
|
const fileInput = ref<HTMLInputElement | null>(null);
|
||||||
const avatarBusy = ref(false);
|
const avatarBusy = ref(false);
|
||||||
@@ -224,7 +226,8 @@ const memberSince = computed(() => {
|
|||||||
|
|
||||||
<div class="flex flex-row flex-wrap items-center justify-between gap-x-16 gap-y-2">
|
<div class="flex flex-row flex-wrap items-center justify-between gap-x-16 gap-y-2">
|
||||||
<span class="whitespace-nowrap">Email</span>
|
<span class="whitespace-nowrap">Email</span>
|
||||||
<span class="flex-1 min-w-60 max-w-120 text-sm text-[var(--text-secondary)] overflow-hidden text-ellipsis">
|
<span
|
||||||
|
class="flex-1 min-w-60 max-w-120 text-sm text-[var(--text-secondary)] overflow-hidden text-ellipsis">
|
||||||
{{ user?.email }}
|
{{ user?.email }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -235,6 +238,20 @@ const memberSince = computed(() => {
|
|||||||
{{ memberSince }}
|
{{ memberSince }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section
|
||||||
|
class="flex flex-wrap items-center justify-between gap-3 mt-3 p-4 rounded-lg border border-red-400/35">
|
||||||
|
<div class="flex flex-col gap-0.5 flex-1 min-w-60">
|
||||||
|
<h2 class="text-[15px] font-semibold">Delete account</h2>
|
||||||
|
<p class="text-[13px] leading-snug text-[var(--text-secondary)]">
|
||||||
|
Permanently deletes your agents, topics, and api keys from this instance.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" @click="openDialog(DialogType.DeleteAccount)"
|
||||||
|
class="flex items-center h-8.5 px-3 text-sm font-medium rounded-md bg-red-600 text-white">
|
||||||
|
Delete account
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { DialogType } from '~/composables/useDialog';
|
||||||
|
|
||||||
|
const BUTTON_CLASS = 'flex items-center justify-center gap-2 h-8.5 px-3 text-sm rounded-md bg-[var(--bg-container)] @hover:bg-[var(--color-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]';
|
||||||
|
const PRIMARY_BUTTON_CLASS = 'flex items-center justify-center gap-2 h-8.5 px-3 text-sm font-medium rounded-md bg-[var(--color-accent)] text-[var(--color-accent-text)] disabled:opacity-40 disabled:cursor-not-allowed transition-opacity duration-200';
|
||||||
|
const OUTLINE_BUTTON_CLASS = 'flex items-center justify-center gap-2 h-8.5 px-3 text-sm rounded-md border border-[var(--color-border)] text-[var(--text-secondary)] @hover:bg-[var(--color-hover)] disabled:text-[var(--text-tertiary)] disabled:cursor-not-allowed disabled:hover:bg-transparent transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]';
|
||||||
|
|
||||||
|
const { client: authClient, user } = useAuth();
|
||||||
|
const { providers } = await useModels();
|
||||||
|
|
||||||
|
// tinted pill used for the states that are on, same look as the role badge
|
||||||
|
const ACCENT_PILL_STYLE = {
|
||||||
|
backgroundColor: 'color-mix(in srgb, var(--color-accent) 20%, transparent)',
|
||||||
|
borderColor: 'color-mix(in srgb, var(--color-accent) 50%, transparent)',
|
||||||
|
};
|
||||||
|
|
||||||
|
// fixed locale and time zone so the server and client render the same text
|
||||||
|
const formatDate = (date: string | Date) => new Date(date).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
timeZone: 'UTC',
|
||||||
|
});
|
||||||
|
|
||||||
|
// The server does not know the viewer's time zone, so it renders the date only
|
||||||
|
// and the time of day is filled in once the page is running in the browser.
|
||||||
|
const mounted = ref(false);
|
||||||
|
onMounted(() => {
|
||||||
|
mounted.value = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatDateTime = (date: string | Date) => {
|
||||||
|
if (!mounted.value) return formatDate(date);
|
||||||
|
|
||||||
|
return new Date(date).toLocaleString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Password
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const { openDialog } = useDialog();
|
||||||
|
|
||||||
|
const savedKeyCount = computed(() => providers.value.filter(p => !!p.config.apiKey).length);
|
||||||
|
|
||||||
|
const { data: passwordInfo, refresh: refreshPasswordInfo } = await useFetch<{ changedAt: string | null }>('/api/account/password', {
|
||||||
|
key: 'account_password',
|
||||||
|
});
|
||||||
|
|
||||||
|
const openChangePassword = () => {
|
||||||
|
openDialog(DialogType.ChangePassword, async (result) => {
|
||||||
|
if (result.ok === false) return;
|
||||||
|
|
||||||
|
await Promise.all([refreshPasswordInfo(), loadSessions()]);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Two-factor authentication
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const twoFactorEnabled = computed(() => !!user.value?.twoFactorEnabled);
|
||||||
|
|
||||||
|
const openTwoFactor = (action: 'enable' | 'disable' | 'regenerate') => {
|
||||||
|
// Turning two-factor on or off swaps the session for a new one, and the
|
||||||
|
// dialog can be dismissed after that happened, so always reload.
|
||||||
|
openDialog(DialogType.TwoFactor, async () => {
|
||||||
|
await loadSessions();
|
||||||
|
}, { action });
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sessions
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type SessionRow = {
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
ipAddress: string | null;
|
||||||
|
userAgent: string | null;
|
||||||
|
current: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sessionsError = ref('');
|
||||||
|
const sessionsBusy = ref(false);
|
||||||
|
|
||||||
|
// Better Auth only lists sessions for a device that signed in within the last
|
||||||
|
// day, so this goes through our own endpoint instead.
|
||||||
|
const { data: sessionData, error: sessionsLoadError, refresh: loadSessions } = await useFetch<SessionRow[]>('/api/account/sessions', {
|
||||||
|
key: 'account_sessions',
|
||||||
|
});
|
||||||
|
|
||||||
|
const sessions = computed(() => sessionData.value ?? []);
|
||||||
|
|
||||||
|
// this device first, then most recently active
|
||||||
|
const sortedSessions = computed(() => [...sessions.value].sort((a, b) => {
|
||||||
|
if (a.current) return -1;
|
||||||
|
if (b.current) return 1;
|
||||||
|
|
||||||
|
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
||||||
|
}));
|
||||||
|
|
||||||
|
watch(sessionsLoadError, (error) => {
|
||||||
|
sessionsError.value = error ? 'Your sessions could not be loaded.' : '';
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
const revokeSession = async (id: string) => {
|
||||||
|
if (sessionsBusy.value) return;
|
||||||
|
|
||||||
|
sessionsBusy.value = true;
|
||||||
|
sessionsError.value = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await $fetch(`/api/account/sessions/${id}`, { method: 'DELETE' });
|
||||||
|
await loadSessions();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to revoke session:', error);
|
||||||
|
sessionsError.value = 'That session could not be signed out. Try again.';
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionsBusy.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const revokeOtherSessions = async () => {
|
||||||
|
if (sessionsBusy.value) return;
|
||||||
|
|
||||||
|
sessionsBusy.value = true;
|
||||||
|
sessionsError.value = '';
|
||||||
|
|
||||||
|
const { error } = await authClient.revokeOtherSessions();
|
||||||
|
if (error) {
|
||||||
|
sessionsError.value = 'Your other sessions could not be signed out. Try again.';
|
||||||
|
} else {
|
||||||
|
await loadSessions();
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionsBusy.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const describeDevice = (userAgent: string | null | undefined) => {
|
||||||
|
const ua = userAgent ?? '';
|
||||||
|
|
||||||
|
const browser =
|
||||||
|
/Edg\//.test(ua) ? 'Edge'
|
||||||
|
: /OPR\//.test(ua) ? 'Opera'
|
||||||
|
: /Firefox\//.test(ua) ? 'Firefox'
|
||||||
|
: /Chrome\//.test(ua) ? 'Chrome'
|
||||||
|
: /Safari\//.test(ua) ? 'Safari'
|
||||||
|
: 'Unknown browser';
|
||||||
|
|
||||||
|
const os =
|
||||||
|
/Android/.test(ua) ? 'Android'
|
||||||
|
: /iPhone|iPad|iPod/.test(ua) ? 'iOS'
|
||||||
|
: /Windows/.test(ua) ? 'Windows'
|
||||||
|
: /Mac OS X/.test(ua) ? 'macOS'
|
||||||
|
: /Linux/.test(ua) ? 'Linux'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: os ? `${browser} on ${os}` : browser,
|
||||||
|
mobile: /Android|iPhone|iPad|iPod|Mobile/.test(ua),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex flex-col py-4 overflow-auto [scrollbar-width:thin] [scrollbar-color:#888_transparent] [scrollbar-gutter:stable]">
|
||||||
|
<div class="flex flex-col gap-7 w-full max-w-3xl">
|
||||||
|
<!-- PASSWORD -->
|
||||||
|
<section class="flex flex-col gap-3">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div class="flex flex-col gap-0.5 flex-1 min-w-60">
|
||||||
|
<h2 class="font-medium">Password</h2>
|
||||||
|
<p class="text-[13px] text-[var(--text-secondary)]">
|
||||||
|
{{ passwordInfo?.changedAt
|
||||||
|
? `Last changed ${formatDate(passwordInfo.changedAt)}`
|
||||||
|
: 'Never changed' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" :class="BUTTON_CLASS" @click="openChangePassword">
|
||||||
|
Change password…
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- TWO-FACTOR -->
|
||||||
|
<section class="flex flex-col gap-3 pt-6 border-t border-[var(--color-border)]">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div class="flex flex-col gap-0.5 flex-1 min-w-60">
|
||||||
|
<h2 class="font-medium flex items-center gap-2">
|
||||||
|
Two-factor sign-in
|
||||||
|
<span v-if="twoFactorEnabled"
|
||||||
|
class="text-xs font-normal px-2 py-0.25 rounded-md border text-[var(--text-primary)]"
|
||||||
|
:style="ACCENT_PILL_STYLE">On</span>
|
||||||
|
<span v-else
|
||||||
|
class="text-xs font-normal px-2 py-0.25 rounded-md border border-[var(--color-border-active)] text-[var(--text-secondary)]">Off</span>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button v-if="twoFactorEnabled" type="button" :class="OUTLINE_BUTTON_CLASS"
|
||||||
|
@click="openTwoFactor('disable')">
|
||||||
|
Turn off
|
||||||
|
</button>
|
||||||
|
<button v-else type="button" :class="[PRIMARY_BUTTON_CLASS, 'font-semibold']"
|
||||||
|
@click="openTwoFactor('enable')">
|
||||||
|
Set up
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="twoFactorEnabled" class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<span class="flex-1 min-w-60 text-sm">
|
||||||
|
Backup codes
|
||||||
|
<span class="text-[13px]" :class="twoFactorEnabled ? 'text-[var(--text-secondary)]' : ''">
|
||||||
|
· {{ twoFactorEnabled
|
||||||
|
? 'each one signs you in once if you lose your authenticator app'
|
||||||
|
: 'available once two-factor is on' }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<button type="button" :disabled="!twoFactorEnabled" :class="OUTLINE_BUTTON_CLASS"
|
||||||
|
@click="openTwoFactor('regenerate')">
|
||||||
|
Regenerate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- SESSIONS -->
|
||||||
|
<section class="flex flex-col gap-3 pt-6 border-t border-[var(--color-border)]">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div class="flex flex-col gap-0.5 flex-1 min-w-60">
|
||||||
|
<h2 class="font-medium">Sessions</h2>
|
||||||
|
<p class="text-[13px] text-[var(--text-secondary)]">
|
||||||
|
Devices signed in to your account.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" :disabled="sessionsBusy || sessions.length < 2" :class="OUTLINE_BUTTON_CLASS"
|
||||||
|
@click="revokeOtherSessions">
|
||||||
|
Sign out everywhere else
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="sessionsError" role="alert" class="text-sm text-red-400">{{ sessionsError }}</p>
|
||||||
|
|
||||||
|
<ul class="flex flex-col gap-1">
|
||||||
|
<li v-for="s in sortedSessions" :key="s.id"
|
||||||
|
class="flex flex-wrap items-center gap-3 min-h-13 px-3 py-2 rounded-lg bg-[var(--bg-container)]">
|
||||||
|
<span class="text-5 flex-shrink-0 text-[var(--text-secondary)]"
|
||||||
|
:class="describeDevice(s.userAgent).mobile ? 'i-tabler-device-mobile' : 'i-tabler-device-desktop'"></span>
|
||||||
|
<div class="flex flex-col flex-1 min-w-40">
|
||||||
|
<span class="text-sm font-medium">
|
||||||
|
{{ describeDevice(s.userAgent).label }}
|
||||||
|
<span v-if="s.ipAddress" class="font-normal text-[var(--text-tertiary)]">
|
||||||
|
· {{ s.ipAddress }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-[var(--text-secondary)]">
|
||||||
|
{{ s.current ? 'Active now' : `Last active ${formatDateTime(s.updatedAt)}` }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span v-if="s.current" class="text-xs px-2 py-0.5 rounded-md border text-[var(--text-primary)]"
|
||||||
|
:style="ACCENT_PILL_STYLE">
|
||||||
|
This device
|
||||||
|
</span>
|
||||||
|
<button v-else type="button" :disabled="sessionsBusy"
|
||||||
|
class="flex items-center h-7.5 px-2.5 text-[13px] rounded-md border border-[var(--color-border)] text-[var(--text-primary)] @hover:bg-[var(--color-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
|
||||||
|
@click="revokeSession(s.id)">
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
CREATE TABLE "two_factors" (
|
||||||
|
"id" text PRIMARY KEY,
|
||||||
|
"secret" text NOT NULL,
|
||||||
|
"backup_codes" text NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"verified" boolean DEFAULT true,
|
||||||
|
"failed_verification_count" integer DEFAULT 0,
|
||||||
|
"locked_until" timestamp with time zone
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "users" ADD COLUMN "two_factor_enabled" boolean DEFAULT false;--> statement-breakpoint
|
||||||
|
CREATE INDEX "two_factors_secret_idx" ON "two_factors" ("secret");--> statement-breakpoint
|
||||||
|
CREATE INDEX "two_factors_userId_idx" ON "two_factors" ("user_id");--> statement-breakpoint
|
||||||
|
ALTER TABLE "two_factors" ADD CONSTRAINT "two_factors_user_id_users_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -253,6 +253,7 @@ export const users = pgTable("users", {
|
|||||||
email: text("email").notNull().unique(),
|
email: text("email").notNull().unique(),
|
||||||
emailVerified: boolean("email_verified").default(false).notNull(),
|
emailVerified: boolean("email_verified").default(false).notNull(),
|
||||||
image: text("image"),
|
image: text("image"),
|
||||||
|
twoFactorEnabled: boolean("two_factor_enabled").default(false),
|
||||||
createdAt: timestamptz("created_at").defaultNow().notNull(),
|
createdAt: timestamptz("created_at").defaultNow().notNull(),
|
||||||
updatedAt: timestamptz("updated_at")
|
updatedAt: timestamptz("updated_at")
|
||||||
.defaultNow()
|
.defaultNow()
|
||||||
@@ -260,6 +261,25 @@ export const users = pgTable("users", {
|
|||||||
.notNull(),
|
.notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const twoFactors = pgTable(
|
||||||
|
"two_factors",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
secret: text("secret").notNull(),
|
||||||
|
backupCodes: text("backup_codes").notNull(),
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
verified: boolean("verified").default(true),
|
||||||
|
failedVerificationCount: integer("failed_verification_count").default(0),
|
||||||
|
lockedUntil: timestamptz("locked_until"),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("two_factors_secret_idx").on(table.secret),
|
||||||
|
index("two_factors_userId_idx").on(table.userId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
export const sessions = pgTable(
|
export const sessions = pgTable(
|
||||||
"sessions",
|
"sessions",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { drizzleAdapter } from "@better-auth/drizzle-adapter/relations-v2";
|
import { drizzleAdapter } from "@better-auth/drizzle-adapter/relations-v2";
|
||||||
import { db } from "../server/lib/db";
|
import { db } from "../server/lib/db";
|
||||||
import { betterAuth } from 'better-auth/minimal';
|
import { betterAuth } from 'better-auth/minimal';
|
||||||
|
import { twoFactor } from 'better-auth/plugins/two-factor';
|
||||||
import * as schema from '../drizzle/schema';
|
import * as schema from '../drizzle/schema';
|
||||||
|
|
||||||
export const auth = betterAuth({
|
export const auth = betterAuth({
|
||||||
@@ -22,6 +23,12 @@ export const auth = betterAuth({
|
|||||||
baseURL: process.env.NUXT_PUBLIC_URL ?? 'http://localhost:3000',
|
baseURL: process.env.NUXT_PUBLIC_URL ?? 'http://localhost:3000',
|
||||||
trustedOrigins: [process.env.NUXT_PUBLIC_URL ?? 'http://localhost:3000'],
|
trustedOrigins: [process.env.NUXT_PUBLIC_URL ?? 'http://localhost:3000'],
|
||||||
|
|
||||||
|
plugins: [
|
||||||
|
twoFactor({
|
||||||
|
issuer: 'Veridian',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
|
||||||
emailAndPassword: {
|
emailAndPassword: {
|
||||||
// if DISABLE_LOCAL_AUTH is set to "1" or "true" then local auth is disabled
|
// if DISABLE_LOCAL_AUTH is set to "1" or "true" then local auth is disabled
|
||||||
enabled: process.env.DISABLE_LOCAL_AUTH?.toLowerCase() !== 'true' && process.env.DISABLE_LOCAL_AUTH !== '1',
|
enabled: process.env.DISABLE_LOCAL_AUTH?.toLowerCase() !== 'true' && process.env.DISABLE_LOCAL_AUTH !== '1',
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ export default defineNuxtConfig({
|
|||||||
'@nuxt/hints/runtime/hydration/component',
|
'@nuxt/hints/runtime/hydration/component',
|
||||||
'@tanstack/vue-virtual',
|
'@tanstack/vue-virtual',
|
||||||
'better-auth/vue',
|
'better-auth/vue',
|
||||||
|
'uqr',
|
||||||
'nanoid',
|
'nanoid',
|
||||||
'drizzle-orm/pg-core'
|
'drizzle-orm/pg-core'
|
||||||
],
|
],
|
||||||
|
|||||||
+5
-4
@@ -20,7 +20,7 @@
|
|||||||
"@ai-sdk/openai-compatible": "^2.0.41",
|
"@ai-sdk/openai-compatible": "^2.0.41",
|
||||||
"@aws-sdk/client-s3": "^3.1028.0",
|
"@aws-sdk/client-s3": "^3.1028.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.1028.0",
|
"@aws-sdk/s3-request-presigner": "^3.1028.0",
|
||||||
"@better-auth/drizzle-adapter": "https://pkg.pr.new/better-auth/better-auth/@better-auth/drizzle-adapter@9489",
|
"@better-auth/drizzle-adapter": "^1.7.7",
|
||||||
"@floating-ui/vue": "^1.1.11",
|
"@floating-ui/vue": "^1.1.11",
|
||||||
"@iconify-json/mynaui": "^1.2.17",
|
"@iconify-json/mynaui": "^1.2.17",
|
||||||
"@nuxt/fonts": "0.14.0",
|
"@nuxt/fonts": "0.14.0",
|
||||||
@@ -32,10 +32,10 @@
|
|||||||
"@tanstack/vue-virtual": "^3.13.23",
|
"@tanstack/vue-virtual": "^3.13.23",
|
||||||
"ai": "^6.0.182",
|
"ai": "^6.0.182",
|
||||||
"ai-sdk-ollama": "^3.8.3",
|
"ai-sdk-ollama": "^3.8.3",
|
||||||
"better-auth": "^1.6.2",
|
"better-auth": "^1.7.7",
|
||||||
"comark": "0.6.2",
|
"comark": "0.6.2",
|
||||||
"comlink": "^4.4.2",
|
"comlink": "^4.4.2",
|
||||||
"drizzle-orm": "1.0.0-rc.4-5d5b77c",
|
"drizzle-orm": "1.0.0-rc.4",
|
||||||
"glob": "^13.0.6",
|
"glob": "^13.0.6",
|
||||||
"katex": "0.17.0",
|
"katex": "0.17.0",
|
||||||
"longcat-ai-sdk-provider": "^0.0.5",
|
"longcat-ai-sdk-provider": "^0.0.5",
|
||||||
@@ -43,6 +43,7 @@
|
|||||||
"nuxt": "^4.4.2",
|
"nuxt": "^4.4.2",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
"shiki": "^3.23.0",
|
"shiki": "^3.23.0",
|
||||||
|
"uqr": "^0.1.3",
|
||||||
"vue": "^3.6.0-beta.10",
|
"vue": "^3.6.0-beta.10",
|
||||||
"vue-router": "^5.0.4",
|
"vue-router": "^5.0.4",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
@@ -54,7 +55,7 @@
|
|||||||
"@types/node": "^25.6.0",
|
"@types/node": "^25.6.0",
|
||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "^8.20.0",
|
||||||
"@unocss/nuxt": "^66.6.8",
|
"@unocss/nuxt": "^66.6.8",
|
||||||
"drizzle-kit": "^1.0.0-beta.21",
|
"drizzle-kit": "1.0.0-rc.4",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"unocss": "^66.6.8"
|
"unocss": "^66.6.8"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import * as z from 'zod';
|
||||||
|
import {
|
||||||
|
agents,
|
||||||
|
files,
|
||||||
|
models,
|
||||||
|
providers,
|
||||||
|
settings,
|
||||||
|
toolCalls,
|
||||||
|
topics,
|
||||||
|
users,
|
||||||
|
} from '~~/drizzle/schema';
|
||||||
|
import { auth } from '~~/lib/auth';
|
||||||
|
import { db } from '~~/server/lib/db';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes the signed in user's account and everything that belongs to it.
|
||||||
|
*
|
||||||
|
* The app tables only store a plain user id, nothing cascades from the users
|
||||||
|
* table, so every one of them is cleared here. It all happens in one
|
||||||
|
* transaction: either the whole account is gone or nothing changed.
|
||||||
|
*
|
||||||
|
* Objects in file storage are left alone on purpose.
|
||||||
|
*/
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await protectRoute(event);
|
||||||
|
|
||||||
|
const userId = event.context.user!.id as string;
|
||||||
|
|
||||||
|
const result = await readValidatedBody(event, (body) =>
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
password: z.string().min(1),
|
||||||
|
// required when two-factor is on: a code from the authenticator
|
||||||
|
// app, or one of the backup codes
|
||||||
|
code: z.string().min(1).optional(),
|
||||||
|
backupCode: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.safeParse(body),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: result.error.issues[0]!.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { password, code, backupCode } = result.data;
|
||||||
|
|
||||||
|
// read from the database, the session can lag behind by a few minutes
|
||||||
|
const account = await db.query.users.findFirst({
|
||||||
|
where: {
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const twoFactorEnabled = !!account?.twoFactorEnabled;
|
||||||
|
|
||||||
|
if (twoFactorEnabled && code === undefined) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Two-factor code required',
|
||||||
|
data: {
|
||||||
|
code: 'TWO_FACTOR_REQUIRED',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await auth.api.verifyPassword({
|
||||||
|
body: {
|
||||||
|
password,
|
||||||
|
},
|
||||||
|
headers: event.headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (twoFactorEnabled && backupCode) {
|
||||||
|
await auth.api.verifyBackupCode({
|
||||||
|
body: {
|
||||||
|
code: code!,
|
||||||
|
},
|
||||||
|
headers: event.headers,
|
||||||
|
});
|
||||||
|
} else if (twoFactorEnabled) {
|
||||||
|
await auth.api.verifyTOTP({
|
||||||
|
body: {
|
||||||
|
code: code!,
|
||||||
|
},
|
||||||
|
headers: event.headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
const errorCode = error?.body?.code as string | undefined;
|
||||||
|
|
||||||
|
if (errorCode === undefined) {
|
||||||
|
console.error('Failed to verify credentials:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw createError({
|
||||||
|
statusCode: errorCode === undefined ? 500 : 400,
|
||||||
|
statusMessage: 'Failed to delete account',
|
||||||
|
data: {
|
||||||
|
code: errorCode ?? 'INTERNAL_ERROR',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
// messages, their parts, generations and attachments cascade from topics
|
||||||
|
await tx.delete(topics).where(eq(topics.userId, userId));
|
||||||
|
await tx.delete(agents).where(eq(agents.userId, userId));
|
||||||
|
await tx.delete(models).where(eq(models.userId, userId));
|
||||||
|
await tx.delete(providers).where(eq(providers.userId, userId));
|
||||||
|
// nothing points a tool call back at its message, so these never cascade
|
||||||
|
await tx.delete(toolCalls).where(eq(toolCalls.userId, userId));
|
||||||
|
// embeddings go with their file
|
||||||
|
await tx.delete(files).where(eq(files.userId, userId));
|
||||||
|
await tx.delete(settings).where(eq(settings.userId, userId));
|
||||||
|
|
||||||
|
// sessions, accounts and two-factor rows cascade from the user
|
||||||
|
await tx.delete(users).where(eq(users.id, userId));
|
||||||
|
});
|
||||||
|
|
||||||
|
// the session is gone, but the browser would keep trusting its cached
|
||||||
|
// copy for a few minutes unless the cookies go too
|
||||||
|
const { authCookies } = await auth.$context;
|
||||||
|
|
||||||
|
for (const cookie of Object.values(authCookies)) {
|
||||||
|
deleteCookie(event, cookie.name, {
|
||||||
|
path: cookie.attributes.path,
|
||||||
|
secure: cookie.attributes.secure,
|
||||||
|
sameSite: cookie.attributes.sameSite,
|
||||||
|
httpOnly: cookie.attributes.httpOnly,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { db } from '~~/server/lib/db';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reports when the password was last changed, or null if it is still the one
|
||||||
|
* the account was created with. The credential account row is only written
|
||||||
|
* when the account is created and when its password changes, so its
|
||||||
|
* updatedAt is the time of the last change.
|
||||||
|
*/
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await protectRoute(event);
|
||||||
|
|
||||||
|
const userId = event.context.user!.id as string;
|
||||||
|
|
||||||
|
const account = await db.query.accounts.findFirst({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
providerId: 'credential',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
return { changedAt: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// the two timestamps are written a moment apart on sign up
|
||||||
|
const changed = account.updatedAt.getTime() - account.createdAt.getTime() > 1000;
|
||||||
|
|
||||||
|
return { changedAt: changed ? account.updatedAt : null };
|
||||||
|
});
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { and, eq } from 'drizzle-orm';
|
||||||
|
import * as z from 'zod';
|
||||||
|
import { providers } from '~~/drizzle/schema';
|
||||||
|
import { auth } from '~~/lib/auth';
|
||||||
|
import { db } from '~~/server/lib/db';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Changes the password and swaps in the provider API keys re-encrypted with
|
||||||
|
* the new password in one go. API keys are encrypted in the browser with a key
|
||||||
|
* derived from the password, so a password change that leaves the old
|
||||||
|
* ciphertexts behind would make every saved key unreadable.
|
||||||
|
*/
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await protectRoute(event);
|
||||||
|
|
||||||
|
const userId = event.context.user!.id as string;
|
||||||
|
|
||||||
|
const result = await readValidatedBody(event, (body) =>
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
currentPassword: z.string().min(1),
|
||||||
|
newPassword: z.string().min(8).max(128),
|
||||||
|
providerKeys: z.array(z.object({
|
||||||
|
id: z.string(),
|
||||||
|
apiKey: z.string().min(1),
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
.safeParse(body),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: result.error.issues[0]!.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { currentPassword, newPassword, providerKeys } = result.data;
|
||||||
|
|
||||||
|
const existing = await db.query.providers.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const newKeys = new Map(providerKeys.map(p => [p.id, p.apiKey]));
|
||||||
|
|
||||||
|
// Every saved key has to come back re-encrypted, otherwise it would be
|
||||||
|
// stuck encrypted with the old password.
|
||||||
|
const withKey = existing.filter(p => !!p.config.apiKey);
|
||||||
|
const complete = withKey.every(p => newKeys.has(p.id)) && newKeys.size === withKey.length;
|
||||||
|
|
||||||
|
if (!complete) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 409,
|
||||||
|
statusMessage: 'Provider keys out of date',
|
||||||
|
data: {
|
||||||
|
code: 'PROVIDER_KEYS_OUT_OF_DATE',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let cookies: string[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// The password change runs inside the transaction so a rejected
|
||||||
|
// password rolls the key updates back.
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
for (const provider of withKey) {
|
||||||
|
await tx.update(providers)
|
||||||
|
.set({
|
||||||
|
config: {
|
||||||
|
...provider.config,
|
||||||
|
apiKey: newKeys.get(provider.id)!,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.where(and(eq(providers.id, provider.id), eq(providers.userId, userId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const { headers } = await auth.api.changePassword({
|
||||||
|
body: {
|
||||||
|
currentPassword,
|
||||||
|
newPassword,
|
||||||
|
// other devices still hold an encryption key derived from the
|
||||||
|
// old password, so they have to sign in again
|
||||||
|
revokeOtherSessions: true,
|
||||||
|
},
|
||||||
|
headers: event.headers,
|
||||||
|
returnHeaders: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
cookies = headers.getSetCookie();
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
const code = error?.body?.code as string | undefined;
|
||||||
|
|
||||||
|
if (code === undefined) {
|
||||||
|
console.error('Failed to change password:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw createError({
|
||||||
|
statusCode: code === undefined ? 500 : 400,
|
||||||
|
statusMessage: 'Failed to change password',
|
||||||
|
message: error?.body?.message ?? 'Failed to change password',
|
||||||
|
data: {
|
||||||
|
code: code ?? 'INTERNAL_ERROR',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const cookie of cookies) {
|
||||||
|
appendResponseHeader(event, 'set-cookie', cookie);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { and, eq } from 'drizzle-orm';
|
||||||
|
import { sessions } from '~~/drizzle/schema';
|
||||||
|
import { auth } from '~~/lib/auth';
|
||||||
|
import { db } from '~~/server/lib/db';
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const sessionData = await auth.api.getSession({ headers: event.headers });
|
||||||
|
|
||||||
|
if (sessionData === null) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: 'Unauthorized',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = getRouterParam(event, 'id')!;
|
||||||
|
|
||||||
|
// signing out the current device goes through the normal sign out, which
|
||||||
|
// also clears its cookies
|
||||||
|
if (id === sessionData.session.id) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Cannot revoke the current session',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = await db.delete(sessions)
|
||||||
|
.where(and(eq(sessions.id, id), eq(sessions.userId, sessionData.user.id)))
|
||||||
|
.returning({ id: sessions.id });
|
||||||
|
|
||||||
|
if (deleted.length === 0) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
statusMessage: 'Not Found',
|
||||||
|
message: 'Session not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { auth } from '~~/lib/auth';
|
||||||
|
import { db } from '~~/server/lib/db';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists the signed in devices for the current user. Session tokens are left
|
||||||
|
* out on purpose: the client only ever needs the id to sign a device out.
|
||||||
|
*/
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const sessionData = await auth.api.getSession({ headers: event.headers });
|
||||||
|
|
||||||
|
if (sessionData === null) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: 'Unauthorized',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db.query.sessions.findMany({
|
||||||
|
where: {
|
||||||
|
userId: sessionData.user.id,
|
||||||
|
expiresAt: {
|
||||||
|
gt: new Date(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
updatedAt: 'desc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.map(session => ({
|
||||||
|
id: session.id,
|
||||||
|
createdAt: session.createdAt,
|
||||||
|
updatedAt: session.updatedAt,
|
||||||
|
ipAddress: session.ipAddress,
|
||||||
|
userAgent: session.userAgent,
|
||||||
|
current: session.id === sessionData.session.id,
|
||||||
|
}));
|
||||||
|
});
|
||||||
@@ -22,7 +22,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
// Path 2: Session auth (for client-side access)
|
// Path 2: Session auth (for client-side access)
|
||||||
if (!authorized) {
|
if (!authorized) {
|
||||||
try {
|
try {
|
||||||
const sessionData = await auth.api.getSession(event);
|
const sessionData = await auth.api.getSession({ headers: event.headers });
|
||||||
if (sessionData) {
|
if (sessionData) {
|
||||||
event.context.user = sessionData.user;
|
event.context.user = sessionData.user;
|
||||||
authorized = true;
|
authorized = true;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { H3Event } from 'h3';
|
|||||||
import { auth } from '~~/lib/auth';
|
import { auth } from '~~/lib/auth';
|
||||||
|
|
||||||
export const protectRoute = async (event: H3Event) => {
|
export const protectRoute = async (event: H3Event) => {
|
||||||
const sessionData = await auth.api.getSession(event);
|
const sessionData = await auth.api.getSession({ headers: event.headers });
|
||||||
|
|
||||||
if (sessionData === null) {
|
if (sessionData === null) {
|
||||||
throw createError({
|
throw createError({
|
||||||
|
|||||||
Reference in New Issue
Block a user