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