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 { 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';
+6 -4
View File
@@ -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)]">
+2 -5
View File
@@ -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 = '';
+11 -2
View File
@@ -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>
+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);
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,
+1 -1
View File
@@ -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>
+10 -2
View File
@@ -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)]"
+4 -1
View File
@@ -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>
+1 -1
View File
@@ -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>
+5 -2
View File
@@ -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>
+8 -10
View File
@@ -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>
+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) => {
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
+2 -4
View File
@@ -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">
+10 -7
View File
@@ -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">
+10 -7
View File
@@ -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 -9
View File
@@ -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>