feat: admin controls
This commit is contained in:
@@ -5,7 +5,7 @@ import { base64ToUint8Array, decrypt, deriveKey, encryptData, uint8ArrayToBase64
|
||||
const emit = defineEmits(['confirm', 'cancel']);
|
||||
|
||||
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)]';
|
||||
|
||||
@@ -45,18 +45,19 @@ const importKey = (jwk: JsonWebKey) =>
|
||||
// one. The server never sees the plaintext keys.
|
||||
const decryptProviderKeys = async (
|
||||
oldJwk: JsonWebKey,
|
||||
): Promise<Result<{ id: string; plaintext: string }[], string[]>> => {
|
||||
): Promise<Result<{ id: string; originalApiKey: string; plaintext: string }[], string[]>> => {
|
||||
const oldKey = await importKey(oldJwk);
|
||||
|
||||
const decrypted: { id: string; plaintext: string }[] = [];
|
||||
const decrypted: { id: string; originalApiKey: string; plaintext: string }[] = [];
|
||||
const failed: string[] = [];
|
||||
|
||||
for (const provider of providers.value) {
|
||||
if (!provider.config.apiKey) continue;
|
||||
|
||||
try {
|
||||
const plaintext = await decrypt(oldKey, base64ToUint8Array(provider.config.apiKey));
|
||||
decrypted.push({ id: provider.id, plaintext });
|
||||
const originalApiKey = provider.config.apiKey;
|
||||
const plaintext = await decrypt(oldKey, base64ToUint8Array(originalApiKey));
|
||||
decrypted.push({ id: provider.id, originalApiKey, plaintext });
|
||||
} catch {
|
||||
failed.push(provider.name);
|
||||
}
|
||||
@@ -101,9 +102,9 @@ const submit = async () => {
|
||||
steps[1]!.state = 'active';
|
||||
const newJwk = await deriveKey(form.next, userId);
|
||||
const newKey = await importKey(newJwk);
|
||||
const providerKeys: { id: string; apiKey: string }[] = [];
|
||||
for (const { id, plaintext } of decrypted.data) {
|
||||
providerKeys.push({ id, apiKey: uint8ArrayToBase64(await encryptData(newKey, plaintext)) });
|
||||
const providerKeys: { id: string; originalApiKey: string; apiKey: string }[] = [];
|
||||
for (const { id, originalApiKey, plaintext } of decrypted.data) {
|
||||
providerKeys.push({ id, originalApiKey, apiKey: uint8ArrayToBase64(await encryptData(newKey, plaintext)) });
|
||||
}
|
||||
steps[1]!.state = 'done';
|
||||
|
||||
@@ -142,10 +143,7 @@ const submit = async () => {
|
||||
// from here on this browser has to use the new key
|
||||
localStorage.setItem('encryptionKey', JSON.stringify(newJwk));
|
||||
|
||||
const newKeys = new Map(providerKeys.map(p => [p.id, p.apiKey]));
|
||||
providers.value = providers.value.map(p =>
|
||||
newKeys.has(p.id) ? { ...p, config: { ...p.config, apiKey: newKeys.get(p.id)! } } : p
|
||||
);
|
||||
await refresh();
|
||||
|
||||
// the server signs the other devices out as part of the same request
|
||||
steps[2]!.state = 'done';
|
||||
|
||||
@@ -13,7 +13,8 @@ const props = defineProps<{
|
||||
|
||||
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 busy = ref(false);
|
||||
@@ -96,14 +97,15 @@ const revoke = async () => {
|
||||
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">
|
||||
<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.
|
||||
not deleted. Using the link signs them out on all devices.
|
||||
</span>
|
||||
</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">
|
||||
<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>
|
||||
<span>Also reset two-factor sign-in</span>
|
||||
</label>
|
||||
|
||||
<p v-if="revoked" role="status" class="text-sm text-[var(--text-secondary)]">
|
||||
|
||||
@@ -88,7 +88,7 @@ const submitPassword = async () => {
|
||||
if (action.value === 'enable') {
|
||||
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.');
|
||||
} else {
|
||||
// nothing is turned on until a code from the app is verified
|
||||
@@ -136,12 +136,9 @@ const verifyCode = async () => {
|
||||
code.value = '';
|
||||
busy.value = false;
|
||||
|
||||
// the boxes are disabled while busy and can't take focus until now
|
||||
await nextTick();
|
||||
codeInputEl.value?.focus();
|
||||
} else {
|
||||
// set here rather than by whoever opened the dialog, so it is right
|
||||
// even if the dialog gets dismissed on the last step
|
||||
if (user.value) user.value = { ...user.value, twoFactorEnabled: true };
|
||||
step.value = 'codes';
|
||||
}
|
||||
@@ -155,7 +152,7 @@ const numberedBackupCodes = computed(() =>
|
||||
);
|
||||
|
||||
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);
|
||||
checkInput.value = '';
|
||||
error.value = '';
|
||||
|
||||
@@ -97,10 +97,13 @@ const submit = async () => {
|
||||
config.apiKey = uint8ArrayToBase64(await encryptData(cryptoKey, key));
|
||||
}
|
||||
|
||||
await $fetch(`/api/provider/${provider.id}`, {
|
||||
const saved = await $fetch(`/api/provider/${provider.id}`, {
|
||||
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) {
|
||||
console.error('Failed to save provider:', err);
|
||||
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);
|
||||
|
||||
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');
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -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);
|
||||
if (!provider) return;
|
||||
|
||||
@@ -142,10 +142,14 @@ export const useModels = async () => {
|
||||
|
||||
await $fetch(`/api/provider/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: updates,
|
||||
body: {
|
||||
...updates,
|
||||
expectedUpdatedAt: expectedUpdatedAt ?? new Date(original.updatedAt).toISOString(),
|
||||
},
|
||||
onRequest() {
|
||||
// a config update only carries the fields that changed
|
||||
providers.value = providers.value.map(p =>
|
||||
p.id === id ? { ...p, ...updates } : p
|
||||
p.id === id ? { ...p, ...updates, config: { ...p.config, ...updates.config } } : p
|
||||
);
|
||||
},
|
||||
onRequestError() {
|
||||
@@ -166,6 +170,7 @@ export const useModels = async () => {
|
||||
|
||||
return {
|
||||
providers,
|
||||
refresh,
|
||||
allModels,
|
||||
getModel,
|
||||
getProvider,
|
||||
|
||||
@@ -259,7 +259,7 @@ const submitTwoFactor = async () => {
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="email"
|
||||
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"
|
||||
class="focus:ring-2 focus:ring-[var(--color-accent)]/20 border border-[var(--color-border)] transition-all" />
|
||||
</div>
|
||||
|
||||
@@ -96,7 +96,8 @@ const submit = async () => {
|
||||
</script>
|
||||
|
||||
<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 -->
|
||||
<div
|
||||
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">
|
||||
</div>
|
||||
|
||||
</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>
|
||||
@@ -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>Deleted: API keys for <span class="case-capital">{{ recovery.keyProviders.join(', ') }}</span></span>
|
||||
</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">
|
||||
<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>
|
||||
<span>Reset: two-factor sign-in</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)]"
|
||||
|
||||
@@ -5,7 +5,8 @@ definePageMeta({
|
||||
</script>
|
||||
|
||||
<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 -->
|
||||
<div
|
||||
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">
|
||||
</div>
|
||||
|
||||
</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>
|
||||
|
||||
@@ -228,7 +228,7 @@ const submit = async () => {
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="email"
|
||||
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"
|
||||
class="focus:ring-2 focus:ring-[var(--color-accent)]/20 border border-[var(--color-border)] transition-all" />
|
||||
</div>
|
||||
|
||||
@@ -136,7 +136,8 @@ const submit = async () => {
|
||||
</script>
|
||||
|
||||
<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 -->
|
||||
<div
|
||||
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">
|
||||
</div>
|
||||
|
||||
</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>
|
||||
@@ -183,7 +186,7 @@ const submit = async () => {
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="email"
|
||||
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"
|
||||
class="focus:ring-2 focus:ring-[var(--color-accent)]/20 border border-[var(--color-border)] transition-all" />
|
||||
</div>
|
||||
|
||||
@@ -131,7 +131,7 @@ const setRole = (user: UserRow, role: 'admin' | 'user') => {
|
||||
|
||||
confirmThen({
|
||||
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',
|
||||
}, change);
|
||||
};
|
||||
@@ -141,8 +141,6 @@ const signOutEverywhere = (user: UserRow) => run(user, `${user.name} could not b
|
||||
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 });
|
||||
};
|
||||
@@ -268,9 +266,11 @@ const openCreateUser = () => {
|
||||
<span class="flex flex-col min-w-0">
|
||||
<span class="font-medium whitespace-nowrap overflow-hidden text-ellipsis">
|
||||
{{ 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 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 }}
|
||||
</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>
|
||||
|
||||
<!-- your own account is managed from Profile and Security -->
|
||||
<Dropdown v-else-if="u.id !== me?.id" placement="bottom-end" dropdownClass="w-60">
|
||||
<template #default="{ setRef, isOpen, toggle }">
|
||||
<button :ref="setRef" type="button" :aria-label="`Actions for ${u.name}`"
|
||||
@@ -376,10 +375,9 @@ const openCreateUser = () => {
|
||||
</div>
|
||||
|
||||
<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
|
||||
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.
|
||||
Invited accounts have no password until their owner opens the setup link.
|
||||
you manage your account from Profile and Security, you can't remove
|
||||
your own admin role.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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) => {
|
||||
if (!provider.value) return;
|
||||
// Config saves run one after the other, and each reads the revision only when
|
||||
// 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(
|
||||
"jwk",
|
||||
JSON.parse(window.localStorage.getItem("encryptionKey")!),
|
||||
"AES-GCM",
|
||||
false,
|
||||
["encrypt", "decrypt"]
|
||||
);
|
||||
const encypted = await encryptData(key, value);
|
||||
const saveConfig = (
|
||||
id: string,
|
||||
failure: string,
|
||||
config: () => Promise<{ apiKey?: string; apiProxyUrl?: string }>,
|
||||
expectedEncryptionKey?: string,
|
||||
) => {
|
||||
configSaves = configSaves.then(async () => {
|
||||
// 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) {
|
||||
clearTimeout(apiKeyTimeout);
|
||||
const updateApiKey = (value: string) => {
|
||||
const snapshot = provider.value;
|
||||
const storedKey = localStorage.getItem("encryptionKey");
|
||||
if (!snapshot || !storedKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
apiKeyTimeout = setTimeout(async () => {
|
||||
await updateProvider(provider.value!.id, {
|
||||
config: {
|
||||
...provider.value!.config,
|
||||
apiKey: uint8ArrayToBase64(encypted),
|
||||
},
|
||||
});
|
||||
clearTimeout(apiKeyTimeout);
|
||||
apiKeyTimeout = setTimeout(() => {
|
||||
saveConfig(snapshot.id, "The key could not be saved. Reload this page before trying again.", async () => {
|
||||
const key = await crypto.subtle.importKey("jwk", JSON.parse(storedKey), "AES-GCM", false, ["encrypt", "decrypt"]);
|
||||
const encrypted = await encryptData(key, value);
|
||||
return { apiKey: uint8ArrayToBase64(encrypted) };
|
||||
}, storedKey);
|
||||
}, 700);
|
||||
};
|
||||
|
||||
let proxyUrlTimeout: NodeJS.Timeout | undefined;
|
||||
|
||||
const updateProxyUrl = async (value: string) => {
|
||||
if (!provider.value) return;
|
||||
|
||||
const updateProxyUrl = (value: string) => {
|
||||
const snapshot = provider.value;
|
||||
if (!snapshot) {
|
||||
return;
|
||||
}
|
||||
apiProxyUrl.value = value;
|
||||
|
||||
if (proxyUrlTimeout) {
|
||||
clearTimeout(proxyUrlTimeout);
|
||||
}
|
||||
|
||||
proxyUrlTimeout = setTimeout(async () => {
|
||||
await updateProvider(provider.value!.id, {
|
||||
config: {
|
||||
...provider.value!.config,
|
||||
apiProxyUrl: value,
|
||||
},
|
||||
});
|
||||
clearTimeout(proxyUrlTimeout);
|
||||
proxyUrlTimeout = setTimeout(() => {
|
||||
saveConfig(snapshot.id, "The address could not be saved. Reload this page before trying again.", async () => ({ apiProxyUrl: value }));
|
||||
}, 700);
|
||||
};
|
||||
|
||||
onUnmounted(() => {
|
||||
clearTimeout(apiKeyTimeout);
|
||||
clearTimeout(proxyUrlTimeout);
|
||||
});
|
||||
|
||||
const fetchingModels = ref(false);
|
||||
|
||||
const fetchModels = async () => {
|
||||
@@ -407,6 +425,8 @@ const isFormValid = computed(() => {
|
||||
</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">
|
||||
<p class="text-[var(--text-secondary)]">
|
||||
<span class="i-mynaui-lock inline-block"></span> Your API key is encrypted using
|
||||
|
||||
@@ -27,8 +27,7 @@ const openProvider = (id: string) => navigateTo(`/settings/providers/${id}`);
|
||||
</h2>
|
||||
<div
|
||||
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)"
|
||||
:key="p.id"
|
||||
<button @click="openProvider(p.id)" v-for="p in providers?.filter(p => p.enabled)" :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)]">
|
||||
<div class="flex flex-col flex-grow">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
@@ -51,8 +50,7 @@ const openProvider = (id: string) => navigateTo(`/settings/providers/${id}`);
|
||||
</h2>
|
||||
<div
|
||||
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)"
|
||||
:key="p.id"
|
||||
<button @click="openProvider(p.id)" v-for="p in providers?.filter(p => !p.enabled)" :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)]">
|
||||
<div class="flex flex-col flex-grow">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
|
||||
@@ -83,13 +83,16 @@ const submit = async () => {
|
||||
</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 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 -->
|
||||
<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>
|
||||
|
||||
<div class="z-10 max-w-104 w-full flex flex-col gap-5">
|
||||
|
||||
@@ -15,13 +15,16 @@ const back = () => navigateTo('/setup/instance');
|
||||
</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 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 -->
|
||||
<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>
|
||||
|
||||
<div class="z-10 max-w-104 w-full flex flex-col gap-5">
|
||||
|
||||
@@ -9,13 +9,14 @@ const finish = () => navigateTo(to ?? '/');
|
||||
</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 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">
|
||||
<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>
|
||||
|
||||
<div class="z-10 max-w-104 w-full flex flex-col gap-5">
|
||||
@@ -25,8 +26,7 @@ const finish = () => navigateTo(to ?? '/');
|
||||
</div>
|
||||
|
||||
<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
|
||||
land on Home.
|
||||
Veridian runs on your own API keys. Add one now and it'll be ready for you're ready to get started.
|
||||
</p>
|
||||
|
||||
<OnboardingProviderForm submit-label="Fetch models and continue" @done="finish"></OnboardingProviderForm>
|
||||
|
||||
+2
-1
@@ -385,7 +385,8 @@ export const recoveryLinks = pgTable(
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.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),
|
||||
expiresAt: timestamptz("expires_at").notNull(),
|
||||
createdAt: timestamptz("created_at").defaultNow().notNull(),
|
||||
|
||||
+48
-29
@@ -1,5 +1,4 @@
|
||||
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 { betterAuth } from 'better-auth/minimal';
|
||||
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 * as schema from '../drizzle/schema';
|
||||
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 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);
|
||||
|
||||
// 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 = {
|
||||
admin: accessControl.newRole({
|
||||
user: ['list', 'set-role', 'ban', 'get', 'update'],
|
||||
session: ['list', 'revoke', 'delete'],
|
||||
user: ['set-role', 'ban'],
|
||||
session: ['revoke'],
|
||||
}),
|
||||
user: accessControl.newRole({
|
||||
user: [],
|
||||
@@ -33,7 +35,7 @@ const roles = {
|
||||
};
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: drizzleAdapter(db, {
|
||||
database: drizzleAdapter(authDatabase, {
|
||||
provider: "pg",
|
||||
schema,
|
||||
usePlural: true,
|
||||
@@ -44,7 +46,7 @@ export const auth = betterAuth({
|
||||
updateAge: 60 * 60 * 24 * 1, // 1 day
|
||||
cookieCache: {
|
||||
enabled: true,
|
||||
maxAge: 5 * 60,
|
||||
maxAge: 60,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -53,8 +55,21 @@ export const auth = betterAuth({
|
||||
|
||||
hooks: {
|
||||
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 (await isSetupNeeded()) {
|
||||
throw new APIError('FORBIDDEN', {
|
||||
message: 'This instance has not been set up yet',
|
||||
code: 'SETUP_NEEDED',
|
||||
});
|
||||
}
|
||||
|
||||
const mode = await getRegistrationMode();
|
||||
|
||||
if (mode === 'closed') {
|
||||
@@ -65,9 +80,12 @@ export const auth = betterAuth({
|
||||
}
|
||||
|
||||
if (mode === 'invite') {
|
||||
const tx = currentTransaction();
|
||||
if (!tx) throw transactionRequired();
|
||||
|
||||
const token = ctx.headers?.get(INVITE_TOKEN_HEADER);
|
||||
|
||||
if (!token || !await claimInvite(token)) {
|
||||
if (!token || !await claimInvite(tx, token)) {
|
||||
throw new APIError('FORBIDDEN', {
|
||||
message: 'This invite link is not valid anymore',
|
||||
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) {
|
||||
const password = ctx.body.password;
|
||||
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
|
||||
// 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;
|
||||
if (!ADMIN_MUTATIONS.has(ctx.path)) 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;
|
||||
|
||||
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) => {
|
||||
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);
|
||||
if (token && await getRegistrationMode() === 'invite') {
|
||||
await releaseInvite(token);
|
||||
const tx = currentTransaction();
|
||||
if (!tx) throw transactionRequired();
|
||||
|
||||
if (!await hasUsableAdministrator(tx)) {
|
||||
throw new APIError('CONFLICT', {
|
||||
message: 'The instance must keep a usable administrator',
|
||||
code: 'LAST_ADMIN',
|
||||
});
|
||||
}
|
||||
}),
|
||||
},
|
||||
|
||||
+1
-2
@@ -119,7 +119,7 @@ export default defineNuxtConfig({
|
||||
'@nuxt/hints/runtime/hydration/component',
|
||||
'@tanstack/vue-virtual',
|
||||
'better-auth/vue',
|
||||
'uqr',
|
||||
'uqr',
|
||||
'nanoid',
|
||||
'drizzle-orm/pg-core'
|
||||
],
|
||||
@@ -167,7 +167,6 @@ export default defineNuxtConfig({
|
||||
},
|
||||
|
||||
public: {
|
||||
disableSignup: process.env.DISABLE_SIGNUP === 'true' || process.env.DISABLE_SIGNUP === '1',
|
||||
publicUrl: process.env.NUXT_PUBLIC_URL ?? 'http://localhost:3000',
|
||||
}
|
||||
},
|
||||
|
||||
+42
-47
@@ -1,65 +1,60 @@
|
||||
/**
|
||||
* 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';
|
||||
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
||||
import { Pool } from "pg";
|
||||
|
||||
const LIFETIME_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
// Standalone so operators can recover an account without running the server.
|
||||
const main = async (): Promise<number> => {
|
||||
const email = process.argv[2]?.trim().toLowerCase();
|
||||
|
||||
if (!email) {
|
||||
console.error('Usage: bun run recover <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',
|
||||
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 user = found.rows[0] as { id: string; name: string } | undefined;
|
||||
const token = randomBytes(32).toString("base64url");
|
||||
const tokenHash = createHash("sha256").update(token).digest("hex");
|
||||
const expiresAt = new Date(Date.now() + LIFETIME_MS);
|
||||
|
||||
if (!user) {
|
||||
console.error(`No account with the email ${email}.`);
|
||||
await pool.end();
|
||||
await client.query("DELETE FROM recovery_links WHERE user_id = $1", [user.id]);
|
||||
await client.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 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;
|
||||
} 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());
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { and, eq, ne } from 'drizzle-orm';
|
||||
import * as z from 'zod';
|
||||
import { users } from '~~/drizzle/schema';
|
||||
import { auth } from '~~/lib/auth';
|
||||
import { hasUsableAdministrator } from '~~/server/lib/account-security';
|
||||
import { db } from '~~/server/lib/db';
|
||||
|
||||
/**
|
||||
@@ -43,23 +42,16 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const twoFactorEnabled = !!account?.twoFactorEnabled;
|
||||
|
||||
// an instance always keeps one admin, someone has to be able to manage it
|
||||
if (account?.role === 'admin') {
|
||||
const otherAdmins = await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(and(eq(users.role, 'admin'), ne(users.id, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (otherAdmins.length === 0) {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
statusMessage: 'The last admin cannot be deleted',
|
||||
data: {
|
||||
code: 'LAST_ADMIN',
|
||||
},
|
||||
});
|
||||
}
|
||||
// Checked again when the account is deleted. Doing it here as well keeps
|
||||
// a deletion that is refused anyway from using up a backup code.
|
||||
if (account?.role === 'admin' && !await db.transaction(tx => hasUsableAdministrator(tx, userId))) {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
statusMessage: 'The last usable admin cannot be deleted',
|
||||
data: {
|
||||
code: 'LAST_ADMIN',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
// copy for a few minutes unless the cookies go too
|
||||
@@ -121,7 +120,6 @@ export default defineEventHandler(async (event) => {
|
||||
deleteCookie(event, cookie.name, {
|
||||
path: cookie.attributes.path,
|
||||
secure: cookie.attributes.secure,
|
||||
sameSite: cookie.attributes.sameSite,
|
||||
httpOnly: cookie.attributes.httpOnly,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,116 +1,82 @@
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import * as z from 'zod';
|
||||
import { providers } from '~~/drizzle/schema';
|
||||
import { auth } from '~~/lib/auth';
|
||||
import { db } from '~~/server/lib/db';
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import * as z from "zod";
|
||||
import { providers, recoveryLinks } from "~~/drizzle/schema";
|
||||
import { lockAccount, revokeTwoFactorTrust } from "~~/server/lib/account-security";
|
||||
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) => {
|
||||
await protectRoute(event);
|
||||
|
||||
const userId = event.context.user!.id as string;
|
||||
|
||||
const result = await readValidatedBody(event, (body) =>
|
||||
z
|
||||
.object({
|
||||
currentPassword: z.string().min(1),
|
||||
newPassword: z.string().min(8).max(128),
|
||||
providerKeys: z.array(z.object({
|
||||
id: z.string(),
|
||||
apiKey: z.string().min(1),
|
||||
})),
|
||||
})
|
||||
.safeParse(body),
|
||||
);
|
||||
const { user } = await protectRoute(event);
|
||||
const result = await readValidatedBody(event, (body) => z.object({
|
||||
currentPassword: z.string().min(1).max(128),
|
||||
newPassword: z.string().min(8).max(128),
|
||||
providerKeys: z.array(z.object({
|
||||
id: z.string(),
|
||||
originalApiKey: z.string().min(1),
|
||||
apiKey: z.string().min(1),
|
||||
})),
|
||||
}).safeParse(body));
|
||||
|
||||
if (!result.success) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: result.error.issues[0]!.message,
|
||||
});
|
||||
throw createError({ statusCode: 400, message: result.error.issues[0]?.message });
|
||||
}
|
||||
|
||||
const { currentPassword, newPassword, providerKeys } = result.data;
|
||||
|
||||
const existing = await db.query.providers.findMany({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
const newKeys = new Map(providerKeys.map(p => [p.id, p.apiKey]));
|
||||
|
||||
// Every saved key has to come back re-encrypted, otherwise it would be
|
||||
// stuck encrypted with the old password.
|
||||
const withKey = existing.filter(p => !!p.config.apiKey);
|
||||
const complete = withKey.every(p => newKeys.has(p.id)) && newKeys.size === withKey.length;
|
||||
|
||||
if (!complete) {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
statusMessage: 'Provider keys out of date',
|
||||
data: {
|
||||
code: 'PROVIDER_KEYS_OUT_OF_DATE',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let cookies: string[] = [];
|
||||
const replacements = new Map(providerKeys.map(key => [key.id, key]));
|
||||
let cookies: string[];
|
||||
|
||||
try {
|
||||
// The password change runs inside the transaction so a rejected
|
||||
// password rolls the key updates back.
|
||||
await db.transaction(async (tx) => {
|
||||
for (const provider of withKey) {
|
||||
await tx.update(providers)
|
||||
.set({
|
||||
config: {
|
||||
...provider.config,
|
||||
apiKey: newKeys.get(provider.id)!,
|
||||
},
|
||||
})
|
||||
.where(and(eq(providers.id, provider.id), eq(providers.userId, userId)));
|
||||
cookies = await authTransaction(async (tx) => {
|
||||
await lockAccount(tx, user.id);
|
||||
const existing = await tx.select().from(providers).where(eq(providers.userId, user.id));
|
||||
const withKey = existing.filter(provider => !!provider.config.apiKey);
|
||||
const complete = replacements.size === providerKeys.length
|
||||
&& replacements.size === withKey.length
|
||||
&& withKey.every(provider => replacements.get(provider.id)?.originalApiKey === provider.config.apiKey);
|
||||
|
||||
if (!complete) {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
statusMessage: "Provider keys out of date",
|
||||
data: { code: "PROVIDER_KEYS_OUT_OF_DATE" },
|
||||
});
|
||||
}
|
||||
|
||||
const { headers } = await auth.api.changePassword({
|
||||
body: {
|
||||
currentPassword,
|
||||
newPassword,
|
||||
// other devices still hold an encryption key derived from the
|
||||
// old password, so they have to sign in again
|
||||
revokeOtherSessions: true,
|
||||
},
|
||||
body: { currentPassword, newPassword, revokeOtherSessions: true },
|
||||
headers: event.headers,
|
||||
returnHeaders: true,
|
||||
});
|
||||
|
||||
cookies = headers.getSetCookie();
|
||||
});
|
||||
} catch (error: any) {
|
||||
const code = error?.body?.code as string | undefined;
|
||||
await tx.delete(recoveryLinks).where(eq(recoveryLinks.userId, user.id));
|
||||
await revokeTwoFactorTrust(tx, user.id);
|
||||
|
||||
if (code === undefined) {
|
||||
console.error('Failed to change password:', error);
|
||||
// Invalidate pending config writes even for providers without a key.
|
||||
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 createError({
|
||||
statusCode: code === undefined ? 500 : 400,
|
||||
statusMessage: 'Failed to change password',
|
||||
message: error?.body?.message ?? 'Failed to change password',
|
||||
data: {
|
||||
code: code ?? 'INTERNAL_ERROR',
|
||||
},
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
// No replacement session reaches the browser until its transaction commits.
|
||||
for (const cookie of cookies) {
|
||||
appendResponseHeader(event, 'set-cookie', cookie);
|
||||
appendResponseHeader(event, "set-cookie", cookie);
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { sessions } from '~~/drizzle/schema';
|
||||
import { auth } from '~~/lib/auth';
|
||||
import { db } from '~~/server/lib/db';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const sessionData = await auth.api.getSession({ headers: event.headers });
|
||||
|
||||
if (sessionData === null) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'Unauthorized',
|
||||
});
|
||||
}
|
||||
const sessionData = await protectRoute(event);
|
||||
|
||||
const id = getRouterParam(event, 'id')!;
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { auth } from '~~/lib/auth';
|
||||
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.
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const sessionData = await auth.api.getSession({ headers: event.headers });
|
||||
|
||||
if (sessionData === null) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'Unauthorized',
|
||||
});
|
||||
}
|
||||
const sessionData = await protectRoute(event);
|
||||
|
||||
const result = await db.query.sessions.findMany({
|
||||
where: {
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ export default defineEventHandler(async (event) => {
|
||||
const result = await readValidatedBody(event, (body) =>
|
||||
z
|
||||
.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(),
|
||||
})
|
||||
.safeParse(body),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { twoFactors, users } from '~~/drizzle/schema';
|
||||
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
|
||||
@@ -26,6 +27,8 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await lockAccount(tx, id);
|
||||
await revokeTwoFactorTrust(tx, id);
|
||||
await tx.delete(twoFactors).where(eq(twoFactors.userId, id));
|
||||
await tx.update(users).set({ twoFactorEnabled: false }).where(eq(users.id, id));
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
return auth.handler(toWebRequest(event));
|
||||
const SIGN_UP = "/api/auth/sign-up/email";
|
||||
|
||||
// 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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -22,8 +22,11 @@ export default defineEventHandler(async (event) => {
|
||||
// Path 2: Session auth (for client-side access)
|
||||
if (!authorized) {
|
||||
try {
|
||||
const sessionData = await auth.api.getSession({ headers: event.headers });
|
||||
if (sessionData) {
|
||||
const sessionData = await auth.api.getSession({
|
||||
headers: event.headers,
|
||||
query: { disableCookieCache: true },
|
||||
});
|
||||
if (sessionData && !sessionData.user.banned) {
|
||||
event.context.user = sessionData.user;
|
||||
authorized = true;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
* from. Public on purpose, the person opening the link has no account yet.
|
||||
@@ -16,7 +32,7 @@ export default defineEventHandler(async (event) => {
|
||||
inviter: {
|
||||
id: invite.inviterId,
|
||||
name: invite.inviterName,
|
||||
image: invite.inviterImage,
|
||||
image: publicImage(invite.inviterImage),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,68 +1,63 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { providers } from "~~/drizzle/schema";
|
||||
import { and, eq, gt } from "drizzle-orm";
|
||||
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 * as z from 'zod';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await protectRoute(event);
|
||||
|
||||
const userId = event.context.user!.id;
|
||||
|
||||
const result = await readValidatedBody(event, (body) =>
|
||||
z
|
||||
.object({
|
||||
config: z.object({
|
||||
apiKey: z.string().optional(),
|
||||
apiProxyUrl: z.string().optional(),
|
||||
}).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
})
|
||||
.safeParse(body),
|
||||
);
|
||||
const { user, session } = await protectRoute(event);
|
||||
const result = await readValidatedBody(event, (body) => z.object({
|
||||
config: z.object({
|
||||
apiKey: z.string().optional(),
|
||||
apiProxyUrl: z.string().optional(),
|
||||
}).optional(),
|
||||
expectedUpdatedAt: z.iso.datetime().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
}).refine(body => body.config === undefined || body.expectedUpdatedAt !== undefined, {
|
||||
message: "Provider config updates require a revision",
|
||||
}).safeParse(body));
|
||||
|
||||
if (!result.success) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: result.error.issues[0]!.message,
|
||||
});
|
||||
throw createError({ statusCode: 400, message: result.error.issues[0]?.message });
|
||||
}
|
||||
|
||||
const providerId = getRouterParam(event, 'providerId')!;
|
||||
|
||||
const { config, enabled } = result.data;
|
||||
|
||||
const existing = await db.query.providers.findFirst({
|
||||
where: {
|
||||
id: providerId,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Invalid provider',
|
||||
});
|
||||
const providerId = getRouterParam(event, "providerId");
|
||||
if (!providerId) {
|
||||
throw createError({ statusCode: 400, statusMessage: "Invalid provider" });
|
||||
}
|
||||
const { config, enabled, expectedUpdatedAt } = result.data;
|
||||
|
||||
const res = await db.update(providers)
|
||||
.set({
|
||||
config,
|
||||
return await db.transaction(async (tx) => {
|
||||
await lockAccount(tx, user.id);
|
||||
// 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,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(providers.id, providerId),
|
||||
eq(providers.userId, userId),
|
||||
)
|
||||
);
|
||||
updatedAt,
|
||||
}).where(and(eq(providers.id, providerId), eq(providers.userId, user.id)));
|
||||
|
||||
if (res.rowCount === 0) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Invalid provider',
|
||||
});
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
return { ok: true, updatedAt };
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,9 @@
|
||||
import { and, eq, gt, sql } from 'drizzle-orm';
|
||||
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 { db } from '~~/server/lib/db';
|
||||
|
||||
/**
|
||||
* 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 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 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
|
||||
// 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 (!link) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// if either of these fails the link comes back with the rest of the
|
||||
// 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({
|
||||
providerId: 'credential',
|
||||
accountId: link.userId,
|
||||
@@ -63,32 +85,37 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
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
|
||||
// until the key is entered again. The provider and its models stay.
|
||||
await tx.update(providers)
|
||||
.set({
|
||||
config: sql`${providers.config} - 'apiKey'`,
|
||||
enabled: false,
|
||||
updatedAt: sql`${providers.updatedAt}`,
|
||||
})
|
||||
.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) {
|
||||
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';
|
||||
});
|
||||
|
||||
// used or expired between the check above and here
|
||||
if (recovered === null) {
|
||||
throw createError({
|
||||
statusCode: 410,
|
||||
statusMessage: 'This recovery link is not valid anymore',
|
||||
data: {
|
||||
code: 'RECOVERY_LINK_INVALID',
|
||||
},
|
||||
});
|
||||
throw invalidLink();
|
||||
}
|
||||
|
||||
return { newAccount: recovered === 'created' };
|
||||
|
||||
@@ -2,7 +2,7 @@ import { sql } from 'drizzle-orm';
|
||||
import * as z from 'zod';
|
||||
import { INSTANCE_SETTINGS_ID, instanceSettings, users } from '~~/drizzle/schema';
|
||||
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
|
||||
const SETUP_LOCK_ID = 7_261_008;
|
||||
@@ -40,7 +40,7 @@ export default defineEventHandler(async (event) => {
|
||||
let alreadyDone = false;
|
||||
|
||||
try {
|
||||
await db.transaction(async (tx) => {
|
||||
await authTransaction(async (tx) => {
|
||||
// two people submitting the form at the same moment wait here, and
|
||||
// the second one then finds the first one's account
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(${SETUP_LOCK_ID})`);
|
||||
|
||||
@@ -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-%"),
|
||||
),
|
||||
));
|
||||
};
|
||||
@@ -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
@@ -10,6 +10,8 @@ import {
|
||||
users,
|
||||
} from '~~/drizzle/schema';
|
||||
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.
|
||||
@@ -21,7 +23,15 @@ import { db } from '~~/server/lib/db';
|
||||
* Objects in file storage are left alone on purpose.
|
||||
*/
|
||||
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
|
||||
await tx.delete(topics).where(eq(topics.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
|
||||
await tx.delete(users).where(eq(users.id, userId));
|
||||
return Ok(undefined);
|
||||
});
|
||||
};
|
||||
|
||||
+6
-12
@@ -1,26 +1,20 @@
|
||||
import type { H3Event } from 'h3';
|
||||
import { db } from '~~/server/lib/db';
|
||||
|
||||
/**
|
||||
* Like protectRoute(), but only lets admins through.
|
||||
*
|
||||
* The role is read from the database: the session cookie keeps a copy of the
|
||||
* user for a few minutes, and someone who just stopped being an admin should
|
||||
* not keep the access for that long.
|
||||
* protectRoute() reads the user from the database on every request, so someone
|
||||
* who just stopped being an admin loses the access right away.
|
||||
*/
|
||||
export const protectAdminRoute = async (event: H3Event) => {
|
||||
await protectRoute(event);
|
||||
const sessionData = await protectRoute(event);
|
||||
|
||||
const account = await db.query.users.findFirst({
|
||||
where: {
|
||||
id: event.context.user!.id as string,
|
||||
},
|
||||
});
|
||||
|
||||
if (account?.role !== 'admin' || account.banned) {
|
||||
if (sessionData.user.role !== 'admin') {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: 'Forbidden',
|
||||
});
|
||||
}
|
||||
|
||||
return sessionData;
|
||||
};
|
||||
|
||||
@@ -2,9 +2,12 @@ import type { H3Event } from 'h3';
|
||||
import { auth } from '~~/lib/auth';
|
||||
|
||||
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({
|
||||
statusCode: 401,
|
||||
statusMessage: 'Unauthorized',
|
||||
@@ -12,4 +15,5 @@ export const protectRoute = async (event: H3Event) => {
|
||||
}
|
||||
|
||||
event.context.user = sessionData.user;
|
||||
return sessionData;
|
||||
};
|
||||
|
||||
+6
-10
@@ -1,5 +1,6 @@
|
||||
import { and, eq, gt, lt, lte, or, sql } from 'drizzle-orm';
|
||||
import { invites, users } from '../../drizzle/schema';
|
||||
import type { Transaction } from '../lib/auth-transaction';
|
||||
import { db } from '../lib/db';
|
||||
|
||||
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
|
||||
* 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> => {
|
||||
const claimed = await db.update(invites)
|
||||
export const claimInvite = async (tx: Transaction, token: string): Promise<boolean> => {
|
||||
const claimed = await tx.update(invites)
|
||||
.set({ uses: sql`${invites.uses} + 1` })
|
||||
.where(and(eq(invites.token, token), usable()))
|
||||
.returning({ id: invites.id });
|
||||
@@ -20,13 +23,6 @@ export const claimInvite = async (token: string): Promise<boolean> => {
|
||||
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) => {
|
||||
const rows = await db
|
||||
.select({
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
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 { db } from '~~/server/lib/db';
|
||||
import { lockAccount } from "~~/server/lib/account-security";
|
||||
|
||||
export const RECOVERY_LINK_LIFETIME_MS = 24 * 60 * 60 * 1000;
|
||||
// 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);
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await lockAccount(tx, 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.insert(recoveryLinks).values({
|
||||
tokenHash: hashRecoveryToken(token),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { users } from '~~/drizzle/schema';
|
||||
import { db } from '~~/server/lib/db';
|
||||
import { users } from '../../drizzle/schema';
|
||||
import { db } from '../lib/db';
|
||||
|
||||
/**
|
||||
* An instance needs setting up for as long as it has no accounts. The first
|
||||
|
||||
Reference in New Issue
Block a user