feat: admin controls

This commit is contained in:
Zoe
2026-10-15 10:07:41 -05:00
parent b6f4d85ed7
commit ff7797d3f8
41 changed files with 612 additions and 427 deletions
+10 -12
View File
@@ -5,7 +5,7 @@ import { base64ToUint8Array, decrypt, deriveKey, encryptData, uint8ArrayToBase64
const emit = defineEmits(['confirm', 'cancel']); const emit = defineEmits(['confirm', 'cancel']);
const { user } = useAuth(); const { user } = useAuth();
const { providers } = await useModels(); const { providers, refresh } = 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)]'; const INPUT_CLASS = 'w-full h-9 px-2.5 text-sm rounded-md bg-[var(--bg-container)] placeholder:text-[var(--text-tertiary)]';
@@ -45,18 +45,19 @@ const importKey = (jwk: JsonWebKey) =>
// one. The server never sees the plaintext keys. // one. The server never sees the plaintext keys.
const decryptProviderKeys = async ( const decryptProviderKeys = async (
oldJwk: JsonWebKey, oldJwk: JsonWebKey,
): Promise<Result<{ id: string; plaintext: string }[], string[]>> => { ): Promise<Result<{ id: string; originalApiKey: string; plaintext: string }[], string[]>> => {
const oldKey = await importKey(oldJwk); const oldKey = await importKey(oldJwk);
const decrypted: { id: string; plaintext: string }[] = []; const decrypted: { id: string; originalApiKey: string; plaintext: string }[] = [];
const failed: string[] = []; const failed: string[] = [];
for (const provider of providers.value) { for (const provider of providers.value) {
if (!provider.config.apiKey) continue; if (!provider.config.apiKey) continue;
try { try {
const plaintext = await decrypt(oldKey, base64ToUint8Array(provider.config.apiKey)); const originalApiKey = provider.config.apiKey;
decrypted.push({ id: provider.id, plaintext }); const plaintext = await decrypt(oldKey, base64ToUint8Array(originalApiKey));
decrypted.push({ id: provider.id, originalApiKey, plaintext });
} catch { } catch {
failed.push(provider.name); failed.push(provider.name);
} }
@@ -101,9 +102,9 @@ const submit = async () => {
steps[1]!.state = 'active'; steps[1]!.state = 'active';
const newJwk = await deriveKey(form.next, userId); const newJwk = await deriveKey(form.next, userId);
const newKey = await importKey(newJwk); const newKey = await importKey(newJwk);
const providerKeys: { id: string; apiKey: string }[] = []; const providerKeys: { id: string; originalApiKey: string; apiKey: string }[] = [];
for (const { id, plaintext } of decrypted.data) { for (const { id, originalApiKey, plaintext } of decrypted.data) {
providerKeys.push({ id, apiKey: uint8ArrayToBase64(await encryptData(newKey, plaintext)) }); providerKeys.push({ id, originalApiKey, apiKey: uint8ArrayToBase64(await encryptData(newKey, plaintext)) });
} }
steps[1]!.state = 'done'; steps[1]!.state = 'done';
@@ -142,10 +143,7 @@ const submit = async () => {
// from here on this browser has to use the new key // from here on this browser has to use the new key
localStorage.setItem('encryptionKey', JSON.stringify(newJwk)); localStorage.setItem('encryptionKey', JSON.stringify(newJwk));
const newKeys = new Map(providerKeys.map(p => [p.id, p.apiKey])); await refresh();
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 // the server signs the other devices out as part of the same request
steps[2]!.state = 'done'; steps[2]!.state = 'done';
+6 -4
View File
@@ -13,7 +13,8 @@ const props = defineProps<{
const emit = defineEmits(['confirm', 'cancel']); const emit = defineEmits(['confirm', 'cancel']);
// also turn two-factor off and sign every device out when the link is used // also turn two-factor off when the link is used, every device is signed out
// either way
const resetSecurity = ref(true); const resetSecurity = ref(true);
const busy = ref(false); const busy = ref(false);
@@ -96,14 +97,15 @@ const revoke = async () => {
were given before. were given before.
</p> </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"> <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> <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"> <div class="flex flex-col gap-0.5">
<strong class="font-semibold">This deletes their saved API keys</strong> <strong class="font-semibold">This deletes their saved API keys</strong>
<span class="text-[13px] text-[var(--text-secondary)]"> <span class="text-[13px] text-[var(--text-secondary)]">
The keys are encrypted with the old password and nobody can decrypt them without it. They 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 are removed when the link is used, not when it is created. Chats, agents and files are
kept. not deleted. Using the link signs them out on all devices.
</span> </span>
</div> </div>
</div> </div>
@@ -135,7 +137,7 @@ const revoke = async () => {
<label v-if="!user.pending" class="flex items-start gap-2.5 text-sm cursor-pointer"> <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" <input v-model="resetSecurity" type="checkbox" :disabled="busy" class="mt-0.75 flex-shrink-0"
style="accent-color: var(--color-accent)" /> style="accent-color: var(--color-accent)" />
<span>Also reset two-factor and sign out all their sessions</span> <span>Also reset two-factor sign-in</span>
</label> </label>
<p v-if="revoked" role="status" class="text-sm text-[var(--text-secondary)]"> <p v-if="revoked" role="status" class="text-sm text-[var(--text-secondary)]">
+2 -5
View File
@@ -88,7 +88,7 @@ const submitPassword = async () => {
if (action.value === 'enable') { if (action.value === 'enable') {
const { data, error: err } = await authClient.twoFactor.enable(body); const { data, error: err } = await authClient.twoFactor.enable(body);
if (err || !data) { if (err || !data || data.method !== 'totp') {
error.value = describeError(err, 'Two-factor could not be set up. Try again.'); error.value = describeError(err, 'Two-factor could not be set up. Try again.');
} else { } else {
// nothing is turned on until a code from the app is verified // nothing is turned on until a code from the app is verified
@@ -136,12 +136,9 @@ const verifyCode = async () => {
code.value = ''; code.value = '';
busy.value = false; busy.value = false;
// the boxes are disabled while busy and can't take focus until now
await nextTick(); await nextTick();
codeInputEl.value?.focus(); codeInputEl.value?.focus();
} else { } 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 }; if (user.value) user.value = { ...user.value, twoFactorEnabled: true };
step.value = 'codes'; step.value = 'codes';
} }
@@ -155,7 +152,7 @@ const numberedBackupCodes = computed(() =>
); );
const startCheck = async () => { const startCheck = async () => {
// a different code every time, so going back to peek at one is not enough // a different code every time
checkIndex.value = Math.floor(Math.random() * backupCodes.value.length); checkIndex.value = Math.floor(Math.random() * backupCodes.value.length);
checkInput.value = ''; checkInput.value = '';
error.value = ''; error.value = '';
+11 -2
View File
@@ -97,10 +97,13 @@ const submit = async () => {
config.apiKey = uint8ArrayToBase64(await encryptData(cryptoKey, key)); config.apiKey = uint8ArrayToBase64(await encryptData(cryptoKey, key));
} }
await $fetch(`/api/provider/${provider.id}`, { const saved = await $fetch(`/api/provider/${provider.id}`, {
method: 'PATCH', method: 'PATCH',
body: { config, enabled: true }, body: { config, enabled: true, expectedUpdatedAt: new Date(provider.updatedAt).toISOString() },
}); });
providers.value = providers.value.map(p => p.id === provider.id
? { ...p, config, enabled: true, updatedAt: new Date(saved.updatedAt) }
: p);
} catch (err) { } catch (err) {
console.error('Failed to save provider:', err); console.error('Failed to save provider:', err);
error.value = 'The provider could not be saved. Try again.'; error.value = 'The provider could not be saved. Try again.';
@@ -146,6 +149,12 @@ const submit = async () => {
? { ...p, config, enabled: true, models: models.map(m => ({ ...m, enabled: m.enabled || enabledIds.has(m.id) })) } ? { ...p, config, enabled: true, models: models.map(m => ({ ...m, enabled: m.enabled || enabledIds.has(m.id) })) }
: p); : p);
if (!models.some(model => model.outputModalities.includes("text") && (model.enabled || enabledIds.has(model.id)))) {
error.value = "The models were fetched, but none could be enabled. Try again.";
busy.value = false;
return;
}
emit('done'); emit('done');
}; };
</script> </script>
+8 -3
View File
@@ -134,7 +134,7 @@ export const useModels = async () => {
}); });
} }
const updateProvider = async (id: string, updates: Partial<Provider>) => { const updateProvider = async (id: string, updates: Partial<Provider>, expectedUpdatedAt?: string) => {
const provider = providers.value.find(p => p.id === id); const provider = providers.value.find(p => p.id === id);
if (!provider) return; if (!provider) return;
@@ -142,10 +142,14 @@ export const useModels = async () => {
await $fetch(`/api/provider/${id}`, { await $fetch(`/api/provider/${id}`, {
method: 'PATCH', method: 'PATCH',
body: updates, body: {
...updates,
expectedUpdatedAt: expectedUpdatedAt ?? new Date(original.updatedAt).toISOString(),
},
onRequest() { onRequest() {
// a config update only carries the fields that changed
providers.value = providers.value.map(p => providers.value = providers.value.map(p =>
p.id === id ? { ...p, ...updates } : p p.id === id ? { ...p, ...updates, config: { ...p.config, ...updates.config } } : p
); );
}, },
onRequestError() { onRequestError() {
@@ -166,6 +170,7 @@ export const useModels = async () => {
return { return {
providers, providers,
refresh,
allModels, allModels,
getModel, getModel,
getProvider, getProvider,
+1 -1
View File
@@ -259,7 +259,7 @@ const submitTwoFactor = async () => {
<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>
<input required pattern=".{1,}@.{1,}\..{2,3}" ref="emailInputEl" type="email" <input required pattern=".+@.+\..+" ref="emailInputEl" type="email"
autocomplete="email" id="email" v-model="form.email" placeholder="name@example.com" autocomplete="email" id="email" v-model="form.email" placeholder="name@example.com"
class="focus:ring-2 focus:ring-[var(--color-accent)]/20 border border-[var(--color-border)] transition-all" /> class="focus:ring-2 focus:ring-[var(--color-accent)]/20 border border-[var(--color-border)] transition-all" />
</div> </div>
+10 -2
View File
@@ -96,7 +96,8 @@ const submit = async () => {
</script> </script>
<template> <template>
<div class="relative flex flex-col items-center justify-center w-full h-full p-4 overflow-hidden"> <div class="relative isolate flex flex-col items-center justify-center w-full min-h-full p-4">
<div aria-hidden="true" class="absolute inset-0 -z-10 overflow-hidden pointer-events-none">
<!-- Background decorative blobs --> <!-- Background decorative blobs -->
<div <div
class="absolute -top-24 -right-24 w-96 h-96 bg-[var(--color-accent)] opacity-10 blur-[100px] rounded-full pointer-events-none"> class="absolute -top-24 -right-24 w-96 h-96 bg-[var(--color-accent)] opacity-10 blur-[100px] rounded-full pointer-events-none">
@@ -105,6 +106,8 @@ const submit = async () => {
class="absolute -bottom-24 -left-24 w-96 h-96 bg-[var(--color-accent)] opacity-5 blur-[100px] rounded-full pointer-events-none"> 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>
</div>
<div class="z-10 max-w-104 w-full flex flex-col gap-5"> <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"> <div class="flex flex-col items-center gap-1 text-center">
<h1 class="text-3xl font-bold tracking-tight">Veridian</h1> <h1 class="text-3xl font-bold tracking-tight">Veridian</h1>
@@ -159,10 +162,15 @@ const submit = async () => {
<span class="i-tabler-trash text-4 mt-0.5 flex-shrink-0 text-red-400" aria-hidden="true"></span> <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> <span>Deleted: API keys for <span class="case-capital">{{ recovery.keyProviders.join(', ') }}</span></span>
</li> </li>
<li class="flex items-start gap-2">
<span class="i-tabler-logout text-4 mt-0.5 flex-shrink-0 text-[var(--text-secondary)]"
aria-hidden="true"></span>
<span>Signed out: every device that is signed in to this account</span>
</li>
<li v-if="recovery.resetSecurity" class="flex items-start gap-2"> <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)]" <span class="i-tabler-shield-off text-4 mt-0.5 flex-shrink-0 text-[var(--text-secondary)]"
aria-hidden="true"></span> aria-hidden="true"></span>
<span>Reset: two-factor sign-in, all active sessions</span> <span>Reset: two-factor sign-in</span>
</li> </li>
<li class="flex items-start gap-2"> <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)]" <span class="i-tabler-circle-check text-4 mt-0.5 flex-shrink-0 text-[var(--text-secondary)]"
+4 -1
View File
@@ -5,7 +5,8 @@ definePageMeta({
</script> </script>
<template> <template>
<div class="relative flex flex-col items-center justify-center w-full h-full p-4 overflow-hidden"> <div class="relative isolate flex flex-col items-center justify-center w-full min-h-full p-4">
<div aria-hidden="true" class="absolute inset-0 -z-10 overflow-hidden pointer-events-none">
<!-- Background decorative blobs --> <!-- Background decorative blobs -->
<div <div
class="absolute -top-24 -right-24 w-96 h-96 bg-[var(--color-accent)] opacity-10 blur-[100px] rounded-full pointer-events-none"> class="absolute -top-24 -right-24 w-96 h-96 bg-[var(--color-accent)] opacity-10 blur-[100px] rounded-full pointer-events-none">
@@ -14,6 +15,8 @@ definePageMeta({
class="absolute -bottom-24 -left-24 w-96 h-96 bg-[var(--color-accent)] opacity-5 blur-[100px] rounded-full pointer-events-none"> 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>
</div>
<div class="z-10 max-w-104 w-full flex flex-col gap-5"> <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"> <div class="flex flex-col items-center gap-1 text-center">
<h1 class="text-3xl font-bold tracking-tight">Veridian</h1> <h1 class="text-3xl font-bold tracking-tight">Veridian</h1>
+1 -1
View File
@@ -228,7 +228,7 @@ const submit = async () => {
<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>
<input required pattern=".{1,}@.{1,}\..{2,3}" ref="emailInputEl" type="email" <input required pattern=".+@.+\..+" ref="emailInputEl" type="email"
autocomplete="email" id="email" v-model="form.email" placeholder="name@example.com" autocomplete="email" id="email" v-model="form.email" placeholder="name@example.com"
class="focus:ring-2 focus:ring-[var(--color-accent)]/20 border border-[var(--color-border)] transition-all" /> class="focus:ring-2 focus:ring-[var(--color-accent)]/20 border border-[var(--color-border)] transition-all" />
</div> </div>
+5 -2
View File
@@ -136,7 +136,8 @@ const submit = async () => {
</script> </script>
<template> <template>
<div class="relative flex flex-col items-center justify-center w-full h-full p-4 overflow-hidden"> <div class="relative isolate flex flex-col items-center justify-center w-full min-h-full p-4">
<div aria-hidden="true" class="absolute inset-0 -z-10 overflow-hidden pointer-events-none">
<!-- Background decorative blobs --> <!-- Background decorative blobs -->
<div <div
class="absolute -top-24 -right-24 w-96 h-96 bg-[var(--color-accent)] opacity-10 blur-[100px] rounded-full pointer-events-none"> class="absolute -top-24 -right-24 w-96 h-96 bg-[var(--color-accent)] opacity-10 blur-[100px] rounded-full pointer-events-none">
@@ -145,6 +146,8 @@ const submit = async () => {
class="absolute -bottom-24 -left-24 w-96 h-96 bg-[var(--color-accent)] opacity-5 blur-[100px] rounded-full pointer-events-none"> 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>
</div>
<div class="z-10 max-w-104 w-full flex flex-col gap-5"> <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"> <div class="flex flex-col items-center gap-1 text-center">
<h1 class="text-3xl font-bold tracking-tight">Veridian</h1> <h1 class="text-3xl font-bold tracking-tight">Veridian</h1>
@@ -183,7 +186,7 @@ const submit = async () => {
<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>
<input required pattern=".{1,}@.{1,}\..{2,3}" ref="emailInputEl" type="email" <input required pattern=".+@.+\..+" ref="emailInputEl" type="email"
autocomplete="email" id="email" v-model="form.email" placeholder="name@example.com" autocomplete="email" id="email" v-model="form.email" placeholder="name@example.com"
class="focus:ring-2 focus:ring-[var(--color-accent)]/20 border border-[var(--color-border)] transition-all" /> class="focus:ring-2 focus:ring-[var(--color-accent)]/20 border border-[var(--color-border)] transition-all" />
</div> </div>
+8 -10
View File
@@ -131,7 +131,7 @@ const setRole = (user: UserRow, role: 'admin' | 'user') => {
confirmThen({ confirmThen({
title: `Make ${user.name} an admin?`, title: `Make ${user.name} an admin?`,
message: 'Admins can create, disable and delete accounts, change roles and change who can register. They never see anyone else\'s chats, files or API keys.', message: 'Admins can create, disable and delete accounts, change roles and change who can register.',
confirmLabel: 'Make admin', confirmLabel: 'Make admin',
}, change); }, change);
}; };
@@ -141,8 +141,6 @@ 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) => { const recoverAccount = (user: UserRow) => {
openDialog(DialogType.RecoverAccount, undefined, { user }); openDialog(DialogType.RecoverAccount, undefined, { user });
}; };
@@ -268,9 +266,11 @@ const openCreateUser = () => {
<span class="flex flex-col min-w-0"> <span class="flex flex-col min-w-0">
<span class="font-medium whitespace-nowrap overflow-hidden text-ellipsis"> <span class="font-medium whitespace-nowrap overflow-hidden text-ellipsis">
{{ u.name }} {{ u.name }}
<span v-if="u.id === me?.id" class="font-normal text-[var(--text-secondary)]">(you)</span> <span v-if="u.id === me?.id"
class="font-normal text-[var(--text-secondary)]">(you)</span>
</span> </span>
<span class="text-xs text-[var(--text-secondary)] whitespace-nowrap overflow-hidden text-ellipsis"> <span
class="text-xs text-[var(--text-secondary)] whitespace-nowrap overflow-hidden text-ellipsis">
{{ u.email }} {{ u.email }}
</span> </span>
</span> </span>
@@ -308,7 +308,6 @@ const openCreateUser = () => {
<span class="i-svg-spinners-180-ring-with-bg text-4 text-[var(--text-secondary)]"></span> <span class="i-svg-spinners-180-ring-with-bg text-4 text-[var(--text-secondary)]"></span>
</span> </span>
<!-- your own account is managed from Profile and Security -->
<Dropdown v-else-if="u.id !== me?.id" placement="bottom-end" dropdownClass="w-60"> <Dropdown v-else-if="u.id !== me?.id" placement="bottom-end" dropdownClass="w-60">
<template #default="{ setRef, isOpen, toggle }"> <template #default="{ setRef, isOpen, toggle }">
<button :ref="setRef" type="button" :aria-label="`Actions for ${u.name}`" <button :ref="setRef" type="button" :aria-label="`Actions for ${u.name}`"
@@ -376,10 +375,9 @@ const openCreateUser = () => {
</div> </div>
<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 Invited accounts have no password until their owner opens the setup link.
shown here. Invited accounts have no password until their owner opens the setup link. Disabled you manage your account from Profile and Security, you can't remove
accounts can't sign in and nothing of theirs is deleted. Your own account is your own admin role.
managed from Profile and Security, and you can't remove your own admin role.
</p> </p>
</div> </div>
</template> </template>
+58 -38
View File
@@ -68,55 +68,73 @@ const toggleProvider = async () => {
}); });
}; };
let apiKeyTimeout: NodeJS.Timeout | undefined; let apiKeyTimeout: ReturnType<typeof setTimeout> | undefined;
let proxyUrlTimeout: ReturnType<typeof setTimeout> | undefined;
const configError = ref("");
const updateApiKey = async (value: string) => { // Config saves run one after the other, and each reads the revision only when
if (!provider.value) return; // it is sent. A save that was typed while another was still on its way would
// otherwise carry the revision from before that one and be turned down.
let configSaves: Promise<void> = Promise.resolve();
const key = await crypto.subtle.importKey( const saveConfig = (
"jwk", id: string,
JSON.parse(window.localStorage.getItem("encryptionKey")!), failure: string,
"AES-GCM", config: () => Promise<{ apiKey?: string; apiProxyUrl?: string }>,
false, expectedEncryptionKey?: string,
["encrypt", "decrypt"] ) => {
); configSaves = configSaves.then(async () => {
const encypted = await encryptData(key, value); // A queued edit must not pair an old encryption key with a new revision.
if (expectedEncryptionKey !== undefined && localStorage.getItem("encryptionKey") !== expectedEncryptionKey) {
configError.value = failure;
return;
}
const current = providers.value.find(p => p.id === id);
if (!current) {
return;
}
configError.value = "";
try {
await updateProvider(id, { config: await config() }, new Date(current.updatedAt).toISOString());
} catch {
configError.value = failure;
}
});
};
if (apiKeyTimeout) { const updateApiKey = (value: string) => {
clearTimeout(apiKeyTimeout); const snapshot = provider.value;
const storedKey = localStorage.getItem("encryptionKey");
if (!snapshot || !storedKey) {
return;
} }
clearTimeout(apiKeyTimeout);
apiKeyTimeout = setTimeout(async () => { apiKeyTimeout = setTimeout(() => {
await updateProvider(provider.value!.id, { saveConfig(snapshot.id, "The key could not be saved. Reload this page before trying again.", async () => {
config: { const key = await crypto.subtle.importKey("jwk", JSON.parse(storedKey), "AES-GCM", false, ["encrypt", "decrypt"]);
...provider.value!.config, const encrypted = await encryptData(key, value);
apiKey: uint8ArrayToBase64(encypted), return { apiKey: uint8ArrayToBase64(encrypted) };
}, }, storedKey);
});
}, 700); }, 700);
}; };
let proxyUrlTimeout: NodeJS.Timeout | undefined; const updateProxyUrl = (value: string) => {
const snapshot = provider.value;
const updateProxyUrl = async (value: string) => { if (!snapshot) {
if (!provider.value) return; return;
}
apiProxyUrl.value = value; apiProxyUrl.value = value;
clearTimeout(proxyUrlTimeout);
if (proxyUrlTimeout) { proxyUrlTimeout = setTimeout(() => {
clearTimeout(proxyUrlTimeout); saveConfig(snapshot.id, "The address could not be saved. Reload this page before trying again.", async () => ({ apiProxyUrl: value }));
}
proxyUrlTimeout = setTimeout(async () => {
await updateProvider(provider.value!.id, {
config: {
...provider.value!.config,
apiProxyUrl: value,
},
});
}, 700); }, 700);
}; };
onUnmounted(() => {
clearTimeout(apiKeyTimeout);
clearTimeout(proxyUrlTimeout);
});
const fetchingModels = ref(false); const fetchingModels = ref(false);
const fetchModels = async () => { const fetchModels = async () => {
@@ -407,6 +425,8 @@ const isFormValid = computed(() => {
</div> </div>
</div> </div>
<p v-if="configError" role="alert" class="text-sm text-red-400">{{ configError }}</p>
<div class="flex flex-row justify-center text-xs"> <div class="flex flex-row justify-center text-xs">
<p class="text-[var(--text-secondary)]"> <p class="text-[var(--text-secondary)]">
<span class="i-mynaui-lock inline-block"></span> Your API key is encrypted using <span class="i-mynaui-lock inline-block"></span> Your API key is encrypted using
+2 -4
View File
@@ -27,8 +27,7 @@ const openProvider = (id: string) => navigateTo(`/settings/providers/${id}`);
</h2> </h2>
<div <div
class="grid gap-4 grid-cols-[repeat(auto-fill,_minmax(max(240px,_calc((100%_-_16px_*_(3_-_1))_/_3)),_1fr))]"> class="grid gap-4 grid-cols-[repeat(auto-fill,_minmax(max(240px,_calc((100%_-_16px_*_(3_-_1))_/_3)),_1fr))]">
<button @click="openProvider(p.id)" v-for="p in providers?.filter(p => p.enabled)" <button @click="openProvider(p.id)" v-for="p in providers?.filter(p => p.enabled)" :key="p.id"
:key="p.id"
class="flex flex-col h-44 p-4 transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg border border-[var(--color-border)] @hover:border-[var(--color-border-active)]"> class="flex flex-col h-44 p-4 transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg border border-[var(--color-border)] @hover:border-[var(--color-border-active)]">
<div class="flex flex-col flex-grow"> <div class="flex flex-col flex-grow">
<div class="flex items-center gap-2 mb-2"> <div class="flex items-center gap-2 mb-2">
@@ -51,8 +50,7 @@ const openProvider = (id: string) => navigateTo(`/settings/providers/${id}`);
</h2> </h2>
<div <div
class="grid gap-4 grid-cols-[repeat(auto-fill,_minmax(max(240px,_calc((100%_-_16px_*_(3_-_1))_/_3)),_1fr))]"> class="grid gap-4 grid-cols-[repeat(auto-fill,_minmax(max(240px,_calc((100%_-_16px_*_(3_-_1))_/_3)),_1fr))]">
<button @click="openProvider(p.id)" v-for="p in providers?.filter(p => !p.enabled)" <button @click="openProvider(p.id)" v-for="p in providers?.filter(p => !p.enabled)" :key="p.id"
:key="p.id"
class="flex flex-col h-44 p-4 transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg border border-[var(--color-border)] @hover:border-[var(--color-border-active)]"> class="flex flex-col h-44 p-4 transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg border border-[var(--color-border)] @hover:border-[var(--color-border-active)]">
<div class="flex flex-col flex-grow"> <div class="flex flex-col flex-grow">
<div class="flex items-center gap-2 mb-2"> <div class="flex items-center gap-2 mb-2">
+10 -7
View File
@@ -83,13 +83,16 @@ const submit = async () => {
</script> </script>
<template> <template>
<div class="relative flex flex-col items-center justify-center w-full h-full p-4 overflow-hidden"> <div class="relative isolate flex flex-col items-center justify-center w-full min-h-full p-4">
<!-- Background decorative blobs --> <div aria-hidden="true" class="absolute inset-0 -z-10 overflow-hidden pointer-events-none">
<div <!-- Background decorative blobs -->
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 -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
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> </div>
<div class="z-10 max-w-104 w-full flex flex-col gap-5"> <div class="z-10 max-w-104 w-full flex flex-col gap-5">
+10 -7
View File
@@ -15,13 +15,16 @@ const back = () => navigateTo('/setup/instance');
</script> </script>
<template> <template>
<div class="relative flex flex-col items-center justify-center w-full h-full p-4 overflow-hidden"> <div class="relative isolate flex flex-col items-center justify-center w-full min-h-full p-4">
<!-- Background decorative blobs --> <div aria-hidden="true" class="absolute inset-0 -z-10 overflow-hidden pointer-events-none">
<div <!-- Background decorative blobs -->
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 -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
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> </div>
<div class="z-10 max-w-104 w-full flex flex-col gap-5"> <div class="z-10 max-w-104 w-full flex flex-col gap-5">
+9 -9
View File
@@ -9,13 +9,14 @@ const finish = () => navigateTo(to ?? '/');
</script> </script>
<template> <template>
<div class="relative flex flex-col items-center justify-center w-full h-full p-4 overflow-hidden"> <div class="relative isolate flex flex-col items-center justify-center w-full min-h-full p-4">
<!-- Background decorative blobs --> <div aria-hidden="true" class="absolute inset-0 -z-10 overflow-hidden pointer-events-none">
<div <div
class="absolute -top-24 -right-24 w-96 h-96 bg-[var(--color-accent)] opacity-10 blur-[100px] rounded-full pointer-events-none"> 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>
<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"> 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> </div>
<div class="z-10 max-w-104 w-full flex flex-col gap-5"> <div class="z-10 max-w-104 w-full flex flex-col gap-5">
@@ -25,8 +26,7 @@ const finish = () => navigateTo(to ?? '/');
</div> </div>
<p class="text-sm leading-normal text-center text-[var(--text-secondary)]"> <p class="text-sm leading-normal text-center text-[var(--text-secondary)]">
Veridian runs on your own API keys. Add one provider now and its models are ready when you Veridian runs on your own API keys. Add one now and it'll be ready for you're ready to get started.
land on Home.
</p> </p>
<OnboardingProviderForm submit-label="Fetch models and continue" @done="finish"></OnboardingProviderForm> <OnboardingProviderForm submit-label="Fetch models and continue" @done="finish"></OnboardingProviderForm>
+2 -1
View File
@@ -385,7 +385,8 @@ export const recoveryLinks = pgTable(
userId: text("user_id") userId: text("user_id")
.notNull() .notNull()
.references(() => users.id, { onDelete: "cascade" }), .references(() => users.id, { onDelete: "cascade" }),
// also turn two-factor off and sign every device out when it is used // also turn two-factor off when it is used, every device is signed out
// either way
resetSecurity: boolean("reset_security").notNull().default(true), resetSecurity: boolean("reset_security").notNull().default(true),
expiresAt: timestamptz("expires_at").notNull(), expiresAt: timestamptz("expires_at").notNull(),
createdAt: timestamptz("created_at").defaultNow().notNull(), createdAt: timestamptz("created_at").defaultNow().notNull(),
+48 -29
View File
@@ -1,5 +1,4 @@
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 { APIError, createAuthMiddleware, getSessionFromCtx, isAPIError } from 'better-auth/api'; import { APIError, createAuthMiddleware, getSessionFromCtx, isAPIError } from 'better-auth/api';
import { betterAuth } from 'better-auth/minimal'; import { betterAuth } from 'better-auth/minimal';
import { createAccessControl } from 'better-auth/plugins/access'; import { createAccessControl } from 'better-auth/plugins/access';
@@ -8,23 +7,26 @@ import { defaultStatements } from 'better-auth/plugins/admin/access';
import { twoFactor } from 'better-auth/plugins/two-factor'; import { twoFactor } from 'better-auth/plugins/two-factor';
import * as schema from '../drizzle/schema'; import * as schema from '../drizzle/schema';
import { getRegistrationMode } from '../server/utils/instance'; import { getRegistrationMode } from '../server/utils/instance';
import { claimInvite, INVITE_TOKEN_HEADER, releaseInvite } from '../server/utils/invites'; import { hasUsableAdministrator } from '../server/lib/account-security';
import { authDatabase, currentTransaction } from '../server/lib/auth-transaction';
import { claimInvite, INVITE_TOKEN_HEADER } from '../server/utils/invites';
import { isSetupNeeded } from '../server/utils/setup';
const MIN_PASSWORD_LENGTH = 8; const MIN_PASSWORD_LENGTH = 8;
const ADMIN_MUTATIONS = new Set(['/admin/set-role', '/admin/ban-user', '/admin/unban-user', '/admin/update-user']);
const transactionRequired = () => new APIError('INTERNAL_SERVER_ERROR', {
message: 'This has to run in a transaction',
code: 'TRANSACTION_REQUIRED',
});
const accessControl = createAccessControl(defaultStatements); const accessControl = createAccessControl(defaultStatements);
// Admins manage accounts, not content. Signing in as someone else and setting
// 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.
// Deleting is left out too, because Better Auth would only remove the account
// 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: ['list', 'set-role', 'ban', 'get', 'update'], user: ['set-role', 'ban'],
session: ['list', 'revoke', 'delete'], session: ['revoke'],
}), }),
user: accessControl.newRole({ user: accessControl.newRole({
user: [], user: [],
@@ -33,7 +35,7 @@ const roles = {
}; };
export const auth = betterAuth({ export const auth = betterAuth({
database: drizzleAdapter(db, { database: drizzleAdapter(authDatabase, {
provider: "pg", provider: "pg",
schema, schema,
usePlural: true, usePlural: true,
@@ -44,7 +46,7 @@ export const auth = betterAuth({
updateAge: 60 * 60 * 24 * 1, // 1 day updateAge: 60 * 60 * 24 * 1, // 1 day
cookieCache: { cookieCache: {
enabled: true, enabled: true,
maxAge: 5 * 60, maxAge: 60,
}, },
}, },
@@ -53,8 +55,21 @@ export const auth = betterAuth({
hooks: { hooks: {
before: createAuthMiddleware(async (ctx) => { before: createAuthMiddleware(async (ctx) => {
// Who may register is an instance setting admins change at runtime. if (ctx.path === "/change-password" && ctx.request) {
throw new APIError("FORBIDDEN", {
message: "Use the account password endpoint",
code: "USE_ACCOUNT_PASSWORD_ENDPOINT",
});
}
if (ctx.path === '/sign-up/email') { if (ctx.path === '/sign-up/email') {
if (await isSetupNeeded()) {
throw new APIError('FORBIDDEN', {
message: 'This instance has not been set up yet',
code: 'SETUP_NEEDED',
});
}
const mode = await getRegistrationMode(); const mode = await getRegistrationMode();
if (mode === 'closed') { if (mode === 'closed') {
@@ -65,9 +80,12 @@ export const auth = betterAuth({
} }
if (mode === 'invite') { if (mode === 'invite') {
const tx = currentTransaction();
if (!tx) throw transactionRequired();
const token = ctx.headers?.get(INVITE_TOKEN_HEADER); const token = ctx.headers?.get(INVITE_TOKEN_HEADER);
if (!token || !await claimInvite(token)) { if (!token || !await claimInvite(tx, token)) {
throw new APIError('FORBIDDEN', { throw new APIError('FORBIDDEN', {
message: 'This invite link is not valid anymore', message: 'This invite link is not valid anymore',
code: 'INVITE_INVALID', code: 'INVITE_INVALID',
@@ -76,9 +94,6 @@ export const auth = betterAuth({
} }
} }
// Creating a user as an admin skips the sign up password rules. No
// password at all is fine: that account can't be signed in to until
// its owner sets one with a setup link.
if (ctx.path === '/admin/create-user' && ctx.body?.password !== undefined) { if (ctx.path === '/admin/create-user' && ctx.body?.password !== undefined) {
const password = ctx.body.password; const password = ctx.body.password;
if (typeof password !== 'string' || password.length < MIN_PASSWORD_LENGTH) { if (typeof password !== 'string' || password.length < MIN_PASSWORD_LENGTH) {
@@ -89,12 +104,13 @@ export const auth = betterAuth({
} }
} }
// The admin endpoints already stop admins from removing or disabling if (!ADMIN_MUTATIONS.has(ctx.path)) return;
// themselves. Giving up their own admin role is the one way left to
// end up with an instance nobody can manage, so that is refused too.
if (ctx.path !== '/admin/set-role' && ctx.path !== '/admin/update-user') return;
const role = ctx.path === '/admin/set-role' ? ctx.body?.role : ctx.body?.data?.role; if (!currentTransaction()) throw transactionRequired();
if (ctx.path !== '/admin/set-role') return;
const role = ctx.body?.role;
if (role === undefined) return; if (role === undefined) return;
const roleList = (Array.isArray(role) ? role : String(role).split(',')).map((r) => String(r).trim()); const roleList = (Array.isArray(role) ? role : String(role).split(',')).map((r) => String(r).trim());
@@ -109,14 +125,17 @@ export const auth = betterAuth({
} }
}), }),
// The invite was claimed before the account was made. When making it
// then fails, say because the email is taken, the use is given back.
after: createAuthMiddleware(async (ctx) => { after: createAuthMiddleware(async (ctx) => {
if (ctx.path !== '/sign-up/email' || !isAPIError(ctx.context.returned)) return; if (!ADMIN_MUTATIONS.has(ctx.path) || isAPIError(ctx.context.returned)) return;
const token = ctx.headers?.get(INVITE_TOKEN_HEADER); const tx = currentTransaction();
if (token && await getRegistrationMode() === 'invite') { if (!tx) throw transactionRequired();
await releaseInvite(token);
if (!await hasUsableAdministrator(tx)) {
throw new APIError('CONFLICT', {
message: 'The instance must keep a usable administrator',
code: 'LAST_ADMIN',
});
} }
}), }),
}, },
+1 -2
View File
@@ -119,7 +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', 'uqr',
'nanoid', 'nanoid',
'drizzle-orm/pg-core' 'drizzle-orm/pg-core'
], ],
@@ -167,7 +167,6 @@ export default defineNuxtConfig({
}, },
public: { public: {
disableSignup: process.env.DISABLE_SIGNUP === 'true' || process.env.DISABLE_SIGNUP === '1',
publicUrl: process.env.NUXT_PUBLIC_URL ?? 'http://localhost:3000', publicUrl: process.env.NUXT_PUBLIC_URL ?? 'http://localhost:3000',
} }
}, },
+42 -47
View File
@@ -1,65 +1,60 @@
/** import { createHash, randomBytes, randomUUID } from "node:crypto";
* Prints a one-time recovery link for an account. Meant for whoever runs the import { Pool } from "pg";
* 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 LIFETIME_MS = 24 * 60 * 60 * 1000;
// Standalone so operators can recover an account without running the server.
const main = async (): Promise<number> => { const main = async (): Promise<number> => {
const email = process.argv[2]?.trim().toLowerCase(); const email = process.argv[2]?.trim().toLowerCase();
if (!email) { if (!email) {
console.error('Usage: bun run recover <email>'); console.error("Usage: bun run recover <email>");
return 1; return 1;
} }
const pool = new Pool({ const pool = new Pool({
connectionString: process.env.DATABASE_URL || 'postgres://postgres:password@localhost:5432/veridian', connectionString: process.env.DATABASE_URL || "postgres://postgres:password@localhost:5432/veridian",
}); });
const client = await pool.connect();
try {
await client.query("BEGIN");
// Same user-row lock as the web issuance and redemption endpoints.
const found = await client.query<{ id: string; name: string }>(
"SELECT id, name FROM users WHERE lower(email) = $1 FOR UPDATE", [email],
);
const user = found.rows[0];
if (!user) {
await client.query("ROLLBACK");
console.error(`No account with the email ${email}.`);
return 1;
}
const found = await pool.query('SELECT id, name FROM users WHERE lower(email) = $1', [email]); const token = randomBytes(32).toString("base64url");
const user = found.rows[0] as { id: string; name: string } | undefined; const tokenHash = createHash("sha256").update(token).digest("hex");
const expiresAt = new Date(Date.now() + LIFETIME_MS);
if (!user) { await client.query("DELETE FROM recovery_links WHERE user_id = $1", [user.id]);
console.error(`No account with the email ${email}.`); await client.query(
await pool.end(); "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 client.query("COMMIT");
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;
} catch (error) {
await client.query("ROLLBACK");
console.error("Failed to create recovery link:", error);
return 1; return 1;
} finally {
client.release();
await pool.end();
} }
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()); process.exit(await main());
+19 -21
View File
@@ -1,7 +1,6 @@
import { and, eq, ne } from 'drizzle-orm';
import * as z from 'zod'; import * as z from 'zod';
import { users } from '~~/drizzle/schema';
import { auth } from '~~/lib/auth'; import { auth } from '~~/lib/auth';
import { hasUsableAdministrator } from '~~/server/lib/account-security';
import { db } from '~~/server/lib/db'; import { db } from '~~/server/lib/db';
/** /**
@@ -43,23 +42,16 @@ export default defineEventHandler(async (event) => {
const twoFactorEnabled = !!account?.twoFactorEnabled; const twoFactorEnabled = !!account?.twoFactorEnabled;
// an instance always keeps one admin, someone has to be able to manage it // Checked again when the account is deleted. Doing it here as well keeps
if (account?.role === 'admin') { // a deletion that is refused anyway from using up a backup code.
const otherAdmins = await db if (account?.role === 'admin' && !await db.transaction(tx => hasUsableAdministrator(tx, userId))) {
.select({ id: users.id }) throw createError({
.from(users) statusCode: 409,
.where(and(eq(users.role, 'admin'), ne(users.id, userId))) statusMessage: 'The last usable admin cannot be deleted',
.limit(1); data: {
code: 'LAST_ADMIN',
if (otherAdmins.length === 0) { },
throw createError({ });
statusCode: 409,
statusMessage: 'The last admin cannot be deleted',
data: {
code: 'LAST_ADMIN',
},
});
}
} }
if (twoFactorEnabled && code === undefined) { if (twoFactorEnabled && code === undefined) {
@@ -111,7 +103,14 @@ export default defineEventHandler(async (event) => {
}); });
} }
await deleteAccount(userId); const deleted = await deleteAccount(userId);
if (!deleted.ok) {
throw createError({
statusCode: 409,
statusMessage: "The last usable admin cannot be deleted",
data: { code: deleted.error },
});
}
// the session is gone, but the browser would keep trusting its cached // the session is gone, but the browser would keep trusting its cached
// copy for a few minutes unless the cookies go too // copy for a few minutes unless the cookies go too
@@ -121,7 +120,6 @@ export default defineEventHandler(async (event) => {
deleteCookie(event, cookie.name, { deleteCookie(event, cookie.name, {
path: cookie.attributes.path, path: cookie.attributes.path,
secure: cookie.attributes.secure, secure: cookie.attributes.secure,
sameSite: cookie.attributes.sameSite,
httpOnly: cookie.attributes.httpOnly, httpOnly: cookie.attributes.httpOnly,
}); });
} }
+58 -92
View File
@@ -1,116 +1,82 @@
import { and, eq } from 'drizzle-orm'; import { and, eq, sql } from "drizzle-orm";
import * as z from 'zod'; import * as z from "zod";
import { providers } from '~~/drizzle/schema'; import { providers, recoveryLinks } from "~~/drizzle/schema";
import { auth } from '~~/lib/auth'; import { lockAccount, revokeTwoFactorTrust } from "~~/server/lib/account-security";
import { db } from '~~/server/lib/db'; import { auth } from "~~/lib/auth";
import { authTransaction } from "~~/server/lib/auth-transaction";
/**
* 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) => { export default defineEventHandler(async (event) => {
await protectRoute(event); const { user } = await protectRoute(event);
const result = await readValidatedBody(event, (body) => z.object({
const userId = event.context.user!.id as string; currentPassword: z.string().min(1).max(128),
newPassword: z.string().min(8).max(128),
const result = await readValidatedBody(event, (body) => providerKeys: z.array(z.object({
z id: z.string(),
.object({ originalApiKey: z.string().min(1),
currentPassword: z.string().min(1), apiKey: z.string().min(1),
newPassword: z.string().min(8).max(128), })),
providerKeys: z.array(z.object({ }).safeParse(body));
id: z.string(),
apiKey: z.string().min(1),
})),
})
.safeParse(body),
);
if (!result.success) { if (!result.success) {
throw createError({ throw createError({ statusCode: 400, message: result.error.issues[0]?.message });
statusCode: 400,
message: result.error.issues[0]!.message,
});
} }
const { currentPassword, newPassword, providerKeys } = result.data; const { currentPassword, newPassword, providerKeys } = result.data;
const replacements = new Map(providerKeys.map(key => [key.id, key]));
const existing = await db.query.providers.findMany({ let cookies: string[];
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 { try {
// The password change runs inside the transaction so a rejected cookies = await authTransaction(async (tx) => {
// password rolls the key updates back. await lockAccount(tx, user.id);
await db.transaction(async (tx) => { const existing = await tx.select().from(providers).where(eq(providers.userId, user.id));
for (const provider of withKey) { const withKey = existing.filter(provider => !!provider.config.apiKey);
await tx.update(providers) const complete = replacements.size === providerKeys.length
.set({ && replacements.size === withKey.length
config: { && withKey.every(provider => replacements.get(provider.id)?.originalApiKey === provider.config.apiKey);
...provider.config,
apiKey: newKeys.get(provider.id)!, if (!complete) {
}, throw createError({
}) statusCode: 409,
.where(and(eq(providers.id, provider.id), eq(providers.userId, userId))); statusMessage: "Provider keys out of date",
data: { code: "PROVIDER_KEYS_OUT_OF_DATE" },
});
} }
const { headers } = await auth.api.changePassword({ const { headers } = await auth.api.changePassword({
body: { body: { currentPassword, newPassword, revokeOtherSessions: true },
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, headers: event.headers,
returnHeaders: true, returnHeaders: true,
}); });
cookies = headers.getSetCookie(); await tx.delete(recoveryLinks).where(eq(recoveryLinks.userId, user.id));
}); await revokeTwoFactorTrust(tx, user.id);
} catch (error: any) {
const code = error?.body?.code as string | undefined;
if (code === undefined) { // Invalidate pending config writes even for providers without a key.
console.error('Failed to change password:', error); const updatedAt = new Date(Math.max(Date.now(), ...existing.map(provider => provider.updatedAt.getTime() + 1)));
await tx.update(providers).set({ updatedAt }).where(eq(providers.userId, user.id));
for (const key of providerKeys) {
await tx.update(providers).set({
config: sql`jsonb_set(${providers.config}, '{apiKey}', ${JSON.stringify(key.apiKey)}::jsonb)`,
updatedAt,
}).where(and(eq(providers.id, key.id), eq(providers.userId, user.id)));
}
return headers.getSetCookie();
});
} catch (error) {
if (error instanceof Error && "body" in error) {
const body = z.object({ code: z.string(), message: z.string().optional() }).safeParse(error.body);
if (body.success) {
throw createError({ statusCode: 400, message: body.data.message, data: { code: body.data.code } });
}
} }
throw 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',
},
});
} }
// No replacement session reaches the browser until its transaction commits.
for (const cookie of cookies) { for (const cookie of cookies) {
appendResponseHeader(event, 'set-cookie', cookie); appendResponseHeader(event, "set-cookie", cookie);
} }
return { ok: true }; return { ok: true };
}); });
+1 -9
View File
@@ -1,17 +1,9 @@
import { and, eq } from 'drizzle-orm'; import { and, eq } from 'drizzle-orm';
import { sessions } from '~~/drizzle/schema'; import { sessions } from '~~/drizzle/schema';
import { auth } from '~~/lib/auth';
import { db } from '~~/server/lib/db'; import { db } from '~~/server/lib/db';
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const sessionData = await auth.api.getSession({ headers: event.headers }); const sessionData = await protectRoute(event);
if (sessionData === null) {
throw createError({
statusCode: 401,
statusMessage: 'Unauthorized',
});
}
const id = getRouterParam(event, 'id')!; const id = getRouterParam(event, 'id')!;
+1 -9
View File
@@ -1,4 +1,3 @@
import { auth } from '~~/lib/auth';
import { db } from '~~/server/lib/db'; import { db } from '~~/server/lib/db';
/** /**
@@ -6,14 +5,7 @@ import { db } from '~~/server/lib/db';
* out on purpose: the client only ever needs the id to sign a device out. * out on purpose: the client only ever needs the id to sign a device out.
*/ */
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const sessionData = await auth.api.getSession({ headers: event.headers }); const sessionData = await protectRoute(event);
if (sessionData === null) {
throw createError({
statusCode: 401,
statusMessage: 'Unauthorized',
});
}
const result = await db.query.sessions.findMany({ const result = await db.query.sessions.findMany({
where: { where: {
+8 -1
View File
@@ -34,7 +34,14 @@ export default defineEventHandler(async (event) => {
}); });
} }
await deleteAccount(id); const deleted = await deleteAccount(id);
if (!deleted.ok) {
throw createError({
statusCode: 409,
statusMessage: "The last usable admin cannot be deleted",
data: { code: deleted.error },
});
}
return { ok: true }; return { ok: true };
}); });
+1 -1
View File
@@ -12,7 +12,7 @@ export default defineEventHandler(async (event) => {
const result = await readValidatedBody(event, (body) => const result = await readValidatedBody(event, (body) =>
z z
.object({ .object({
// also turn two-factor off and sign every device out // also turn two-factor off, every device is signed out either way
resetSecurity: z.boolean(), resetSecurity: z.boolean(),
}) })
.safeParse(body), .safeParse(body),
@@ -1,6 +1,7 @@
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { twoFactors, users } from '~~/drizzle/schema'; import { twoFactors, users } from '~~/drizzle/schema';
import { db } from '~~/server/lib/db'; import { db } from '~~/server/lib/db';
import { lockAccount, revokeTwoFactorTrust } from "~~/server/lib/account-security";
/** /**
* Turns two-factor sign-in off for a user who lost their authenticator app * Turns two-factor sign-in off for a user who lost their authenticator app
@@ -26,6 +27,8 @@ export default defineEventHandler(async (event) => {
} }
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
await lockAccount(tx, id);
await revokeTwoFactorTrust(tx, id);
await tx.delete(twoFactors).where(eq(twoFactors.userId, id)); await tx.delete(twoFactors).where(eq(twoFactors.userId, id));
await tx.update(users).set({ twoFactorEnabled: false }).where(eq(users.id, id)); await tx.update(users).set({ twoFactorEnabled: false }).where(eq(users.id, id));
}); });
+44 -3
View File
@@ -1,5 +1,46 @@
import { auth } from '~~/lib/auth'; import { auth } from "~~/lib/auth";
import { lockAdministration } from "~~/server/lib/account-security";
import { authTransaction } from "~~/server/lib/auth-transaction";
export default defineEventHandler((event) => { const SIGN_UP = "/api/auth/sign-up/email";
return auth.handler(toWebRequest(event));
// Requests that have to be undone as a whole when they fail. The hooks in
// lib/auth.ts refuse to run these without a transaction, so a URL that slips
// past this list is rejected there instead of going through unguarded.
const ADMIN_MUTATIONS = new Set([
"/api/auth/admin/set-role",
"/api/auth/admin/ban-user",
"/api/auth/admin/unban-user",
"/api/auth/admin/update-user",
]);
export default defineEventHandler(async (event) => {
const request = toWebRequest(event);
const path = new URL(request.url).pathname.replace(/\/+$/, "");
const adminMutation = ADMIN_MUTATIONS.has(path);
if (request.method !== "POST" || (!adminMutation && path !== SIGN_UP)) {
return auth.handler(request);
}
try {
return await authTransaction(async (tx) => {
if (adminMutation) {
// Deletion, banning and demotion all wait for each other here.
await lockAdministration(tx);
}
const response = await auth.handler(request);
if (!response.ok) {
// The auth handler returns errors as responses; roll back any writes.
throw response;
}
return response;
});
} catch (error) {
if (error instanceof Response) {
return error;
}
throw error;
}
}); });
+5 -2
View File
@@ -22,8 +22,11 @@ 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({ headers: event.headers }); const sessionData = await auth.api.getSession({
if (sessionData) { headers: event.headers,
query: { disableCookieCache: true },
});
if (sessionData && !sessionData.user.banned) {
event.context.user = sessionData.user; event.context.user = sessionData.user;
authorized = true; authorized = true;
} }
+17 -1
View File
@@ -1,3 +1,19 @@
import { generateFileToken } from '~~/server/utils/file-token';
const FILE_PREFIX = '/api/files/';
// Uploaded avatars are served to signed in people only, and whoever opens an
// invite is not signed in yet. A short-lived signed URL lets them see it.
const publicImage = (image: string | null): string | null => {
const secret = process.env.BETTER_AUTH_SECRET;
if (!image || !image.startsWith(FILE_PREFIX) || !secret) return image;
const { exp, sig } = generateFileToken(image.slice(FILE_PREFIX.length), secret);
return `${image}?exp=${exp}&sig=${sig}`;
};
/** /**
* Tells the register page whether an invite link still works and who it is * Tells the register page whether an invite link still works and who it is
* from. Public on purpose, the person opening the link has no account yet. * from. Public on purpose, the person opening the link has no account yet.
@@ -16,7 +32,7 @@ export default defineEventHandler(async (event) => {
inviter: { inviter: {
id: invite.inviterId, id: invite.inviterId,
name: invite.inviterName, name: invite.inviterName,
image: invite.inviterImage, image: publicImage(invite.inviterImage),
}, },
}; };
}); });
+52 -57
View File
@@ -1,68 +1,63 @@
import { and, eq } from "drizzle-orm"; import { and, eq, gt } from "drizzle-orm";
import { providers } from "~~/drizzle/schema"; import * as z from "zod";
import { providers, sessions } from "~~/drizzle/schema";
import { lockAccount } from "~~/server/lib/account-security";
import { db } from "~~/server/lib/db"; import { db } from "~~/server/lib/db";
import * as z from 'zod';
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
await protectRoute(event); const { user, session } = await protectRoute(event);
const result = await readValidatedBody(event, (body) => z.object({
const userId = event.context.user!.id; config: z.object({
apiKey: z.string().optional(),
const result = await readValidatedBody(event, (body) => apiProxyUrl: z.string().optional(),
z }).optional(),
.object({ expectedUpdatedAt: z.iso.datetime().optional(),
config: z.object({ enabled: z.boolean().optional(),
apiKey: z.string().optional(), }).refine(body => body.config === undefined || body.expectedUpdatedAt !== undefined, {
apiProxyUrl: z.string().optional(), message: "Provider config updates require a revision",
}).optional(), }).safeParse(body));
enabled: z.boolean().optional(),
})
.safeParse(body),
);
if (!result.success) { if (!result.success) {
throw createError({ throw createError({ statusCode: 400, message: result.error.issues[0]?.message });
statusCode: 400,
message: result.error.issues[0]!.message,
});
} }
const providerId = getRouterParam(event, 'providerId')!; const providerId = getRouterParam(event, "providerId");
if (!providerId) {
const { config, enabled } = result.data; throw createError({ statusCode: 400, statusMessage: "Invalid provider" });
const existing = await db.query.providers.findFirst({
where: {
id: providerId,
userId,
},
});
if (!existing) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid provider',
});
} }
const { config, enabled, expectedUpdatedAt } = result.data;
const res = await db.update(providers) return await db.transaction(async (tx) => {
.set({ await lockAccount(tx, user.id);
config, // A request may have waited behind a password rotation or recovery.
const [activeSession] = await tx.select({ id: sessions.id }).from(sessions).where(and(
eq(sessions.id, session.id), eq(sessions.userId, user.id), gt(sessions.expiresAt, new Date()),
));
if (!activeSession) {
throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
}
const [existing] = await tx.select().from(providers).where(and(
eq(providers.id, providerId), eq(providers.userId, user.id),
));
if (!existing) {
throw createError({ statusCode: 404, statusMessage: "Invalid provider" });
}
if (config !== undefined && existing.updatedAt.getTime() !== new Date(expectedUpdatedAt ?? "").getTime()) {
throw createError({
statusCode: 409,
statusMessage: "Provider config out of date",
data: { code: "PROVIDER_KEYS_OUT_OF_DATE" },
});
}
const updatedAt = new Date(Math.max(Date.now(), existing.updatedAt.getTime() + 1));
await tx.update(providers).set({
config: config === undefined ? undefined : { ...existing.config, ...config },
enabled, enabled,
}) updatedAt,
.where( }).where(and(eq(providers.id, providerId), eq(providers.userId, user.id)));
and(
eq(providers.id, providerId),
eq(providers.userId, userId),
)
);
if (res.rowCount === 0) { return { ok: true, updatedAt };
throw createError({ });
statusCode: 400, });
statusMessage: 'Invalid provider',
});
}
return { ok: true };
});
+41 -14
View File
@@ -1,8 +1,9 @@
import { and, eq, gt, sql } from 'drizzle-orm'; import { and, eq, gt, sql } from 'drizzle-orm';
import * as z from 'zod'; import * as z from 'zod';
import { providers, recoveryLinks, sessions, twoFactors, users } from '~~/drizzle/schema'; import { accounts, providers, recoveryLinks, sessions, twoFactors, users } from '~~/drizzle/schema';
import { lockAccount, revokeTwoFactorTrust } from "~~/server/lib/account-security";
import { authTransaction } from "~~/server/lib/auth-transaction";
import { auth } from '~~/lib/auth'; import { auth } from '~~/lib/auth';
import { db } from '~~/server/lib/db';
/** /**
* Sets a password with a one-time link. * Sets a password with a one-time link.
@@ -33,24 +34,45 @@ export default defineEventHandler(async (event) => {
}); });
} }
const tokenHash = hashRecoveryToken(getRouterParam(event, 'token')!); const token = getRouterParam(event, 'token')!;
const tokenHash = hashRecoveryToken(token);
const { password } = result.data; const { password } = result.data;
const invalidLink = () => createError({
statusCode: 410,
statusMessage: 'This recovery link is not valid anymore',
data: {
code: 'RECOVERY_LINK_INVALID',
},
});
// Hashing a password is slow on purpose and anyone can call this, so a
// link that does not work is turned away before any of that is done.
const candidate = await findRecoveryLink(token);
if (!candidate) {
throw invalidLink();
}
const { password: hasher, internalAdapter } = await auth.$context; const { password: hasher, internalAdapter } = await auth.$context;
const passwordHash = await hasher.hash(password); const passwordHash = await hasher.hash(password);
const recovered = await db.transaction(async (tx) => { const recovered = await authTransaction(async (tx) => {
await lockAccount(tx, candidate.userId);
// deleting the link is what makes it work once: of two requests with // deleting the link is what makes it work once: of two requests with
// the same link only one gets a row back // the same link only one gets a row back
const [link] = await tx.delete(recoveryLinks) const [link] = await tx.delete(recoveryLinks)
.where(and(eq(recoveryLinks.tokenHash, tokenHash), gt(recoveryLinks.expiresAt, new Date()))) .where(and(eq(recoveryLinks.tokenHash, tokenHash), gt(recoveryLinks.expiresAt, new Date())))
.returning({ userId: recoveryLinks.userId, resetSecurity: recoveryLinks.resetSecurity }); .returning({ userId: recoveryLinks.userId, resetSecurity: recoveryLinks.resetSecurity });
if (!link) return null; if (!link) {
return null;
}
// if either of these fails the link comes back with the rest of the // if either of these fails the link comes back with the rest of the
// transaction // transaction
if (!await hasPassword(link.userId)) { const [credential] = await tx.select({ id: accounts.id }).from(accounts)
.where(and(eq(accounts.userId, link.userId), eq(accounts.providerId, 'credential')));
if (!credential) {
await internalAdapter.linkAccount({ await internalAdapter.linkAccount({
providerId: 'credential', providerId: 'credential',
accountId: link.userId, accountId: link.userId,
@@ -63,32 +85,37 @@ export default defineEventHandler(async (event) => {
await internalAdapter.updatePassword(link.userId, passwordHash); await internalAdapter.updatePassword(link.userId, passwordHash);
await tx.update(providers).set({
updatedAt: sql`greatest(date_trunc('milliseconds', clock_timestamp()), date_trunc('milliseconds', ${providers.updatedAt}) + interval '1 millisecond')`,
}).where(eq(providers.userId, link.userId));
// A provider without its key can't be used, so it is switched off // 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. // until the key is entered again. The provider and its models stay.
await tx.update(providers) await tx.update(providers)
.set({ .set({
config: sql`${providers.config} - 'apiKey'`, config: sql`${providers.config} - 'apiKey'`,
enabled: false, enabled: false,
updatedAt: sql`${providers.updatedAt}`,
}) })
.where(and(eq(providers.userId, link.userId), sql`coalesce(${providers.config}->>'apiKey', '') <> ''`)); .where(and(eq(providers.userId, link.userId), sql`coalesce(${providers.config}->>'apiKey', '') <> ''`));
// Every device is signed out, always: the ones still signed in hold
// an encryption key derived from the old password.
await tx.delete(sessions).where(eq(sessions.userId, link.userId));
await revokeTwoFactorTrust(tx, link.userId);
if (link.resetSecurity) { if (link.resetSecurity) {
await tx.delete(twoFactors).where(eq(twoFactors.userId, link.userId)); 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.update(users).set({ twoFactorEnabled: false }).where(eq(users.id, link.userId));
await tx.delete(sessions).where(eq(sessions.userId, link.userId));
} }
return 'recovered'; return 'recovered';
}); });
// used or expired between the check above and here
if (recovered === null) { if (recovered === null) {
throw createError({ throw invalidLink();
statusCode: 410,
statusMessage: 'This recovery link is not valid anymore',
data: {
code: 'RECOVERY_LINK_INVALID',
},
});
} }
return { newAccount: recovered === 'created' }; return { newAccount: recovered === 'created' };
+2 -2
View File
@@ -2,7 +2,7 @@ import { sql } from 'drizzle-orm';
import * as z from 'zod'; import * as z from 'zod';
import { INSTANCE_SETTINGS_ID, instanceSettings, users } from '~~/drizzle/schema'; import { INSTANCE_SETTINGS_ID, instanceSettings, users } from '~~/drizzle/schema';
import { auth } from '~~/lib/auth'; import { auth } from '~~/lib/auth';
import { db } from '~~/server/lib/db'; import { authTransaction } from '~~/server/lib/auth-transaction';
// any fixed number works, it only has to be the same for every request // any fixed number works, it only has to be the same for every request
const SETUP_LOCK_ID = 7_261_008; const SETUP_LOCK_ID = 7_261_008;
@@ -40,7 +40,7 @@ export default defineEventHandler(async (event) => {
let alreadyDone = false; let alreadyDone = false;
try { try {
await db.transaction(async (tx) => { await authTransaction(async (tx) => {
// two people submitting the form at the same moment wait here, and // two people submitting the form at the same moment wait here, and
// the second one then finds the first one's account // the second one then finds the first one's account
await tx.execute(sql`select pg_advisory_xact_lock(${SETUP_LOCK_ID})`); await tx.execute(sql`select pg_advisory_xact_lock(${SETUP_LOCK_ID})`);
+46
View File
@@ -0,0 +1,46 @@
import type { Transaction } from "./auth-transaction";
import { and, eq, isNotNull, ne, or, isNull, sql, like } from "drizzle-orm";
import { accounts, users, verifications } from "../../drizzle/schema";
const ADMIN_LOCK_ID = 7_261_009;
// Deletion, banning and demotion must all hold this lock until commit.
export const lockAdministration = async (tx: Transaction): Promise<void> => {
await tx.execute(sql`select pg_advisory_xact_lock(${ADMIN_LOCK_ID})`);
};
export const hasUsableAdministrator = async (tx: Transaction, excludedUserId?: string): Promise<boolean> => {
const rows = await tx.select({ id: users.id })
.from(users)
.innerJoin(accounts, eq(accounts.userId, users.id))
.where(and(
eq(users.role, "admin"),
excludedUserId === undefined ? undefined : ne(users.id, excludedUserId),
or(eq(users.banned, false), isNull(users.banned)),
eq(accounts.providerId, "credential"),
isNotNull(accounts.password),
ne(accounts.password, ""),
))
.limit(1);
return rows.length > 0;
};
// All credential, recovery-link and provider-config writes use the user row
// as their common lock, including when no credential or recovery row exists.
export const lockAccount = async (tx: Transaction, userId: string): Promise<boolean> => {
const rows = await tx.select({ id: users.id }).from(users)
.where(eq(users.id, userId)).for("update");
return rows.length > 0;
};
export const revokeTwoFactorTrust = async (tx: Transaction, userId: string): Promise<void> => {
await tx.delete(verifications).where(and(
eq(verifications.value, userId),
or(
like(verifications.identifier, "trust-device-%"),
like(verifications.identifier, "2fa-%"),
),
));
};
+30
View File
@@ -0,0 +1,30 @@
import { AsyncLocalStorage } from "node:async_hooks";
import { db } from "./db";
export type Transaction = Parameters<Parameters<typeof db.transaction>[0]>[0];
const storage = new AsyncLocalStorage<Transaction>();
/** The transaction opened by authTransaction() around the running code, if any. */
export const currentTransaction = (): Transaction | undefined => storage.getStore();
// What the one auth instance is given as its database. Every access goes to
// the transaction opened by authTransaction() around the running code, or to
// the pool when there is none, so no second auth instance is needed to make
// Better Auth write on a transaction's connection.
export const authDatabase = new Proxy(db, {
get(_target, property) {
const target = storage.getStore() ?? db;
const value = Reflect.get(target, property, target);
return typeof value === "function" ? value.bind(target) : value;
},
});
/**
* Runs the callback in a transaction that everything the auth instance does
* inside it joins: its endpoints, its hooks and its internal adapter all
* commit or roll back together with the callback's own writes.
*/
export const authTransaction = <T>(callback: (tx: Transaction) => Promise<T>): Promise<T> =>
db.transaction(tx => storage.run(tx, () => callback(tx)));
+12 -1
View File
@@ -10,6 +10,8 @@ import {
users, users,
} from '~~/drizzle/schema'; } from '~~/drizzle/schema';
import { db } from '~~/server/lib/db'; import { db } from '~~/server/lib/db';
import { hasUsableAdministrator, lockAccount, lockAdministration } from "~~/server/lib/account-security";
import { Err, Ok } from "~~/types/result";
/** /**
* Deletes an account and everything that belongs to it. * Deletes an account and everything that belongs to it.
@@ -21,7 +23,15 @@ import { db } from '~~/server/lib/db';
* Objects in file storage are left alone on purpose. * Objects in file storage are left alone on purpose.
*/ */
export const deleteAccount = async (userId: string) => { export const deleteAccount = async (userId: string) => {
await db.transaction(async (tx) => { return await db.transaction(async (tx) => {
await lockAdministration(tx);
await lockAccount(tx, userId);
const [account] = await tx.select({ role: users.role }).from(users).where(eq(users.id, userId));
if (account?.role === "admin" && !await hasUsableAdministrator(tx, userId)) {
return Err("LAST_ADMIN" as const);
}
// messages, their parts, generations and attachments cascade from topics // messages, their parts, generations and attachments cascade from topics
await tx.delete(topics).where(eq(topics.userId, userId)); await tx.delete(topics).where(eq(topics.userId, userId));
await tx.delete(agents).where(eq(agents.userId, userId)); await tx.delete(agents).where(eq(agents.userId, userId));
@@ -35,5 +45,6 @@ export const deleteAccount = async (userId: string) => {
// sessions, accounts and two-factor rows cascade from the user // sessions, accounts and two-factor rows cascade from the user
await tx.delete(users).where(eq(users.id, userId)); await tx.delete(users).where(eq(users.id, userId));
return Ok(undefined);
}); });
}; };
+6 -12
View File
@@ -1,26 +1,20 @@
import type { H3Event } from 'h3'; import type { H3Event } from 'h3';
import { db } from '~~/server/lib/db';
/** /**
* Like protectRoute(), but only lets admins through. * Like protectRoute(), but only lets admins through.
* *
* The role is read from the database: the session cookie keeps a copy of the * protectRoute() reads the user from the database on every request, so someone
* user for a few minutes, and someone who just stopped being an admin should * who just stopped being an admin loses the access right away.
* not keep the access for that long.
*/ */
export const protectAdminRoute = async (event: H3Event) => { export const protectAdminRoute = async (event: H3Event) => {
await protectRoute(event); const sessionData = await protectRoute(event);
const account = await db.query.users.findFirst({ if (sessionData.user.role !== 'admin') {
where: {
id: event.context.user!.id as string,
},
});
if (account?.role !== 'admin' || account.banned) {
throw createError({ throw createError({
statusCode: 403, statusCode: 403,
statusMessage: 'Forbidden', statusMessage: 'Forbidden',
}); });
} }
return sessionData;
}; };
+6 -2
View File
@@ -2,9 +2,12 @@ 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({ headers: event.headers }); const sessionData = await auth.api.getSession({
headers: event.headers,
query: { disableCookieCache: true },
});
if (sessionData === null) { if (sessionData === null || sessionData.user.banned) {
throw createError({ throw createError({
statusCode: 401, statusCode: 401,
statusMessage: 'Unauthorized', statusMessage: 'Unauthorized',
@@ -12,4 +15,5 @@ export const protectRoute = async (event: H3Event) => {
} }
event.context.user = sessionData.user; event.context.user = sessionData.user;
return sessionData;
}; };
+6 -10
View File
@@ -1,5 +1,6 @@
import { and, eq, gt, lt, lte, or, sql } from 'drizzle-orm'; import { and, eq, gt, lt, lte, or, sql } from 'drizzle-orm';
import { invites, users } from '../../drizzle/schema'; import { invites, users } from '../../drizzle/schema';
import type { Transaction } from '../lib/auth-transaction';
import { db } from '../lib/db'; import { db } from '../lib/db';
export const INVITE_TOKEN_HEADER = 'x-invite-token'; export const INVITE_TOKEN_HEADER = 'x-invite-token';
@@ -9,10 +10,12 @@ const usable = () => and(lt(invites.uses, invites.maxUses), gt(invites.expiresAt
/** /**
* Takes one use of an invite. It is a single statement, so two people * Takes one use of an invite. It is a single statement, so two people
* registering with the same single use link at once cannot both get in. * registering with the same single use link at once cannot both get in. It
* runs on the transaction of the registration, a failed one gives the use back
* by rolling back.
*/ */
export const claimInvite = async (token: string): Promise<boolean> => { export const claimInvite = async (tx: Transaction, token: string): Promise<boolean> => {
const claimed = await db.update(invites) const claimed = await tx.update(invites)
.set({ uses: sql`${invites.uses} + 1` }) .set({ uses: sql`${invites.uses} + 1` })
.where(and(eq(invites.token, token), usable())) .where(and(eq(invites.token, token), usable()))
.returning({ id: invites.id }); .returning({ id: invites.id });
@@ -20,13 +23,6 @@ export const claimInvite = async (token: string): Promise<boolean> => {
return claimed.length > 0; return claimed.length > 0;
}; };
/** Gives a use back when the registration it was taken for did not go through. */
export const releaseInvite = async (token: string) => {
await db.update(invites)
.set({ uses: sql`${invites.uses} - 1` })
.where(and(eq(invites.token, token), gt(invites.uses, 0)));
};
export const findUsableInvite = async (token: string) => { export const findUsableInvite = async (token: string) => {
const rows = await db const rows = await db
.select({ .select({
+4 -1
View File
@@ -1,7 +1,8 @@
import { createHash, randomBytes } from 'node:crypto'; import { createHash, randomBytes } from 'node:crypto';
import { and, eq, gt, lte, sql } from 'drizzle-orm'; import { and, eq, gt, lte } from 'drizzle-orm';
import { accounts, recoveryLinks, users } from '~~/drizzle/schema'; import { accounts, recoveryLinks, users } from '~~/drizzle/schema';
import { db } from '~~/server/lib/db'; import { db } from '~~/server/lib/db';
import { lockAccount } from "~~/server/lib/account-security";
export const RECOVERY_LINK_LIFETIME_MS = 24 * 60 * 60 * 1000; export const RECOVERY_LINK_LIFETIME_MS = 24 * 60 * 60 * 1000;
// a new person may take a while to get round to it // a new person may take a while to get round to it
@@ -36,7 +37,9 @@ export const createRecoveryLink = async (userId: string, resetSecurity: boolean)
const expiresAt = new Date(Date.now() + lifetime); const expiresAt = new Date(Date.now() + lifetime);
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
await lockAccount(tx, userId);
await tx.delete(recoveryLinks).where(eq(recoveryLinks.userId, userId)); await tx.delete(recoveryLinks).where(eq(recoveryLinks.userId, userId));
// nothing else ever looks at a link that ran out, so they go here
await tx.delete(recoveryLinks).where(lte(recoveryLinks.expiresAt, new Date())); await tx.delete(recoveryLinks).where(lte(recoveryLinks.expiresAt, new Date()));
await tx.insert(recoveryLinks).values({ await tx.insert(recoveryLinks).values({
tokenHash: hashRecoveryToken(token), tokenHash: hashRecoveryToken(token),
+2 -2
View File
@@ -1,5 +1,5 @@
import { users } from '~~/drizzle/schema'; import { users } from '../../drizzle/schema';
import { db } from '~~/server/lib/db'; import { db } from '../lib/db';
/** /**
* An instance needs setting up for as long as it has no accounts. The first * An instance needs setting up for as long as it has no accounts. The first