diff --git a/app/components/Dialog/ChangePassword.vue b/app/components/Dialog/ChangePassword.vue index 88f1018..c020b18 100644 --- a/app/components/Dialog/ChangePassword.vue +++ b/app/components/Dialog/ChangePassword.vue @@ -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> => { +): Promise> => { 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'; diff --git a/app/components/Dialog/RecoverAccount.vue b/app/components/Dialog/RecoverAccount.vue index ccf12e2..694137a 100644 --- a/app/components/Dialog/RecoverAccount.vue +++ b/app/components/Dialog/RecoverAccount.vue @@ -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.

-
+
This deletes their saved API keys 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.
@@ -135,7 +137,7 @@ const revoke = async () => {

diff --git a/app/components/Dialog/TwoFactor.vue b/app/components/Dialog/TwoFactor.vue index c4aadd2..0acd66d 100644 --- a/app/components/Dialog/TwoFactor.vue +++ b/app/components/Dialog/TwoFactor.vue @@ -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 = ''; diff --git a/app/components/Onboarding/ProviderForm.vue b/app/components/Onboarding/ProviderForm.vue index 9a08a5d..1096944 100644 --- a/app/components/Onboarding/ProviderForm.vue +++ b/app/components/Onboarding/ProviderForm.vue @@ -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'); }; diff --git a/app/composables/useModels.ts b/app/composables/useModels.ts index 01a4f8e..e95ecb0 100644 --- a/app/composables/useModels.ts +++ b/app/composables/useModels.ts @@ -134,7 +134,7 @@ export const useModels = async () => { }); } - const updateProvider = async (id: string, updates: Partial) => { + const updateProvider = async (id: string, updates: Partial, 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, diff --git a/app/pages/auth/login.vue b/app/pages/auth/login.vue index bde3d3e..3dd1f3a 100644 --- a/app/pages/auth/login.vue +++ b/app/pages/auth/login.vue @@ -259,7 +259,7 @@ const submitTwoFactor = async () => {

-
diff --git a/app/pages/auth/recover/[token].vue b/app/pages/auth/recover/[token].vue index 3f6d9d9..5588281 100644 --- a/app/pages/auth/recover/[token].vue +++ b/app/pages/auth/recover/[token].vue @@ -96,7 +96,8 @@ const submit = async () => {