feat: complete admin invite and user creation and recovery flows

This commit is contained in:
Zoe
2026-10-08 14:24:47 -05:00
parent 61f9bb74b4
commit b6f4d85ed7
23 changed files with 4430 additions and 110 deletions
+3
View File
@@ -26,6 +26,9 @@ COPY --from=build /app/drizzle/migrations /app/drizzle/migrations
# Copy entrypoint script # Copy entrypoint script
COPY --from=build /app/scripts/start.ts /app/scripts/start.ts COPY --from=build /app/scripts/start.ts /app/scripts/start.ts
# Copy the account recovery script (bun run recover <email>)
COPY --from=build /app/scripts/recover.ts /app/scripts/recover.ts
# Copy package.json and lockfile for production dependency install # Copy package.json and lockfile for production dependency install
COPY --from=build /app/package.json /app/package.json COPY --from=build /app/package.json /app/package.json
COPY --from=build /app/bun.lock* ./ COPY --from=build /app/bun.lock* ./
+98 -94
View File
@@ -1,46 +1,36 @@
<script setup lang="ts"> <script setup lang="ts">
const MIN_PASSWORD_LENGTH = 8;
const GENERATED_PASSWORD_LENGTH = 16;
// no look-alikes (0/O, 1/l/I), this gets read out or typed by hand
const PASSWORD_ALPHABET = 'abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789';
const emit = defineEmits(['confirm', 'cancel']); const emit = defineEmits(['confirm', 'cancel']);
const { client: authClient } = useAuth();
const form = reactive({ const form = reactive({
name: '', name: '',
email: '', email: '',
password: '',
}); });
const busy = ref(false); const busy = ref(false);
const error = ref(''); const error = ref('');
// set once the account exists, the dialog then shows its setup link
const link = ref('');
const copied = ref(false);
const nameInputEl = ref<HTMLInputElement | null>(null); const nameInputEl = ref<HTMLInputElement | null>(null);
onMounted(() => { onMounted(() => {
nameInputEl.value?.focus(); nameInputEl.value?.focus();
}); });
const ready = computed(() => const ready = computed(() => form.name.trim() !== '' && form.email.trim() !== '');
form.name.trim() !== '' && form.email.trim() !== '' && form.password.length >= MIN_PASSWORD_LENGTH);
const generatePassword = () => { const copyLink = async () => {
// values past the last full run of the alphabet are skipped, so every try {
// character stays equally likely await navigator.clipboard.writeText(link.value);
const limit = 256 - (256 % PASSWORD_ALPHABET.length); copied.value = true;
let password = ''; setTimeout(() => {
copied.value = false;
while (password.length < GENERATED_PASSWORD_LENGTH) { }, 2000);
for (const value of crypto.getRandomValues(new Uint8Array(GENERATED_PASSWORD_LENGTH))) { } catch (err) {
if (value >= limit || password.length === GENERATED_PASSWORD_LENGTH) continue; console.error('Failed to copy setup link:', err);
password += PASSWORD_ALPHABET[value % PASSWORD_ALPHABET.length];
}
} }
form.password = password;
}; };
const submit = async () => { const submit = async () => {
@@ -49,17 +39,20 @@ const submit = async () => {
busy.value = true; busy.value = true;
error.value = ''; error.value = '';
const { error: createError } = await authClient.admin.createUser({ try {
name: form.name.trim(), const { token } = await $fetch<{ token: string }>('/api/admin/users', {
email: form.email.trim(), method: 'POST',
password: form.password, body: {
role: 'user', name: form.name.trim(),
}); email: form.email.trim(),
},
});
busy.value = false; link.value = `${window.location.origin}/auth/recover/${token}`;
} catch (err: any) {
busy.value = false;
if (createError) { switch (err?.data?.data?.code as string | undefined) {
switch (createError.code) {
case 'USER_ALREADY_EXISTS': case 'USER_ALREADY_EXISTS':
case 'USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL': case 'USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL':
error.value = 'An account with this email already exists.'; error.value = 'An account with this email already exists.';
@@ -67,14 +60,11 @@ const submit = async () => {
case 'INVALID_EMAIL': case 'INVALID_EMAIL':
error.value = 'That email address is not valid.'; error.value = 'That email address is not valid.';
break; break;
case 'PASSWORD_TOO_SHORT': case 'INVALID_BODY':
error.value = `The password needs at least ${MIN_PASSWORD_LENGTH} characters.`; error.value = 'Check the name and the email address.';
break;
case 'PASSWORD_TOO_LONG':
error.value = 'That password is too long.';
break; break;
default: default:
console.error('Failed to create user:', createError); console.error('Failed to create user:', err);
error.value = 'The account could not be created. Try again.'; error.value = 'The account could not be created. Try again.';
break; break;
} }
@@ -82,7 +72,7 @@ const submit = async () => {
return; return;
} }
emit('confirm', true); busy.value = false;
}; };
</script> </script>
@@ -90,66 +80,80 @@ const submit = async () => {
<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" <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="create-user-title" @submit.prevent="submit"> aria-labelledby="create-user-title" @submit.prevent="submit">
<header class="flex flex-col gap-1"> <header class="flex flex-col gap-1">
<h2 id="create-user-title" class="text-lg font-semibold">Create user</h2> <h2 id="create-user-title" class="text-lg font-semibold">
{{ link ? 'Account created' : 'Create user' }}
</h2>
<p class="text-sm text-[var(--text-secondary)]"> <p class="text-sm text-[var(--text-secondary)]">
Creates the account right away. Nothing is sent to them, so pass the password on yourself. {{ link
? `Send ${form.name.trim()} this link. They open it, choose a password and are in.`
: 'Creates the account without a password. You get a link for them to choose their own, so you never know it.' }}
</p> </p>
</header> </header>
<div class="flex flex-col gap-1.5"> <template v-if="link">
<label for="create-user-name" <div class="flex flex-col gap-1.5">
class="text-[13px] font-medium uppercase tracking-wider text-[var(--text-secondary)]"> <label for="create-user-link"
Name class="text-[13px] font-medium uppercase tracking-wider text-[var(--text-secondary)]">
</label> Setup link
<input id="create-user-name" ref="nameInputEl" v-model="form.name" type="text" required maxlength="128" </label>
:disabled="busy" autocomplete="off" <div class="flex items-center rounded-md bg-[var(--bg-container)]">
class="w-full h-9 px-2.5 text-sm rounded-md bg-[var(--bg-container)]" /> <input id="create-user-link" type="text" readonly :value="link"
</div> @focus="($event.target as HTMLInputElement).select()"
class="flex-1 min-w-0 h-9 px-2.5 text-[13px] font-mono bg-transparent outline-none" />
<div class="flex flex-col gap-1.5"> <button type="button" :aria-label="copied ? 'Copied' : 'Copy setup link'" @click="copyLink"
<label for="create-user-email" class="flex items-center justify-center w-9 h-9 flex-shrink-0 text-[var(--text-secondary)] @hover:text-[var(--text-primary)]">
class="text-[13px] font-medium uppercase tracking-wider text-[var(--text-secondary)]"> <span class="text-4.5" :class="copied ? 'i-mynaui-check' : 'i-mynaui-copy'"></span>
Email </button>
</label> </div>
<input id="create-user-email" v-model="form.email" type="email" required :disabled="busy" <span class="text-xs leading-normal text-[var(--text-secondary)]">
autocomplete="off" class="w-full h-9 px-2.5 text-sm rounded-md bg-[var(--bg-container)]" /> Works once. Expires in 7 days. Shown only now; if it gets lost, make a new one from the
</div> user's menu. Nobody can sign in to the account until it is used.
</span>
<div class="flex flex-col gap-1.5">
<label for="create-user-password"
class="text-[13px] font-medium uppercase tracking-wider text-[var(--text-secondary)]">
Temporary password
</label>
<div class="flex gap-2">
<!-- shown in the clear, the admin has to read it to pass it on -->
<input id="create-user-password" v-model="form.password" type="text" required
:minlength="MIN_PASSWORD_LENGTH" maxlength="128" :disabled="busy" autocomplete="off"
autocapitalize="off" spellcheck="false"
class="flex-1 min-w-0 h-9 px-2.5 text-sm font-mono rounded-md bg-[var(--bg-container)]" />
<button type="button" :disabled="busy" @click="generatePassword"
class="flex items-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">
Generate
</button>
</div> </div>
<p class="text-xs leading-normal text-[var(--text-secondary)]">
At least {{ MIN_PASSWORD_LENGTH }} characters. They can change it under Settings → Security
after signing in.
</p>
</div>
<p v-if="error" role="alert" class="text-sm text-red-400">{{ error }}</p> <footer class="flex justify-end mt-auto">
<button type="button"
class="flex items-center h-10 px-4 rounded-md font-semibold bg-[var(--color-accent)] text-[var(--color-accent-text)]"
@click="emit('confirm', true)">
Done
</button>
</footer>
</template>
<footer class="flex justify-between gap-2 mt-auto"> <template v-else>
<button type="button" :disabled="busy" <div class="flex flex-col gap-1.5">
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" <label for="create-user-name"
@click="emit('cancel')"> class="text-[13px] font-medium uppercase tracking-wider text-[var(--text-secondary)]">
Cancel Name
</button> </label>
<button type="submit" :disabled="busy || !ready" <input id="create-user-name" ref="nameInputEl" v-model="form.name" type="text" required
class="flex items-center gap-2 h-10 px-4 rounded-md font-semibold bg-[var(--color-accent)] text-[var(--color-accent-text)] disabled:opacity-40 disabled:cursor-not-allowed"> maxlength="128" :disabled="busy" autocomplete="off"
<span v-if="busy" class="i-svg-spinners-180-ring-with-bg text-4"></span> class="w-full h-9 px-2.5 text-sm rounded-md bg-[var(--bg-container)]" />
Create user </div>
</button>
</footer> <div class="flex flex-col gap-1.5">
<label for="create-user-email"
class="text-[13px] font-medium uppercase tracking-wider text-[var(--text-secondary)]">
Email
</label>
<input id="create-user-email" v-model="form.email" type="email" required :disabled="busy"
autocomplete="off" class="w-full h-9 px-2.5 text-sm rounded-md bg-[var(--bg-container)]" />
</div>
<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 || !ready"
class="flex items-center gap-2 h-10 px-4 rounded-md font-semibold bg-[var(--color-accent)] text-[var(--color-accent-text)] disabled:opacity-40 disabled:cursor-not-allowed">
<span v-if="busy" class="i-svg-spinners-180-ring-with-bg text-4"></span>
Create user
</button>
</footer>
</template>
</form> </form>
</template> </template>
+174
View File
@@ -0,0 +1,174 @@
<script setup lang="ts">
const props = defineProps<{
user: {
id: string;
name: string;
email: string;
image?: string | null;
twoFactorEnabled?: boolean;
// has no password yet, so this makes a setup link and nothing is lost
pending?: boolean;
};
}>();
const emit = defineEmits(['confirm', 'cancel']);
// also turn two-factor off and sign every device out when the link is used
const resetSecurity = ref(true);
const busy = ref(false);
const error = ref('');
// set once the link exists, the dialog then shows it instead of the options
const link = ref('');
const copied = ref(false);
const revoked = ref(false);
const copyLink = async () => {
try {
await navigator.clipboard.writeText(link.value);
copied.value = true;
setTimeout(() => {
copied.value = false;
}, 2000);
} catch (err) {
console.error('Failed to copy recovery link:', err);
}
};
const create = async () => {
if (busy.value) return;
busy.value = true;
error.value = '';
try {
const { token } = await $fetch<{ token: string }>(`/api/admin/users/${props.user.id}/recovery`, {
method: 'POST',
body: { resetSecurity: resetSecurity.value },
});
link.value = `${window.location.origin}/auth/recover/${token}`;
} catch (err) {
console.error('Failed to create recovery link:', err);
error.value = 'The recovery link could not be created. Try again.';
}
busy.value = false;
};
const revoke = async () => {
if (busy.value) return;
busy.value = true;
error.value = '';
try {
await $fetch(`/api/admin/users/${props.user.id}/recovery`, { method: 'DELETE' });
link.value = '';
revoked.value = true;
} catch (err) {
console.error('Failed to revoke recovery link:', err);
error.value = 'The link could not be revoked. Try again.';
}
busy.value = false;
};
</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="recover-account-title" @submit.prevent="create">
<header class="flex items-center gap-3">
<UserAvatar :user="user" class="w-10 h-10 rounded-lg"></UserAvatar>
<div class="flex flex-col min-w-0">
<h2 id="recover-account-title" class="text-lg font-semibold">
{{ user.pending ? 'Account setup link' : 'Recover account' }}
</h2>
<p class="text-sm text-[var(--text-secondary)] overflow-hidden text-ellipsis whitespace-nowrap">
{{ user.name }} · {{ user.email }}
</p>
</div>
</header>
<p v-if="user.pending && !link" class="text-sm leading-normal text-[var(--text-secondary)]">
This account has no password yet. A new link lets them choose one, and replaces the link they
were given before.
</p>
<div v-if="!user.pending" role="note" class="flex gap-2.5 px-3 py-2.5 rounded-lg border border-red-400/40 bg-red-600/12 text-sm leading-normal">
<span class="i-tabler-alert-triangle text-4.5 mt-0.5 flex-shrink-0 text-red-400" aria-hidden="true"></span>
<div class="flex flex-col gap-0.5">
<strong class="font-semibold">This deletes their saved API keys</strong>
<span class="text-[13px] text-[var(--text-secondary)]">
The keys are encrypted with the old password and nobody can decrypt them without it. They
are removed when the link is used, not when it is created. Chats, agents and files are
kept.
</span>
</div>
</div>
<template v-if="link">
<div class="flex flex-col gap-1.5">
<label for="recover-account-link"
class="text-[13px] font-medium uppercase tracking-wider text-[var(--text-secondary)]">
{{ user.pending ? 'Setup link' : 'Recovery link' }}
</label>
<div class="flex items-center rounded-md bg-[var(--bg-container)]">
<input id="recover-account-link" type="text" readonly :value="link"
@focus="($event.target as HTMLInputElement).select()"
class="flex-1 min-w-0 h-9 px-2.5 text-[13px] font-mono bg-transparent outline-none" />
<button type="button" :aria-label="copied ? 'Copied' : 'Copy recovery link'" @click="copyLink"
class="flex items-center justify-center w-9 h-9 flex-shrink-0 text-[var(--text-secondary)] @hover:text-[var(--text-primary)]">
<span class="text-4.5" :class="copied ? 'i-mynaui-check' : 'i-mynaui-copy'"></span>
</button>
</div>
<span class="text-xs leading-normal text-[var(--text-secondary)]">
Works once. Expires in {{ user.pending ? '7 days' : '24 hours' }}. Shown only now; closing
this dialog hides it for good.
Send it to them yourself, over a channel you trust.
</span>
</div>
</template>
<template v-else>
<label v-if="!user.pending" class="flex items-start gap-2.5 text-sm cursor-pointer">
<input v-model="resetSecurity" type="checkbox" :disabled="busy" class="mt-0.75 flex-shrink-0"
style="accent-color: var(--color-accent)" />
<span>Also reset two-factor and sign out all their sessions</span>
</label>
<p v-if="revoked" role="status" class="text-sm text-[var(--text-secondary)]">
The link was revoked and no longer works.
</p>
</template>
<p v-if="error" role="alert" class="text-sm text-red-400">{{ error }}</p>
<footer class="flex justify-between gap-2 mt-auto">
<template v-if="link">
<button type="button" :disabled="busy" @click="revoke"
class="flex items-center h-10 px-4 rounded-md border border-[var(--color-border)] text-red-400 @hover:bg-[var(--color-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-[background-color] duration-200">
Revoke link
</button>
<button type="button"
class="flex items-center h-10 px-4 rounded-md font-semibold bg-[var(--color-accent)] text-[var(--color-accent-text)]"
@click="emit('confirm', true)">
Done
</button>
</template>
<template v-else>
<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')">
{{ revoked ? 'Close' : 'Cancel' }}
</button>
<button type="submit" :disabled="busy"
class="flex items-center gap-2 h-10 px-4 rounded-md font-semibold bg-[var(--color-accent)] text-[var(--color-accent-text)] disabled:opacity-40 disabled:cursor-not-allowed">
<span v-if="busy" class="i-svg-spinners-180-ring-with-bg text-4"></span>
{{ user.pending ? 'Create setup link' : 'Create recovery link' }}
</button>
</template>
</footer>
</form>
</template>
+3
View File
@@ -7,6 +7,7 @@ import DeleteAccount from './DeleteAccount.vue';
import Confirm from './Confirm.vue'; import Confirm from './Confirm.vue';
import CreateUser from './CreateUser.vue'; import CreateUser from './CreateUser.vue';
import CreateInvite from './CreateInvite.vue'; import CreateInvite from './CreateInvite.vue';
import RecoverAccount from './RecoverAccount.vue';
import { DialogType } from '~/composables/useDialog'; import { DialogType } from '~/composables/useDialog';
@@ -74,6 +75,8 @@ onUnmounted(() => {
<DeleteAccount v-else-if="page === DialogType.DeleteAccount" @confirm="confirm" @cancel="close" /> <DeleteAccount v-else-if="page === DialogType.DeleteAccount" @confirm="confirm" @cancel="close" />
<CreateUser v-else-if="page === DialogType.CreateUser" @confirm="confirm" @cancel="close" /> <CreateUser v-else-if="page === DialogType.CreateUser" @confirm="confirm" @cancel="close" />
<CreateInvite v-else-if="page === DialogType.CreateInvite" @confirm="confirm" @cancel="close" /> <CreateInvite v-else-if="page === DialogType.CreateInvite" @confirm="confirm" @cancel="close" />
<RecoverAccount v-else-if="page === DialogType.RecoverAccount" :user="data.user" @confirm="confirm"
@cancel="close" />
<Confirm v-else-if="page === DialogType.Confirm" v-bind="data" @confirm="confirm" @cancel="close" /> <Confirm v-else-if="page === DialogType.Confirm" v-bind="data" @confirm="confirm" @cancel="close" />
</div> </div>
</div> </div>
+1
View File
@@ -9,6 +9,7 @@ export enum DialogType {
DeleteAccount = 'delete-account', DeleteAccount = 'delete-account',
CreateUser = 'create-user', CreateUser = 'create-user',
CreateInvite = 'create-invite', CreateInvite = 'create-invite',
RecoverAccount = 'recover-account',
} }
interface DialogOptions { interface DialogOptions {
+26 -1
View File
@@ -10,6 +10,10 @@ definePageMeta({
const to = useRoute().query.to as string | undefined; const to = useRoute().query.to as string | undefined;
// set by the recovery page after a new password was chosen: 'keys' when saved
// API keys were deleted along the way, anything else when there were none
const recovered = useRoute().query.recovered as string | undefined;
if (session.value !== null) { if (session.value !== null) {
await navigateTo(to ?? '/'); await navigateTo(to ?? '/');
} }
@@ -236,6 +240,20 @@ const submitTwoFactor = async () => {
</p> </p>
</div> </div>
<div v-if="recovered !== undefined && step === 'credentials'" role="status"
class="flex gap-2.5 mb-4 px-3.5 py-3 rounded-lg bg-green-600/12 border border-green-400/40 text-sm leading-normal">
<span class="i-tabler-circle-check text-4.5 mt-0.5 flex-shrink-0 text-green-400" aria-hidden="true"></span>
<span>
<strong class="font-semibold">Your password is changed.</strong>
<span class="text-[var(--text-secondary)]">
Log in with the new one.
<template v-if="recovered === 'keys'">
Your saved API keys were deleted, add them again under Settings → AI Providers.
</template>
</span>
</span>
</div>
<div class="rounded-2xl"> <div class="rounded-2xl">
<form v-show="step === 'credentials'" 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">
@@ -308,7 +326,14 @@ const submitTwoFactor = async () => {
</form> </form>
</div> </div>
<p v-if="instance?.registration === 'open' && step === 'credentials'" class="text-sm text-center mt-3 text-[var(--text-secondary)]"> <p v-if="step === 'credentials'" class="text-sm text-center mt-3">
<NuxtLink to="/auth/recover"
class="text-[var(--text-secondary)] @hover:text-[var(--text-primary)] @hover:underline">
Forgot your password?
</NuxtLink>
</p>
<p v-if="instance?.registration === 'open' && step === 'credentials'" class="text-sm text-center mt-1.5 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">
+231
View File
@@ -0,0 +1,231 @@
<script setup lang="ts">
import { deriveKey } from '~/utils/crypto';
definePageMeta({
layout: 'auth',
});
const token = useRoute().params.token as string;
const { signIn } = useAuth();
const { data: link } = await useAsyncData(`recovery_${token}`, () =>
$fetch(`/api/recover/${encodeURIComponent(token)}`));
// set when the link turns out to be used or expired on submit
const rejected = ref(false);
const valid = computed(() => !rejected.value && link.value?.valid ? link.value : null);
// An account an admin created has no password yet. Its owner picks the first
// one here, and nothing is lost by that.
const setup = computed(() => valid.value?.newAccount ? valid.value : null);
// someone who forgot their password, which costs them their saved API keys
const recovery = computed(() => valid.value && !valid.value.newAccount ? valid.value : null);
const keyCount = computed(() => recovery.value?.keyProviders.length ?? 0);
const form = reactive({
password: '',
confirm: '',
});
const understood = ref(false);
const busy = ref(false);
const error = ref('');
const confirmInputEl = ref<HTMLInputElement | null>(null);
// nothing to agree to when there are no keys to lose
const ready = computed(() => keyCount.value === 0 || understood.value);
const submit = async () => {
if (busy.value || !ready.value) return;
if (form.password !== form.confirm) {
confirmInputEl.value!.setCustomValidity('Passwords do not match');
confirmInputEl.value!.reportValidity();
return;
}
busy.value = true;
error.value = '';
try {
await $fetch(`/api/recover/${encodeURIComponent(token)}`, {
method: 'POST',
body: { password: form.password },
});
} catch (err: any) {
busy.value = false;
if (err?.data?.data?.code === 'RECOVERY_LINK_INVALID') {
rejected.value = true;
return;
}
console.error('Failed to reset password:', err);
error.value = setup.value
? 'The password could not be set. Try again.'
: 'The password could not be changed. Nothing was deleted, try again.';
return;
}
// a key left in this browser was derived from some other password
localStorage.removeItem('encryptionKey');
// A new account goes straight in: the password is still in memory, which is
// the only time the key for their API keys can be derived.
if (setup.value) {
const res = await signIn(setup.value.email, form.password);
if (res.ok && !res.data.twoFactorRequired) {
const key = await deriveKey(form.password, res.data.user.id);
localStorage.setItem('encryptionKey', JSON.stringify(key));
return navigateTo('/welcome');
}
// the password is set, so logging in by hand still works
return navigateTo('/auth/login');
}
// the login page confirms the change, there is nothing left to do here
return navigateTo(`/auth/login?recovered=${keyCount.value > 0 ? 'keys' : 'done'}`);
};
</script>
<template>
<div class="relative flex flex-col items-center justify-center w-full h-full p-4 overflow-hidden">
<!-- Background decorative blobs -->
<div
class="absolute -top-24 -right-24 w-96 h-96 bg-[var(--color-accent)] opacity-10 blur-[100px] rounded-full pointer-events-none">
</div>
<div
class="absolute -bottom-24 -left-24 w-96 h-96 bg-[var(--color-accent)] opacity-5 blur-[100px] rounded-full pointer-events-none">
</div>
<div class="z-10 max-w-104 w-full flex flex-col gap-5">
<div class="flex flex-col items-center gap-1 text-center">
<h1 class="text-3xl font-bold tracking-tight">Veridian</h1>
<p class="text-[var(--text-secondary)]">
<template v-if="setup">Welcome, {{ setup.name }}</template>
<template v-else-if="recovery">Set a new password for {{ recovery.email }}</template>
<template v-else>This link doesn't work</template>
</p>
</div>
<template v-if="!valid">
<p role="alert"
class="px-3 py-2.5 rounded-lg bg-[var(--bg-container)] border border-[var(--color-border)] text-sm leading-snug text-[var(--text-secondary)]">
This link doesn't work anymore. It was used already, has expired, or was replaced by a
newer one. Ask whoever runs this instance for a new one.
</p>
<p class="text-sm text-center text-[var(--text-secondary)]">
<NuxtLink to="/auth/login" class="text-[var(--color-accent)] font-medium @hover:underline">
Back to log in
</NuxtLink>
</p>
</template>
<template v-else>
<p v-if="setup" class="text-sm leading-normal text-center text-[var(--text-secondary)]">
An account was created for you as <strong class="font-medium text-[var(--text-primary)]">{{
setup.email }}</strong>. Choose a password to start using it.
</p>
<div v-if="recovery" role="note"
class="flex flex-col gap-2 px-3 py-3 rounded-lg border text-sm leading-normal"
:class="keyCount > 0 ? 'border-red-400/40 bg-red-600/12' : 'border-[var(--color-border)]'">
<template v-if="keyCount > 0">
<div class="flex items-center gap-2">
<span class="i-tabler-alert-triangle text-4.5 flex-shrink-0 text-red-400"
aria-hidden="true"></span>
<strong class="font-semibold">
Your {{ keyCount }} saved API {{ keyCount === 1 ? 'key' : 'keys' }} will be deleted
</strong>
</div>
<p class="text-[13px] text-[var(--text-secondary)]">
They were encrypted with your old password. Without it nobody can decrypt them, not
you, not an admin, not the server. There is no way to get them back.
</p>
</template>
<p v-else class="text-[13px] text-[var(--text-secondary)]">
You have no saved API keys, so nothing of yours is lost.
</p>
<ul class="flex flex-col gap-1 text-[13px]">
<li v-if="keyCount > 0" class="flex items-start gap-2">
<span class="i-tabler-trash text-4 mt-0.5 flex-shrink-0 text-red-400" aria-hidden="true"></span>
<span>Deleted: API keys for <span class="case-capital">{{ recovery.keyProviders.join(', ') }}</span></span>
</li>
<li v-if="recovery.resetSecurity" class="flex items-start gap-2">
<span class="i-tabler-shield-off text-4 mt-0.5 flex-shrink-0 text-[var(--text-secondary)]"
aria-hidden="true"></span>
<span>Reset: two-factor sign-in, all active sessions</span>
</li>
<li class="flex items-start gap-2">
<span class="i-tabler-circle-check text-4 mt-0.5 flex-shrink-0 text-[var(--text-secondary)]"
aria-hidden="true"></span>
<span>Kept: chats, agents, files, provider and model lists, settings</span>
</li>
</ul>
</div>
<form class="flex flex-col gap-2" @submit.prevent="submit">
<div class="flex flex-col gap-1.5">
<label for="password"
class="text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)] ml-1">{{
setup ? 'Password' : 'New password' }}</label>
<input required minlength="8" maxlength="128" type="password" autocomplete="new-password"
id="password" v-model="form.password" placeholder="••••••••" :disabled="busy"
class="focus:ring-2 focus:ring-[var(--color-accent)]/20 border border-[var(--color-border)] transition-all" />
</div>
<div class="flex flex-col gap-1.5">
<label for="confirmPassword"
class="text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)] ml-1">Confirm
password</label>
<input required minlength="8" maxlength="128" ref="confirmInputEl" type="password"
autocomplete="new-password" id="confirmPassword" v-model="form.confirm"
placeholder="••••••••" :disabled="busy" @input="confirmInputEl?.setCustomValidity('')"
class="focus:ring-2 focus:ring-[var(--color-accent)]/20 border border-[var(--color-border)] transition-all" />
</div>
<label v-if="keyCount > 0" class="flex items-start gap-2.5 mt-1 text-[13px] leading-normal cursor-pointer">
<!-- a plain checkbox: the auth layout sizes every input as a text field -->
<input v-model="understood" type="checkbox" :disabled="busy"
class="!w-auto !p-0 mt-0.75 flex-shrink-0" style="accent-color: var(--color-accent)" />
<span>
I understand my API keys will be permanently deleted and I'll need to add them again.
</span>
</label>
<p v-if="setup" class="flex gap-2 mt-1 text-xs leading-normal text-[var(--text-secondary)]">
<span class="i-mynaui-lock text-4 mt-0.25 flex-shrink-0" aria-hidden="true"></span>
<span>
Your password also encrypts your API keys in the browser. If you forget it, saved
keys can't be recovered and have to be re-entered.
</span>
</p>
<p v-if="error" role="alert" class="text-sm text-red-400">{{ error }}</p>
<button :disabled="busy || !ready" type="submit"
class="accent w-full mt-2 h-11 flex items-center justify-center font-semibold disabled:opacity-40 disabled:cursor-not-allowed">
<span v-if="busy" class="i-svg-spinners-90-ring-with-bg text-xl"></span>
<span v-else>{{ setup ? 'Set password and continue' : keyCount > 0 ? 'Reset password and delete keys' : 'Reset password' }}</span>
</button>
</form>
<p v-if="recovery" class="text-sm text-center text-[var(--text-secondary)]">
Remember your old password after all?
<NuxtLink to="/auth/login" class="text-[var(--color-accent)] font-medium @hover:underline">
Log in instead
</NuxtLink>
and change it from Settings to keep your keys.
</p>
</template>
</div>
</div>
</template>
+69
View File
@@ -0,0 +1,69 @@
<script setup lang="ts">
definePageMeta({
layout: 'auth',
});
</script>
<template>
<div class="relative flex flex-col items-center justify-center w-full h-full p-4 overflow-hidden">
<!-- Background decorative blobs -->
<div
class="absolute -top-24 -right-24 w-96 h-96 bg-[var(--color-accent)] opacity-10 blur-[100px] rounded-full pointer-events-none">
</div>
<div
class="absolute -bottom-24 -left-24 w-96 h-96 bg-[var(--color-accent)] opacity-5 blur-[100px] rounded-full pointer-events-none">
</div>
<div class="z-10 max-w-104 w-full flex flex-col gap-5">
<div class="flex flex-col items-center gap-1 text-center">
<h1 class="text-3xl font-bold tracking-tight">Veridian</h1>
<p class="text-[var(--text-secondary)]">Forgot your password?</p>
</div>
<p class="text-sm leading-normal text-[var(--text-secondary)]">
This instance doesn't send email. An administrator can create a one-time recovery link for
you.
</p>
<ol class="flex flex-col gap-2.5 text-sm">
<li v-for="(step, index) in [
'Contact whoever runs this instance.',
'They send you a link that works once and expires in 24 hours.',
'Open it here and choose a new password.',
]" :key="step" class="flex items-start gap-3">
<span aria-hidden="true"
class="flex items-center justify-center w-6 h-6 flex-shrink-0 rounded-md border border-[var(--color-border-active)] text-xs text-[var(--text-secondary)]">
{{ index + 1 }}
</span>
<span class="mt-0.5">{{ step }}</span>
</li>
</ol>
<p class="text-[13px] leading-normal text-[var(--text-secondary)]">
Operators locked out of the only admin account can run
<code class="px-1.5 py-0.5 rounded bg-[var(--bg-container)] font-mono text-xs text-[var(--text-primary)] whitespace-nowrap">bun run recover &lt;email&gt;</code>
on the server to print a link.
</p>
<div role="note"
class="flex gap-2.5 px-3.5 py-3 rounded-lg bg-red-600/12 border border-red-400/40 text-sm leading-normal">
<span class="i-tabler-alert-triangle text-4.5 mt-0.5 flex-shrink-0 text-red-400"
aria-hidden="true"></span>
<span>
<strong class="font-semibold">Recovery deletes your saved API keys.</strong>
<span class="text-[var(--text-secondary)]">
They are encrypted with your current password and can't be read without it. Your
chats, agents and files are kept.
</span>
</span>
</div>
<p class="text-sm text-center text-[var(--text-secondary)]">
Remembered it?
<NuxtLink to="/auth/login" class="text-[var(--color-accent)] font-medium @hover:underline">
Back to log in
</NuxtLink>
</p>
</div>
</div>
</template>
+1 -1
View File
@@ -157,7 +157,7 @@ const submit = async () => {
Someone else just created the first account, so this instance already has its Someone else just created the first account, so this instance already has its
administrator. Nothing was created for you. administrator. Nothing was created for you.
</p> </p>
<NuxtLink to="/auth/login" class="accent w-full h-11 flex items-center justify-center font-semibold"> <NuxtLink to="/auth/login" class="w-full h-11 flex items-center justify-center font-semibold rounded-md bg-[var(--color-accent)] text-[var(--color-accent-text)]">
Go to log in Go to log in
</NuxtLink> </NuxtLink>
</div> </div>
+19 -6
View File
@@ -9,6 +9,8 @@ type UserRow = {
role: 'admin' | 'user'; role: 'admin' | 'user';
banned: boolean; banned: boolean;
twoFactorEnabled: boolean; twoFactorEnabled: boolean;
// created by an admin and has not set a password yet
pending: boolean;
createdAt: string; createdAt: string;
lastActiveAt: string | null; lastActiveAt: string | null;
}; };
@@ -139,6 +141,12 @@ const signOutEverywhere = (user: UserRow) => run(user, `${user.name} could not b
return !error; return !error;
}); });
// The dialog makes the link and shows it, nothing in the list changes. For an
// account that has no password yet it is a new setup link instead.
const recoverAccount = (user: UserRow) => {
openDialog(DialogType.RecoverAccount, undefined, { user });
};
const resetTwoFactor = (user: UserRow) => confirmThen({ const resetTwoFactor = (user: UserRow) => confirmThen({
title: `Reset two-factor for ${user.name}?`, title: `Reset two-factor for ${user.name}?`,
message: 'Turns two-factor sign-in off for this account and throws away its backup codes. They sign in with only their password until they set it up again.', message: 'Turns two-factor sign-in off for this account and throws away its backup codes. They sign in with only their password until they set it up again.',
@@ -196,9 +204,8 @@ const createInvite = () => {
}; };
const openCreateUser = () => { const openCreateUser = () => {
openDialog(DialogType.CreateUser, async (result) => { // the dialog can be closed after the account was made, so always reload
if (result.ok === false) return; openDialog(DialogType.CreateUser, async () => {
await loadUsers(); await loadUsers();
}); });
}; };
@@ -278,9 +285,9 @@ const openCreateUser = () => {
</span> </span>
<span role="cell" class="flex items-center gap-1.5 text-[13px]" <span role="cell" class="flex items-center gap-1.5 text-[13px]"
:class="u.banned ? 'text-red-400' : 'text-green-400'"> :class="u.banned ? 'text-red-400' : u.pending ? 'text-amber-400' : 'text-green-400'">
<span aria-hidden="true" class="w-1.75 h-1.75 rounded-full bg-current"></span> <span aria-hidden="true" class="w-1.75 h-1.75 rounded-full bg-current"></span>
{{ u.banned ? 'Disabled' : 'Active' }} {{ u.banned ? 'Disabled' : u.pending ? 'Invited' : 'Active' }}
</span> </span>
<span role="cell" class="text-[13px]" <span role="cell" class="text-[13px]"
@@ -323,6 +330,11 @@ const openCreateUser = () => {
<span class="i-tabler-shield-check text-4.5" aria-hidden="true"></span> <span class="i-tabler-shield-check text-4.5" aria-hidden="true"></span>
Make admin Make admin
</button> </button>
<button type="button" role="menuitem" :class="MENU_ITEM_CLASS"
@click="close(); recoverAccount(u)">
<span class="i-tabler-key text-4.5" aria-hidden="true"></span>
{{ u.pending ? 'New setup link…' : 'Recover account…' }}
</button>
<button v-if="u.twoFactorEnabled" type="button" role="menuitem" :class="MENU_ITEM_CLASS" <button v-if="u.twoFactorEnabled" type="button" role="menuitem" :class="MENU_ITEM_CLASS"
@click="close(); resetTwoFactor(u)"> @click="close(); resetTwoFactor(u)">
<span class="i-tabler-shield-off text-4.5" aria-hidden="true"></span> <span class="i-tabler-shield-off text-4.5" aria-hidden="true"></span>
@@ -365,7 +377,8 @@ const openCreateUser = () => {
<p class="mt-4 max-w-190 text-[13px] leading-relaxed text-[var(--text-secondary)]"> <p class="mt-4 max-w-190 text-[13px] leading-relaxed text-[var(--text-secondary)]">
Admins manage accounts, not content: chats, agents, files and API keys of other users are never Admins manage accounts, not content: chats, agents, files and API keys of other users are never
shown here. Disabled accounts can't sign in and nothing of theirs is deleted. Your own account is shown here. Invited accounts have no password until their owner opens the setup link. Disabled
accounts can't sign in and nothing of theirs is deleted. Your own account is
managed from Profile and Security, and you can't remove your own admin role. managed from Profile and Security, and you can't remove your own admin role.
</p> </p>
</div> </div>
@@ -0,0 +1,11 @@
CREATE TABLE "recovery_links" (
"id" text PRIMARY KEY,
"token_hash" text NOT NULL UNIQUE,
"user_id" text NOT NULL,
"reset_security" boolean DEFAULT true NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE INDEX "recovery_links_userId_idx" ON "recovery_links" ("user_id");--> statement-breakpoint
ALTER TABLE "recovery_links" ADD CONSTRAINT "recovery_links_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
+18
View File
@@ -374,3 +374,21 @@ export const invites = pgTable(
createdAt: timestamptz("created_at").defaultNow().notNull(), createdAt: timestamptz("created_at").defaultNow().notNull(),
}, },
); );
// One-time links that let someone who forgot their password set a new one.
// Only a hash of the token is kept, the link itself is shown once when made.
export const recoveryLinks = pgTable(
"recovery_links",
{
id: text("id").primaryKey().$defaultFn(nanoid),
tokenHash: text("token_hash").notNull().unique(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// also turn two-factor off and sign every device out when it is used
resetSecurity: boolean("reset_security").notNull().default(true),
expiresAt: timestamptz("expires_at").notNull(),
createdAt: timestamptz("created_at").defaultNow().notNull(),
},
(table) => [index("recovery_links_userId_idx").on(table.userId)],
);
+9 -5
View File
@@ -18,10 +18,12 @@ const accessControl = createAccessControl(defaultStatements);
// another user's password or email are left out on purpose: the first would // another user's password or email are left out on purpose: the first would
// show their chats, the others would lock them out of their encrypted API keys. // show their chats, the others would lock them out of their encrypted API keys.
// Deleting is left out too, because Better Auth would only remove the account // Deleting is left out too, because Better Auth would only remove the account
// and leave its agents, topics and files behind. /api/admin/users does that. // and leave its agents, topics and files behind. Creating is left out so new
// accounts are always made without a password and with a setup link.
// /api/admin/users does both.
const roles = { const roles = {
admin: accessControl.newRole({ admin: accessControl.newRole({
user: ['create', 'list', 'set-role', 'ban', 'get', 'update'], user: ['list', 'set-role', 'ban', 'get', 'update'],
session: ['list', 'revoke', 'delete'], session: ['list', 'revoke', 'delete'],
}), }),
user: accessControl.newRole({ user: accessControl.newRole({
@@ -74,9 +76,11 @@ export const auth = betterAuth({
} }
} }
// creating a user as an admin skips the sign up password rules // Creating a user as an admin skips the sign up password rules. No
if (ctx.path === '/admin/create-user') { // password at all is fine: that account can't be signed in to until
const password = ctx.body?.password; // its owner sets one with a setup link.
if (ctx.path === '/admin/create-user' && ctx.body?.password !== undefined) {
const password = ctx.body.password;
if (typeof password !== 'string' || password.length < MIN_PASSWORD_LENGTH) { if (typeof password !== 'string' || password.length < MIN_PASSWORD_LENGTH) {
throw new APIError('BAD_REQUEST', { throw new APIError('BAD_REQUEST', {
message: 'Password is too short', message: 'Password is too short',
+2 -1
View File
@@ -7,7 +7,8 @@
"dev": "nuxt dev", "dev": "nuxt dev",
"generate": "nuxt generate", "generate": "nuxt generate",
"preview": "nuxt preview", "preview": "nuxt preview",
"postinstall": "nuxt prepare" "postinstall": "nuxt prepare",
"recover": "bun scripts/recover.ts"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "^3.0.71", "@ai-sdk/anthropic": "^3.0.71",
+65
View File
@@ -0,0 +1,65 @@
/**
* Prints a one-time recovery link for an account. Meant for whoever runs the
* server when nobody can sign in as an admin anymore:
*
* bun run recover <email>
*
* It only talks to the database, the server does not have to be running. The
* link works like the ones admins make under Settings → Users: it lets the
* account set a new password, deletes its saved API keys, turns two-factor
* off and signs every device out.
*
* Kept free of imports from the app so it also runs in the production image.
* The link has to be made the same way as in server/utils/recovery.ts.
*/
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import { Pool } from 'pg';
const LIFETIME_MS = 24 * 60 * 60 * 1000;
const main = async (): Promise<number> => {
const email = process.argv[2]?.trim().toLowerCase();
if (!email) {
console.error('Usage: bun run recover <email>');
return 1;
}
const pool = new Pool({
connectionString: process.env.DATABASE_URL || 'postgres://postgres:password@localhost:5432/veridian',
});
const found = await pool.query('SELECT id, name FROM users WHERE lower(email) = $1', [email]);
const user = found.rows[0] as { id: string; name: string } | undefined;
if (!user) {
console.error(`No account with the email ${email}.`);
await pool.end();
return 1;
}
const token = randomBytes(32).toString('base64url');
const tokenHash = createHash('sha256').update(token).digest('hex');
const expiresAt = new Date(Date.now() + LIFETIME_MS);
// only one link works at a time
await pool.query('DELETE FROM recovery_links WHERE user_id = $1', [user.id]);
await pool.query(
'INSERT INTO recovery_links (id, token_hash, user_id, reset_security, expires_at) VALUES ($1, $2, $3, true, $4)',
[randomUUID(), tokenHash, user.id, expiresAt],
);
await pool.end();
const baseUrl = (process.env.NUXT_PUBLIC_URL ?? 'http://localhost:3000').replace(/\/$/, '');
console.log(`Recovery link for ${user.name} <${email}>:`);
console.log('');
console.log(` ${baseUrl}/auth/recover/${token}`);
console.log('');
console.log('It works once and expires in 24 hours. Using it deletes the saved API keys');
console.log('of this account, turns two-factor off and signs every device out.');
return 0;
};
process.exit(await main());
@@ -0,0 +1,12 @@
import { eq } from 'drizzle-orm';
import { recoveryLinks } from '~~/drizzle/schema';
import { db } from '~~/server/lib/db';
/** Cancels the recovery link of a user, if there is one. */
export default defineEventHandler(async (event) => {
await protectAdminRoute(event);
await db.delete(recoveryLinks).where(eq(recoveryLinks.userId, getRouterParam(event, 'id')!));
return { ok: true };
});
@@ -0,0 +1,44 @@
import * as z from 'zod';
import { db } from '~~/server/lib/db';
/**
* Makes a one-time link the user can set a new password with, or their first
* one if the account was created by an admin and never had a password. The link is
* only returned here, afterwards nobody can read it back, admins included.
*/
export default defineEventHandler(async (event) => {
await protectAdminRoute(event);
const result = await readValidatedBody(event, (body) =>
z
.object({
// also turn two-factor off and sign every device out
resetSecurity: z.boolean(),
})
.safeParse(body),
);
if (!result.success) {
throw createError({
statusCode: 400,
message: result.error.issues[0]!.message,
});
}
const id = getRouterParam(event, 'id')!;
const account = await db.query.users.findFirst({
where: {
id,
},
});
if (!account) {
throw createError({
statusCode: 404,
statusMessage: 'User not found',
});
}
return await createRecoveryLink(id, result.data.resetSecurity);
});
+5 -2
View File
@@ -1,5 +1,5 @@
import { asc, eq, max } from 'drizzle-orm'; import { asc, eq, max, sql } from 'drizzle-orm';
import { sessions, users } from '~~/drizzle/schema'; import { accounts, sessions, users } from '~~/drizzle/schema';
import { db } from '~~/server/lib/db'; import { db } from '~~/server/lib/db';
/** /**
@@ -24,6 +24,9 @@ export default defineEventHandler(async (event) => {
// sessions are touched as they are used, the newest one is the // sessions are touched as they are used, the newest one is the
// closest thing there is to a last seen time // closest thing there is to a last seen time
lastActiveAt: max(sessions.updatedAt), lastActiveAt: max(sessions.updatedAt),
// no password yet: created by an admin and still waiting for its
// owner to open the setup link
pending: sql<boolean>`not exists (select 1 from ${accounts} where ${accounts.userId} = ${users.id} and ${accounts.providerId} = 'credential')`,
}) })
.from(users) .from(users)
.leftJoin(sessions, eq(sessions.userId, users.id)) .leftJoin(sessions, eq(sessions.userId, users.id))
+63
View File
@@ -0,0 +1,63 @@
import * as z from 'zod';
import { auth } from '~~/lib/auth';
/**
* Creates an account for someone, without a password. Nobody can sign in to
* it until its owner opens the setup link returned here and picks one, so the
* admin never knows a password of theirs. The link is only returned here.
*/
export default defineEventHandler(async (event) => {
await protectAdminRoute(event);
const result = await readValidatedBody(event, (body) =>
z
.object({
name: z.string().trim().min(1).max(128),
email: z.email(),
})
.safeParse(body),
);
if (!result.success) {
throw createError({
statusCode: 400,
message: result.error.issues[0]!.message,
data: {
code: 'INVALID_BODY',
},
});
}
const { name, email } = result.data;
let userId: string;
try {
// called without the request, the admin check already happened above
const { user } = await auth.api.createUser({
body: {
name,
email,
role: 'user',
},
});
userId = user.id;
} catch (error: any) {
const code = error?.body?.code as string | undefined;
if (code === undefined) {
console.error('Failed to create user:', error);
}
throw createError({
statusCode: code === undefined ? 500 : 400,
statusMessage: 'Failed to create user',
data: {
code: code ?? 'INTERNAL_ERROR',
},
});
}
return await createRecoveryLink(userId, false);
});
+40
View File
@@ -0,0 +1,40 @@
import { and, eq, sql } from 'drizzle-orm';
import { providers } from '~~/drizzle/schema';
import { db } from '~~/server/lib/db';
/**
* Tells the recovery page whether a link still works and what using it will
* do: set the first password of a new account, or replace a forgotten one. Public on purpose: whoever opens it cannot sign in, that is the point.
*/
export default defineEventHandler(async (event) => {
const link = await findRecoveryLink(getRouterParam(event, 'token')!);
if (!link) {
return { valid: false as const };
}
// an account that never had a password is being set up, not recovered
if (!await hasPassword(link.userId)) {
return {
valid: true as const,
newAccount: true as const,
email: link.email,
name: link.name,
};
}
const withKey = await db
.select({ name: providers.name })
.from(providers)
.where(and(eq(providers.userId, link.userId), sql`coalesce(${providers.config}->>'apiKey', '') <> ''`));
return {
valid: true as const,
newAccount: false as const,
email: link.email,
// the providers whose saved key is lost when the link is used
keyProviders: withKey.map(p => p.name),
resetSecurity: link.resetSecurity,
twoFactorEnabled: !!link.twoFactorEnabled,
};
});
+95
View File
@@ -0,0 +1,95 @@
import { and, eq, gt, sql } from 'drizzle-orm';
import * as z from 'zod';
import { providers, recoveryLinks, sessions, twoFactors, users } from '~~/drizzle/schema';
import { auth } from '~~/lib/auth';
import { db } from '~~/server/lib/db';
/**
* Sets a password with a one-time link.
*
* For an account an admin just created this is its first password, and there
* is nothing else to do. For a forgotten password it is a recovery:
*
* The saved API keys are encrypted with a key derived from the old password.
* Nobody has that password anymore, so the keys can never be read again and
* are deleted here. Chats, agents, files and everything else stay.
*/
export default defineEventHandler(async (event) => {
const result = await readValidatedBody(event, (body) =>
z
.object({
password: z.string().min(8).max(128),
})
.safeParse(body),
);
if (!result.success) {
throw createError({
statusCode: 400,
message: result.error.issues[0]!.message,
data: {
code: 'INVALID_BODY',
},
});
}
const tokenHash = hashRecoveryToken(getRouterParam(event, 'token')!);
const { password } = result.data;
const { password: hasher, internalAdapter } = await auth.$context;
const passwordHash = await hasher.hash(password);
const recovered = await db.transaction(async (tx) => {
// deleting the link is what makes it work once: of two requests with
// the same link only one gets a row back
const [link] = await tx.delete(recoveryLinks)
.where(and(eq(recoveryLinks.tokenHash, tokenHash), gt(recoveryLinks.expiresAt, new Date())))
.returning({ userId: recoveryLinks.userId, resetSecurity: recoveryLinks.resetSecurity });
if (!link) return null;
// if either of these fails the link comes back with the rest of the
// transaction
if (!await hasPassword(link.userId)) {
await internalAdapter.linkAccount({
providerId: 'credential',
accountId: link.userId,
password: passwordHash,
userId: link.userId,
});
return 'created';
}
await internalAdapter.updatePassword(link.userId, passwordHash);
// A provider without its key can't be used, so it is switched off
// until the key is entered again. The provider and its models stay.
await tx.update(providers)
.set({
config: sql`${providers.config} - 'apiKey'`,
enabled: false,
})
.where(and(eq(providers.userId, link.userId), sql`coalesce(${providers.config}->>'apiKey', '') <> ''`));
if (link.resetSecurity) {
await tx.delete(twoFactors).where(eq(twoFactors.userId, link.userId));
await tx.update(users).set({ twoFactorEnabled: false }).where(eq(users.id, link.userId));
await tx.delete(sessions).where(eq(sessions.userId, link.userId));
}
return 'recovered';
});
if (recovered === null) {
throw createError({
statusCode: 410,
statusMessage: 'This recovery link is not valid anymore',
data: {
code: 'RECOVERY_LINK_INVALID',
},
});
}
return { newAccount: recovered === 'created' };
});
+69
View File
@@ -0,0 +1,69 @@
import { createHash, randomBytes } from 'node:crypto';
import { and, eq, gt, lte, sql } from 'drizzle-orm';
import { accounts, recoveryLinks, users } from '~~/drizzle/schema';
import { db } from '~~/server/lib/db';
export const RECOVERY_LINK_LIFETIME_MS = 24 * 60 * 60 * 1000;
// a new person may take a while to get round to it
export const SETUP_LINK_LIFETIME_MS = 7 * 24 * 60 * 60 * 1000;
/**
* An account an admin created has no password until its owner sets one with
* a setup link. Until then nobody can sign in to it.
*/
export const hasPassword = async (userId: string): Promise<boolean> => {
const rows = await db
.select({ id: accounts.id })
.from(accounts)
.where(and(eq(accounts.userId, userId), eq(accounts.providerId, 'credential')))
.limit(1);
return rows.length > 0;
};
// scripts/recover.ts makes links the same way, keep the two in step
export const hashRecoveryToken = (token: string) => createHash('sha256').update(token).digest('hex');
/**
* Makes a one-time link a user can set a password with and returns its token:
* a recovery link for someone who forgot theirs, a setup link for an account
* that never had one. Only one link works at a time, making a new one cancels
* the ones before it.
*/
export const createRecoveryLink = async (userId: string, resetSecurity: boolean) => {
const token = randomBytes(32).toString('base64url');
const lifetime = await hasPassword(userId) ? RECOVERY_LINK_LIFETIME_MS : SETUP_LINK_LIFETIME_MS;
const expiresAt = new Date(Date.now() + lifetime);
await db.transaction(async (tx) => {
await tx.delete(recoveryLinks).where(eq(recoveryLinks.userId, userId));
await tx.delete(recoveryLinks).where(lte(recoveryLinks.expiresAt, new Date()));
await tx.insert(recoveryLinks).values({
tokenHash: hashRecoveryToken(token),
userId,
resetSecurity,
expiresAt,
});
});
return { token, expiresAt };
};
export const findRecoveryLink = async (token: string) => {
const rows = await db
.select({
id: recoveryLinks.id,
userId: recoveryLinks.userId,
resetSecurity: recoveryLinks.resetSecurity,
expiresAt: recoveryLinks.expiresAt,
email: users.email,
name: users.name,
twoFactorEnabled: users.twoFactorEnabled,
})
.from(recoveryLinks)
.innerJoin(users, eq(users.id, recoveryLinks.userId))
.where(and(eq(recoveryLinks.tokenHash, hashRecoveryToken(token)), gt(recoveryLinks.expiresAt, new Date())))
.limit(1);
return rows[0] ?? null;
};