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>
|
||||
|
||||
Reference in New Issue
Block a user