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
+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">