Compare commits
8 Commits
2b9f8d64d7
...
ff7797d3f8
| Author | SHA1 | Date | |
|---|---|---|---|
| ff7797d3f8 | |||
| b6f4d85ed7 | |||
| 61f9bb74b4 | |||
| fae05ebc4c | |||
| f6e1bc1f47 | |||
| 7b0800882a | |||
| 4bba529ba8 | |||
| ff33e9c573 |
@@ -26,6 +26,9 @@ COPY --from=build /app/drizzle/migrations /app/drizzle/migrations
|
||||
# Copy entrypoint script
|
||||
COPY --from=build /app/scripts/start.ts /app/scripts/start.ts
|
||||
|
||||
# Copy the account recovery script (bun run recover <email>)
|
||||
COPY --from=build /app/scripts/recover.ts /app/scripts/recover.ts
|
||||
|
||||
# Copy package.json and lockfile for production dependency install
|
||||
COPY --from=build /app/package.json /app/package.json
|
||||
COPY --from=build /app/bun.lock* ./
|
||||
|
||||
@@ -178,7 +178,12 @@ watch(() => props.agent?.defaultModelId, (newModelId) => {
|
||||
}
|
||||
})
|
||||
|
||||
// sending needs a model, and there are none until a provider is set up
|
||||
const noModels = computed(() => allModels.value.length === 0);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (noModels.value) return;
|
||||
|
||||
if (isUploading.value || hasFailedUploads.value) {
|
||||
return;
|
||||
}
|
||||
@@ -454,8 +459,8 @@ onUnmounted(() => {
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<FileSelector ref="fileSelectorRef" :selected-model="selectedModel" v-model="files"></FileSelector>
|
||||
<div v-if="allowManualRole" class="flex items-center">
|
||||
<button aria-label="Send message" @click="handleSubmit"
|
||||
:disabled="((!inputValue.content.trim() && files.length === 0) && !loading) || isUploading || hasFailedUploads"
|
||||
<button :aria-label="noModels ? 'Send (add a provider first)' : 'Send message'" @click="handleSubmit"
|
||||
:disabled="((!inputValue.content.trim() && files.length === 0) && !loading) || isUploading || hasFailedUploads || noModels"
|
||||
:class="[
|
||||
'h-8 w-8 rounded-l-lg rounded-r-none transition-all duration-200 flex items-center justify-center disabled:cursor-not-allowed disabled:bg-transparent',
|
||||
(inputValue.content.trim() || files.length > 0) && !loading && !isUploading && !hasFailedUploads
|
||||
@@ -496,8 +501,8 @@ onUnmounted(() => {
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
<button v-else aria-label="Send message" @click="handleSubmit"
|
||||
:disabled="((!inputValue.content.trim() && files.length === 0) && !loading) || isUploading || hasFailedUploads"
|
||||
<button v-else :aria-label="noModels ? 'Send (add a provider first)' : 'Send message'" @click="handleSubmit"
|
||||
:disabled="((!inputValue.content.trim() && files.length === 0) && !loading) || isUploading || hasFailedUploads || noModels"
|
||||
:class="[
|
||||
'h-8 w-8 rounded-xl transition-all duration-200 flex items-center justify-center disabled:cursor-not-allowed disabled:bg-transparent',
|
||||
(inputValue.content.trim() || files.length > 0) && !loading && !isUploading && !hasFailedUploads
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
<script setup lang="ts">
|
||||
const LENGTH = 6;
|
||||
|
||||
// the code typed so far, always a run of digits with no gaps
|
||||
const code = defineModel<string>({ default: '' });
|
||||
|
||||
const props = defineProps<{
|
||||
large?: boolean;
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
complete: [code: string];
|
||||
}>();
|
||||
|
||||
const inputs = ref<HTMLInputElement[]>([]);
|
||||
|
||||
const digits = computed(() => Array.from({ length: LENGTH }, (_, i) => code.value[i] ?? ''));
|
||||
|
||||
const focusAt = (index: number) => {
|
||||
inputs.value[Math.max(0, Math.min(LENGTH - 1, index))]?.focus();
|
||||
};
|
||||
|
||||
const setCode = (value: string) => {
|
||||
const next = value.slice(0, LENGTH);
|
||||
code.value = next;
|
||||
|
||||
// code.value still reads the old value until the parent re-renders, so
|
||||
// this has to check what was just written
|
||||
if (next.length === LENGTH) {
|
||||
emit('complete', next);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInput = (index: number, event: Event) => {
|
||||
const el = event.target as HTMLInputElement;
|
||||
// more than one digit arrives at once when the browser autofills the code
|
||||
const typed = el.value.replace(/\D/g, '');
|
||||
|
||||
if (typed !== '') {
|
||||
const next = code.value.slice(0, index) + typed + code.value.slice(index + typed.length);
|
||||
setCode(next);
|
||||
}
|
||||
|
||||
// the box only ever shows its own digit, whatever was typed into it
|
||||
nextTick(() => {
|
||||
el.value = digits.value[index]!;
|
||||
if (typed !== '') focusAt(index + typed.length);
|
||||
});
|
||||
};
|
||||
|
||||
const handleKeydown = (index: number, event: KeyboardEvent) => {
|
||||
if (event.key === 'Backspace') {
|
||||
event.preventDefault();
|
||||
|
||||
if (digits.value[index] !== '') {
|
||||
code.value = code.value.slice(0, index) + code.value.slice(index + 1);
|
||||
} else if (index > 0) {
|
||||
code.value = code.value.slice(0, index - 1) + code.value.slice(index);
|
||||
focusAt(index - 1);
|
||||
}
|
||||
} else if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault();
|
||||
focusAt(index - 1);
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
event.preventDefault();
|
||||
focusAt(index + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaste = (event: ClipboardEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
const pasted = (event.clipboardData?.getData('text') ?? '').replace(/\D/g, '');
|
||||
if (pasted === '') return;
|
||||
|
||||
setCode(pasted);
|
||||
focusAt(pasted.length);
|
||||
};
|
||||
|
||||
const handleFocus = (index: number, event: FocusEvent) => {
|
||||
// boxes fill left to right, so never leave the cursor past the first empty one
|
||||
if (index > code.value.length) {
|
||||
focusAt(code.value.length);
|
||||
return;
|
||||
}
|
||||
|
||||
(event.target as HTMLInputElement).select();
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
focus: () => focusAt(code.value.length),
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex gap-2">
|
||||
<input v-for="(digit, index) in digits" :key="index" :ref="(el) => { inputs[index] = el as HTMLInputElement; }"
|
||||
type="text" inputmode="numeric" pattern="[0-9]*" :aria-label="`Digit ${index + 1}`"
|
||||
:autocomplete="index === 0 ? 'one-time-code' : 'off'" :value="digit" :disabled="props.disabled" :class="[
|
||||
'flex-1 min-w-0 w-full p-0 text-center font-mono rounded-lg bg-[var(--bg-container)] text-[var(--text-primary)] border border-[var(--color-border)] outline-none transition-[border-color,box-shadow] duration-150 focus:border-[var(--color-accent)] focus:ring-3 focus:ring-[var(--color-accent)]/20 disabled:opacity-50',
|
||||
props.large ? 'h-14 text-2xl' : 'h-13 text-[22px]',
|
||||
]" @input="handleInput(index, $event)" @keydown="handleKeydown(index, $event)" @paste="handlePaste"
|
||||
@focus="handleFocus(index, $event)" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,243 @@
|
||||
<script setup lang="ts">
|
||||
import { Err, Ok, type Result } from '~~/types/result';
|
||||
import { base64ToUint8Array, decrypt, deriveKey, encryptData, uint8ArrayToBase64 } from '~/utils/crypto';
|
||||
|
||||
const emit = defineEmits(['confirm', 'cancel']);
|
||||
|
||||
const { user } = useAuth();
|
||||
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)]';
|
||||
|
||||
type StepState = 'pending' | 'active' | 'done' | 'failed';
|
||||
|
||||
const form = reactive({
|
||||
current: '',
|
||||
next: '',
|
||||
confirm: '',
|
||||
});
|
||||
const busy = ref(false);
|
||||
const done = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
const savedKeyCount = computed(() => providers.value.filter(p => !!p.config.apiKey).length);
|
||||
|
||||
const steps = reactive<{ label: string; hint?: string; state: StepState }[]>([
|
||||
{ label: 'Unlock your saved API keys in this browser', state: 'pending' },
|
||||
{ label: 'Re-encrypt them with the new password', state: 'pending' },
|
||||
{ label: 'Save the new password and keys together', state: 'pending' },
|
||||
{ label: 'Sign out your other devices', state: 'pending' },
|
||||
]);
|
||||
|
||||
const started = computed(() => steps.some(s => s.state !== 'pending'));
|
||||
|
||||
const fail = (step: number, message: string) => {
|
||||
steps[step]!.state = 'failed';
|
||||
error.value = message;
|
||||
busy.value = false;
|
||||
};
|
||||
|
||||
const importKey = (jwk: JsonWebKey) =>
|
||||
crypto.subtle.importKey('jwk', jwk, 'AES-GCM', false, ['encrypt', 'decrypt']);
|
||||
|
||||
// API keys are stored encrypted with a key derived from the password, so they
|
||||
// have to be decrypted with the old password and encrypted again with the new
|
||||
// one. The server never sees the plaintext keys.
|
||||
const decryptProviderKeys = async (
|
||||
oldJwk: JsonWebKey,
|
||||
): Promise<Result<{ id: string; originalApiKey: string; plaintext: string }[], string[]>> => {
|
||||
const oldKey = await importKey(oldJwk);
|
||||
|
||||
const decrypted: { id: string; originalApiKey: string; plaintext: string }[] = [];
|
||||
const failed: string[] = [];
|
||||
|
||||
for (const provider of providers.value) {
|
||||
if (!provider.config.apiKey) continue;
|
||||
|
||||
try {
|
||||
const originalApiKey = provider.config.apiKey;
|
||||
const plaintext = await decrypt(oldKey, base64ToUint8Array(originalApiKey));
|
||||
decrypted.push({ id: provider.id, originalApiKey, plaintext });
|
||||
} catch {
|
||||
failed.push(provider.name);
|
||||
}
|
||||
}
|
||||
|
||||
if (failed.length > 0) {
|
||||
return Err(failed);
|
||||
}
|
||||
|
||||
return Ok(decrypted);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (busy.value || done.value || !user.value) return;
|
||||
|
||||
error.value = '';
|
||||
for (const step of steps) step.state = 'pending';
|
||||
|
||||
if (form.next !== form.confirm) {
|
||||
error.value = 'The new passwords do not match.';
|
||||
return;
|
||||
}
|
||||
|
||||
if (form.next === form.current) {
|
||||
error.value = 'The new password is the same as the current one.';
|
||||
return;
|
||||
}
|
||||
|
||||
busy.value = true;
|
||||
|
||||
const userId = user.value.id;
|
||||
|
||||
steps[0]!.state = 'active';
|
||||
const oldJwk = await deriveKey(form.current, userId);
|
||||
const decrypted = await decryptProviderKeys(oldJwk);
|
||||
if (decrypted.ok === false) {
|
||||
fail(0, `Your saved API keys could not be decrypted with that password. Check your current password. If it is right, re-enter the key for ${decrypted.error.join(', ')} under AI Providers first.`);
|
||||
return;
|
||||
}
|
||||
steps[0]!.state = 'done';
|
||||
|
||||
steps[1]!.state = 'active';
|
||||
const newJwk = await deriveKey(form.next, userId);
|
||||
const newKey = await importKey(newJwk);
|
||||
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';
|
||||
|
||||
steps[2]!.state = 'active';
|
||||
try {
|
||||
await $fetch('/api/account/password', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
currentPassword: form.current,
|
||||
newPassword: form.next,
|
||||
providerKeys,
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
const code = err?.data?.data?.code as string | undefined;
|
||||
|
||||
switch (code) {
|
||||
case 'INVALID_PASSWORD':
|
||||
fail(2, 'Your current password is not right. Nothing was changed.');
|
||||
break;
|
||||
case 'PASSWORD_TOO_SHORT':
|
||||
case 'PASSWORD_TOO_LONG':
|
||||
fail(2, 'The new password has to be between 8 and 128 characters.');
|
||||
break;
|
||||
case 'PROVIDER_KEYS_OUT_OF_DATE':
|
||||
fail(2, 'Your providers changed in another tab. Reload the page and try again.');
|
||||
break;
|
||||
default:
|
||||
fail(2, 'Your password could not be changed. Nothing was saved, try again.');
|
||||
break;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// from here on this browser has to use the new key
|
||||
localStorage.setItem('encryptionKey', JSON.stringify(newJwk));
|
||||
|
||||
await refresh();
|
||||
|
||||
// the server signs the other devices out as part of the same request
|
||||
steps[2]!.state = 'done';
|
||||
steps[3]!.state = 'done';
|
||||
|
||||
form.current = '';
|
||||
form.next = '';
|
||||
form.confirm = '';
|
||||
busy.value = false;
|
||||
done.value = true;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="flex flex-col gap-5 p-6 w-screen md:w-130 max-w-full h-full md:h-auto overflow-y-auto"
|
||||
aria-labelledby="change-password-title" @submit.prevent="submit">
|
||||
<header class="flex flex-col gap-1">
|
||||
<h2 id="change-password-title" class="text-lg font-semibold">Change password</h2>
|
||||
<p class="text-sm text-[var(--text-secondary)]">
|
||||
<template v-if="savedKeyCount > 0">
|
||||
Your {{ savedKeyCount }} saved API {{ savedKeyCount === 1 ? 'key is' : 'keys are' }}
|
||||
re-encrypted with the new password and kept. You will automatically be logged out of
|
||||
all other devices.
|
||||
</template>
|
||||
<template v-else>
|
||||
Your password also encrypts the API keys you save in this browser.
|
||||
</template>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- hidden while it runs, and brought back with what was typed if a step fails -->
|
||||
<div v-if="!busy && !done" class="flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-sm" for="current-password">Current password</label>
|
||||
<input id="current-password" v-model="form.current" type="password" required :disabled="busy"
|
||||
autocomplete="current-password" :class="INPUT_CLASS" />
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<div class="flex flex-col gap-1 flex-1 min-w-40">
|
||||
<label class="text-sm" for="new-password">New password</label>
|
||||
<input id="new-password" v-model="form.next" type="password" required minlength="8" maxlength="128"
|
||||
:disabled="busy" autocomplete="new-password" :class="INPUT_CLASS" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 flex-1 min-w-40">
|
||||
<label class="text-sm" for="confirm-password">Confirm</label>
|
||||
<input id="confirm-password" v-model="form.confirm" type="password" required minlength="8"
|
||||
maxlength="128" :disabled="busy" autocomplete="new-password" :class="INPUT_CLASS" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ol v-if="started" aria-label="Progress" class="flex flex-col gap-2.5 text-sm">
|
||||
<li v-for="step in steps" :key="step.label" class="flex items-start gap-2.5"
|
||||
:class="step.state === 'pending' ? 'text-[var(--text-tertiary)]' : ''">
|
||||
<span class="text-4.5 mt-0.25 flex-shrink-0" :class="{
|
||||
'i-mynaui-circle': step.state === 'pending',
|
||||
'i-svg-spinners-180-ring-with-bg': step.state === 'active',
|
||||
'i-mynaui-check-circle text-[var(--color-accent)]': step.state === 'done',
|
||||
'i-mynaui-x-circle text-red-400': step.state === 'failed',
|
||||
}"></span>
|
||||
<span class="flex flex-col">
|
||||
<span>{{ step.label }}</span>
|
||||
<span v-if="step.hint && step.state === 'active'" class="text-xs text-[var(--text-secondary)]">
|
||||
{{ step.hint }}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<p v-if="error" role="alert" class="text-sm text-red-400">{{ error }}</p>
|
||||
<p v-else-if="done" role="status" class="text-sm text-[var(--text-secondary)]">
|
||||
Your password is changed. Use the new one the next time you sign in.
|
||||
</p>
|
||||
<p v-else-if="busy" class="text-xs text-[var(--text-secondary)]">
|
||||
Keep this tab open until it finishes. Plain-text keys never leave this browser.
|
||||
</p>
|
||||
|
||||
<footer class="flex justify-end gap-2 mt-auto md:mt-0">
|
||||
<button v-if="done" type="button"
|
||||
class="flex items-center justify-center h-9 px-3 text-sm font-medium rounded-md bg-[var(--color-accent)] text-[var(--color-accent-text)] @hover:bg-[var(--color-accent-hover)] transition-[background-color] duration-200"
|
||||
@click="emit('confirm', true)">
|
||||
Done
|
||||
</button>
|
||||
<template v-else>
|
||||
<button type="button" :disabled="busy"
|
||||
class="flex items-center justify-center h-9 px-3 text-sm rounded-md border border-[var(--color-border)] text-[var(--text-secondary)] @hover:bg-[var(--color-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-[background-color] duration-200"
|
||||
@click="emit('cancel')">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" :disabled="busy"
|
||||
class="flex items-center justify-center h-9 px-3 text-sm font-medium rounded-md bg-[var(--color-accent)] text-[var(--color-accent-text)] @hover:bg-[var(--color-accent-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-[background-color] duration-200">
|
||||
{{ busy ? 'Changing password…' : 'Change password' }}
|
||||
</button>
|
||||
</template>
|
||||
</footer>
|
||||
</form>
|
||||
</template>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
title?: string;
|
||||
message?: string;
|
||||
confirmLabel?: string;
|
||||
danger?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(['confirm', 'cancel']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4.5 p-6 w-screen md:w-110 max-w-full h-full md:h-auto" role="alertdialog"
|
||||
aria-labelledby="confirm-title" aria-describedby="confirm-message">
|
||||
<h2 id="confirm-title" class="text-lg font-semibold">{{ title ?? 'Are you sure?' }}</h2>
|
||||
|
||||
<p v-if="message" id="confirm-message" class="text-sm leading-normal text-[var(--text-secondary)]">
|
||||
{{ message }}
|
||||
</p>
|
||||
|
||||
<footer class="flex justify-between gap-2 mt-auto">
|
||||
<button type="button"
|
||||
class="flex items-center h-10 px-4 rounded-md border border-[var(--color-border)] text-[var(--text-secondary)] @hover:bg-[var(--color-hover)] transition-[background-color] duration-200"
|
||||
@click="emit('cancel')">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" class="flex items-center h-10 px-4 rounded-md font-semibold"
|
||||
:class="danger ? 'bg-red-600 text-white' : 'bg-[var(--color-accent)] text-[var(--color-accent-text)]'"
|
||||
@click="emit('confirm', true)">
|
||||
{{ confirmLabel ?? 'Confirm' }}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,141 @@
|
||||
<script setup lang="ts">
|
||||
const MAX_USES = 1000;
|
||||
|
||||
const DURATIONS = [
|
||||
{ hours: 1, label: '1 hour' },
|
||||
{ hours: 24, label: '1 day' },
|
||||
{ hours: 24 * 7, label: '7 days' },
|
||||
{ hours: 24 * 30, label: '30 days' },
|
||||
];
|
||||
|
||||
const emit = defineEmits(['confirm', 'cancel']);
|
||||
|
||||
const hours = ref(24 * 7);
|
||||
const maxUses = ref(1);
|
||||
|
||||
const busy = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
// set once the link exists, the dialog then shows it instead of the options
|
||||
const link = ref('');
|
||||
const copied = ref(false);
|
||||
|
||||
const usesValid = computed(() => Number.isInteger(maxUses.value) && maxUses.value >= 1 && maxUses.value <= MAX_USES);
|
||||
|
||||
const copyLink = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(link.value);
|
||||
copied.value = true;
|
||||
setTimeout(() => {
|
||||
copied.value = false;
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy invite link:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (busy.value || !usesValid.value) return;
|
||||
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
|
||||
try {
|
||||
const invite = await $fetch<{ token: string }>('/api/admin/invites', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
maxUses: maxUses.value,
|
||||
expiresInHours: hours.value,
|
||||
},
|
||||
});
|
||||
|
||||
link.value = `${window.location.origin}/auth/register?invite=${invite.token}`;
|
||||
} catch (err) {
|
||||
console.error('Failed to create invite:', err);
|
||||
error.value = 'The invite link could not be created. Try again.';
|
||||
}
|
||||
|
||||
busy.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="flex flex-col gap-4.5 p-6 w-screen md:w-130 max-w-full h-full md:h-auto overflow-y-auto"
|
||||
aria-labelledby="create-invite-title" @submit.prevent="submit">
|
||||
<header class="flex flex-col gap-1">
|
||||
<h2 id="create-invite-title" class="text-lg font-semibold">
|
||||
{{ link ? 'Invite link created' : 'New invite link' }}
|
||||
</h2>
|
||||
<p class="text-sm text-[var(--text-secondary)]">
|
||||
{{ link
|
||||
? 'Send it to whoever should join. You can copy it again or revoke it under Instance.'
|
||||
: 'Anyone with the link can create an account until it runs out.' }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<template v-if="link">
|
||||
<div class="flex items-center rounded-md bg-[var(--bg-container)]">
|
||||
<input type="text" readonly :value="link" aria-label="Invite link" @focus="($event.target as HTMLInputElement).select()"
|
||||
class="flex-1 min-w-0 h-9 px-2.5 text-[13px] font-mono bg-transparent outline-none" />
|
||||
<button type="button" :aria-label="copied ? 'Copied' : 'Copy invite link'" @click="copyLink"
|
||||
class="flex items-center justify-center w-9 h-9 flex-shrink-0 text-[var(--text-secondary)] @hover:text-[var(--text-primary)]">
|
||||
<span class="text-4.5" :class="copied ? 'i-mynaui-check' : 'i-mynaui-copy'"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<footer class="flex justify-end mt-auto">
|
||||
<button type="button"
|
||||
class="flex items-center h-10 px-4 rounded-md font-semibold bg-[var(--color-accent)] text-[var(--color-accent-text)]"
|
||||
@click="emit('confirm', true)">
|
||||
Done
|
||||
</button>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<fieldset class="flex flex-col">
|
||||
<legend class="mb-1.5 text-[13px] font-medium uppercase tracking-wider text-[var(--text-secondary)]">
|
||||
Valid for
|
||||
</legend>
|
||||
<div class="flex gap-0.5 p-0.5 rounded-lg bg-[var(--bg-container)] self-start">
|
||||
<button v-for="d in DURATIONS" :key="d.hours" type="button" :aria-pressed="hours === d.hours"
|
||||
:disabled="busy" @click="hours = d.hours"
|
||||
class="flex items-center h-7.5 px-3 text-sm rounded-md disabled:cursor-not-allowed transition-colors duration-200"
|
||||
:class="hours === d.hours
|
||||
? 'bg-[var(--color-accent)] text-[var(--color-accent-text)] font-medium'
|
||||
: 'text-[var(--text-secondary)] enabled:@hover:bg-[var(--color-hover)]'">
|
||||
{{ d.label }}
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="create-invite-uses"
|
||||
class="text-[13px] font-medium uppercase tracking-wider text-[var(--text-secondary)]">
|
||||
Number of uses
|
||||
</label>
|
||||
<input id="create-invite-uses" v-model.number="maxUses" type="number" required min="1"
|
||||
:max="MAX_USES" step="1" :disabled="busy"
|
||||
class="w-28 h-9 px-2.5 text-sm rounded-md bg-[var(--bg-container)]" />
|
||||
<p class="text-xs leading-normal text-[var(--text-secondary)]">
|
||||
How many people can register with this link, up to {{ MAX_USES }}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p v-if="error" role="alert" class="text-sm text-red-400">{{ error }}</p>
|
||||
|
||||
<footer class="flex justify-between gap-2 mt-auto">
|
||||
<button type="button" :disabled="busy"
|
||||
class="flex items-center h-10 px-4 rounded-md border border-[var(--color-border)] text-[var(--text-secondary)] @hover:bg-[var(--color-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-[background-color] duration-200"
|
||||
@click="emit('cancel')">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" :disabled="busy || !usesValid"
|
||||
class="flex items-center gap-2 h-10 px-4 rounded-md font-semibold bg-[var(--color-accent)] text-[var(--color-accent-text)] disabled:opacity-40 disabled:cursor-not-allowed">
|
||||
<span v-if="busy" class="i-svg-spinners-180-ring-with-bg text-4"></span>
|
||||
Create link
|
||||
</button>
|
||||
</footer>
|
||||
</template>
|
||||
</form>
|
||||
</template>
|
||||
@@ -0,0 +1,159 @@
|
||||
<script setup lang="ts">
|
||||
const emit = defineEmits(['confirm', 'cancel']);
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
email: '',
|
||||
});
|
||||
|
||||
const busy = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
// set once the account exists, the dialog then shows its setup link
|
||||
const link = ref('');
|
||||
const copied = ref(false);
|
||||
|
||||
const nameInputEl = ref<HTMLInputElement | null>(null);
|
||||
|
||||
onMounted(() => {
|
||||
nameInputEl.value?.focus();
|
||||
});
|
||||
|
||||
const ready = computed(() => form.name.trim() !== '' && form.email.trim() !== '');
|
||||
|
||||
const copyLink = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(link.value);
|
||||
copied.value = true;
|
||||
setTimeout(() => {
|
||||
copied.value = false;
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy setup link:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (busy.value || !ready.value) return;
|
||||
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
|
||||
try {
|
||||
const { token } = await $fetch<{ token: string }>('/api/admin/users', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
name: form.name.trim(),
|
||||
email: form.email.trim(),
|
||||
},
|
||||
});
|
||||
|
||||
link.value = `${window.location.origin}/auth/recover/${token}`;
|
||||
} catch (err: any) {
|
||||
busy.value = false;
|
||||
|
||||
switch (err?.data?.data?.code as string | undefined) {
|
||||
case 'USER_ALREADY_EXISTS':
|
||||
case 'USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL':
|
||||
error.value = 'An account with this email already exists.';
|
||||
break;
|
||||
case 'INVALID_EMAIL':
|
||||
error.value = 'That email address is not valid.';
|
||||
break;
|
||||
case 'INVALID_BODY':
|
||||
error.value = 'Check the name and the email address.';
|
||||
break;
|
||||
default:
|
||||
console.error('Failed to create user:', err);
|
||||
error.value = 'The account could not be created. Try again.';
|
||||
break;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
busy.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="flex flex-col gap-4.5 p-6 w-screen md:w-130 max-w-full h-full md:h-auto overflow-y-auto"
|
||||
aria-labelledby="create-user-title" @submit.prevent="submit">
|
||||
<header class="flex flex-col gap-1">
|
||||
<h2 id="create-user-title" class="text-lg font-semibold">
|
||||
{{ link ? 'Account created' : 'Create user' }}
|
||||
</h2>
|
||||
<p class="text-sm text-[var(--text-secondary)]">
|
||||
{{ link
|
||||
? `Send ${form.name.trim()} this link. They open it, choose a password and are in.`
|
||||
: 'Creates the account without a password. You get a link for them to choose their own, so you never know it.' }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<template v-if="link">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="create-user-link"
|
||||
class="text-[13px] font-medium uppercase tracking-wider text-[var(--text-secondary)]">
|
||||
Setup link
|
||||
</label>
|
||||
<div class="flex items-center rounded-md bg-[var(--bg-container)]">
|
||||
<input id="create-user-link" type="text" readonly :value="link"
|
||||
@focus="($event.target as HTMLInputElement).select()"
|
||||
class="flex-1 min-w-0 h-9 px-2.5 text-[13px] font-mono bg-transparent outline-none" />
|
||||
<button type="button" :aria-label="copied ? 'Copied' : 'Copy setup link'" @click="copyLink"
|
||||
class="flex items-center justify-center w-9 h-9 flex-shrink-0 text-[var(--text-secondary)] @hover:text-[var(--text-primary)]">
|
||||
<span class="text-4.5" :class="copied ? 'i-mynaui-check' : 'i-mynaui-copy'"></span>
|
||||
</button>
|
||||
</div>
|
||||
<span class="text-xs leading-normal text-[var(--text-secondary)]">
|
||||
Works once. Expires in 7 days. Shown only now; if it gets lost, make a new one from the
|
||||
user's menu. Nobody can sign in to the account until it is used.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<footer class="flex justify-end mt-auto">
|
||||
<button type="button"
|
||||
class="flex items-center h-10 px-4 rounded-md font-semibold bg-[var(--color-accent)] text-[var(--color-accent-text)]"
|
||||
@click="emit('confirm', true)">
|
||||
Done
|
||||
</button>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="create-user-name"
|
||||
class="text-[13px] font-medium uppercase tracking-wider text-[var(--text-secondary)]">
|
||||
Name
|
||||
</label>
|
||||
<input id="create-user-name" ref="nameInputEl" v-model="form.name" type="text" required
|
||||
maxlength="128" :disabled="busy" autocomplete="off"
|
||||
class="w-full h-9 px-2.5 text-sm rounded-md bg-[var(--bg-container)]" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="create-user-email"
|
||||
class="text-[13px] font-medium uppercase tracking-wider text-[var(--text-secondary)]">
|
||||
Email
|
||||
</label>
|
||||
<input id="create-user-email" v-model="form.email" type="email" required :disabled="busy"
|
||||
autocomplete="off" class="w-full h-9 px-2.5 text-sm rounded-md bg-[var(--bg-container)]" />
|
||||
</div>
|
||||
|
||||
<p v-if="error" role="alert" class="text-sm text-red-400">{{ error }}</p>
|
||||
|
||||
<footer class="flex justify-between gap-2 mt-auto">
|
||||
<button type="button" :disabled="busy"
|
||||
class="flex items-center h-10 px-4 rounded-md border border-[var(--color-border)] text-[var(--text-secondary)] @hover:bg-[var(--color-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-[background-color] duration-200"
|
||||
@click="emit('cancel')">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" :disabled="busy || !ready"
|
||||
class="flex items-center gap-2 h-10 px-4 rounded-md font-semibold bg-[var(--color-accent)] text-[var(--color-accent-text)] disabled:opacity-40 disabled:cursor-not-allowed">
|
||||
<span v-if="busy" class="i-svg-spinners-180-ring-with-bg text-4"></span>
|
||||
Create user
|
||||
</button>
|
||||
</footer>
|
||||
</template>
|
||||
</form>
|
||||
</template>
|
||||
@@ -0,0 +1,155 @@
|
||||
<script setup lang="ts">
|
||||
import CodeInput from '~/components/CodeInput.vue';
|
||||
|
||||
const emit = defineEmits(['confirm', 'cancel']);
|
||||
|
||||
const { user, session } = useAuth();
|
||||
|
||||
const password = ref('');
|
||||
|
||||
// accounts with two-factor also have to prove the second factor
|
||||
const needsCode = ref(!!user.value?.twoFactorEnabled);
|
||||
const code = ref('');
|
||||
const useBackupCode = ref(false);
|
||||
|
||||
const codeReady = computed(() => {
|
||||
if (!needsCode.value) return true;
|
||||
|
||||
return useBackupCode.value ? code.value.trim() !== '' : code.value.length === 6;
|
||||
});
|
||||
|
||||
const toggleBackupCode = () => {
|
||||
useBackupCode.value = !useBackupCode.value;
|
||||
code.value = '';
|
||||
error.value = '';
|
||||
};
|
||||
const busy = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
const passwordInputEl = ref<HTMLInputElement | null>(null);
|
||||
|
||||
onMounted(() => {
|
||||
passwordInputEl.value?.focus();
|
||||
});
|
||||
|
||||
const submit = async () => {
|
||||
if (busy.value || password.value === '' || !codeReady.value) return;
|
||||
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
|
||||
try {
|
||||
await $fetch('/api/account', {
|
||||
method: 'DELETE',
|
||||
body: {
|
||||
password: password.value,
|
||||
code: needsCode.value ? code.value.trim() : undefined,
|
||||
backupCode: needsCode.value ? useBackupCode.value : undefined,
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
switch (err?.data?.data?.code as string | undefined) {
|
||||
case 'INVALID_PASSWORD':
|
||||
error.value = 'That password is not right. Nothing was deleted.';
|
||||
password.value = '';
|
||||
break;
|
||||
case 'INVALID_CODE':
|
||||
error.value = 'That code is not right. Check your authenticator app. Nothing was deleted.';
|
||||
code.value = '';
|
||||
break;
|
||||
case 'INVALID_BACKUP_CODE':
|
||||
error.value = 'That backup code is not valid, or it was already used. Nothing was deleted.';
|
||||
code.value = '';
|
||||
break;
|
||||
case 'TWO_FACTOR_REQUIRED':
|
||||
// two-factor was turned on somewhere else since this page loaded
|
||||
needsCode.value = true;
|
||||
error.value = 'This account has two-factor sign-in on. Enter a code to continue.';
|
||||
break;
|
||||
case 'LAST_ADMIN':
|
||||
error.value = 'You are the only admin of this instance. Make someone else an admin first. Nothing was deleted.';
|
||||
break;
|
||||
default:
|
||||
error.value = 'Your account could not be deleted. Nothing was deleted, try again.';
|
||||
break;
|
||||
}
|
||||
|
||||
busy.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// nothing of this account is left, so drop every local trace of it too
|
||||
localStorage.removeItem('encryptionKey');
|
||||
user.value = null;
|
||||
session.value = null;
|
||||
clearNuxtData();
|
||||
|
||||
emit('confirm', true);
|
||||
|
||||
await navigateTo('/auth/login');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="flex flex-col gap-4.5 p-6 w-screen md:w-130 max-w-full h-full md:h-auto overflow-y-auto"
|
||||
aria-labelledby="delete-account-title" @submit.prevent="submit">
|
||||
<header class="flex flex-col gap-1">
|
||||
<h2 id="delete-account-title" class="text-lg font-semibold">Delete your account</h2>
|
||||
<p class="text-sm text-[var(--text-secondary)]">
|
||||
This can't be undone.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<p class="text-sm leading-normal">
|
||||
Deleting <strong class="font-medium">{{ user?.email }}</strong> removes your agents, topics and
|
||||
messages, your providers with their saved API keys, your file list, and your settings from this
|
||||
instance. You are signed out everywhere.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="delete-account-password"
|
||||
class="text-[13px] font-medium uppercase tracking-wider text-[var(--text-secondary)]">
|
||||
Password
|
||||
</label>
|
||||
<input id="delete-account-password" ref="passwordInputEl" v-model="password" type="password" required
|
||||
:disabled="busy" autocomplete="current-password"
|
||||
class="w-full h-9 px-2.5 text-sm rounded-md bg-[var(--bg-container)]" />
|
||||
</div>
|
||||
|
||||
<div v-if="needsCode && useBackupCode" class="flex flex-col gap-1.5">
|
||||
<label for="delete-account-backup-code"
|
||||
class="text-[13px] font-medium uppercase tracking-wider text-[var(--text-secondary)]">
|
||||
Backup code
|
||||
</label>
|
||||
<input id="delete-account-backup-code" v-model="code" type="text" required :disabled="busy"
|
||||
autocomplete="off" autocapitalize="off" spellcheck="false" placeholder="xxxxx-xxxxx"
|
||||
class="w-full h-9 px-2.5 text-sm font-mono rounded-md bg-[var(--bg-container)] placeholder:text-[var(--text-tertiary)]" />
|
||||
</div>
|
||||
<fieldset v-else-if="needsCode" class="flex flex-col">
|
||||
<legend class="mb-2 text-[13px] font-medium uppercase tracking-wider text-[var(--text-secondary)]">
|
||||
6-digit code from your authenticator app
|
||||
</legend>
|
||||
<CodeInput v-model="code" :disabled="busy" />
|
||||
</fieldset>
|
||||
|
||||
<button v-if="needsCode" type="button" :disabled="busy" @click="toggleBackupCode"
|
||||
class="self-start -mt-2 text-sm text-[var(--color-accent)] font-medium @hover:underline">
|
||||
{{ useBackupCode ? 'Use your authenticator app instead' : 'Use a backup code instead' }}
|
||||
</button>
|
||||
|
||||
<p v-if="error" role="alert" class="text-sm text-red-400">{{ error }}</p>
|
||||
|
||||
<footer class="flex justify-between gap-2 mt-auto">
|
||||
<button type="button" :disabled="busy"
|
||||
class="flex items-center h-10 px-4 rounded-md border border-[var(--color-border)] text-[var(--text-secondary)] @hover:bg-[var(--color-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-[background-color] duration-200"
|
||||
@click="emit('cancel')">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" :disabled="busy || password === '' || !codeReady"
|
||||
class="flex items-center gap-2 h-10 px-4 rounded-md font-semibold bg-red-600 text-white disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<span v-if="busy" class="i-svg-spinners-180-ring-with-bg text-4"></span>
|
||||
Delete account
|
||||
</button>
|
||||
</footer>
|
||||
</form>
|
||||
</template>
|
||||
@@ -0,0 +1,176 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
user: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
image?: string | null;
|
||||
twoFactorEnabled?: boolean;
|
||||
// has no password yet, so this makes a setup link and nothing is lost
|
||||
pending?: boolean;
|
||||
};
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(['confirm', 'cancel']);
|
||||
|
||||
// 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);
|
||||
const error = ref('');
|
||||
|
||||
// set once the link exists, the dialog then shows it instead of the options
|
||||
const link = ref('');
|
||||
const copied = ref(false);
|
||||
const revoked = ref(false);
|
||||
|
||||
const copyLink = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(link.value);
|
||||
copied.value = true;
|
||||
setTimeout(() => {
|
||||
copied.value = false;
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy recovery link:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const create = async () => {
|
||||
if (busy.value) return;
|
||||
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
|
||||
try {
|
||||
const { token } = await $fetch<{ token: string }>(`/api/admin/users/${props.user.id}/recovery`, {
|
||||
method: 'POST',
|
||||
body: { resetSecurity: resetSecurity.value },
|
||||
});
|
||||
|
||||
link.value = `${window.location.origin}/auth/recover/${token}`;
|
||||
} catch (err) {
|
||||
console.error('Failed to create recovery link:', err);
|
||||
error.value = 'The recovery link could not be created. Try again.';
|
||||
}
|
||||
|
||||
busy.value = false;
|
||||
};
|
||||
|
||||
const revoke = async () => {
|
||||
if (busy.value) return;
|
||||
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
|
||||
try {
|
||||
await $fetch(`/api/admin/users/${props.user.id}/recovery`, { method: 'DELETE' });
|
||||
link.value = '';
|
||||
revoked.value = true;
|
||||
} catch (err) {
|
||||
console.error('Failed to revoke recovery link:', err);
|
||||
error.value = 'The link could not be revoked. Try again.';
|
||||
}
|
||||
|
||||
busy.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="flex flex-col gap-4.5 p-6 w-screen md:w-130 max-w-full h-full md:h-auto overflow-y-auto"
|
||||
aria-labelledby="recover-account-title" @submit.prevent="create">
|
||||
<header class="flex items-center gap-3">
|
||||
<UserAvatar :user="user" class="w-10 h-10 rounded-lg"></UserAvatar>
|
||||
<div class="flex flex-col min-w-0">
|
||||
<h2 id="recover-account-title" class="text-lg font-semibold">
|
||||
{{ user.pending ? 'Account setup link' : 'Recover account' }}
|
||||
</h2>
|
||||
<p class="text-sm text-[var(--text-secondary)] overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{{ user.name }} · {{ user.email }}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p v-if="user.pending && !link" class="text-sm leading-normal text-[var(--text-secondary)]">
|
||||
This account has no password yet. A new link lets them choose one, and replaces the link they
|
||||
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">
|
||||
<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
|
||||
not deleted. Using the link signs them out on all devices.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="link">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="recover-account-link"
|
||||
class="text-[13px] font-medium uppercase tracking-wider text-[var(--text-secondary)]">
|
||||
{{ user.pending ? 'Setup link' : 'Recovery link' }}
|
||||
</label>
|
||||
<div class="flex items-center rounded-md bg-[var(--bg-container)]">
|
||||
<input id="recover-account-link" type="text" readonly :value="link"
|
||||
@focus="($event.target as HTMLInputElement).select()"
|
||||
class="flex-1 min-w-0 h-9 px-2.5 text-[13px] font-mono bg-transparent outline-none" />
|
||||
<button type="button" :aria-label="copied ? 'Copied' : 'Copy recovery link'" @click="copyLink"
|
||||
class="flex items-center justify-center w-9 h-9 flex-shrink-0 text-[var(--text-secondary)] @hover:text-[var(--text-primary)]">
|
||||
<span class="text-4.5" :class="copied ? 'i-mynaui-check' : 'i-mynaui-copy'"></span>
|
||||
</button>
|
||||
</div>
|
||||
<span class="text-xs leading-normal text-[var(--text-secondary)]">
|
||||
Works once. Expires in {{ user.pending ? '7 days' : '24 hours' }}. Shown only now; closing
|
||||
this dialog hides it for good.
|
||||
Send it to them yourself, over a channel you trust.
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<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 sign-in</span>
|
||||
</label>
|
||||
|
||||
<p v-if="revoked" role="status" class="text-sm text-[var(--text-secondary)]">
|
||||
The link was revoked and no longer works.
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<p v-if="error" role="alert" class="text-sm text-red-400">{{ error }}</p>
|
||||
|
||||
<footer class="flex justify-between gap-2 mt-auto">
|
||||
<template v-if="link">
|
||||
<button type="button" :disabled="busy" @click="revoke"
|
||||
class="flex items-center h-10 px-4 rounded-md border border-[var(--color-border)] text-red-400 @hover:bg-[var(--color-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-[background-color] duration-200">
|
||||
Revoke link
|
||||
</button>
|
||||
<button type="button"
|
||||
class="flex items-center h-10 px-4 rounded-md font-semibold bg-[var(--color-accent)] text-[var(--color-accent-text)]"
|
||||
@click="emit('confirm', true)">
|
||||
Done
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button type="button" :disabled="busy"
|
||||
class="flex items-center h-10 px-4 rounded-md border border-[var(--color-border)] text-[var(--text-secondary)] @hover:bg-[var(--color-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-[background-color] duration-200"
|
||||
@click="emit('cancel')">
|
||||
{{ revoked ? 'Close' : 'Cancel' }}
|
||||
</button>
|
||||
<button type="submit" :disabled="busy"
|
||||
class="flex items-center gap-2 h-10 px-4 rounded-md font-semibold bg-[var(--color-accent)] text-[var(--color-accent-text)] disabled:opacity-40 disabled:cursor-not-allowed">
|
||||
<span v-if="busy" class="i-svg-spinners-180-ring-with-bg text-4"></span>
|
||||
{{ user.pending ? 'Create setup link' : 'Create recovery link' }}
|
||||
</button>
|
||||
</template>
|
||||
</footer>
|
||||
</form>
|
||||
</template>
|
||||
@@ -1,138 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import GeneralSettings from '~/components/Settings/GeneralSettings.vue';
|
||||
import ProviderSettings from '~/components/Settings/ProviderSettings.vue';
|
||||
import ProviderSidebar from '~/components/Settings/ProviderSidebar.vue';
|
||||
import SystemAssistants from '~/components/Settings/SystemAssistants.vue';
|
||||
import AppearanceSettings from '~/components/Settings/AppearanceSettings.vue';
|
||||
import AIServiceProvider from '~/components/Settings/AIServiceProvider.vue';
|
||||
|
||||
const { providers } = await useModels();
|
||||
|
||||
const PAGES_CONFIG = {
|
||||
general: {
|
||||
label: 'General',
|
||||
icon: 'i-mynaui-cog-four',
|
||||
component: GeneralSettings
|
||||
},
|
||||
appearance: {
|
||||
label: 'Appearance',
|
||||
icon: 'i-tabler-palette',
|
||||
component: AppearanceSettings
|
||||
},
|
||||
providers: {
|
||||
label: 'AI Providers',
|
||||
icon: 'i-mynaui-api',
|
||||
component: ProviderSettings,
|
||||
sidebar: ProviderSidebar
|
||||
},
|
||||
systemAssistants: {
|
||||
label: 'System Assistants',
|
||||
icon: 'i-mynaui-sparkles',
|
||||
component: SystemAssistants
|
||||
},
|
||||
} as const;
|
||||
|
||||
const props = defineProps<{
|
||||
page: keyof typeof PAGES_CONFIG;
|
||||
params?: string;
|
||||
}>();
|
||||
|
||||
const { close, setOptions } = useDialog();
|
||||
|
||||
const mobileNavOpen = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', () => {
|
||||
if (window.innerWidth >= 768) {
|
||||
mobileNavOpen.value = false;
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const handleNavigate = (page: string, params?: string) => {
|
||||
setOptions({ page: page as keyof typeof PAGES_CONFIG, params });
|
||||
mobileNavOpen.value = false;
|
||||
};
|
||||
|
||||
const runtimePage = computed(() => {
|
||||
// 1. Get the base config (e.g., 'providers' or 'general')
|
||||
const config = PAGES_CONFIG[props.page as keyof typeof PAGES_CONFIG] || PAGES_CONFIG.general;
|
||||
|
||||
// 2. Determine the actual component to show
|
||||
let component = config.component;
|
||||
let label = config.label as string;
|
||||
|
||||
if (props.page === 'providers' && props.params) {
|
||||
component = AIServiceProvider;
|
||||
const providerId = props.params;
|
||||
const provider = providers.value!.find(p => p.id === providerId);
|
||||
label = provider ? provider.name : 'Unknown Provider';
|
||||
}
|
||||
|
||||
return {
|
||||
...config,
|
||||
label,
|
||||
component,
|
||||
params: props.params,
|
||||
} as {
|
||||
label: string;
|
||||
icon: string;
|
||||
component: Component;
|
||||
sidebar?: Component;
|
||||
params: string;
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-2 flex flex-col md:flex-row h-screen md:h-[70vh] md:h-[70dvh] w-screen md:w-[85vw]">
|
||||
<nav :class="[
|
||||
'w-full md:w-64 flex-col gap-1 mr-2 overflow-y-auto text-xl md:text-base',
|
||||
// Mobile: hidden by default, shown as fixed overlay
|
||||
'hidden md:flex',
|
||||
mobileNavOpen ? '!flex fixed inset-y-0 left-0 z-15 bg-[var(--bg-base)] p-2 shadow-xl' : ''
|
||||
]">
|
||||
<!-- Mobile close button -->
|
||||
<button
|
||||
class="md:hidden flex items-center justify-center h-9 w-full rounded-lg @hover:bg-[var(--color-hover)] text-[var(--text-secondary)]"
|
||||
@click="mobileNavOpen = false">
|
||||
<span class="i-mynaui-x-solid mr-2"></span>
|
||||
Close
|
||||
</button>
|
||||
|
||||
<!-- If the page has a custom sidebar (for nested lists), show it; otherwise show default nav -->
|
||||
<component v-if="runtimePage?.sidebar" :is="runtimePage.sidebar" @navigate="handleNavigate"
|
||||
:params="props.params" />
|
||||
|
||||
<button v-else v-for="(config, id) in PAGES_CONFIG" :key="id" @click="handleNavigate(id)"
|
||||
:class="[page === id ? 'bg-[var(--color-hover)]' : '@hover:bg-[var(--color-hover)]', 'flex justify-between items-center shrink-0 px-1 rounded-lg transition-colors cursor-pointer h-9']">
|
||||
<div class="flex items-center gap-2 max-w-full flex-1">
|
||||
<span :class="['w-5 h-5 text-5', config.icon]"></span>
|
||||
{{ config.label }}
|
||||
</div>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- DYNAMIC CONTENT -->
|
||||
<main
|
||||
class="flex-1 flex flex-col overflow-y-hidden max-h-full h-full px-3 bg-[var(--bg-surface)] border rounded-none md:rounded-lg border-[var(--color-border)]">
|
||||
<header class="flex items-center justify-between pl-2 pb-2 pt-2 ">
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Mobile menu button -->
|
||||
<button
|
||||
class="md:hidden flex items-center justify-center h-8 w-8 rounded-lg @hover:bg-[var(--color-hover)]"
|
||||
@click="mobileNavOpen = true">
|
||||
<span class="i-mynaui-menu text-5"></span>
|
||||
</button>
|
||||
<h2 class="text-lg font-semibold m-0 case-capital">{{ runtimePage.label }}</h2>
|
||||
</div>
|
||||
<button
|
||||
class="@hover:bg-[var(--color-hover)] p-1.5 rounded-md transition-colors duration-200 ease-[cubic-bezier(0,0.55,0.45,1)]"
|
||||
@click="close">
|
||||
<span class="i-mynaui-x-solid"></span>
|
||||
</button>
|
||||
</header>
|
||||
<component @navigate="handleNavigate" :is="runtimePage.component" :params="props.params" />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,359 @@
|
||||
<script setup lang="ts">
|
||||
import { renderSVG } from 'uqr';
|
||||
import CodeInput from '~/components/CodeInput.vue';
|
||||
|
||||
type Action = 'enable' | 'disable' | 'regenerate';
|
||||
type Step = 'password' | 'verify' | 'codes' | 'check';
|
||||
|
||||
const props = defineProps<{
|
||||
action?: Action;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(['confirm', 'cancel']);
|
||||
|
||||
const { client: authClient, user } = useAuth();
|
||||
|
||||
const action = computed<Action>(() => props.action ?? 'enable');
|
||||
|
||||
const FLOWS: Record<Action, { title: string; steps: Step[] }> = {
|
||||
enable: { title: 'Set up two-factor sign-in', steps: ['password', 'verify', 'codes', 'check'] },
|
||||
regenerate: { title: 'New backup codes', steps: ['password', 'codes', 'check'] },
|
||||
disable: { title: 'Turn off two-factor sign-in', steps: ['password'] },
|
||||
};
|
||||
|
||||
const STEP_LABELS: Record<Step, string> = {
|
||||
password: 'Confirm your password',
|
||||
verify: 'Scan and confirm',
|
||||
codes: 'Save your backup codes',
|
||||
check: 'Check what you saved',
|
||||
};
|
||||
|
||||
const INPUT_CLASS = 'w-full h-9 px-2.5 text-sm rounded-md bg-[var(--bg-container)] placeholder:text-[var(--text-tertiary)]';
|
||||
const CANCEL_CLASS = 'flex items-center h-10 px-4 rounded-md border border-[var(--color-border)] text-[var(--text-secondary)] @hover:bg-[var(--color-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-[background-color] duration-200';
|
||||
const PRIMARY_CLASS = 'flex items-center gap-2 h-10 px-4 rounded-md font-semibold bg-[var(--color-accent)] text-[var(--color-accent-text)] @hover:bg-[var(--color-accent-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-[background-color] duration-200';
|
||||
const DANGER_CLASS = 'flex items-center gap-2 h-10 px-4 rounded-md font-semibold bg-red-600 text-white disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
const SECONDARY_CLASS = 'flex items-center gap-2 h-8.5 px-3 text-sm rounded-md bg-[var(--bg-container)] @hover:bg-[var(--color-hover)] transition-colors duration-200';
|
||||
|
||||
const flow = computed(() => FLOWS[action.value]);
|
||||
const step = ref<Step>('password');
|
||||
const stepIndex = computed(() => flow.value.steps.indexOf(step.value));
|
||||
|
||||
const password = ref('');
|
||||
const code = ref('');
|
||||
const busy = ref(false);
|
||||
const error = ref('');
|
||||
const totpUri = ref('');
|
||||
const backupCodes = ref<string[]>([]);
|
||||
const copied = ref<'secret' | 'codes' | null>(null);
|
||||
|
||||
// last step: ask for one of the codes back, to make sure they were really saved
|
||||
const checkIndex = ref(0);
|
||||
const checkInput = ref('');
|
||||
const checkInputEl = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const passwordInputEl = ref<HTMLInputElement | null>(null);
|
||||
const codeInputEl = ref<InstanceType<typeof CodeInput> | null>(null);
|
||||
|
||||
onMounted(() => {
|
||||
passwordInputEl.value?.focus();
|
||||
});
|
||||
|
||||
const totpSecret = computed(() => {
|
||||
if (!totpUri.value) return '';
|
||||
|
||||
return new URL(totpUri.value).searchParams.get('secret') ?? '';
|
||||
});
|
||||
|
||||
const qrCode = computed(() => totpUri.value ? renderSVG(totpUri.value, { border: 2 }) : '');
|
||||
|
||||
type AuthClientError = { code?: string; message?: string; status?: number } | null;
|
||||
|
||||
const describeError = (err: AuthClientError, fallback: string): string => {
|
||||
if (err?.status === 429) return 'Too many attempts. Wait a few seconds and try again.';
|
||||
if (err?.code === 'INVALID_PASSWORD') return 'That password is not right.';
|
||||
if (err?.code === 'ACCOUNT_TEMPORARILY_LOCKED') return 'Too many wrong codes. Try again later.';
|
||||
if (err?.code === 'INVALID_CODE') return 'That code is not right. Check your authenticator app and try again.';
|
||||
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const submitPassword = async () => {
|
||||
if (busy.value || password.value === '') return;
|
||||
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
|
||||
const body = { password: password.value };
|
||||
|
||||
if (action.value === 'enable') {
|
||||
const { data, error: err } = await authClient.twoFactor.enable(body);
|
||||
|
||||
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
|
||||
totpUri.value = data.totpURI;
|
||||
backupCodes.value = data.backupCodes;
|
||||
step.value = 'verify';
|
||||
|
||||
await nextTick();
|
||||
codeInputEl.value?.focus();
|
||||
}
|
||||
} else if (action.value === 'regenerate') {
|
||||
const { data, error: err } = await authClient.twoFactor.generateBackupCodes(body);
|
||||
|
||||
if (err || !data) {
|
||||
error.value = describeError(err, 'New backup codes could not be made. Try again.');
|
||||
} else {
|
||||
backupCodes.value = data.backupCodes;
|
||||
step.value = 'codes';
|
||||
}
|
||||
} else {
|
||||
const { error: err } = await authClient.twoFactor.disable(body);
|
||||
|
||||
if (err) {
|
||||
error.value = describeError(err, 'Two-factor could not be turned off. Try again.');
|
||||
} else {
|
||||
if (user.value) user.value = { ...user.value, twoFactorEnabled: false };
|
||||
emit('confirm', true);
|
||||
}
|
||||
}
|
||||
|
||||
password.value = '';
|
||||
busy.value = false;
|
||||
};
|
||||
|
||||
const verifyCode = async () => {
|
||||
if (busy.value || code.value.length !== 6) return;
|
||||
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
|
||||
const { error: err } = await authClient.twoFactor.verifyTotp({ code: code.value });
|
||||
|
||||
if (err) {
|
||||
error.value = describeError(err, 'That code could not be verified. Try again.');
|
||||
code.value = '';
|
||||
busy.value = false;
|
||||
|
||||
await nextTick();
|
||||
codeInputEl.value?.focus();
|
||||
} else {
|
||||
if (user.value) user.value = { ...user.value, twoFactorEnabled: true };
|
||||
step.value = 'codes';
|
||||
}
|
||||
|
||||
busy.value = false;
|
||||
};
|
||||
|
||||
// the numbers are part of what gets saved, the last step asks for a code by number
|
||||
const numberedBackupCodes = computed(() =>
|
||||
backupCodes.value.map((backupCode, index) => `${index + 1}. ${backupCode}`).join('\n')
|
||||
);
|
||||
|
||||
const startCheck = async () => {
|
||||
// a different code every time
|
||||
checkIndex.value = Math.floor(Math.random() * backupCodes.value.length);
|
||||
checkInput.value = '';
|
||||
error.value = '';
|
||||
step.value = 'check';
|
||||
|
||||
await nextTick();
|
||||
checkInputEl.value?.focus();
|
||||
};
|
||||
|
||||
const backToCodes = () => {
|
||||
error.value = '';
|
||||
step.value = 'codes';
|
||||
};
|
||||
|
||||
// forgiving about spacing and the dash, since the code may be read off paper
|
||||
const normalizeBackupCode = (value: string) => value.replace(/[\s-]/g, '');
|
||||
|
||||
const submitCheck = () => {
|
||||
const expected = backupCodes.value[checkIndex.value] ?? '';
|
||||
|
||||
if (normalizeBackupCode(checkInput.value) !== normalizeBackupCode(expected)) {
|
||||
error.value = `That is not code ${checkIndex.value + 1}. Capital letters matter. Go back and check what you saved.`;
|
||||
return;
|
||||
}
|
||||
|
||||
emit('confirm', true);
|
||||
};
|
||||
|
||||
const copyText = async (text: string, what: 'secret' | 'codes') => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
copied.value = what;
|
||||
setTimeout(() => {
|
||||
if (copied.value === what) copied.value = null;
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadBackupCodes = () => {
|
||||
const text = `Veridian backup codes for ${user.value?.email ?? 'your account'}\nEach code works once.\n\n${numberedBackupCodes.value}\n`;
|
||||
const url = URL.createObjectURL(new Blob([text], { type: 'text/plain' }));
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = 'veridian-backup-codes.txt';
|
||||
link.click();
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="flex flex-col gap-4.5 p-6 w-screen md:w-130 max-w-full h-full md:h-auto overflow-y-auto"
|
||||
aria-labelledby="two-factor-title">
|
||||
<header class="flex items-start justify-between gap-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 id="two-factor-title" class="text-lg font-semibold">{{ flow.title }}</h2>
|
||||
<p class="text-sm text-[var(--text-secondary)]">
|
||||
<template v-if="flow.steps.length > 1">
|
||||
Step {{ stepIndex + 1 }} of {{ flow.steps.length }} ·
|
||||
</template>
|
||||
{{ STEP_LABELS[step] }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="flow.steps.length > 1" aria-hidden="true" class="flex gap-1 pt-2">
|
||||
<span v-for="(_, index) in flow.steps" :key="index" class="w-5 h-1 rounded-sm"
|
||||
:class="index <= stepIndex ? 'bg-[var(--color-accent)]' : 'bg-[var(--color-border-active)]'"></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- STEP: PASSWORD -->
|
||||
<form v-if="step === 'password'" class="flex flex-col gap-4.5 flex-1" @submit.prevent="submitPassword">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="two-factor-password"
|
||||
class="text-[13px] font-medium uppercase tracking-wider text-[var(--text-secondary)]">
|
||||
Password
|
||||
</label>
|
||||
<input id="two-factor-password" ref="passwordInputEl" v-model="password" type="password" required
|
||||
:disabled="busy" autocomplete="current-password" :class="INPUT_CLASS" />
|
||||
<p v-if="error" role="alert" class="text-sm text-red-400">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<ol class="flex flex-col gap-1.5 text-[13px] text-[var(--text-secondary)]">
|
||||
<li v-if="action === 'regenerate'">
|
||||
Your current backup codes stop working as soon as the new ones are made.
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<footer class="flex justify-between gap-2 mt-auto">
|
||||
<button type="button" :disabled="busy" :class="CANCEL_CLASS" @click="emit('cancel')">Cancel</button>
|
||||
<button type="submit" :disabled="busy" :class="action === 'disable' ? DANGER_CLASS : PRIMARY_CLASS">
|
||||
<span v-if="busy" class="i-svg-spinners-180-ring-with-bg text-4"></span>
|
||||
{{ action === 'disable' ? 'Turn off' : 'Continue' }}
|
||||
</button>
|
||||
</footer>
|
||||
</form>
|
||||
|
||||
<!-- STEP: SCAN AND CONFIRM -->
|
||||
<form v-else-if="step === 'verify'" class="flex flex-col gap-4.5 flex-1" @submit.prevent="verifyCode">
|
||||
<div class="flex flex-wrap gap-4 p-4 rounded-lg bg-[var(--bg-surface)] border border-[var(--color-border)]">
|
||||
<div class="w-37 h-37 flex-shrink-0 rounded-lg overflow-hidden bg-white" role="img"
|
||||
aria-label="QR code for your authenticator app" v-html="qrCode"></div>
|
||||
<div class="flex flex-col gap-2.5 flex-1 min-w-40">
|
||||
<p class="text-sm leading-normal">
|
||||
Scan this with an authenticator app such as Ente, KeepassXC, or Authy.
|
||||
</p>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs text-[var(--text-secondary)]">Can't scan? Enter this key by hand</span>
|
||||
<div class="flex items-center gap-1 pl-2.5 rounded-md bg-[var(--bg-container)]">
|
||||
<span
|
||||
class="flex-1 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap font-mono text-[13px] select-all">
|
||||
{{ totpSecret }}
|
||||
</span>
|
||||
<button type="button" :aria-label="copied === 'secret' ? 'Copied' : 'Copy setup key'"
|
||||
class="flex items-center justify-center w-8.5 h-8.5 flex-shrink-0 text-[var(--text-secondary)] @hover:text-[var(--text-primary)] transition-colors"
|
||||
@click="copyText(totpSecret, 'secret')">
|
||||
<span class="text-4"
|
||||
:class="copied === 'secret' ? 'i-mynaui-check' : 'i-mynaui-copy'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<fieldset class="flex flex-col gap-2">
|
||||
<legend class="mb-2 text-[13px] font-medium uppercase tracking-wider text-[var(--text-secondary)]">
|
||||
Enter the 6-digit code to verify that enrollment was successful
|
||||
</legend>
|
||||
<CodeInput ref="codeInputEl" v-model="code" :disabled="busy" @complete="verifyCode" />
|
||||
<p v-if="error" role="alert" class="text-sm text-red-400">{{ error }}</p>
|
||||
</fieldset>
|
||||
|
||||
<footer class="flex justify-between gap-2 mt-auto">
|
||||
<button type="button" :disabled="busy" :class="CANCEL_CLASS" @click="emit('cancel')">Cancel</button>
|
||||
<button type="submit" :disabled="busy || code.length !== 6" :class="PRIMARY_CLASS">
|
||||
<span v-if="busy" class="i-svg-spinners-180-ring-with-bg text-4"></span>
|
||||
Verify code
|
||||
</button>
|
||||
</footer>
|
||||
</form>
|
||||
|
||||
<!-- STEP: BACKUP CODES -->
|
||||
<div v-else-if="step === 'codes'" class="flex flex-col gap-4.5 flex-1">
|
||||
<p class="text-sm leading-normal">
|
||||
<template v-if="action === 'enable'">Two-factor sign-in is on. </template>
|
||||
Save these codes somewhere safe. Each one signs you in once if you lose your authenticator app.
|
||||
They are not shown again.
|
||||
</p>
|
||||
|
||||
<ul
|
||||
class="grid grid-cols-2 gap-x-6 gap-y-1.5 p-4 rounded-lg bg-[var(--bg-surface)] border border-[var(--color-border)] font-mono text-sm [list-style-type:none]">
|
||||
<li v-for="(backupCode, index) in backupCodes" :key="backupCode" class="flex gap-2">
|
||||
<span class="w-5 text-right text-[var(--text-tertiary)] select-none">{{ index + 1 }}.</span>
|
||||
<span>{{ backupCode }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button type="button" :class="SECONDARY_CLASS" @click="copyText(numberedBackupCodes, 'codes')">
|
||||
<span class="text-4" :class="copied === 'codes' ? 'i-mynaui-check' : 'i-mynaui-copy'"></span>
|
||||
{{ copied === 'codes' ? 'Copied' : 'Copy' }}
|
||||
</button>
|
||||
<button type="button" :class="SECONDARY_CLASS" @click="downloadBackupCodes">
|
||||
<span class="i-mynaui-download text-4"></span>
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<footer class="flex justify-end gap-2 mt-auto">
|
||||
<button type="button" :class="PRIMARY_CLASS" @click="startCheck">I saved them</button>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- STEP: CHECK A SAVED CODE -->
|
||||
<form v-else class="flex flex-col gap-4.5 flex-1" @submit.prevent="submitCheck">
|
||||
<p class="text-sm leading-normal">
|
||||
One last check that your codes made it somewhere safe. Find them where you saved them and enter
|
||||
code {{ checkIndex + 1 }} of {{ backupCodes.length }}.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="two-factor-check"
|
||||
class="text-[13px] font-medium tracking-wider text-[var(--text-secondary)]">
|
||||
Backup code {{ checkIndex + 1 }} of {{ backupCodes.length }}
|
||||
</label>
|
||||
<input id="two-factor-check" ref="checkInputEl" v-model="checkInput" type="text" required
|
||||
autocomplete="off" autocapitalize="off" spellcheck="false" placeholder="xxxxx-xxxxx"
|
||||
:class="[INPUT_CLASS, 'font-mono']" />
|
||||
<p v-if="error" role="alert" class="text-sm text-red-400">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<ol class="flex flex-col gap-1.5 text-[13px] text-[var(--text-secondary)] [list-style-type:none]">
|
||||
<li>Entering it here does not use the code up.</li>
|
||||
</ol>
|
||||
|
||||
<footer class="flex justify-between gap-2 mt-auto">
|
||||
<button type="button" :class="CANCEL_CLASS" @click="backToCodes">Back to the codes</button>
|
||||
<button type="submit" :class="PRIMARY_CLASS">Finish</button>
|
||||
</footer>
|
||||
</form>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,7 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import Textbox from './Textbox.vue';
|
||||
import Settings from './Settings.vue';
|
||||
import QuickSwitcher from './QuickSwitcher.vue';
|
||||
import ChangePassword from './ChangePassword.vue';
|
||||
import TwoFactor from './TwoFactor.vue';
|
||||
import DeleteAccount from './DeleteAccount.vue';
|
||||
import Confirm from './Confirm.vue';
|
||||
import CreateUser from './CreateUser.vue';
|
||||
import CreateInvite from './CreateInvite.vue';
|
||||
import RecoverAccount from './RecoverAccount.vue';
|
||||
|
||||
import { DialogType } from '~/composables/useDialog';
|
||||
|
||||
@@ -11,7 +17,7 @@ const { addShortcut } = useKeyboardShortcuts();
|
||||
const unbindSettingsShortcut = addShortcut(['ctrl', ','], (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
openDialog(DialogType.Settings);
|
||||
navigateTo('/settings');
|
||||
});
|
||||
|
||||
const unbindQuickSwitcherShortcut = addShortcut(['ctrl', 'k'], (event) => {
|
||||
@@ -57,12 +63,21 @@ onUnmounted(() => {
|
||||
<div
|
||||
class="absolute w-full h-full md:w-auto md:h-auto max-w-6xl bg-[var(--bg-base)] rounded-none md:rounded-2xl shadow-2xl border border-[var(--color-border)] flex">
|
||||
<KeepAlive>
|
||||
<Settings v-if="page === DialogType.Settings" :page="data.page" :params="data.params" />
|
||||
|
||||
<Textbox v-else-if="page === DialogType.Textbox" v-bind="data" @confirm="confirm" @cancel="close" />
|
||||
<Textbox v-if="page === DialogType.Textbox" v-bind="data" @confirm="confirm" @cancel="close" />
|
||||
|
||||
<QuickSwitcher v-else-if="page === DialogType.QuickSwitcher" @close="close" />
|
||||
</KeepAlive>
|
||||
|
||||
<!-- kept out of KeepAlive so passwords never linger after it closes -->
|
||||
<ChangePassword v-if="page === DialogType.ChangePassword" @confirm="confirm" @cancel="close" />
|
||||
<TwoFactor v-else-if="page === DialogType.TwoFactor" :action="data.action" @confirm="confirm"
|
||||
@cancel="close" />
|
||||
<DeleteAccount v-else-if="page === DialogType.DeleteAccount" @confirm="confirm" @cancel="close" />
|
||||
<CreateUser v-else-if="page === DialogType.CreateUser" @confirm="confirm" @cancel="close" />
|
||||
<CreateInvite v-else-if="page === DialogType.CreateInvite" @confirm="confirm" @cancel="close" />
|
||||
<RecoverAccount v-else-if="page === DialogType.RecoverAccount" :user="data.user" @confirm="confirm"
|
||||
@cancel="close" />
|
||||
<Confirm v-else-if="page === DialogType.Confirm" v-bind="data" @confirm="confirm" @cancel="close" />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick } from 'vue';
|
||||
import { useFloating, offset, flip, shift, autoUpdate, size, hide } from '@floating-ui/vue';
|
||||
import { DialogType } from '~/composables/useDialog';
|
||||
import type { Model, ModelWithProvider, Provider, ProviderWithModels } from '~/composables/useModels';
|
||||
import { sortByReleaseDate } from '~/utils/sort';
|
||||
import RowVirtualizerFixed from './RowVirtualizerFixed.vue';
|
||||
|
||||
const { openDialog } = useDialog();
|
||||
const { allModels } = await useModels();
|
||||
const { addShortcut } = useKeyboardShortcuts();
|
||||
|
||||
@@ -17,7 +15,16 @@ const props = defineProps<{
|
||||
|
||||
const selectedModel = defineModel<ModelWithProvider | null>();
|
||||
|
||||
const openProviderSettings = async (providerId?: string) => {
|
||||
closeDropdown();
|
||||
|
||||
await navigateTo(providerId ? `/settings/providers/${providerId}` : '/settings/providers');
|
||||
};
|
||||
|
||||
const isOpen = ref(false);
|
||||
|
||||
// nothing to pick from until a provider is set up, so the picker explains that
|
||||
const noModels = computed(() => allModels.value.length === 0);
|
||||
const dropdownButton = ref<HTMLElement | null>(null);
|
||||
const dropdownContent = ref<HTMLElement | null>(null);
|
||||
const searchInputRef = ref<HTMLInputElement | null>(null);
|
||||
@@ -217,11 +224,13 @@ onUnmounted(() => {
|
||||
isOpen
|
||||
? 'bg-[var(--color-hover)] text-[var(--text-primary)]'
|
||||
: 'text-[var(--text-secondary)] @hover:text-[var(--text-primary)] @hover:bg-[var(--color-hover)]',
|
||||
noModels ? 'border border-dashed border-[var(--color-border-active)] !text-[var(--text-primary)]' : '',
|
||||
]">
|
||||
<ModelIcon v-if="selectedModel" class="text-white" :avatar="true" variant="color"
|
||||
<span v-if="noModels" class="i-mynaui-api text-4.5" aria-hidden="true"></span>
|
||||
<ModelIcon v-if="selectedModel && !noModels" class="text-white" :avatar="true" variant="color"
|
||||
:model-id="selectedModel.externalId" size="22" />
|
||||
<span class="max-w-full truncate">
|
||||
{{ selectedModel ? selectedModel.name : 'Select a model' }}
|
||||
{{ noModels ? 'No models yet' : selectedModel ? selectedModel.name : 'Select a model' }}
|
||||
</span>
|
||||
<span class="i-mynaui-chevron-down text-3.5 transition-transform duration-200"
|
||||
:class="{ 'rotate-180': isOpen }"></span>
|
||||
@@ -242,9 +251,21 @@ onUnmounted(() => {
|
||||
? 'hidden'
|
||||
: 'visible',
|
||||
}"
|
||||
class="fixed z-100 w-full max-w-[420px] min-w-[280px] flex flex-col rounded-xl border border-[var(--color-border)] bg-[var(--bg-surface)] shadow-lg overflow-hidden"
|
||||
:class="transformOrigin">
|
||||
:class="[transformOrigin, noModels ? 'max-w-80' : 'max-w-[420px]']"
|
||||
class="fixed z-100 w-full min-w-[280px] flex flex-col rounded-xl border border-[var(--color-border)] bg-[var(--bg-surface)] shadow-lg overflow-hidden">
|
||||
|
||||
<div v-if="noModels" class="flex flex-col gap-2.5 p-4">
|
||||
<div class="font-semibold">No models yet</div>
|
||||
<p class="text-sm leading-normal text-[var(--text-secondary)]">
|
||||
Veridian uses your own API keys. Add a provider and its models show up here.
|
||||
</p>
|
||||
<button type="button" @click="openProviderSettings()"
|
||||
class="self-start flex items-center h-8.5 px-3 text-sm font-semibold rounded-md bg-[var(--color-accent)] text-[var(--color-accent-text)]">
|
||||
Add a provider
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="relative">
|
||||
<span
|
||||
class="i-mynaui-search absolute left-3 top-1/2 -translate-y-1/2 text-4 text-[var(--text-secondary)]"></span>
|
||||
@@ -269,7 +290,7 @@ onUnmounted(() => {
|
||||
class="px-4 py-1.5 text-[13px] h-10.5 items-end font-medium text-[var(--text-secondary)] case-capital tracking-wider flex justify-between">
|
||||
{{ item.provider.name }}
|
||||
<button
|
||||
@click="closeDropdown(); openDialog(DialogType.Settings, undefined, { page: 'providers', params: item.provider.id })"
|
||||
@click="openProviderSettings(item.provider.id)"
|
||||
class="flex h-5 w-5 items-center justify-center @hover:bg-[var(--color-hover)] rounded transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
<span class="i-mynaui-cog-four text-4"></span>
|
||||
</button>
|
||||
@@ -295,12 +316,13 @@ onUnmounted(() => {
|
||||
<div class="p-1 border-t border-[var(--color-border)]">
|
||||
<button
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-sm text-[var(--text-secondary)] @hover:text-[var(--text-primary)] @hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-150"
|
||||
@click="closeDropdown(); openDialog(DialogType.Settings, undefined, { page: 'providers' });">
|
||||
@click="openProviderSettings()">
|
||||
<span class="i-mynaui-cog-four text-4.5"></span>
|
||||
<span>Manage Providers</span>
|
||||
<span class="i-mynaui-arrow-right text-4 ml-auto"></span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</KeepAlive>
|
||||
</Transition>
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
<script setup lang="ts">
|
||||
import type { Model } from '~/composables/useModels';
|
||||
import { encryptData, uint8ArrayToBase64 } from '~/utils/crypto';
|
||||
import { providerIcons } from '~/utils/model-mapping';
|
||||
import { sortByReleaseDate } from '~/utils/sort';
|
||||
|
||||
// the providers offered up front, the rest sit behind "More…"
|
||||
const FEATURED_PROVIDERS = ['openrouter', 'anthropic', 'openai', 'google', 'ollama'];
|
||||
// these run on your own machine and are reached by address, not by key
|
||||
const LOCAL_PROVIDERS = ['ollama', 'vllm'];
|
||||
// A provider can list hundreds of models, so only the newest few are turned on
|
||||
// to start with. The rest are a click away in the provider's settings.
|
||||
const MODELS_ENABLED_AT_START = 8;
|
||||
|
||||
const PROVIDER_LABELS: Record<string, string> = {
|
||||
openrouter: 'OpenRouter',
|
||||
anthropic: 'Anthropic',
|
||||
openai: 'OpenAI',
|
||||
google: 'Google',
|
||||
ollama: 'Ollama',
|
||||
};
|
||||
|
||||
const KEY_PLACEHOLDERS: Record<string, string> = {
|
||||
openrouter: 'sk-or-…',
|
||||
anthropic: 'sk-ant-…',
|
||||
openai: 'sk-…',
|
||||
};
|
||||
|
||||
// Tint of the chosen option. UnoCSS can't put an opacity on a CSS variable, so
|
||||
// "bg-[var(--color-accent)]/10" comes out as the solid accent colour.
|
||||
const SELECTED_STYLE = {
|
||||
backgroundColor: 'color-mix(in srgb, var(--color-accent) 10%, transparent)',
|
||||
};
|
||||
|
||||
defineProps<{
|
||||
submitLabel: string;
|
||||
// shown next to skip, for flows that have a step before this one
|
||||
showBack?: boolean;
|
||||
}>();
|
||||
|
||||
// done: a provider was connected or the step was skipped
|
||||
const emit = defineEmits(['done', 'back']);
|
||||
|
||||
const { providers } = await useModels();
|
||||
|
||||
const showAll = ref(false);
|
||||
const selectedType = ref(FEATURED_PROVIDERS.find(type => providers.value.some(p => p.type === type))
|
||||
?? providers.value[0]?.type ?? '');
|
||||
|
||||
const featured = computed(() => FEATURED_PROVIDERS
|
||||
.map(type => providers.value.find(p => p.type === type))
|
||||
.filter(p => p !== undefined));
|
||||
|
||||
const others = computed(() => providers.value.filter(p => !FEATURED_PROVIDERS.includes(p.type)));
|
||||
|
||||
const shownProviders = computed(() => showAll.value ? [...featured.value, ...others.value] : featured.value);
|
||||
|
||||
const selected = computed(() => providers.value.find(p => p.type === selectedType.value));
|
||||
const isLocal = computed(() => LOCAL_PROVIDERS.includes(selectedType.value));
|
||||
|
||||
const apiKey = ref('');
|
||||
const apiKeyVisible = ref(false);
|
||||
const baseUrl = ref('');
|
||||
|
||||
const busy = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
const ready = computed(() => isLocal.value ? baseUrl.value.trim() !== '' : apiKey.value.trim() !== '');
|
||||
|
||||
const selectProvider = (type: string) => {
|
||||
selectedType.value = type;
|
||||
error.value = '';
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
const provider = selected.value;
|
||||
if (busy.value || !ready.value || !provider) return;
|
||||
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
|
||||
const storedKey = localStorage.getItem('encryptionKey');
|
||||
if (storedKey === null) {
|
||||
error.value = 'This browser has no encryption key for your account. Log out and back in, then add the provider under Settings.';
|
||||
busy.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const key = apiKey.value.trim();
|
||||
const config = { ...provider.config };
|
||||
|
||||
try {
|
||||
if (isLocal.value) {
|
||||
config.apiProxyUrl = baseUrl.value.trim();
|
||||
} else {
|
||||
const cryptoKey = await crypto.subtle.importKey('jwk', JSON.parse(storedKey), 'AES-GCM', false, ['encrypt', 'decrypt']);
|
||||
config.apiKey = uint8ArrayToBase64(await encryptData(cryptoKey, key));
|
||||
}
|
||||
|
||||
const saved = await $fetch(`/api/provider/${provider.id}`, {
|
||||
method: 'PATCH',
|
||||
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.';
|
||||
busy.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
let models: Model[];
|
||||
try {
|
||||
const response = await $fetch<{ models: Model[] }>(`/api/provider/${provider.id}/models`, {
|
||||
method: 'POST',
|
||||
body: { providerApiKey: isLocal.value ? undefined : key },
|
||||
});
|
||||
models = response.models;
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch models:', err);
|
||||
error.value = isLocal.value
|
||||
? 'No models could be fetched from that address. Check that it is right and reachable from the server.'
|
||||
: 'No models could be fetched with that key. Check that it is right and try again.';
|
||||
busy.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const toEnable = models
|
||||
.filter(m => m.outputModalities.includes('text'))
|
||||
.sort(sortByReleaseDate)
|
||||
.slice(0, MODELS_ENABLED_AT_START);
|
||||
|
||||
if (toEnable.length === 0) {
|
||||
error.value = 'The provider answered, but has no chat models to offer. Try another provider.';
|
||||
busy.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// a model that fails to turn on here can still be turned on in settings
|
||||
const results = await Promise.allSettled(toEnable.map(m => $fetch(`/api/model/${m.id}`, {
|
||||
method: 'PATCH',
|
||||
body: { enabled: true },
|
||||
})));
|
||||
const enabledIds = new Set(toEnable.filter((_, i) => results[i]!.status === 'fulfilled').map(m => m.id));
|
||||
|
||||
providers.value = providers.value.map(p => p.id === provider.id
|
||||
? { ...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>
|
||||
|
||||
<template>
|
||||
<form class="flex flex-col gap-4" @submit.prevent="submit">
|
||||
<fieldset class="flex flex-col">
|
||||
<legend
|
||||
class="mb-1.5 ml-1 text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)]">
|
||||
Provider
|
||||
</legend>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<button v-for="p in shownProviders" :key="p.id" type="button"
|
||||
:aria-pressed="selectedType === p.type" :disabled="busy" @click="selectProvider(p.type)"
|
||||
class="flex items-center justify-center gap-2 h-11 px-2 min-w-0 text-sm rounded-lg border disabled:cursor-not-allowed transition-colors duration-200"
|
||||
:style="selectedType === p.type ? SELECTED_STYLE : undefined" :class="selectedType === p.type
|
||||
? 'border-[var(--color-accent)] font-medium'
|
||||
: 'border-[var(--color-border)] enabled:@hover:bg-[var(--color-hover)]'">
|
||||
<component v-if="providerIcons[p.type]" :is="providerIcons[p.type]" :color="true"
|
||||
class="w-5.5 h-5.5 flex-shrink-0 text-[var(--text-primary)]" />
|
||||
<span class="case-capital overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{{ PROVIDER_LABELS[p.type] ?? p.name }}
|
||||
</span>
|
||||
</button>
|
||||
<button v-if="!showAll && others.length > 0" type="button" :disabled="busy"
|
||||
@click="showAll = true"
|
||||
class="flex items-center justify-center h-11 text-sm rounded-lg border border-[var(--color-border)] text-[var(--text-secondary)] enabled:@hover:bg-[var(--color-hover)] disabled:cursor-not-allowed transition-colors duration-200">
|
||||
More…
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div v-if="isLocal" class="flex flex-col gap-1.5">
|
||||
<label for="baseurl"
|
||||
class="text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)] ml-1">
|
||||
Server address
|
||||
</label>
|
||||
<input id="baseurl" v-model="baseUrl" type="url" :disabled="busy" autocomplete="off"
|
||||
spellcheck="false" :placeholder="selected?.defaultBaseUrl || 'http://localhost:11434'"
|
||||
class="text-sm border border-[var(--color-border)]" />
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-1.5">
|
||||
<label for="apikey"
|
||||
class="text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)] ml-1">
|
||||
API key
|
||||
</label>
|
||||
<div
|
||||
class="flex items-center rounded-md bg-[var(--bg-container)] border border-[var(--color-border)]">
|
||||
<input id="apikey" v-model="apiKey" :type="apiKeyVisible ? 'text' : 'password'"
|
||||
:disabled="busy" autocomplete="off" spellcheck="false"
|
||||
:placeholder="KEY_PLACEHOLDERS[selectedType] ?? 'API key'"
|
||||
class="flex-1 min-w-0 text-sm !bg-transparent" />
|
||||
<button type="button" :aria-label="apiKeyVisible ? 'Hide API key' : 'Show API key'"
|
||||
@click="apiKeyVisible = !apiKeyVisible"
|
||||
class="flex items-center justify-center w-10 h-10 flex-shrink-0 text-[var(--text-secondary)]">
|
||||
<span class="text-4.5" :class="apiKeyVisible ? 'i-mynaui-eye' : 'i-mynaui-eye-slash'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="!isLocal" class="flex gap-2 text-xs leading-normal text-[var(--text-secondary)]">
|
||||
<span class="i-mynaui-lock text-4 mt-0.25 flex-shrink-0" aria-hidden="true"></span>
|
||||
<span>
|
||||
Encrypted in this browser with AES-GCM before it is stored. Nobody else on this instance,
|
||||
including admins, can read it.
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<slot name="note"></slot>
|
||||
|
||||
<p v-if="error" role="alert" class="text-sm text-red-400">{{ error }}</p>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button v-if="showBack" type="button" :disabled="busy" @click="emit('back')"
|
||||
class="flex items-center justify-center h-11 px-4 rounded-md border border-[var(--color-border)] text-[var(--text-secondary)] enabled:@hover:bg-[var(--color-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200">
|
||||
Back
|
||||
</button>
|
||||
<button type="button" :disabled="busy" @click="emit('done')"
|
||||
class="flex items-center justify-center h-11 px-4 rounded-md border border-[var(--color-border)] text-[var(--text-secondary)] enabled:@hover:bg-[var(--color-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200">
|
||||
Skip for now
|
||||
</button>
|
||||
<button type="submit" :disabled="busy || !ready"
|
||||
class="accent flex-1 h-11 flex items-center justify-center font-semibold disabled:opacity-40 disabled:cursor-not-allowed">
|
||||
<span v-if="busy" class="i-svg-spinners-90-ring-with-bg text-xl"></span>
|
||||
<span v-else>{{ submitLabel }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
const STEPS = ['Admin account', 'Instance', 'Provider'];
|
||||
|
||||
defineProps<{
|
||||
// 1 based, the steps before it show as done
|
||||
current: number;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ol class="flex items-center gap-2 text-sm">
|
||||
<template v-for="(step, index) in STEPS" :key="step">
|
||||
<li v-if="index > 0" aria-hidden="true" class="flex-1 h-px bg-[var(--color-border)] [list-style-type:none]">
|
||||
</li>
|
||||
<li class="flex items-center gap-2 whitespace-nowrap [list-style-type:none]"
|
||||
:aria-current="index + 1 === current ? 'step' : undefined"
|
||||
:class="index + 1 === current ? 'font-medium text-[var(--text-primary)]' : 'text-[var(--text-secondary)]'">
|
||||
<span class="flex items-center justify-center w-6 h-6 rounded-md text-xs" :class="index + 1 <= current
|
||||
? 'bg-[var(--color-accent)] text-[var(--color-accent-text)] font-semibold'
|
||||
: 'border border-[var(--color-border-active)]'">
|
||||
<span v-if="index + 1 < current" class="i-mynaui-check text-3.5" aria-label="Done"></span>
|
||||
<template v-else>{{ index + 1 }}</template>
|
||||
</span>
|
||||
{{ step }}
|
||||
</li>
|
||||
</template>
|
||||
</ol>
|
||||
</template>
|
||||
@@ -1,11 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
params?: string;
|
||||
}>();
|
||||
|
||||
defineEmits(['navigate']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
hi
|
||||
</template>
|
||||
@@ -1,47 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { providerIcons } from '~/utils/model-mapping';
|
||||
|
||||
defineProps<{
|
||||
params?: string;
|
||||
}>();
|
||||
const { providers } = await useModels();
|
||||
|
||||
defineEmits(['navigate']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-1 overflow-auto">
|
||||
<button @click="$emit('navigate', 'general')"
|
||||
class="flex items-center gap-2 p-2 rounded-lg @hover:bg-[var(--color-hover)] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
<span class="i-mynaui-chevron-left md:text-4"></span> Back to General
|
||||
</button>
|
||||
|
||||
<button @click="$emit('navigate', 'providers')"
|
||||
class="flex items-center gap-2 p-2 rounded-lg @hover:bg-[var(--color-hover)] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
<span class="i-mynaui-envelope-open md:text-4"></span> All
|
||||
</button>
|
||||
|
||||
<div class="px-2 py-4 font-bold md:text-xs text-sm uppercase opacity-50">Enabled Providers</div>
|
||||
|
||||
<button v-for="p in providers?.filter(p => p.enabled)" :key="p.id" @click="$emit('navigate', 'providers', p.id)"
|
||||
:class="['case-capital flex items-center justify-between p-2 @hover:bg-[var(--color-hover)] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg', p.id === params ? 'bg-[var(--color-hover)]' : '']">
|
||||
<div class="flex items-center gap-2">
|
||||
<component v-if="providerIcons[p.type]" :color="true" :is="providerIcons[p.type]"
|
||||
class="h-6 w-6 md:w-4 md:h-4 text-[var(--text-primary)]" />
|
||||
<span>{{ p.name }}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div class="px-2 py-4 font-bold md:text-xs text-sm uppercase opacity-50">Disabled Providers</div>
|
||||
|
||||
<button v-for="p in providers?.filter(p => !p.enabled)" :key="p.id"
|
||||
@click="$emit('navigate', 'providers', p.id)"
|
||||
:class="['case-capital flex items-center justify-between p-2 @hover:bg-[var(--color-hover)] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg', p.id === params ? 'bg-[var(--color-hover)]' : '']">
|
||||
<div class="flex items-center gap-2">
|
||||
<component v-if="providerIcons[p.type]" :color="true" :is="providerIcons[p.type]"
|
||||
class="h-6 w-6 md:w-4 md:h-4 h-full text-[var(--text-primary)]" />
|
||||
<span>{{ p.name }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,6 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { DialogType } from '~/composables/useDialog';
|
||||
|
||||
const { user, signOut } = useAuth();
|
||||
|
||||
// to prevent the user details from going blank for a
|
||||
@@ -16,10 +14,14 @@ watch(user, () => {
|
||||
cachedUser.value = user.value;
|
||||
})
|
||||
|
||||
const { openDialog } = useDialog();
|
||||
|
||||
const { isHovered } = useSidenavContext();
|
||||
|
||||
const openSettings = async () => {
|
||||
dropdownOpen.value = false;
|
||||
|
||||
await navigateTo('/settings');
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
await signOut();
|
||||
|
||||
@@ -31,11 +33,7 @@ const handleLogout = async () => {
|
||||
<header class="flex items-center justify-between overflow-hidden">
|
||||
<button @click="dropdownOpen = !dropdownOpen"
|
||||
class="flex gap-1 pr-1 items-center @hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] rounded-lg transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
<div
|
||||
:class="['w-9 h-9 md:w-7 md:h-7 flex-shrink-0 rounded-lg overflow-hidden bg-[var(--bg-surface)] flex items-center justify-center', user?.image ? '' : 'border border-[var(--color-border)]']">
|
||||
<img v-if="cachedUser?.image" :src="cachedUser.image" class="w-full h-full object-cover" />
|
||||
<span v-else class="w-5.5 h-5.5 md:h-4 md:w-4 i-mynaui-user text-[var(--text-secondary)]"></span>
|
||||
</div>
|
||||
<UserAvatar :user="cachedUser" class="w-9 h-9 md:w-7 md:h-7 rounded-lg md:text-sm" />
|
||||
<span
|
||||
class="md:text-sm font-medium text-ellipsis overflow-hidden text-[var(--text-primary)] whitespace-nowrap">
|
||||
{{ cachedUser?.name }}
|
||||
@@ -55,7 +53,7 @@ const handleLogout = async () => {
|
||||
leave-from-class="opacity-100 scale-100" leave-to-class="opacity-0 scale-95">
|
||||
<div v-if="dropdownOpen" v-click-outside="() => dropdownOpen = false"
|
||||
class="w-full top-full text-sm mt-1 absolute z-30 bg-[var(--bg-surface)] border border-[var(--color-border)] rounded-xl p-1.5 flex flex-col gap-2">
|
||||
<button @click="dropdownOpen = false; openDialog(DialogType.Settings)"
|
||||
<button @click="openSettings"
|
||||
class="text-left px-3 py-1.5 items-center gap-1 @hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
<span class="i-mynaui-cog-four text-4.5"></span>
|
||||
<span>Settings</span>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
const returnTo = useState('settings:returnTo', () => '/');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="flex items-center rounded-lg overflow-hidden flex-1 min-w-0">
|
||||
<NuxtLink :to="returnTo" aria-label="Back"
|
||||
class="flex items-center gap-1 p-1.5 pr-2.5 @hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] rounded-lg decoration-none transition-inherit">
|
||||
<span class="i-mynaui-chevron-left text-5.5 md:text-4.5 text-[var(--text-secondary)]"></span>
|
||||
<span
|
||||
class="md:text-sm font-medium text-ellipsis overflow-hidden text-[var(--text-primary)] whitespace-nowrap">
|
||||
Settings
|
||||
</span>
|
||||
</NuxtLink>
|
||||
</header>
|
||||
</template>
|
||||
@@ -0,0 +1,83 @@
|
||||
<script setup lang="ts">
|
||||
import { providerIcons } from '~/utils/model-mapping';
|
||||
|
||||
const route = useRoute();
|
||||
const { providers } = await useModels();
|
||||
const { isAdmin } = useAuth();
|
||||
|
||||
const ADMIN_GROUP = {
|
||||
label: 'Instance',
|
||||
adminOnly: true,
|
||||
items: [
|
||||
{ to: '/settings/instance/users', name: 'Users', icon: 'i-mynaui-users' },
|
||||
{ to: '/settings/instance', name: 'Instance', icon: 'i-tabler-server' },
|
||||
],
|
||||
};
|
||||
|
||||
const BASE_GROUPS = [
|
||||
{
|
||||
label: 'Account',
|
||||
adminOnly: false,
|
||||
items: [
|
||||
{ to: '/settings/profile', name: 'Profile', icon: 'i-mynaui-user' },
|
||||
{ to: '/settings/security', name: 'Security', icon: 'i-mynaui-shield' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'App',
|
||||
adminOnly: false,
|
||||
items: [
|
||||
{ to: '/settings/general', name: 'General', icon: 'i-mynaui-cog-four' },
|
||||
{ to: '/settings/appearance', name: 'Appearance', icon: 'i-tabler-palette' },
|
||||
{ to: '/settings/providers', name: 'AI Providers', icon: 'i-mynaui-api' },
|
||||
{ to: '/settings/assistants', name: 'System Assistants', icon: 'i-mynaui-sparkles' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const groups = computed(() => isAdmin.value ? [...BASE_GROUPS, ADMIN_GROUP] : BASE_GROUPS);
|
||||
|
||||
const inProviders = computed(() => route.path.startsWith('/settings/providers'));
|
||||
|
||||
// enabled providers first, same order the provider list uses
|
||||
const sortedProviders = computed(() => [
|
||||
...providers.value.filter(p => p.enabled),
|
||||
...providers.value.filter(p => !p.enabled),
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav aria-label="Settings" class="flex flex-col gap-1 overflow-auto">
|
||||
<template v-for="group in groups" :key="group.label">
|
||||
<div
|
||||
class="flex items-center justify-between px-2 pt-3 pb-0.5 text-sm md:text-xs font-medium uppercase tracking-wider">
|
||||
{{ group.label }}
|
||||
<span v-if="group.adminOnly"
|
||||
class="px-1.5 py-0.25 rounded border border-[var(--color-border-active)] text-[10px] tracking-wide">
|
||||
Admin only
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<template v-for="item in group.items" :key="item.to">
|
||||
<SidenavItem :to="item.to" :name="item.name" :icon="item.icon" :active="route.path === item.to" />
|
||||
|
||||
<!-- the providers are listed under their section while it is open -->
|
||||
<div v-if="item.to === '/settings/providers' && inProviders" class="flex flex-col gap-1 pl-4">
|
||||
<NuxtLink v-for="p in sortedProviders" :key="p.id" :to="`/settings/providers/${p.id}`" :class="[
|
||||
'case-capital decoration-none flex items-center gap-2 shrink-0 px-2 h-9 rounded-lg transition-colors',
|
||||
route.path === `/settings/providers/${p.id}`
|
||||
? 'text-[var(--text-primary)] bg-[var(--color-hover)]'
|
||||
: 'text-[var(--text-secondary)] @hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)]',
|
||||
p.enabled ? '' : 'opacity-50',
|
||||
]">
|
||||
<component v-if="providerIcons[p.type]" :color="true" :is="providerIcons[p.type]"
|
||||
class="h-6 w-6 md:w-4 md:h-4 flex-shrink-0 text-[var(--text-primary)]" />
|
||||
<span class="md:text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{{ p.name }}
|
||||
</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -1,6 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { DialogType } from '~/composables/useDialog';
|
||||
|
||||
const { close: closeSidebar, open, sidebarWidth, resize, saveWidth } = useSidebar();
|
||||
const route = useRoute();
|
||||
|
||||
@@ -37,8 +35,6 @@ const onFocusOut = (e: FocusEvent) => {
|
||||
}
|
||||
};
|
||||
|
||||
const { openDialog } = useDialog();
|
||||
|
||||
const onResizeStart = (event: MouseEvent) => {
|
||||
isResizing.value = true;
|
||||
startX.value = event.clientX;
|
||||
@@ -97,8 +93,18 @@ const navKind = computed(() => {
|
||||
if (route.path === '/') return 'home';
|
||||
if (route.path.startsWith('/agent/')) return 'agent';
|
||||
if (route.path === '/resources') return 'resources';
|
||||
if (route.path.startsWith('/settings')) return 'settings';
|
||||
return null;
|
||||
});
|
||||
|
||||
// remember where the user was so the settings back button can return there
|
||||
const settingsReturnTo = useState('settings:returnTo', () => '/');
|
||||
|
||||
watch(() => route.fullPath, (path) => {
|
||||
if (!path.startsWith('/settings')) {
|
||||
settingsReturnTo.value = path;
|
||||
}
|
||||
}, { immediate: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -126,6 +132,7 @@ const navKind = computed(() => {
|
||||
<SidenavHeader v-if="navKind === 'home'" />
|
||||
<SidenavHeaderResources v-else-if="navKind === 'resources'" />
|
||||
<SidenavHeaderAgent v-else-if="navKind === 'agent'" />
|
||||
<SidenavHeaderSettings v-else-if="navKind === 'settings'" />
|
||||
|
||||
<div class="flex items-center justify-end text-[var(--text-secondary)] gap-0.5">
|
||||
<div :class="[
|
||||
@@ -158,15 +165,18 @@ const navKind = computed(() => {
|
||||
<SidenavNavHome v-if="navKind === 'home'" />
|
||||
<SidenavNavAgent v-else-if="navKind === 'agent'" />
|
||||
<SidenavNavResources v-else-if="navKind === 'resources'" />
|
||||
<SidenavNavSettings v-else-if="navKind === 'settings'" />
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between pt-2 z-25 bg-[var(--bg-base)] pb-2">
|
||||
<div class="flex">
|
||||
<Tooltip :hotkey="['ctrl', ',']">
|
||||
<button @click="openDialog(DialogType.Settings)"
|
||||
class="flex items-center justify-center h-9 w-9 md:h-7 md:w-7 cursor-pointer @hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] rounded-lg transition-colors text-[var(--text-secondary)] active:text-[var(--text-primary)]">
|
||||
<NuxtLink to="/settings" aria-label="Settings" :class="[
|
||||
'flex items-center justify-center h-9 w-9 md:h-7 md:w-7 cursor-pointer @hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] rounded-lg transition-colors active:text-[var(--text-primary)]',
|
||||
navKind === 'settings' ? 'text-[var(--text-primary)] bg-[var(--color-hover)]' : 'text-[var(--text-secondary)]',
|
||||
]">
|
||||
<span class="i-mynaui-cog-four text-6 md:text-5"></span>
|
||||
</button>
|
||||
</NuxtLink>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { avatarColor, avatarInitial } from '~/utils/avatar';
|
||||
|
||||
const props = defineProps<{
|
||||
user: {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
image?: string | null;
|
||||
} | null | undefined;
|
||||
}>();
|
||||
|
||||
// fall back to the letter placeholder if the image fails to load
|
||||
const imageFailed = ref(false);
|
||||
|
||||
watch(() => props.user?.image, () => {
|
||||
imageFailed.value = false;
|
||||
});
|
||||
|
||||
const showImage = computed(() => !!props.user?.image && !imageFailed.value);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-shrink-0 overflow-hidden flex items-center justify-center font-semibold text-white select-none"
|
||||
:style="showImage ? {} : { backgroundColor: avatarColor(user?.id ?? '') }">
|
||||
<img v-if="showImage" :src="user!.image!" alt="" class="w-full h-full object-cover"
|
||||
@error="imageFailed = true" />
|
||||
<span v-else aria-hidden="true">{{ avatarInitial(user?.name) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
+93
-10
@@ -1,4 +1,5 @@
|
||||
import type { BetterAuthClientOptions, InferSessionFromClient, InferUserFromClient, User } from 'better-auth/client';
|
||||
import { adminClient, twoFactorClient } from 'better-auth/client/plugins';
|
||||
import { createAuthClient } from 'better-auth/vue';
|
||||
import { Err, Ok, type Result } from '~~/types/result';
|
||||
import { assert } from '~~/utils/assert';
|
||||
@@ -8,19 +9,34 @@ export enum AuthError {
|
||||
SignInFailed = 'SIGN_IN_FAILED',
|
||||
SignUpFailed = 'SIGN_UP_FAILED',
|
||||
SignOutFailed = 'SIGN_OUT_FAILED',
|
||||
UpdateFailed = 'UPDATE_FAILED',
|
||||
NetworkError = 'NETWORK_ERROR',
|
||||
}
|
||||
|
||||
type sessionData = {
|
||||
session: InferSessionFromClient<BetterAuthClientOptions> | null;
|
||||
user: InferUserFromClient<BetterAuthClientOptions> | null;
|
||||
export type AuthUser = InferUserFromClient<BetterAuthClientOptions> & {
|
||||
twoFactorEnabled?: boolean | null;
|
||||
role?: string | null;
|
||||
};
|
||||
|
||||
type sessionData = {
|
||||
session: InferSessionFromClient<BetterAuthClientOptions> | null;
|
||||
user: AuthUser | null;
|
||||
};
|
||||
|
||||
// Signing in either finishes right away, or stops at the second factor when
|
||||
// the account has two-factor authentication turned on.
|
||||
export type SignInResult =
|
||||
| { twoFactorRequired: false; user: User; token: string }
|
||||
| { twoFactorRequired: true };
|
||||
|
||||
export const useAuth = () => {
|
||||
const authClient = createAuthClient({});
|
||||
const authClient = createAuthClient({
|
||||
plugins: [twoFactorClient(), adminClient()],
|
||||
});
|
||||
const session = useState<InferSessionFromClient<BetterAuthClientOptions> | null>('auth:session', () => null);
|
||||
const user = useState<InferUserFromClient<BetterAuthClientOptions> | null>('auth:user', () => null);
|
||||
const user = useState<AuthUser | null>('auth:user', () => null);
|
||||
const sessionFetching = import.meta.server ? ref(false) : useState('auth:sessionFetching', () => false);
|
||||
const isAdmin = computed(() => user.value?.role === 'admin');
|
||||
|
||||
let sessionPromise: Promise<Result<sessionData, AuthError>> | null = null;
|
||||
|
||||
@@ -35,7 +51,7 @@ export const useAuth = () => {
|
||||
sessionPromise = promise;
|
||||
let data: {
|
||||
session: InferSessionFromClient<BetterAuthClientOptions>;
|
||||
user: InferUserFromClient<BetterAuthClientOptions>;
|
||||
user: AuthUser;
|
||||
} | null = null;
|
||||
|
||||
try {
|
||||
@@ -44,7 +60,7 @@ export const useAuth = () => {
|
||||
(
|
||||
await useFetch<{
|
||||
session: InferSessionFromClient<BetterAuthClientOptions>;
|
||||
user: InferUserFromClient<BetterAuthClientOptions>;
|
||||
user: AuthUser;
|
||||
}>('/api/auth/get-session')
|
||||
).data.value ?? null;
|
||||
} else {
|
||||
@@ -74,7 +90,7 @@ export const useAuth = () => {
|
||||
const signIn = async (
|
||||
email: string,
|
||||
password: string
|
||||
): Promise<Result<{ user: User; token: string }, { error: AuthError, data?: any }>> => {
|
||||
): Promise<Result<SignInResult, { error: AuthError, data?: any }>> => {
|
||||
sessionFetching.value = true;
|
||||
|
||||
try {
|
||||
@@ -92,11 +108,16 @@ export const useAuth = () => {
|
||||
return Err({ error: AuthError.SignInFailed });
|
||||
}
|
||||
|
||||
// no session exists yet, verifyTwoFactor() finishes the sign in
|
||||
if ('twoFactorRedirect' in data && data.twoFactorRedirect) {
|
||||
return Ok({ twoFactorRequired: true });
|
||||
}
|
||||
|
||||
user.value = data.user;
|
||||
|
||||
clearNuxtData();
|
||||
|
||||
return Ok({ user: data.user, token: data.token });
|
||||
return Ok({ twoFactorRequired: false, user: data.user, token: data.token });
|
||||
} catch (err) {
|
||||
console.error('Sign in error:', err);
|
||||
return Err({ error: AuthError.NetworkError });
|
||||
@@ -105,10 +126,45 @@ export const useAuth = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const verifyTwoFactor = async (
|
||||
code: string,
|
||||
options: { backupCode?: boolean; trustDevice?: boolean } = {}
|
||||
): Promise<Result<{ user: User; token: string }, { error: AuthError, data?: any }>> => {
|
||||
sessionFetching.value = true;
|
||||
|
||||
try {
|
||||
const body = { code, trustDevice: options.trustDevice ?? false };
|
||||
const { data, error } = options.backupCode
|
||||
? await authClient.twoFactor.verifyBackupCode(body)
|
||||
: await authClient.twoFactor.verifyTotp(body);
|
||||
|
||||
if (error) {
|
||||
return Err({ error: AuthError.SignInFailed, data: error });
|
||||
}
|
||||
|
||||
if (!data || !data.token) {
|
||||
return Err({ error: AuthError.SignInFailed });
|
||||
}
|
||||
|
||||
user.value = data.user as AuthUser;
|
||||
|
||||
clearNuxtData();
|
||||
|
||||
return Ok({ user: data.user as User, token: data.token });
|
||||
} catch (err) {
|
||||
console.error('Two-factor verification error:', err);
|
||||
return Err({ error: AuthError.NetworkError });
|
||||
} finally {
|
||||
sessionFetching.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const signUp = async (
|
||||
email: string,
|
||||
password: string,
|
||||
name: string
|
||||
name: string,
|
||||
// needed while the instance only takes invited people
|
||||
inviteToken?: string,
|
||||
): Promise<Result<{ user: User; token: string }, { error: AuthError, data?: any }>> => {
|
||||
sessionFetching.value = true;
|
||||
|
||||
@@ -117,6 +173,8 @@ export const useAuth = () => {
|
||||
email,
|
||||
password,
|
||||
name,
|
||||
}, {
|
||||
headers: inviteToken ? { 'x-invite-token': inviteToken } : undefined,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
@@ -166,14 +224,39 @@ export const useAuth = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const updateProfile = async (
|
||||
updates: { name?: string; image?: string | null }
|
||||
): Promise<Result<void, AuthError>> => {
|
||||
try {
|
||||
const { error } = await authClient.updateUser(updates);
|
||||
|
||||
if (error) {
|
||||
console.error('Profile update failed:', error);
|
||||
return Err(AuthError.UpdateFailed);
|
||||
}
|
||||
|
||||
if (user.value) {
|
||||
user.value = { ...user.value, ...updates };
|
||||
}
|
||||
|
||||
return Ok(undefined);
|
||||
} catch (err) {
|
||||
console.error('Profile update error:', err);
|
||||
return Err(AuthError.NetworkError);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
client: authClient,
|
||||
session,
|
||||
user,
|
||||
isAdmin,
|
||||
loggedIn: computed(() => !!session.value),
|
||||
signIn,
|
||||
verifyTwoFactor,
|
||||
signUp,
|
||||
signOut,
|
||||
updateProfile,
|
||||
fetchSession,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { Err, Ok, type Result } from "~~/types/result";
|
||||
|
||||
export enum DialogType {
|
||||
Settings = 'settings',
|
||||
Textbox = 'textbox',
|
||||
QuickSwitcher = 'quick-switcher',
|
||||
Confirm = 'confirm',
|
||||
ChangePassword = 'change-password',
|
||||
TwoFactor = 'two-factor',
|
||||
DeleteAccount = 'delete-account',
|
||||
CreateUser = 'create-user',
|
||||
CreateInvite = 'create-invite',
|
||||
RecoverAccount = 'recover-account',
|
||||
}
|
||||
|
||||
interface DialogOptions {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -22,6 +22,31 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
||||
return await navigateTo((to.query.to as string) ?? "/");
|
||||
}
|
||||
|
||||
// A fresh instance has no accounts, so there is nothing to log in to yet
|
||||
// and the first visitor creates the admin account instead.
|
||||
if (!loggedIn.value) {
|
||||
const setupNeeded = useState<boolean | null>('instance:setupNeeded', () => null);
|
||||
const isSetupPage = to.path.toLowerCase() === '/auth/setup';
|
||||
|
||||
// once an account exists the answer never changes back
|
||||
if (setupNeeded.value !== false) {
|
||||
try {
|
||||
setupNeeded.value = (await $fetch<{ setupNeeded: boolean }>('/api/instance')).setupNeeded;
|
||||
} catch (error) {
|
||||
console.error('Failed to check instance setup:', error);
|
||||
setupNeeded.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (setupNeeded.value === true && !isSetupPage) {
|
||||
return await navigateTo('/auth/setup');
|
||||
}
|
||||
|
||||
if (setupNeeded.value !== true && isSetupPage) {
|
||||
return await navigateTo('/auth/login');
|
||||
}
|
||||
}
|
||||
|
||||
// If not authenticated, and not on a signin/signup page, redirect to login page
|
||||
if (!loggedIn.value && !isAuthPage) {
|
||||
return await navigateTo(`/auth/login?to=${to.path}`);
|
||||
|
||||
+194
-7
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import CodeInput from '~/components/CodeInput.vue';
|
||||
import { deriveKey } from '~/utils/crypto';
|
||||
|
||||
const { client: authClient, signIn, session } = useAuth();
|
||||
const { client: authClient, signIn, verifyTwoFactor, session } = useAuth();
|
||||
|
||||
definePageMeta({
|
||||
layout: 'auth',
|
||||
@@ -9,11 +10,16 @@ definePageMeta({
|
||||
|
||||
const to = useRoute().query.to as string | undefined;
|
||||
|
||||
// set by the recovery page after a new password was chosen: 'keys' when saved
|
||||
// API keys were deleted along the way, anything else when there were none
|
||||
const recovered = useRoute().query.recovered as string | undefined;
|
||||
|
||||
if (session.value !== null) {
|
||||
await navigateTo(to ?? '/');
|
||||
}
|
||||
|
||||
const config = useRuntimeConfig();
|
||||
// who may register is an instance setting, admins change it while it runs
|
||||
const { data: instance } = await useFetch('/api/instance', { key: 'instance' });
|
||||
|
||||
const form = reactive({
|
||||
email: '',
|
||||
@@ -21,6 +27,27 @@ const form = reactive({
|
||||
});
|
||||
const loading = ref(false);
|
||||
|
||||
// second step, only shown for accounts with two-factor authentication
|
||||
const step = ref<'credentials' | 'twoFactor'>('credentials');
|
||||
const twoFactor = reactive({
|
||||
code: '',
|
||||
useBackupCode: false,
|
||||
trustDevice: false,
|
||||
error: '',
|
||||
});
|
||||
const codeInputEl = ref<InstanceType<typeof CodeInput> | null>(null);
|
||||
const backupCodeInputEl = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const focusCodeInput = async () => {
|
||||
await nextTick();
|
||||
|
||||
if (twoFactor.useBackupCode) {
|
||||
backupCodeInputEl.value?.focus();
|
||||
} else {
|
||||
codeInputEl.value?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const emailInputEl = ref<HTMLInputElement | null>(null);
|
||||
const passwordInputEl = ref<HTMLInputElement | null>(null);
|
||||
|
||||
@@ -88,6 +115,10 @@ const submit = async () => {
|
||||
emailInputEl.value!.setCustomValidity(res.error.data.message!);
|
||||
emailInputEl.value!.reportValidity();
|
||||
break;
|
||||
case 'BANNED_USER':
|
||||
emailInputEl.value!.setCustomValidity('This account has been disabled by an admin of this instance.');
|
||||
emailInputEl.value!.reportValidity();
|
||||
break;
|
||||
default:
|
||||
console.log(res.error);
|
||||
alert(`Something went wrong. ${res.error.data.message}`);
|
||||
@@ -97,11 +128,94 @@ const submit = async () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = await deriveKey(form.password, res.data.user.id);
|
||||
if (res.data.twoFactorRequired) {
|
||||
twoFactor.code = '';
|
||||
twoFactor.error = '';
|
||||
twoFactor.useBackupCode = false;
|
||||
step.value = 'twoFactor';
|
||||
|
||||
await focusCodeInput();
|
||||
return;
|
||||
}
|
||||
|
||||
return finishSignIn(res.data.user.id);
|
||||
};
|
||||
|
||||
// The password is still in memory at this point, which is the only time we
|
||||
// can derive the key that encrypts the user's API keys.
|
||||
const finishSignIn = async (userId: string) => {
|
||||
const key = await deriveKey(form.password, userId);
|
||||
localStorage.setItem('encryptionKey', JSON.stringify(key));
|
||||
|
||||
return navigateTo(to ?? '/');
|
||||
};
|
||||
|
||||
const backToCredentials = (error = '') => {
|
||||
step.value = 'credentials';
|
||||
twoFactor.code = '';
|
||||
twoFactor.error = '';
|
||||
|
||||
if (error) {
|
||||
passwordInputEl.value!.setCustomValidity(error);
|
||||
passwordInputEl.value!.reportValidity();
|
||||
}
|
||||
};
|
||||
|
||||
const toggleBackupCode = async () => {
|
||||
twoFactor.useBackupCode = !twoFactor.useBackupCode;
|
||||
twoFactor.code = '';
|
||||
twoFactor.error = '';
|
||||
|
||||
await focusCodeInput();
|
||||
};
|
||||
|
||||
const submitTwoFactor = async () => {
|
||||
const code = twoFactor.code.trim();
|
||||
if (code === '' || loading.value) return;
|
||||
|
||||
loading.value = true;
|
||||
twoFactor.error = '';
|
||||
|
||||
const res = await verifyTwoFactor(code, {
|
||||
backupCode: twoFactor.useBackupCode,
|
||||
trustDevice: twoFactor.trustDevice,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
loading.value = false;
|
||||
|
||||
if (res.error.data?.status === 429) {
|
||||
twoFactor.error = 'Too many attempts. Wait a few seconds and try again.';
|
||||
return;
|
||||
}
|
||||
|
||||
switch (res.error.data?.code) {
|
||||
case 'INVALID_CODE':
|
||||
twoFactor.error = 'That code is not right. Check your authenticator app and try again.';
|
||||
break;
|
||||
case 'INVALID_BACKUP_CODE':
|
||||
twoFactor.error = 'That backup code is not valid, or it was already used.';
|
||||
break;
|
||||
case 'ACCOUNT_TEMPORARILY_LOCKED':
|
||||
twoFactor.error = 'Too many wrong codes. Two-factor sign-in is locked for a while, try again later.';
|
||||
break;
|
||||
case 'INVALID_TWO_FACTOR_COOKIE':
|
||||
// the 10 minute window to enter a code ran out
|
||||
backToCredentials('Sign in timed out. Enter your password again.');
|
||||
break;
|
||||
default:
|
||||
twoFactor.error = 'Something went wrong. Try again.';
|
||||
break;
|
||||
}
|
||||
|
||||
twoFactor.code = '';
|
||||
await focusCodeInput();
|
||||
return;
|
||||
}
|
||||
|
||||
await finishSignIn(res.data.user.id);
|
||||
loading.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -117,15 +231,35 @@ const submit = async () => {
|
||||
<div class="z-10 max-w-sm w-full">
|
||||
<div class="flex flex-col items-center mb-4 text-center">
|
||||
<h1 class="text-3xl font-bold tracking-tight">Veridian</h1>
|
||||
<p class="text-[var(--text-secondary)]">Sign in to your account</p>
|
||||
<p class="text-[var(--text-secondary)]">
|
||||
{{ step === 'credentials'
|
||||
? 'Sign in to your account'
|
||||
: twoFactor.useBackupCode
|
||||
? 'Enter one of your backup codes'
|
||||
: 'Enter the code from your authenticator app' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="recovered !== undefined && step === 'credentials'" role="status"
|
||||
class="flex gap-2.5 mb-4 px-3.5 py-3 rounded-lg bg-green-600/12 border border-green-400/40 text-sm leading-normal">
|
||||
<span class="i-tabler-circle-check text-4.5 mt-0.5 flex-shrink-0 text-green-400" aria-hidden="true"></span>
|
||||
<span>
|
||||
<strong class="font-semibold">Your password is changed.</strong>
|
||||
<span class="text-[var(--text-secondary)]">
|
||||
Log in with the new one.
|
||||
<template v-if="recovered === 'keys'">
|
||||
Your saved API keys were deleted, add them again under Settings → AI Providers.
|
||||
</template>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl">
|
||||
<form class="flex flex-col gap-2" @submit.prevent="submit">
|
||||
<form v-show="step === 'credentials'" class="flex flex-col gap-2" @submit.prevent="submit">
|
||||
<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>
|
||||
@@ -144,9 +278,62 @@ const submit = async () => {
|
||||
<span v-else>Sign In</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form v-if="step === 'twoFactor'" class="flex flex-col gap-3" @submit.prevent="submitTwoFactor">
|
||||
<div v-if="twoFactor.useBackupCode" class="flex flex-col gap-1.5">
|
||||
<label for="two-factor-code"
|
||||
class="text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)] ml-1">
|
||||
Backup code
|
||||
</label>
|
||||
<input required ref="backupCodeInputEl" type="text" autocomplete="off" autocapitalize="off"
|
||||
spellcheck="false" id="two-factor-code" v-model="twoFactor.code"
|
||||
placeholder="xxxxx-xxxxx"
|
||||
class="font-mono focus:ring-2 focus:ring-[var(--color-accent)]/20 border border-[var(--color-border)] transition-all" />
|
||||
</div>
|
||||
<fieldset v-else>
|
||||
<legend
|
||||
class="mb-1.5 ml-1 text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)]">
|
||||
6-digit code
|
||||
</legend>
|
||||
<CodeInput ref="codeInputEl" v-model="twoFactor.code" large :disabled="loading"
|
||||
@complete="submitTwoFactor" />
|
||||
</fieldset>
|
||||
|
||||
<p v-if="twoFactor.error" role="alert" class="text-sm text-red-400 ml-1">
|
||||
{{ twoFactor.error }}
|
||||
</p>
|
||||
|
||||
<label class="flex items-center gap-2.5 text-sm text-[var(--text-secondary)] cursor-pointer">
|
||||
<input type="checkbox" v-model="twoFactor.trustDevice"
|
||||
class="w-4 h-4 p-0 m-0 accent-[var(--color-accent)]" />
|
||||
<span>Trust this browser for 30 days</span>
|
||||
</label>
|
||||
|
||||
<button class="accent w-full h-11 flex items-center justify-center font-semibold" type="submit">
|
||||
<span v-if="loading" class="i-svg-spinners-90-ring-with-bg text-xl"></span>
|
||||
<span v-else>Verify</span>
|
||||
</button>
|
||||
|
||||
<div class="flex flex-col items-center gap-2 mt-2 text-sm text-[var(--text-secondary)]">
|
||||
<button type="button" @click="toggleBackupCode"
|
||||
class="text-[var(--color-accent)] font-medium @hover:underline">
|
||||
{{ twoFactor.useBackupCode ? 'Use your authenticator app instead' : 'Use a backup code instead' }}
|
||||
</button>
|
||||
<button type="button" @click="backToCredentials()" class="@hover:underline">
|
||||
Back to log in
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p v-if="!config.public.disableSignup" class="text-sm text-center mt-3 text-[var(--text-secondary)]">
|
||||
<p v-if="step === 'credentials'" class="text-sm text-center mt-3">
|
||||
<NuxtLink to="/auth/recover"
|
||||
class="text-[var(--text-secondary)] @hover:text-[var(--text-primary)] @hover:underline">
|
||||
Forgot your password?
|
||||
</NuxtLink>
|
||||
</p>
|
||||
|
||||
<p v-if="instance?.registration === 'open' && step === 'credentials'" class="text-sm text-center mt-1.5 text-[var(--text-secondary)]">
|
||||
New to Veridian?
|
||||
<NuxtLink :to="to ? `/auth/register?to=${to}` : '/auth/register'"
|
||||
class="text-[var(--color-accent)] font-medium @hover:underline">
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
<script setup lang="ts">
|
||||
import { deriveKey } from '~/utils/crypto';
|
||||
|
||||
definePageMeta({
|
||||
layout: 'auth',
|
||||
});
|
||||
|
||||
const token = useRoute().params.token as string;
|
||||
|
||||
const { signIn } = useAuth();
|
||||
|
||||
const { data: link } = await useAsyncData(`recovery_${token}`, () =>
|
||||
$fetch(`/api/recover/${encodeURIComponent(token)}`));
|
||||
|
||||
// set when the link turns out to be used or expired on submit
|
||||
const rejected = ref(false);
|
||||
|
||||
const valid = computed(() => !rejected.value && link.value?.valid ? link.value : null);
|
||||
|
||||
// An account an admin created has no password yet. Its owner picks the first
|
||||
// one here, and nothing is lost by that.
|
||||
const setup = computed(() => valid.value?.newAccount ? valid.value : null);
|
||||
// someone who forgot their password, which costs them their saved API keys
|
||||
const recovery = computed(() => valid.value && !valid.value.newAccount ? valid.value : null);
|
||||
|
||||
const keyCount = computed(() => recovery.value?.keyProviders.length ?? 0);
|
||||
|
||||
const form = reactive({
|
||||
password: '',
|
||||
confirm: '',
|
||||
});
|
||||
const understood = ref(false);
|
||||
|
||||
const busy = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
const confirmInputEl = ref<HTMLInputElement | null>(null);
|
||||
|
||||
// nothing to agree to when there are no keys to lose
|
||||
const ready = computed(() => keyCount.value === 0 || understood.value);
|
||||
|
||||
const submit = async () => {
|
||||
if (busy.value || !ready.value) return;
|
||||
|
||||
if (form.password !== form.confirm) {
|
||||
confirmInputEl.value!.setCustomValidity('Passwords do not match');
|
||||
confirmInputEl.value!.reportValidity();
|
||||
return;
|
||||
}
|
||||
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
|
||||
try {
|
||||
await $fetch(`/api/recover/${encodeURIComponent(token)}`, {
|
||||
method: 'POST',
|
||||
body: { password: form.password },
|
||||
});
|
||||
} catch (err: any) {
|
||||
busy.value = false;
|
||||
|
||||
if (err?.data?.data?.code === 'RECOVERY_LINK_INVALID') {
|
||||
rejected.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('Failed to reset password:', err);
|
||||
error.value = setup.value
|
||||
? 'The password could not be set. Try again.'
|
||||
: 'The password could not be changed. Nothing was deleted, try again.';
|
||||
return;
|
||||
}
|
||||
|
||||
// a key left in this browser was derived from some other password
|
||||
localStorage.removeItem('encryptionKey');
|
||||
|
||||
// A new account goes straight in: the password is still in memory, which is
|
||||
// the only time the key for their API keys can be derived.
|
||||
if (setup.value) {
|
||||
const res = await signIn(setup.value.email, form.password);
|
||||
|
||||
if (res.ok && !res.data.twoFactorRequired) {
|
||||
const key = await deriveKey(form.password, res.data.user.id);
|
||||
localStorage.setItem('encryptionKey', JSON.stringify(key));
|
||||
|
||||
return navigateTo('/welcome');
|
||||
}
|
||||
|
||||
// the password is set, so logging in by hand still works
|
||||
return navigateTo('/auth/login');
|
||||
}
|
||||
|
||||
// the login page confirms the change, there is nothing left to do here
|
||||
return navigateTo(`/auth/login?recovered=${keyCount.value > 0 ? 'keys' : 'done'}`);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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">
|
||||
<div class="flex flex-col items-center gap-1 text-center">
|
||||
<h1 class="text-3xl font-bold tracking-tight">Veridian</h1>
|
||||
<p class="text-[var(--text-secondary)]">
|
||||
<template v-if="setup">Welcome, {{ setup.name }}</template>
|
||||
<template v-else-if="recovery">Set a new password for {{ recovery.email }}</template>
|
||||
<template v-else>This link doesn't work</template>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<template v-if="!valid">
|
||||
<p role="alert"
|
||||
class="px-3 py-2.5 rounded-lg bg-[var(--bg-container)] border border-[var(--color-border)] text-sm leading-snug text-[var(--text-secondary)]">
|
||||
This link doesn't work anymore. It was used already, has expired, or was replaced by a
|
||||
newer one. Ask whoever runs this instance for a new one.
|
||||
</p>
|
||||
<p class="text-sm text-center text-[var(--text-secondary)]">
|
||||
<NuxtLink to="/auth/login" class="text-[var(--color-accent)] font-medium @hover:underline">
|
||||
Back to log in
|
||||
</NuxtLink>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<p v-if="setup" class="text-sm leading-normal text-center text-[var(--text-secondary)]">
|
||||
An account was created for you as <strong class="font-medium text-[var(--text-primary)]">{{
|
||||
setup.email }}</strong>. Choose a password to start using it.
|
||||
</p>
|
||||
|
||||
<div v-if="recovery" role="note"
|
||||
class="flex flex-col gap-2 px-3 py-3 rounded-lg border text-sm leading-normal"
|
||||
:class="keyCount > 0 ? 'border-red-400/40 bg-red-600/12' : 'border-[var(--color-border)]'">
|
||||
<template v-if="keyCount > 0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="i-tabler-alert-triangle text-4.5 flex-shrink-0 text-red-400"
|
||||
aria-hidden="true"></span>
|
||||
<strong class="font-semibold">
|
||||
Your {{ keyCount }} saved API {{ keyCount === 1 ? 'key' : 'keys' }} will be deleted
|
||||
</strong>
|
||||
</div>
|
||||
<p class="text-[13px] text-[var(--text-secondary)]">
|
||||
They were encrypted with your old password. Without it nobody can decrypt them, not
|
||||
you, not an admin, not the server. There is no way to get them back.
|
||||
</p>
|
||||
</template>
|
||||
<p v-else class="text-[13px] text-[var(--text-secondary)]">
|
||||
You have no saved API keys, so nothing of yours is lost.
|
||||
</p>
|
||||
|
||||
<ul class="flex flex-col gap-1 text-[13px]">
|
||||
<li v-if="keyCount > 0" class="flex items-start gap-2">
|
||||
<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</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)]"
|
||||
aria-hidden="true"></span>
|
||||
<span>Kept: chats, agents, files, provider and model lists, settings</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<form class="flex flex-col gap-2" @submit.prevent="submit">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="password"
|
||||
class="text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)] ml-1">{{
|
||||
setup ? 'Password' : 'New password' }}</label>
|
||||
<input required minlength="8" maxlength="128" type="password" autocomplete="new-password"
|
||||
id="password" v-model="form.password" placeholder="••••••••" :disabled="busy"
|
||||
class="focus:ring-2 focus:ring-[var(--color-accent)]/20 border border-[var(--color-border)] transition-all" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="confirmPassword"
|
||||
class="text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)] ml-1">Confirm
|
||||
password</label>
|
||||
<input required minlength="8" maxlength="128" ref="confirmInputEl" type="password"
|
||||
autocomplete="new-password" id="confirmPassword" v-model="form.confirm"
|
||||
placeholder="••••••••" :disabled="busy" @input="confirmInputEl?.setCustomValidity('')"
|
||||
class="focus:ring-2 focus:ring-[var(--color-accent)]/20 border border-[var(--color-border)] transition-all" />
|
||||
</div>
|
||||
|
||||
<label v-if="keyCount > 0" class="flex items-start gap-2.5 mt-1 text-[13px] leading-normal cursor-pointer">
|
||||
<!-- a plain checkbox: the auth layout sizes every input as a text field -->
|
||||
<input v-model="understood" type="checkbox" :disabled="busy"
|
||||
class="!w-auto !p-0 mt-0.75 flex-shrink-0" style="accent-color: var(--color-accent)" />
|
||||
<span>
|
||||
I understand my API keys will be permanently deleted and I'll need to add them again.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<p v-if="setup" class="flex gap-2 mt-1 text-xs leading-normal text-[var(--text-secondary)]">
|
||||
<span class="i-mynaui-lock text-4 mt-0.25 flex-shrink-0" aria-hidden="true"></span>
|
||||
<span>
|
||||
Your password also encrypts your API keys in the browser. If you forget it, saved
|
||||
keys can't be recovered and have to be re-entered.
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p v-if="error" role="alert" class="text-sm text-red-400">{{ error }}</p>
|
||||
|
||||
<button :disabled="busy || !ready" type="submit"
|
||||
class="accent w-full mt-2 h-11 flex items-center justify-center font-semibold disabled:opacity-40 disabled:cursor-not-allowed">
|
||||
<span v-if="busy" class="i-svg-spinners-90-ring-with-bg text-xl"></span>
|
||||
<span v-else>{{ setup ? 'Set password and continue' : keyCount > 0 ? 'Reset password and delete keys' : 'Reset password' }}</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p v-if="recovery" class="text-sm text-center text-[var(--text-secondary)]">
|
||||
Remember your old password after all?
|
||||
<NuxtLink to="/auth/login" class="text-[var(--color-accent)] font-medium @hover:underline">
|
||||
Log in instead
|
||||
</NuxtLink>
|
||||
and change it from Settings to keep your keys.
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: 'auth',
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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">
|
||||
<div class="flex flex-col items-center gap-1 text-center">
|
||||
<h1 class="text-3xl font-bold tracking-tight">Veridian</h1>
|
||||
<p class="text-[var(--text-secondary)]">Forgot your password?</p>
|
||||
</div>
|
||||
|
||||
<p class="text-sm leading-normal text-[var(--text-secondary)]">
|
||||
This instance doesn't send email. An administrator can create a one-time recovery link for
|
||||
you.
|
||||
</p>
|
||||
|
||||
<ol class="flex flex-col gap-2.5 text-sm">
|
||||
<li v-for="(step, index) in [
|
||||
'Contact whoever runs this instance.',
|
||||
'They send you a link that works once and expires in 24 hours.',
|
||||
'Open it here and choose a new password.',
|
||||
]" :key="step" class="flex items-start gap-3">
|
||||
<span aria-hidden="true"
|
||||
class="flex items-center justify-center w-6 h-6 flex-shrink-0 rounded-md border border-[var(--color-border-active)] text-xs text-[var(--text-secondary)]">
|
||||
{{ index + 1 }}
|
||||
</span>
|
||||
<span class="mt-0.5">{{ step }}</span>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<p class="text-[13px] leading-normal text-[var(--text-secondary)]">
|
||||
Operators locked out of the only admin account can run
|
||||
<code class="px-1.5 py-0.5 rounded bg-[var(--bg-container)] font-mono text-xs text-[var(--text-primary)] whitespace-nowrap">bun run recover <email></code>
|
||||
on the server to print a link.
|
||||
</p>
|
||||
|
||||
<div role="note"
|
||||
class="flex gap-2.5 px-3.5 py-3 rounded-lg bg-red-600/12 border border-red-400/40 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>
|
||||
<span>
|
||||
<strong class="font-semibold">Recovery deletes your saved API keys.</strong>
|
||||
<span class="text-[var(--text-secondary)]">
|
||||
They are encrypted with your current password and can't be read without it. Your
|
||||
chats, agents and files are kept.
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-center text-[var(--text-secondary)]">
|
||||
Remembered it?
|
||||
<NuxtLink to="/auth/login" class="text-[var(--color-accent)] font-medium @hover:underline">
|
||||
Back to log in
|
||||
</NuxtLink>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { deriveKey } from '~/utils/crypto';
|
||||
import { timeLeft } from '~/utils/time';
|
||||
|
||||
const { client: authClient, signUp, session } = useAuth();
|
||||
|
||||
@@ -12,12 +13,38 @@ if (session.value !== null) {
|
||||
await navigateTo(to ?? '/');
|
||||
}
|
||||
|
||||
const config = useRuntimeConfig();
|
||||
// who may register is an instance setting, admins change it while it runs
|
||||
const { data: instance } = await useFetch('/api/instance', { key: 'instance' });
|
||||
|
||||
if (config.public.disableSignup) {
|
||||
if (instance.value?.registration === 'closed') {
|
||||
await navigateTo(to ? `/auth/login?to=${to}` : '/auth/login');
|
||||
}
|
||||
|
||||
const inviteOnly = computed(() => instance.value?.registration === 'invite');
|
||||
|
||||
// invite links look like /auth/register?invite=<token>
|
||||
const inviteToken = typeof useRoute().query.invite === 'string' ? useRoute().query.invite as string : undefined;
|
||||
|
||||
const { data: invite } = await useAsyncData(`invite_${inviteToken ?? 'none'}`, async () => {
|
||||
if (inviteToken === undefined) return null;
|
||||
|
||||
return await $fetch(`/api/invite/${encodeURIComponent(inviteToken)}`);
|
||||
});
|
||||
|
||||
// set when the invite turns out to be used up or expired on submit
|
||||
const inviteRejected = ref(false);
|
||||
|
||||
const validInvite = computed(() => !inviteRejected.value && invite.value?.valid ? invite.value : null);
|
||||
|
||||
// an invite only instance takes nobody without a working link
|
||||
const blocked = computed(() => inviteOnly.value && validInvite.value === null);
|
||||
|
||||
const inviteValidFor = computed(() => {
|
||||
if (!validInvite.value) return '';
|
||||
|
||||
return `Invite valid for ${timeLeft(validInvite.value.expiresAt)} more${validInvite.value.singleUse ? ' · single use' : ''}`;
|
||||
});
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
email: '',
|
||||
@@ -55,6 +82,9 @@ onMounted(() => {
|
||||
form.confirmPassword = tempForm.confirmPassword;
|
||||
hydrated.value = true;
|
||||
|
||||
// there is no form when the invite does not work
|
||||
if (blocked.value) return;
|
||||
|
||||
nameInputEl.value!.addEventListener('input', () => {
|
||||
nameInputEl.value!.setCustomValidity('');
|
||||
});
|
||||
@@ -94,7 +124,7 @@ const submit = async () => {
|
||||
|
||||
loading.value = true;
|
||||
|
||||
const res = await signUp(form.email, form.password, form.name);
|
||||
const res = await signUp(form.email, form.password, form.name, inviteOnly.value ? inviteToken : undefined);
|
||||
loading.value = false;
|
||||
if (!res.ok) {
|
||||
if (!res.error.data) {
|
||||
@@ -121,6 +151,14 @@ const submit = async () => {
|
||||
passwordInputEl.value!.setCustomValidity(res.error.data.message!);
|
||||
passwordInputEl.value!.reportValidity();
|
||||
break;
|
||||
case 'INVITE_INVALID':
|
||||
// used by someone else or expired while this page was open
|
||||
inviteRejected.value = true;
|
||||
break;
|
||||
case 'REGISTRATION_CLOSED':
|
||||
// an admin closed registration while this page was open
|
||||
alert('Registration is closed on this instance. Ask an admin for an account.');
|
||||
break;
|
||||
default:
|
||||
console.log(res.error);
|
||||
alert('Something went wrong: ' + res.error.data.message);
|
||||
@@ -133,7 +171,8 @@ const submit = async () => {
|
||||
const key = await deriveKey(form.password, res.data.user.id);
|
||||
localStorage.setItem('encryptionKey', JSON.stringify(key));
|
||||
|
||||
return navigateTo(to ?? '/');
|
||||
// connecting a provider comes next, there is nothing to chat with before that
|
||||
return navigateTo(to ? `/welcome?to=${to}` : '/welcome');
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -153,7 +192,30 @@ const submit = async () => {
|
||||
<p class="text-[var(--text-secondary)]">Create your account</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl">
|
||||
<p v-if="blocked" role="alert"
|
||||
class="px-3 py-2.5 mb-1 rounded-lg bg-[var(--bg-container)] border border-[var(--color-border)] text-sm leading-snug text-[var(--text-secondary)]">
|
||||
<template v-if="inviteToken === undefined">
|
||||
This instance is invite only. Ask an admin for an invite link to create an account.
|
||||
</template>
|
||||
<template v-else>
|
||||
This invite link doesn't work anymore. It was used already, has expired, or was revoked.
|
||||
Ask an admin for a new one.
|
||||
</template>
|
||||
</p>
|
||||
|
||||
<div v-if="inviteOnly && validInvite"
|
||||
class="flex items-center gap-3 px-3 py-2.5 mb-5 rounded-lg bg-[var(--bg-container)] border border-[var(--color-border)]">
|
||||
<UserAvatar :user="validInvite.inviter" class="w-8 h-8 rounded-lg text-sm"></UserAvatar>
|
||||
<div class="flex flex-col gap-0.25 text-sm min-w-0">
|
||||
<span class="overflow-hidden text-ellipsis">
|
||||
<strong class="font-semibold">{{ validInvite.inviter.name }}</strong> invited you to this
|
||||
instance
|
||||
</span>
|
||||
<span class="text-xs text-[var(--text-secondary)]">{{ inviteValidFor }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!blocked" class="rounded-2xl">
|
||||
<form class="flex flex-col gap-2" @submit.prevent="submit">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="name"
|
||||
@@ -166,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>
|
||||
@@ -189,6 +251,14 @@ const submit = async () => {
|
||||
class="focus:ring-2 focus:ring-[var(--color-accent)]/20 border border-[var(--color-border)] transition-all" />
|
||||
</div>
|
||||
|
||||
<p class="flex gap-2 mt-1 text-xs leading-normal text-[var(--text-secondary)]">
|
||||
<span class="i-mynaui-lock text-4 mt-0.25 flex-shrink-0" aria-hidden="true"></span>
|
||||
<span>
|
||||
Your password also encrypts your API keys in the browser. If you forget it, saved
|
||||
keys can't be recovered and have to be re-entered.
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<button :disabled="!hydrated"
|
||||
class="accent w-full mt-2 h-11 flex items-center justify-center font-semibold" type="submit">
|
||||
<span v-if="loading" class="i-svg-spinners-90-ring-with-bg text-xl"></span>
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
<script setup lang="ts">
|
||||
import { deriveKey } from '~/utils/crypto';
|
||||
|
||||
const { signIn, fetchSession } = useAuth();
|
||||
|
||||
definePageMeta({
|
||||
layout: 'auth',
|
||||
});
|
||||
|
||||
// the auth middleware only lets this page render while no account exists
|
||||
const setupNeeded = useState<boolean | null>('instance:setupNeeded', () => null);
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
});
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
const alreadySetUp = ref(false);
|
||||
|
||||
const nameInputEl = ref<HTMLInputElement | null>(null);
|
||||
const emailInputEl = ref<HTMLInputElement | null>(null);
|
||||
const passwordInputEl = ref<HTMLInputElement | null>(null);
|
||||
const confirmPasswordInputEl = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const tempForm = {
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
};
|
||||
|
||||
// prevent text fields from clearing on hydration
|
||||
onBeforeMount(() => {
|
||||
tempForm.name = (document.getElementById('name') as HTMLInputElement)?.value ?? '';
|
||||
tempForm.email = (document.getElementById('email') as HTMLInputElement)?.value ?? '';
|
||||
tempForm.password = (document.getElementById('password') as HTMLInputElement)?.value ?? '';
|
||||
tempForm.confirmPassword = (document.getElementById('confirmPassword') as HTMLInputElement)?.value ?? '';
|
||||
});
|
||||
|
||||
const hydrated = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
form.name = tempForm.name;
|
||||
form.email = tempForm.email;
|
||||
form.password = tempForm.password;
|
||||
form.confirmPassword = tempForm.confirmPassword;
|
||||
hydrated.value = true;
|
||||
|
||||
emailInputEl.value!.addEventListener('input', () => {
|
||||
emailInputEl.value!.setCustomValidity('');
|
||||
});
|
||||
|
||||
const checkPasswordsMatch = () => {
|
||||
const matches = passwordInputEl.value!.value === confirmPasswordInputEl.value!.value;
|
||||
confirmPasswordInputEl.value!.setCustomValidity(matches ? '' : 'Passwords do not match');
|
||||
};
|
||||
|
||||
passwordInputEl.value!.addEventListener('input', checkPasswordsMatch);
|
||||
confirmPasswordInputEl.value!.addEventListener('input', () => {
|
||||
checkPasswordsMatch();
|
||||
confirmPasswordInputEl.value!.reportValidity();
|
||||
});
|
||||
});
|
||||
|
||||
const submit = async () => {
|
||||
if (loading.value) return;
|
||||
|
||||
const inputs = [nameInputEl, emailInputEl, passwordInputEl, confirmPasswordInputEl];
|
||||
|
||||
for (const input of inputs) {
|
||||
if (input.value?.validity.valid === false) {
|
||||
input.value!.reportValidity();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
|
||||
try {
|
||||
await $fetch('/api/setup', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
name: form.name,
|
||||
email: form.email,
|
||||
password: form.password,
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
loading.value = false;
|
||||
|
||||
// TODO: i18n
|
||||
switch (err?.data?.data?.code as string | undefined) {
|
||||
case 'SETUP_ALREADY_DONE':
|
||||
// someone else created the first account in the meantime
|
||||
setupNeeded.value = false;
|
||||
alreadySetUp.value = true;
|
||||
break;
|
||||
case 'INVALID_EMAIL':
|
||||
emailInputEl.value!.setCustomValidity('That email address is not valid');
|
||||
emailInputEl.value!.reportValidity();
|
||||
break;
|
||||
case 'INVALID_BODY':
|
||||
case 'PASSWORD_TOO_LONG':
|
||||
error.value = err.data.message ?? 'Check the form and try again.';
|
||||
break;
|
||||
default:
|
||||
console.error('Failed to set up the instance:', err);
|
||||
error.value = 'The admin account could not be created. Try again.';
|
||||
break;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setupNeeded.value = false;
|
||||
|
||||
const res = await signIn(form.email, form.password);
|
||||
if (!res.ok || res.data.twoFactorRequired) {
|
||||
// the account exists, so logging in by hand still works
|
||||
return navigateTo('/auth/login');
|
||||
}
|
||||
|
||||
const key = await deriveKey(form.password, res.data.user.id);
|
||||
localStorage.setItem('encryptionKey', JSON.stringify(key));
|
||||
|
||||
// the next steps are for admins only, so make sure the role is loaded
|
||||
await fetchSession();
|
||||
|
||||
// on to the rest of the setup: who can register, then a first provider
|
||||
return navigateTo('/setup/instance');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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">
|
||||
<div class="flex flex-col items-center gap-1 text-center">
|
||||
<h1 class="text-3xl font-bold tracking-tight">Veridian</h1>
|
||||
<p class="text-[var(--text-secondary)]">Set up this instance</p>
|
||||
</div>
|
||||
|
||||
<div v-if="alreadySetUp" class="flex flex-col gap-4">
|
||||
<p role="alert"
|
||||
class="px-3 py-2.5 rounded-lg bg-[var(--bg-container)] border border-[var(--color-border)] text-sm leading-snug text-[var(--text-secondary)]">
|
||||
Someone else just created the first account, so this instance already has its
|
||||
administrator. Nothing was created for you.
|
||||
</p>
|
||||
<NuxtLink to="/auth/login" class="w-full h-11 flex items-center justify-center font-semibold rounded-md bg-[var(--color-accent)] text-[var(--color-accent-text)]">
|
||||
Go to log in
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<OnboardingSetupSteps :current="1"></OnboardingSetupSteps>
|
||||
|
||||
<p
|
||||
class="px-3 py-2.5 rounded-lg bg-[var(--bg-container)] border border-[var(--color-border)] text-sm leading-snug text-[var(--text-secondary)]">
|
||||
No accounts exist yet. The account you create here becomes the
|
||||
<strong class="font-medium text-[var(--text-primary)]">administrator</strong> of this instance.
|
||||
</p>
|
||||
|
||||
<form class="flex flex-col gap-2" @submit.prevent="submit">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="name"
|
||||
class="text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)] ml-1">Name</label>
|
||||
<input required maxlength="128" ref="nameInputEl" type="text" autocomplete="name" id="name"
|
||||
v-model="form.name" placeholder="John Doe"
|
||||
class="focus:ring-2 focus:ring-[var(--color-accent)]/20 border border-[var(--color-border)] transition-all" />
|
||||
</div>
|
||||
|
||||
<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=".+@.+\..+" 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>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="password"
|
||||
class="text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)] ml-1">Password</label>
|
||||
<input required minlength="8" maxlength="128" ref="passwordInputEl" type="password"
|
||||
autocomplete="new-password" id="password" v-model="form.password" placeholder="••••••••"
|
||||
class="focus:ring-2 focus:ring-[var(--color-accent)]/20 border border-[var(--color-border)] transition-all" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="confirmPassword"
|
||||
class="text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)] ml-1">Confirm
|
||||
password</label>
|
||||
<input required minlength="8" maxlength="128" ref="confirmPasswordInputEl" type="password"
|
||||
autocomplete="new-password" id="confirmPassword" v-model="form.confirmPassword"
|
||||
placeholder="••••••••"
|
||||
class="focus:ring-2 focus:ring-[var(--color-accent)]/20 border border-[var(--color-border)] transition-all" />
|
||||
</div>
|
||||
|
||||
<p class="flex gap-2 mt-1 text-xs leading-normal text-[var(--text-secondary)]">
|
||||
<span class="i-mynaui-lock text-4 mt-0.25 flex-shrink-0" aria-hidden="true"></span>
|
||||
<span>
|
||||
Your password also encrypts your API keys in the browser. If you forget it, saved
|
||||
keys can't be recovered and have to be re-entered.
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p v-if="error" role="alert" class="text-sm text-red-400">{{ error }}</p>
|
||||
|
||||
<button :disabled="!hydrated || loading"
|
||||
class="accent w-full mt-2 h-11 flex items-center justify-center font-semibold" type="submit">
|
||||
<span v-if="loading" class="i-svg-spinners-90-ring-with-bg text-xl"></span>
|
||||
<span v-else>Create admin account</span>
|
||||
</button>
|
||||
</form>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="absolute bottom-4 right-4">
|
||||
<ThemeSwitcher size="medium" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
const { open: sidebarOpen, openSidebar } = useSidebar();
|
||||
const { providers } = await useModels();
|
||||
const route = useRoute();
|
||||
|
||||
const TITLES: Record<string, string> = {
|
||||
profile: 'Profile',
|
||||
security: 'Security',
|
||||
general: 'General',
|
||||
appearance: 'Appearance',
|
||||
providers: 'AI Providers',
|
||||
assistants: 'System Assistants',
|
||||
instance: 'Instance',
|
||||
};
|
||||
|
||||
const title = computed(() => {
|
||||
const [section, id] = route.path.split('/').slice(2);
|
||||
|
||||
if (section === 'providers' && id) {
|
||||
const provider = providers.value.find(p => p.id === id);
|
||||
return provider ? provider.name : 'Unknown Provider';
|
||||
}
|
||||
|
||||
if (section === 'instance' && id === 'users') return 'Users';
|
||||
|
||||
return TITLES[section ?? ''] ?? 'Settings';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full w-full flex flex-col">
|
||||
<div class="h-14 flex items-center justify-between px-4 shrink-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<button v-if="!sidebarOpen" @click="openSidebar"
|
||||
class="flex p-2 rounded-lg text-[var(--text-primary)] bg-transparent @hover:bg-[var(--color-hover)] transition-colors">
|
||||
<span class="i-mynaui-panel-left-open text-5"></span>
|
||||
</button>
|
||||
<h1 class="text-lg font-semibold text-[var(--text-primary)] case-capital">{{ title }}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-h-0 flex flex-col px-4">
|
||||
<NuxtPage />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -16,13 +16,11 @@ const updateHinting = (e: Event) => {
|
||||
const hinting = parseInt((e.target as HTMLInputElement).value);
|
||||
updateSettings({ appearance: { hinting } });
|
||||
};
|
||||
|
||||
defineEmits(['navigate']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col gap-6 py-4 overflow-auto [scrollbar-width:thin] [scrollbar-color:#888_transparent] [scrollbar-gutter:stable]">
|
||||
class="flex flex-col gap-6 -mx-4 p-4 overflow-auto [scrollbar-width:thin] [scrollbar-color:#888_transparent] [scrollbar-gutter:stable]">
|
||||
<div class="flex flex-row items-center justify-between gap-2">
|
||||
<h4 class="font-medium">Theme</h4>
|
||||
<div class="flex gap-2">
|
||||
@@ -33,8 +33,6 @@ const getModel = (id: string | null | undefined) => {
|
||||
|
||||
return allModels.value.find(m => m.id === id);
|
||||
}
|
||||
|
||||
defineEmits(['navigate']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div class="py-4 text-sm text-[var(--text-secondary)]">
|
||||
Nothing to configure here yet.
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
redirect: '/settings/profile',
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div></div>
|
||||
</template>
|
||||
@@ -0,0 +1,204 @@
|
||||
<script setup lang="ts">
|
||||
import type { RegistrationMode } from '~~/drizzle/schema';
|
||||
import { DialogType } from '~/composables/useDialog';
|
||||
import { timeLeft } from '~/utils/time';
|
||||
|
||||
type InstanceSettings = {
|
||||
registration: RegistrationMode;
|
||||
registrationLocked: boolean;
|
||||
};
|
||||
|
||||
const REGISTRATION_OPTIONS: { value: RegistrationMode; label: string; description: string }[] = [
|
||||
{
|
||||
value: 'open',
|
||||
label: 'Open',
|
||||
description: 'Anyone who can reach this instance can create an account.',
|
||||
},
|
||||
{
|
||||
value: 'invite',
|
||||
label: 'Invite only',
|
||||
description: 'New accounts need an invite link from an admin.',
|
||||
},
|
||||
{
|
||||
value: 'closed',
|
||||
label: 'Closed',
|
||||
description: 'Nobody can register. Admins create accounts under Users.',
|
||||
},
|
||||
];
|
||||
|
||||
const { isAdmin } = useAuth();
|
||||
const { openDialog } = useDialog();
|
||||
|
||||
if (!isAdmin.value) {
|
||||
await navigateTo('/settings/profile');
|
||||
}
|
||||
|
||||
const { data: instance } = await useFetch<InstanceSettings>('/api/admin/instance', {
|
||||
key: 'admin_instance',
|
||||
});
|
||||
|
||||
type Invite = {
|
||||
id: string;
|
||||
token: string;
|
||||
maxUses: number;
|
||||
uses: number;
|
||||
expiresAt: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
const { data: inviteData, refresh: loadInvites } = await useFetch<Invite[]>('/api/admin/invites', {
|
||||
key: 'admin_invites',
|
||||
});
|
||||
|
||||
const invites = computed(() => inviteData.value ?? []);
|
||||
|
||||
const saving = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
const locked = computed(() => !!instance.value?.registrationLocked);
|
||||
const current = computed(() => REGISTRATION_OPTIONS.find(o => o.value === instance.value?.registration));
|
||||
|
||||
const setRegistration = async (registration: RegistrationMode) => {
|
||||
if (saving.value || locked.value || !instance.value || instance.value.registration === registration) return;
|
||||
|
||||
saving.value = true;
|
||||
error.value = '';
|
||||
|
||||
try {
|
||||
instance.value = await $fetch<InstanceSettings>('/api/admin/instance', {
|
||||
method: 'PUT',
|
||||
body: { registration },
|
||||
});
|
||||
// the sign in pages read this to decide whether to offer registering
|
||||
clearNuxtData('instance');
|
||||
} catch (err) {
|
||||
console.error('Failed to change registration:', err);
|
||||
error.value = 'Registration could not be changed. Try again.';
|
||||
}
|
||||
|
||||
saving.value = false;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Invite links
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const inviteBusy = ref(false);
|
||||
const inviteError = ref('');
|
||||
const copiedId = ref<string | null>(null);
|
||||
|
||||
const invitePath = (invite: Invite) => `/auth/register?invite=${invite.token}`;
|
||||
|
||||
const describeInvite = (invite: Invite) => {
|
||||
return `${invite.uses} of ${invite.maxUses} ${invite.maxUses === 1 ? 'use' : 'uses'} · expires in ${timeLeft(invite.expiresAt)}`;
|
||||
};
|
||||
|
||||
const copyInvite = async (invite: Invite) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(`${window.location.origin}${invitePath(invite)}`);
|
||||
copiedId.value = invite.id;
|
||||
setTimeout(() => {
|
||||
if (copiedId.value === invite.id) copiedId.value = null;
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy invite link:', err);
|
||||
inviteError.value = 'The link could not be copied. Select it and copy it by hand.';
|
||||
}
|
||||
};
|
||||
|
||||
// The dialog asks how long the link lasts and for how many people, and shows
|
||||
// the link. It may be closed after the link was made, so always reload.
|
||||
const createInvite = () => {
|
||||
openDialog(DialogType.CreateInvite, async () => {
|
||||
await loadInvites();
|
||||
});
|
||||
};
|
||||
|
||||
const revokeInvite = async (invite: Invite) => {
|
||||
if (inviteBusy.value) return;
|
||||
|
||||
inviteBusy.value = true;
|
||||
inviteError.value = '';
|
||||
|
||||
try {
|
||||
await $fetch(`/api/admin/invites/${invite.id}`, { method: 'DELETE' });
|
||||
await loadInvites();
|
||||
} catch (err) {
|
||||
console.error('Failed to revoke invite:', err);
|
||||
inviteError.value = 'The invite link could not be revoked. Try again.';
|
||||
}
|
||||
|
||||
inviteBusy.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col py-4 overflow-auto [scrollbar-width:thin] [scrollbar-color:#888_transparent] [scrollbar-gutter:stable]">
|
||||
<div class="flex flex-col gap-7 w-full max-w-3xl">
|
||||
<section class="flex flex-col gap-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex flex-col gap-0.5 flex-1 min-w-70">
|
||||
<h2 class="font-medium">Registration</h2>
|
||||
<p class="text-[13px] text-[var(--text-secondary)]">
|
||||
{{ current?.description ?? 'The instance settings could not be loaded.' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div role="group" aria-label="Registration mode"
|
||||
class="flex gap-0.5 p-0.5 rounded-lg bg-[var(--bg-container)]">
|
||||
<button v-for="option in REGISTRATION_OPTIONS" :key="option.value" type="button"
|
||||
:aria-pressed="instance?.registration === option.value"
|
||||
:disabled="saving || locked || !instance" @click="setRegistration(option.value)"
|
||||
class="flex items-center h-7.5 px-3 text-sm rounded-md disabled:cursor-not-allowed transition-colors duration-200"
|
||||
:class="instance?.registration === option.value
|
||||
? 'bg-[var(--color-accent)] text-[var(--color-accent-text)] font-medium'
|
||||
: 'text-[var(--text-secondary)] enabled:@hover:bg-[var(--color-hover)]'">
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="locked" class="flex gap-2 text-[13px] leading-normal text-[var(--text-secondary)]">
|
||||
<span class="i-mynaui-lock text-4 mt-0.5 flex-shrink-0" aria-hidden="true"></span>
|
||||
<span>
|
||||
Registration is turned off by the <code class="font-mono text-xs">DISABLE_SIGNUP</code>
|
||||
environment variable. Remove it and restart the server to change this here.
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p v-if="error" role="alert" class="text-sm text-red-400">{{ error }}</p>
|
||||
|
||||
<!-- links only let anyone in while the instance is invite only -->
|
||||
<template v-if="instance?.registration === 'invite'">
|
||||
<ul v-if="invites.length > 0" class="flex flex-col gap-1">
|
||||
<li v-for="invite in invites" :key="invite.id"
|
||||
class="flex flex-wrap items-center gap-3 min-h-11 px-3 py-1.5 rounded-lg bg-[var(--bg-container)] text-sm">
|
||||
<span
|
||||
class="flex-1 min-w-55 font-mono text-[13px] overflow-hidden text-ellipsis whitespace-nowrap select-all">
|
||||
{{ invitePath(invite) }}
|
||||
</span>
|
||||
<span class="text-xs text-[var(--text-secondary)]">{{ describeInvite(invite) }}</span>
|
||||
<button type="button" @click="copyInvite(invite)"
|
||||
class="flex items-center h-7 px-2 text-[13px] rounded-md @hover:bg-[var(--color-hover)] transition-colors duration-200">
|
||||
{{ copiedId === invite.id ? 'Copied' : 'Copy' }}
|
||||
</button>
|
||||
<button type="button" :disabled="inviteBusy" @click="revokeInvite(invite)"
|
||||
class="flex items-center h-7 px-2 text-[13px] rounded-md text-red-400 @hover:bg-[var(--color-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200">
|
||||
Revoke
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p v-if="inviteError" role="alert" class="text-sm text-red-400">{{ inviteError }}</p>
|
||||
|
||||
<button type="button" @click="createInvite"
|
||||
class="self-start flex items-center gap-1.5 h-8 px-3 text-sm rounded-md bg-[var(--bg-container)] @hover:bg-[var(--color-hover)] transition-colors duration-200">
|
||||
<span class="i-mynaui-plus text-4" aria-hidden="true"></span>
|
||||
New invite link
|
||||
</button>
|
||||
</template>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,383 @@
|
||||
<script setup lang="ts">
|
||||
import { DialogType } from '~/composables/useDialog';
|
||||
|
||||
type UserRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
image: string | null;
|
||||
role: 'admin' | 'user';
|
||||
banned: boolean;
|
||||
twoFactorEnabled: boolean;
|
||||
// created by an admin and has not set a password yet
|
||||
pending: boolean;
|
||||
createdAt: string;
|
||||
lastActiveAt: string | null;
|
||||
};
|
||||
|
||||
type Filter = 'all' | 'admins' | 'disabled';
|
||||
|
||||
const GRID_CLASS = 'grid grid-cols-[minmax(230px,2fr)_90px_100px_60px_150px_110px_36px] items-center gap-3 px-3';
|
||||
const MENU_ITEM_CLASS = 'flex items-center gap-2 h-8.5 px-2.5 text-sm text-left rounded-lg @hover:bg-[var(--color-hover)] transition-colors duration-150';
|
||||
|
||||
// same tinted pill as the role badge on the profile page
|
||||
const ACCENT_PILL_STYLE = {
|
||||
backgroundColor: 'color-mix(in srgb, var(--color-accent) 20%, transparent)',
|
||||
borderColor: 'color-mix(in srgb, var(--color-accent) 50%, transparent)',
|
||||
};
|
||||
|
||||
const { client: authClient, user: me, isAdmin } = useAuth();
|
||||
const { openDialog } = useDialog();
|
||||
|
||||
if (!isAdmin.value) {
|
||||
await navigateTo('/settings/profile');
|
||||
}
|
||||
|
||||
const { data: userData, error: loadError, refresh: loadUsers } = await useFetch<UserRow[]>('/api/admin/users', {
|
||||
key: 'admin_users',
|
||||
});
|
||||
|
||||
const users = computed(() => userData.value ?? []);
|
||||
|
||||
const search = ref('');
|
||||
const filter = ref<Filter>('all');
|
||||
|
||||
const filters = computed<{ value: Filter; label: string; count: number }[]>(() => [
|
||||
{ value: 'all', label: 'All', count: users.value.length },
|
||||
{ value: 'admins', label: 'Admins', count: users.value.filter(u => u.role === 'admin').length },
|
||||
{ value: 'disabled', label: 'Disabled', count: users.value.filter(u => u.banned).length },
|
||||
]);
|
||||
|
||||
const visibleUsers = computed(() => {
|
||||
const query = search.value.trim().toLowerCase();
|
||||
|
||||
return users.value.filter((u) => {
|
||||
if (filter.value === 'admins' && u.role !== 'admin') return false;
|
||||
if (filter.value === 'disabled' && !u.banned) return false;
|
||||
|
||||
return query === '' || u.name.toLowerCase().includes(query) || u.email.toLowerCase().includes(query);
|
||||
});
|
||||
});
|
||||
|
||||
// fixed locale and time zone so the server and client render the same text
|
||||
const formatDate = (date: string | Date) => new Date(date).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
|
||||
// The server does not know the viewer's time zone, so it renders the date only
|
||||
// and the time of day is filled in once the page is running in the browser.
|
||||
const mounted = ref(false);
|
||||
onMounted(() => {
|
||||
mounted.value = true;
|
||||
});
|
||||
|
||||
const formatDateTime = (date: string | Date) => {
|
||||
if (!mounted.value) return formatDate(date);
|
||||
|
||||
return new Date(date).toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Actions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const busyId = ref<string | null>(null);
|
||||
const actionError = ref('');
|
||||
|
||||
// Runs one action on a user and reloads the list. The action returns false
|
||||
// when it did not go through.
|
||||
const run = async (user: UserRow, failure: string, action: () => Promise<boolean>) => {
|
||||
if (busyId.value !== null) return;
|
||||
|
||||
busyId.value = user.id;
|
||||
actionError.value = '';
|
||||
|
||||
let ok = false;
|
||||
try {
|
||||
ok = await action();
|
||||
} catch (error) {
|
||||
console.error(failure, error);
|
||||
}
|
||||
|
||||
if (!ok) actionError.value = failure;
|
||||
|
||||
await loadUsers();
|
||||
busyId.value = null;
|
||||
};
|
||||
|
||||
const confirmThen = (options: { title: string; message: string; confirmLabel: string; danger?: boolean }, action: () => void) => {
|
||||
openDialog(DialogType.Confirm, (result) => {
|
||||
if (result.ok === false) return;
|
||||
|
||||
action();
|
||||
}, options);
|
||||
};
|
||||
|
||||
const setRole = (user: UserRow, role: 'admin' | 'user') => {
|
||||
const change = () => run(user, `${user.name}'s role could not be changed.`, async () => {
|
||||
const { error } = await authClient.admin.setRole({ userId: user.id, role });
|
||||
return !error;
|
||||
});
|
||||
|
||||
if (role === 'user') return change();
|
||||
|
||||
confirmThen({
|
||||
title: `Make ${user.name} an admin?`,
|
||||
message: 'Admins can create, disable and delete accounts, change roles and change who can register.',
|
||||
confirmLabel: 'Make admin',
|
||||
}, change);
|
||||
};
|
||||
|
||||
const signOutEverywhere = (user: UserRow) => run(user, `${user.name} could not be signed out.`, async () => {
|
||||
const { error } = await authClient.admin.revokeUserSessions({ userId: user.id });
|
||||
return !error;
|
||||
});
|
||||
|
||||
const recoverAccount = (user: UserRow) => {
|
||||
openDialog(DialogType.RecoverAccount, undefined, { user });
|
||||
};
|
||||
|
||||
const resetTwoFactor = (user: UserRow) => confirmThen({
|
||||
title: `Reset two-factor for ${user.name}?`,
|
||||
message: 'Turns two-factor sign-in off for this account and throws away its backup codes. They sign in with only their password until they set it up again.',
|
||||
confirmLabel: 'Reset two-factor',
|
||||
}, () => run(user, `Two-factor could not be reset for ${user.name}.`, async () => {
|
||||
await $fetch(`/api/admin/users/${user.id}/two-factor`, { method: 'DELETE' });
|
||||
return true;
|
||||
}));
|
||||
|
||||
const setDisabled = (user: UserRow, disabled: boolean) => {
|
||||
const failure = `${user.name}'s account could not be ${disabled ? 'disabled' : 'enabled'}.`;
|
||||
|
||||
if (!disabled) {
|
||||
return run(user, failure, async () => {
|
||||
const { error } = await authClient.admin.unbanUser({ userId: user.id });
|
||||
return !error;
|
||||
});
|
||||
}
|
||||
|
||||
confirmThen({
|
||||
title: `Disable ${user.name}'s account?`,
|
||||
message: 'They are signed out and can\'t sign in until the account is enabled again. Nothing is deleted.',
|
||||
confirmLabel: 'Disable account',
|
||||
}, () => run(user, failure, async () => {
|
||||
const { error } = await authClient.admin.banUser({ userId: user.id });
|
||||
return !error;
|
||||
}));
|
||||
};
|
||||
|
||||
const deleteUser = (user: UserRow) => confirmThen({
|
||||
title: `Delete ${user.name}'s account?`,
|
||||
message: `This permanently deletes ${user.email} with their agents, topics, messages, providers, saved API keys and file list. It can't be undone.`,
|
||||
confirmLabel: 'Delete account',
|
||||
danger: true,
|
||||
}, () => run(user, `${user.name}'s account could not be deleted.`, async () => {
|
||||
await $fetch(`/api/admin/users/${user.id}`, { method: 'DELETE' });
|
||||
return true;
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Invite links
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// only offered while the instance is invite only, links do nothing otherwise
|
||||
const { data: instance } = await useFetch<{ registration: string }>('/api/admin/instance', {
|
||||
key: 'admin_instance',
|
||||
});
|
||||
|
||||
// the dialog asks for the details and shows the link to copy
|
||||
const createInvite = () => {
|
||||
openDialog(DialogType.CreateInvite, () => {
|
||||
// the Instance page lists the links, so it has to load the new one
|
||||
clearNuxtData('admin_invites');
|
||||
});
|
||||
};
|
||||
|
||||
const openCreateUser = () => {
|
||||
// the dialog can be closed after the account was made, so always reload
|
||||
openDialog(DialogType.CreateUser, async () => {
|
||||
await loadUsers();
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col pb-4 overflow-auto [scrollbar-width:thin] [scrollbar-color:#888_transparent] [scrollbar-gutter:stable]">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 py-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<label class="flex items-center gap-1.5 h-8 px-2 rounded-md bg-[var(--bg-container)]">
|
||||
<span class="i-mynaui-search text-4 text-[var(--text-secondary)]" aria-hidden="true"></span>
|
||||
<input v-model="search" type="search" aria-label="Search users" placeholder="Search users..."
|
||||
class="w-45 bg-transparent text-[13px] outline-none placeholder:text-[var(--text-tertiary)]" />
|
||||
</label>
|
||||
|
||||
<button v-for="f in filters" :key="f.value" type="button" :aria-pressed="filter === f.value"
|
||||
@click="filter = f.value"
|
||||
class="flex items-center gap-1.5 h-8 px-2.5 text-[13px] rounded-md transition-colors duration-200"
|
||||
:class="filter === f.value
|
||||
? 'bg-[var(--color-hover)] text-[var(--text-primary)]'
|
||||
: 'text-[var(--text-secondary)] @hover:bg-[var(--color-hover)]'">
|
||||
{{ f.label }} <span class="text-[var(--text-secondary)]">{{ f.count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button type="button" @click="openCreateUser"
|
||||
class="flex items-center h-8 px-3 text-[13px] rounded-md border border-[var(--color-border)] @hover:bg-[var(--color-hover)] transition-colors duration-200">
|
||||
Create user
|
||||
</button>
|
||||
<button v-if="instance?.registration === 'invite'" type="button" @click="createInvite"
|
||||
class="flex items-center gap-1.5 h-8 px-3 text-[13px] font-semibold rounded-md bg-[var(--color-accent)] text-[var(--color-accent-text)] disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<span class="i-mynaui-link text-4" aria-hidden="true"></span>
|
||||
Invite link
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="loadError" role="alert" class="pb-3 text-sm text-red-400">The users could not be loaded.</p>
|
||||
<p v-if="actionError" role="alert" class="pb-3 text-sm text-red-400">{{ actionError }}</p>
|
||||
|
||||
<div class="overflow-x-auto rounded-lg border border-[var(--color-border)]">
|
||||
<div role="table" aria-label="Registered users" class="min-w-215">
|
||||
<div role="row" :class="GRID_CLASS"
|
||||
class="h-9 bg-[var(--bg-container)] text-xs font-semibold uppercase tracking-wide text-[var(--text-secondary)]">
|
||||
<span role="columnheader">User</span>
|
||||
<span role="columnheader">Role</span>
|
||||
<span role="columnheader">Status</span>
|
||||
<span role="columnheader">2FA</span>
|
||||
<span role="columnheader">Last active</span>
|
||||
<span role="columnheader">Joined</span>
|
||||
<span role="columnheader" aria-label="Actions"></span>
|
||||
</div>
|
||||
|
||||
<div v-for="u in visibleUsers" :key="u.id" role="row" :class="GRID_CLASS"
|
||||
class="h-14 border-t border-[var(--color-border)] text-sm">
|
||||
<div role="cell" class="flex items-center gap-2.5 min-w-0" :class="u.banned ? 'opacity-60' : ''">
|
||||
<UserAvatar :user="u" class="w-8 h-8 rounded-lg text-sm"></UserAvatar>
|
||||
<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>
|
||||
<span
|
||||
class="text-xs text-[var(--text-secondary)] whitespace-nowrap overflow-hidden text-ellipsis">
|
||||
{{ u.email }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span role="cell">
|
||||
<span v-if="u.role === 'admin'"
|
||||
class="text-xs font-medium px-2 py-0.5 rounded-md border text-[var(--text-primary)]"
|
||||
:style="ACCENT_PILL_STYLE">Admin</span>
|
||||
<span v-else
|
||||
class="text-xs font-medium px-2 py-0.5 rounded-md border border-[var(--color-border-active)] text-[var(--text-secondary)]">User</span>
|
||||
</span>
|
||||
|
||||
<span role="cell" class="flex items-center gap-1.5 text-[13px]"
|
||||
:class="u.banned ? 'text-red-400' : u.pending ? 'text-amber-400' : 'text-green-400'">
|
||||
<span aria-hidden="true" class="w-1.75 h-1.75 rounded-full bg-current"></span>
|
||||
{{ u.banned ? 'Disabled' : u.pending ? 'Invited' : 'Active' }}
|
||||
</span>
|
||||
|
||||
<span role="cell" class="text-[13px]"
|
||||
:class="u.twoFactorEnabled ? 'text-[var(--text-primary)]' : 'text-[var(--text-secondary)]'">
|
||||
{{ u.twoFactorEnabled ? 'On' : 'Off' }}
|
||||
</span>
|
||||
|
||||
<span role="cell" class="text-[13px] text-[var(--text-secondary)]">
|
||||
{{ u.lastActiveAt ? formatDateTime(u.lastActiveAt) : 'Never' }}
|
||||
</span>
|
||||
|
||||
<span role="cell" class="text-[13px] text-[var(--text-secondary)]">
|
||||
{{ formatDate(u.createdAt) }}
|
||||
</span>
|
||||
|
||||
<span role="cell">
|
||||
<span v-if="busyId === u.id" class="flex items-center justify-center w-7.5 h-7.5">
|
||||
<span class="i-svg-spinners-180-ring-with-bg text-4 text-[var(--text-secondary)]"></span>
|
||||
</span>
|
||||
|
||||
<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}`"
|
||||
:disabled="busyId !== null" @click="toggle"
|
||||
class="flex items-center justify-center w-7.5 h-7.5 rounded-md text-[var(--text-secondary)] @hover:bg-[var(--color-hover)] disabled:opacity-50 transition-colors duration-200"
|
||||
:class="isOpen ? 'bg-[var(--color-hover)]' : ''">
|
||||
<span class="i-tabler-dots text-4.5 pointer-events-none"></span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template #dropdown="{ close }">
|
||||
<button v-if="u.role === 'admin'" type="button" role="menuitem" :class="MENU_ITEM_CLASS"
|
||||
@click="close(); setRole(u, 'user')">
|
||||
<span class="i-tabler-shield-minus text-4.5" aria-hidden="true"></span>
|
||||
Remove admin
|
||||
</button>
|
||||
<button v-else type="button" role="menuitem" :class="MENU_ITEM_CLASS"
|
||||
@click="close(); setRole(u, 'admin')">
|
||||
<span class="i-tabler-shield-check text-4.5" aria-hidden="true"></span>
|
||||
Make admin
|
||||
</button>
|
||||
<button type="button" role="menuitem" :class="MENU_ITEM_CLASS"
|
||||
@click="close(); recoverAccount(u)">
|
||||
<span class="i-tabler-key text-4.5" aria-hidden="true"></span>
|
||||
{{ u.pending ? 'New setup link…' : 'Recover account…' }}
|
||||
</button>
|
||||
<button v-if="u.twoFactorEnabled" type="button" role="menuitem" :class="MENU_ITEM_CLASS"
|
||||
@click="close(); resetTwoFactor(u)">
|
||||
<span class="i-tabler-shield-off text-4.5" aria-hidden="true"></span>
|
||||
Reset two-factor
|
||||
</button>
|
||||
<button type="button" role="menuitem" :class="MENU_ITEM_CLASS"
|
||||
@click="close(); signOutEverywhere(u)">
|
||||
<span class="i-tabler-logout text-4.5" aria-hidden="true"></span>
|
||||
Sign out all sessions
|
||||
</button>
|
||||
|
||||
<hr class="w-full my-1 border-0 border-t border-[var(--color-border)]" />
|
||||
|
||||
<button v-if="u.banned" type="button" role="menuitem" :class="MENU_ITEM_CLASS"
|
||||
@click="close(); setDisabled(u, false)">
|
||||
<span class="i-tabler-circle-check text-4.5" aria-hidden="true"></span>
|
||||
Enable account
|
||||
</button>
|
||||
<button v-else type="button" role="menuitem" :class="MENU_ITEM_CLASS"
|
||||
@click="close(); setDisabled(u, true)">
|
||||
<span class="i-tabler-ban text-4.5" aria-hidden="true"></span>
|
||||
Disable account
|
||||
</button>
|
||||
<button type="button" role="menuitem" :class="[MENU_ITEM_CLASS, 'text-red-400']"
|
||||
@click="close(); deleteUser(u)">
|
||||
<span class="i-tabler-trash text-4.5" aria-hidden="true"></span>
|
||||
Delete account…
|
||||
</button>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="visibleUsers.length === 0"
|
||||
class="px-3 py-6 border-t border-[var(--color-border)] text-sm text-center text-[var(--text-secondary)]">
|
||||
{{ users.length === 0 ? 'No users to show.' : 'No users match.' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mt-4 max-w-190 text-[13px] leading-relaxed text-[var(--text-secondary)]">
|
||||
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>
|
||||
@@ -0,0 +1,275 @@
|
||||
<script setup lang="ts">
|
||||
import { Err, Ok, type Result } from '~~/types/result';
|
||||
import { DialogType } from '~/composables/useDialog';
|
||||
|
||||
const AVATAR_SIZE = 256;
|
||||
const MAX_SOURCE_SIZE = 10 * 1024 * 1024;
|
||||
const ACCEPTED_TYPES = ['image/png', 'image/jpeg', 'image/webp'];
|
||||
|
||||
const { user, isAdmin, updateProfile } = useAuth();
|
||||
const { openDialog } = useDialog();
|
||||
|
||||
// same tinted pill as the states that are on in Security
|
||||
const ACCENT_PILL_STYLE = {
|
||||
backgroundColor: 'color-mix(in srgb, var(--color-accent) 20%, transparent)',
|
||||
borderColor: 'color-mix(in srgb, var(--color-accent) 50%, transparent)',
|
||||
};
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const avatarBusy = ref(false);
|
||||
const avatarError = ref('');
|
||||
|
||||
const name = ref(user.value?.name ?? '');
|
||||
const nameSaving = ref(false);
|
||||
const nameError = ref('');
|
||||
|
||||
watch(() => user.value?.name, (value) => {
|
||||
if (!nameSaving.value) name.value = value ?? '';
|
||||
});
|
||||
|
||||
const trimmedName = computed(() => name.value.trim());
|
||||
const nameChanged = computed(() => trimmedName.value !== '' && trimmedName.value !== user.value?.name);
|
||||
|
||||
// Center-crops the image to a square and scales it down, so we only ever
|
||||
// store a small file no matter what the user picked.
|
||||
const cropToSquare = async (file: File): Promise<Result<Blob, string>> => {
|
||||
let bitmap: ImageBitmap;
|
||||
try {
|
||||
bitmap = await createImageBitmap(file);
|
||||
} catch {
|
||||
return Err('That file could not be read as an image.');
|
||||
}
|
||||
|
||||
const side = Math.min(bitmap.width, bitmap.height);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = AVATAR_SIZE;
|
||||
canvas.height = AVATAR_SIZE;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx === null) {
|
||||
bitmap.close();
|
||||
return Err('Your browser could not process the image.');
|
||||
}
|
||||
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
ctx.drawImage(
|
||||
bitmap,
|
||||
(bitmap.width - side) / 2, (bitmap.height - side) / 2, side, side,
|
||||
0, 0, AVATAR_SIZE, AVATAR_SIZE,
|
||||
);
|
||||
bitmap.close();
|
||||
|
||||
// browsers that can't encode webp hand back a png instead
|
||||
const blob = await new Promise<Blob | null>(resolve => canvas.toBlob(resolve, 'image/webp', 0.9));
|
||||
if (blob === null) {
|
||||
return Err('Your browser could not process the image.');
|
||||
}
|
||||
|
||||
return Ok(blob);
|
||||
};
|
||||
|
||||
const uploadAvatar = async (blob: Blob): Promise<Result<string, string>> => {
|
||||
const extension = blob.type === 'image/webp' ? 'webp' : 'png';
|
||||
|
||||
try {
|
||||
const { url, assetUrl } = await $fetch<{ url: string; assetUrl: string }>('/api/upload/presigned', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
file: {
|
||||
name: `avatar.${extension}`,
|
||||
mimeType: blob.type,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': blob.type },
|
||||
body: blob,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return Err('Upload failed. Check that file storage is configured for this instance.');
|
||||
}
|
||||
|
||||
return Ok(assetUrl);
|
||||
} catch (error) {
|
||||
console.error('Avatar upload failed:', error);
|
||||
return Err('Upload failed. Check that file storage is configured for this instance.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = async (event: Event) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
// reset so picking the same file again still fires a change event
|
||||
input.value = '';
|
||||
|
||||
if (!file) return;
|
||||
|
||||
avatarError.value = '';
|
||||
|
||||
if (!ACCEPTED_TYPES.includes(file.type)) {
|
||||
avatarError.value = 'Pick a PNG, JPG or WebP image.';
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > MAX_SOURCE_SIZE) {
|
||||
avatarError.value = 'That image is over 10 MB. Pick a smaller one.';
|
||||
return;
|
||||
}
|
||||
|
||||
avatarBusy.value = true;
|
||||
|
||||
const cropped = await cropToSquare(file);
|
||||
if (cropped.ok === false) {
|
||||
avatarError.value = cropped.error;
|
||||
avatarBusy.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const uploaded = await uploadAvatar(cropped.data);
|
||||
if (uploaded.ok === false) {
|
||||
avatarError.value = uploaded.error;
|
||||
avatarBusy.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = await updateProfile({ image: uploaded.data });
|
||||
if (updated.ok === false) {
|
||||
avatarError.value = 'The image uploaded, but your profile could not be updated. Try again.';
|
||||
}
|
||||
|
||||
avatarBusy.value = false;
|
||||
};
|
||||
|
||||
const removeAvatar = async () => {
|
||||
avatarError.value = '';
|
||||
avatarBusy.value = true;
|
||||
|
||||
const updated = await updateProfile({ image: null });
|
||||
if (updated.ok === false) {
|
||||
avatarError.value = 'Your profile picture could not be removed. Try again.';
|
||||
}
|
||||
|
||||
avatarBusy.value = false;
|
||||
};
|
||||
|
||||
const saveName = async () => {
|
||||
if (!nameChanged.value || nameSaving.value) return;
|
||||
|
||||
nameError.value = '';
|
||||
nameSaving.value = true;
|
||||
|
||||
const updated = await updateProfile({ name: trimmedName.value });
|
||||
if (updated.ok === false) {
|
||||
nameError.value = 'Your name could not be saved. Try again.';
|
||||
} else {
|
||||
name.value = trimmedName.value;
|
||||
}
|
||||
|
||||
nameSaving.value = false;
|
||||
};
|
||||
|
||||
const memberSince = computed(() => {
|
||||
if (!user.value?.createdAt) return '';
|
||||
|
||||
// fixed locale and time zone so the server and client render the same text
|
||||
return new Date(user.value.createdAt).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col gap-5 py-4 overflow-auto [scrollbar-width:thin] [scrollbar-color:#888_transparent] [scrollbar-gutter:stable]">
|
||||
<div class="flex flex-col gap-5 w-full max-w-3xl">
|
||||
<section class="flex flex-wrap items-center gap-5 pb-5 border-b border-[var(--color-border)]">
|
||||
<UserAvatar :user="user" class="w-20 h-20 rounded-xl text-3xl" />
|
||||
|
||||
<div class="flex flex-col gap-2 flex-1 min-w-60">
|
||||
<h2 class="font-medium">Profile picture</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button type="button" :disabled="avatarBusy" @click="fileInput?.click()"
|
||||
class="flex items-center gap-2 h-8 px-3 text-sm rounded-md bg-[var(--bg-container)] @hover:bg-[var(--color-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
<span :class="avatarBusy ? 'i-svg-spinners-180-ring-with-bg' : 'i-mynaui-upload'"
|
||||
class="text-4"></span>
|
||||
{{ avatarBusy ? 'Working…' : 'Upload image' }}
|
||||
</button>
|
||||
<button v-if="user?.image" type="button" :disabled="avatarBusy" @click="removeAvatar"
|
||||
class="flex items-center h-8 px-3 text-sm rounded-md border border-[var(--color-border)] text-[var(--text-secondary)] @hover:bg-[var(--color-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
<input ref="fileInput" type="file" class="hidden" :accept="ACCEPTED_TYPES.join(',')"
|
||||
aria-label="Upload a profile picture" @change="handleFileChange" />
|
||||
<p v-if="avatarError" role="alert" class="text-xs text-red-400">{{ avatarError }}</p>
|
||||
<p v-else class="text-xs text-[var(--text-secondary)]">
|
||||
PNG, JPG or WebP up to 10MB.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<form class="flex flex-row flex-wrap items-center justify-between gap-x-16 gap-y-2"
|
||||
@submit.prevent="saveName">
|
||||
<label class="whitespace-nowrap" for="profile-name">Display name</label>
|
||||
<div class="flex flex-col gap-1 flex-1 min-w-60 max-w-120">
|
||||
<div class="flex gap-2">
|
||||
<input id="profile-name" v-model="name" type="text" autocomplete="name" maxlength="64"
|
||||
class="flex-1 min-w-0 h-8.5 px-2.5 text-sm rounded-md bg-[var(--bg-container)] placeholder:text-[var(--text-tertiary)]" />
|
||||
<button type="submit" :disabled="!nameChanged || nameSaving"
|
||||
class="h-8.5 px-3 items-center text-sm font-semibold rounded-md bg-[var(--color-accent)] text-[var(--color-accent-text)] disabled:opacity-40 disabled:cursor-not-allowed transition-opacity duration-200">
|
||||
{{ nameSaving ? 'Saving…' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="nameError" role="alert" class="text-xs text-red-400">{{ nameError }}</p>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="flex flex-row flex-wrap items-center justify-between gap-x-16 gap-y-2">
|
||||
<span class="whitespace-nowrap">Email</span>
|
||||
<span
|
||||
class="flex-1 min-w-60 max-w-120 text-sm text-[var(--text-secondary)] overflow-hidden text-ellipsis">
|
||||
{{ user?.email }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row flex-wrap items-center justify-between gap-x-16 gap-y-2">
|
||||
<span class="whitespace-nowrap">Member since</span>
|
||||
<span class="flex-1 min-w-60 max-w-120 text-sm text-[var(--text-secondary)]">
|
||||
{{ memberSince }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row flex-wrap items-center justify-between gap-x-16 gap-y-2">
|
||||
<span class="whitespace-nowrap">Role</span>
|
||||
<div class="flex-1 min-w-60 max-w-120 flex items-center gap-2.5">
|
||||
<span v-if="isAdmin"
|
||||
class="text-[13px] font-medium px-2 py-0.5 rounded-md border text-[var(--text-primary)]"
|
||||
:style="ACCENT_PILL_STYLE">Admin</span>
|
||||
<span v-else
|
||||
class="text-[13px] font-medium px-2 py-0.5 rounded-md border border-[var(--color-border-active)] text-[var(--text-secondary)]">User</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section
|
||||
class="flex flex-wrap items-center justify-between gap-3 mt-3 p-4 rounded-lg border border-red-400/35">
|
||||
<div class="flex flex-col gap-0.5 flex-1 min-w-60">
|
||||
<h2 class="text-[15px] font-semibold">Delete account</h2>
|
||||
<p class="text-[13px] leading-snug text-[var(--text-secondary)]">
|
||||
Permanently deletes your agents, topics, and api keys from this instance.
|
||||
<template v-if="isAdmin">The last remaining admin can't delete their account.</template>
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" @click="openDialog(DialogType.DeleteAccount)"
|
||||
class="flex items-center h-8.5 px-3 text-sm font-medium rounded-md bg-red-600 text-white">
|
||||
Delete account
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+66
-49
@@ -2,20 +2,19 @@
|
||||
import { sortByReleaseDate } from '~/utils/sort';
|
||||
import { encryptData, decrypt, uint8ArrayToBase64, base64ToUint8Array } from '~/utils/crypto';
|
||||
import { type Model } from '~/types/model';
|
||||
import ModelItem from './ModelItem.vue';
|
||||
import RowVirtualizerDynamic from '../RowVirtualizerDynamic.vue';
|
||||
import ModelItem from '~/components/Settings/ModelItem.vue';
|
||||
import RowVirtualizerDynamic from '~/components/RowVirtualizerDynamic.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
params?: string;
|
||||
}>();
|
||||
const route = useRoute();
|
||||
const providerId = computed(() => route.params.id as string | undefined);
|
||||
|
||||
const { providers, updateProvider, createModel, updateModel } = await useModels();
|
||||
|
||||
const scrollContainerRef = ref<HTMLDivElement | null>(null);
|
||||
|
||||
const provider = computed(() => {
|
||||
if (props.params === undefined) return null;
|
||||
return providers.value!.find(p => p.id === props.params);
|
||||
if (providerId.value === undefined) return null;
|
||||
return providers.value!.find(p => p.id === providerId.value);
|
||||
});
|
||||
|
||||
watch(provider, async () => {
|
||||
@@ -29,8 +28,8 @@ const apiKey = ref('');
|
||||
const apiProxyUrl = ref(provider.value?.config.apiProxyUrl ?? '');
|
||||
const modelSearch = ref('');
|
||||
|
||||
watch(() => props.params, () => {
|
||||
if (props.params === undefined) return;
|
||||
watch(() => providerId.value, () => {
|
||||
if (providerId.value === undefined) return;
|
||||
apiKey.value = provider.value?.config.apiKey ?? '';
|
||||
apiProxyUrl.value = provider.value?.config.apiProxyUrl ?? '';
|
||||
modelSearch.value = '';
|
||||
@@ -69,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 () => {
|
||||
@@ -373,8 +390,6 @@ const formatMoney = (value: string) => {
|
||||
const isFormValid = computed(() => {
|
||||
return formData.value.name.trim() && formData.value.externalId.trim();
|
||||
});
|
||||
|
||||
defineEmits(['navigate']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -410,6 +425,8 @@ defineEmits(['navigate']);
|
||||
</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
|
||||
+3
-9
@@ -3,10 +3,6 @@ import { providerIcons } from '~/utils/model-mapping';
|
||||
|
||||
const { providers, updateProvider } = await useModels();
|
||||
|
||||
const props = defineProps<{
|
||||
params?: string;
|
||||
}>();
|
||||
|
||||
if (providers.value === undefined) throw new Error('Providers not loaded');
|
||||
|
||||
const toggleProvider = async (id: string) => {
|
||||
@@ -18,7 +14,7 @@ const toggleProvider = async (id: string) => {
|
||||
});
|
||||
};
|
||||
|
||||
defineEmits(['navigate']);
|
||||
const openProvider = (id: string) => navigateTo(`/settings/providers/${id}`);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -31,8 +27,7 @@ defineEmits(['navigate']);
|
||||
</h2>
|
||||
<div
|
||||
class="grid gap-4 grid-cols-[repeat(auto-fill,_minmax(max(240px,_calc((100%_-_16px_*_(3_-_1))_/_3)),_1fr))]">
|
||||
<button @click="$emit('navigate', 'providers', 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">
|
||||
@@ -55,8 +50,7 @@ defineEmits(['navigate']);
|
||||
</h2>
|
||||
<div
|
||||
class="grid gap-4 grid-cols-[repeat(auto-fill,_minmax(max(240px,_calc((100%_-_16px_*_(3_-_1))_/_3)),_1fr))]">
|
||||
<button @click="$emit('navigate', 'providers', 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">
|
||||
@@ -0,0 +1,283 @@
|
||||
<script setup lang="ts">
|
||||
import { DialogType } from '~/composables/useDialog';
|
||||
|
||||
const BUTTON_CLASS = 'flex items-center justify-center gap-2 h-8.5 px-3 text-sm rounded-md bg-[var(--bg-container)] @hover:bg-[var(--color-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]';
|
||||
const PRIMARY_BUTTON_CLASS = 'flex items-center justify-center gap-2 h-8.5 px-3 text-sm font-medium rounded-md bg-[var(--color-accent)] text-[var(--color-accent-text)] disabled:opacity-40 disabled:cursor-not-allowed transition-opacity duration-200';
|
||||
const OUTLINE_BUTTON_CLASS = 'flex items-center justify-center gap-2 h-8.5 px-3 text-sm rounded-md border border-[var(--color-border)] text-[var(--text-secondary)] @hover:bg-[var(--color-hover)] disabled:text-[var(--text-tertiary)] disabled:cursor-not-allowed disabled:hover:bg-transparent transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]';
|
||||
|
||||
const { client: authClient, user } = useAuth();
|
||||
const { providers } = await useModels();
|
||||
|
||||
// tinted pill used for the states that are on, same look as the role badge
|
||||
const ACCENT_PILL_STYLE = {
|
||||
backgroundColor: 'color-mix(in srgb, var(--color-accent) 20%, transparent)',
|
||||
borderColor: 'color-mix(in srgb, var(--color-accent) 50%, transparent)',
|
||||
};
|
||||
|
||||
// fixed locale and time zone so the server and client render the same text
|
||||
const formatDate = (date: string | Date) => new Date(date).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
|
||||
// The server does not know the viewer's time zone, so it renders the date only
|
||||
// and the time of day is filled in once the page is running in the browser.
|
||||
const mounted = ref(false);
|
||||
onMounted(() => {
|
||||
mounted.value = true;
|
||||
});
|
||||
|
||||
const formatDateTime = (date: string | Date) => {
|
||||
if (!mounted.value) return formatDate(date);
|
||||
|
||||
return new Date(date).toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Password
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const { openDialog } = useDialog();
|
||||
|
||||
const savedKeyCount = computed(() => providers.value.filter(p => !!p.config.apiKey).length);
|
||||
|
||||
const { data: passwordInfo, refresh: refreshPasswordInfo } = await useFetch<{ changedAt: string | null }>('/api/account/password', {
|
||||
key: 'account_password',
|
||||
});
|
||||
|
||||
const openChangePassword = () => {
|
||||
openDialog(DialogType.ChangePassword, async (result) => {
|
||||
if (result.ok === false) return;
|
||||
|
||||
await Promise.all([refreshPasswordInfo(), loadSessions()]);
|
||||
});
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Two-factor authentication
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const twoFactorEnabled = computed(() => !!user.value?.twoFactorEnabled);
|
||||
|
||||
const openTwoFactor = (action: 'enable' | 'disable' | 'regenerate') => {
|
||||
// Turning two-factor on or off swaps the session for a new one, and the
|
||||
// dialog can be dismissed after that happened, so always reload.
|
||||
openDialog(DialogType.TwoFactor, async () => {
|
||||
await loadSessions();
|
||||
}, { action });
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sessions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type SessionRow = {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
ipAddress: string | null;
|
||||
userAgent: string | null;
|
||||
current: boolean;
|
||||
};
|
||||
|
||||
const sessionsError = ref('');
|
||||
const sessionsBusy = ref(false);
|
||||
|
||||
// Better Auth only lists sessions for a device that signed in within the last
|
||||
// day, so this goes through our own endpoint instead.
|
||||
const { data: sessionData, error: sessionsLoadError, refresh: loadSessions } = await useFetch<SessionRow[]>('/api/account/sessions', {
|
||||
key: 'account_sessions',
|
||||
});
|
||||
|
||||
const sessions = computed(() => sessionData.value ?? []);
|
||||
|
||||
// this device first, then most recently active
|
||||
const sortedSessions = computed(() => [...sessions.value].sort((a, b) => {
|
||||
if (a.current) return -1;
|
||||
if (b.current) return 1;
|
||||
|
||||
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
||||
}));
|
||||
|
||||
watch(sessionsLoadError, (error) => {
|
||||
sessionsError.value = error ? 'Your sessions could not be loaded.' : '';
|
||||
}, { immediate: true });
|
||||
|
||||
const revokeSession = async (id: string) => {
|
||||
if (sessionsBusy.value) return;
|
||||
|
||||
sessionsBusy.value = true;
|
||||
sessionsError.value = '';
|
||||
|
||||
try {
|
||||
await $fetch(`/api/account/sessions/${id}`, { method: 'DELETE' });
|
||||
await loadSessions();
|
||||
} catch (error) {
|
||||
console.error('Failed to revoke session:', error);
|
||||
sessionsError.value = 'That session could not be signed out. Try again.';
|
||||
}
|
||||
|
||||
sessionsBusy.value = false;
|
||||
};
|
||||
|
||||
const revokeOtherSessions = async () => {
|
||||
if (sessionsBusy.value) return;
|
||||
|
||||
sessionsBusy.value = true;
|
||||
sessionsError.value = '';
|
||||
|
||||
const { error } = await authClient.revokeOtherSessions();
|
||||
if (error) {
|
||||
sessionsError.value = 'Your other sessions could not be signed out. Try again.';
|
||||
} else {
|
||||
await loadSessions();
|
||||
}
|
||||
|
||||
sessionsBusy.value = false;
|
||||
};
|
||||
|
||||
const describeDevice = (userAgent: string | null | undefined) => {
|
||||
const ua = userAgent ?? '';
|
||||
|
||||
const browser =
|
||||
/Edg\//.test(ua) ? 'Edge'
|
||||
: /OPR\//.test(ua) ? 'Opera'
|
||||
: /Firefox\//.test(ua) ? 'Firefox'
|
||||
: /Chrome\//.test(ua) ? 'Chrome'
|
||||
: /Safari\//.test(ua) ? 'Safari'
|
||||
: 'Unknown browser';
|
||||
|
||||
const os =
|
||||
/Android/.test(ua) ? 'Android'
|
||||
: /iPhone|iPad|iPod/.test(ua) ? 'iOS'
|
||||
: /Windows/.test(ua) ? 'Windows'
|
||||
: /Mac OS X/.test(ua) ? 'macOS'
|
||||
: /Linux/.test(ua) ? 'Linux'
|
||||
: '';
|
||||
|
||||
return {
|
||||
label: os ? `${browser} on ${os}` : browser,
|
||||
mobile: /Android|iPhone|iPad|iPod|Mobile/.test(ua),
|
||||
};
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col py-4 overflow-auto [scrollbar-width:thin] [scrollbar-color:#888_transparent] [scrollbar-gutter:stable]">
|
||||
<div class="flex flex-col gap-7 w-full max-w-3xl">
|
||||
<!-- PASSWORD -->
|
||||
<section class="flex flex-col gap-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex flex-col gap-0.5 flex-1 min-w-60">
|
||||
<h2 class="font-medium">Password</h2>
|
||||
<p class="text-[13px] text-[var(--text-secondary)]">
|
||||
{{ passwordInfo?.changedAt
|
||||
? `Last changed ${formatDate(passwordInfo.changedAt)}`
|
||||
: 'Never changed' }}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" :class="BUTTON_CLASS" @click="openChangePassword">
|
||||
Change password…
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- TWO-FACTOR -->
|
||||
<section class="flex flex-col gap-3 pt-6 border-t border-[var(--color-border)]">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex flex-col gap-0.5 flex-1 min-w-60">
|
||||
<h2 class="font-medium flex items-center gap-2">
|
||||
Two-factor sign-in
|
||||
<span v-if="twoFactorEnabled"
|
||||
class="text-xs font-normal px-2 py-0.25 rounded-md border text-[var(--text-primary)]"
|
||||
:style="ACCENT_PILL_STYLE">On</span>
|
||||
<span v-else
|
||||
class="text-xs font-normal px-2 py-0.25 rounded-md border border-[var(--color-border-active)] text-[var(--text-secondary)]">Off</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<button v-if="twoFactorEnabled" type="button" :class="OUTLINE_BUTTON_CLASS"
|
||||
@click="openTwoFactor('disable')">
|
||||
Turn off
|
||||
</button>
|
||||
<button v-else type="button" :class="[PRIMARY_BUTTON_CLASS, 'font-semibold']"
|
||||
@click="openTwoFactor('enable')">
|
||||
Set up
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="twoFactorEnabled" class="flex flex-wrap items-center justify-between gap-3">
|
||||
<span class="flex-1 min-w-60 text-sm">
|
||||
Backup codes
|
||||
<span class="text-[13px]" :class="twoFactorEnabled ? 'text-[var(--text-secondary)]' : ''">
|
||||
· {{ twoFactorEnabled
|
||||
? 'each one signs you in once if you lose your authenticator app'
|
||||
: 'available once two-factor is on' }}
|
||||
</span>
|
||||
</span>
|
||||
<button type="button" :disabled="!twoFactorEnabled" :class="OUTLINE_BUTTON_CLASS"
|
||||
@click="openTwoFactor('regenerate')">
|
||||
Regenerate
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<!-- SESSIONS -->
|
||||
<section class="flex flex-col gap-3 pt-6 border-t border-[var(--color-border)]">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex flex-col gap-0.5 flex-1 min-w-60">
|
||||
<h2 class="font-medium">Sessions</h2>
|
||||
<p class="text-[13px] text-[var(--text-secondary)]">
|
||||
Devices signed in to your account.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" :disabled="sessionsBusy || sessions.length < 2" :class="OUTLINE_BUTTON_CLASS"
|
||||
@click="revokeOtherSessions">
|
||||
Sign out everywhere else
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="sessionsError" role="alert" class="text-sm text-red-400">{{ sessionsError }}</p>
|
||||
|
||||
<ul class="flex flex-col gap-1">
|
||||
<li v-for="s in sortedSessions" :key="s.id"
|
||||
class="flex flex-wrap items-center gap-3 min-h-13 px-3 py-2 rounded-lg bg-[var(--bg-container)]">
|
||||
<span class="text-5 flex-shrink-0 text-[var(--text-secondary)]"
|
||||
:class="describeDevice(s.userAgent).mobile ? 'i-tabler-device-mobile' : 'i-tabler-device-desktop'"></span>
|
||||
<div class="flex flex-col flex-1 min-w-40">
|
||||
<span class="text-sm font-medium">
|
||||
{{ describeDevice(s.userAgent).label }}
|
||||
<span v-if="s.ipAddress" class="font-normal text-[var(--text-tertiary)]">
|
||||
· {{ s.ipAddress }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="text-xs text-[var(--text-secondary)]">
|
||||
{{ s.current ? 'Active now' : `Last active ${formatDateTime(s.updatedAt)}` }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-if="s.current" class="text-xs px-2 py-0.5 rounded-md border text-[var(--text-primary)]"
|
||||
:style="ACCENT_PILL_STYLE">
|
||||
This device
|
||||
</span>
|
||||
<button v-else type="button" :disabled="sessionsBusy"
|
||||
class="flex items-center h-7.5 px-2.5 text-[13px] rounded-md border border-[var(--color-border)] text-[var(--text-primary)] @hover:bg-[var(--color-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
|
||||
@click="revokeSession(s.id)">
|
||||
Revoke
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,155 @@
|
||||
<script setup lang="ts">
|
||||
import type { RegistrationMode } from '~~/drizzle/schema';
|
||||
|
||||
type InstanceSettings = {
|
||||
registration: RegistrationMode;
|
||||
registrationLocked: boolean;
|
||||
};
|
||||
|
||||
const REGISTRATION_OPTIONS: { value: RegistrationMode; label: string; description: string }[] = [
|
||||
{
|
||||
value: 'open',
|
||||
label: 'Open',
|
||||
description: 'Anyone who can reach this URL can create an account.',
|
||||
},
|
||||
{
|
||||
value: 'invite',
|
||||
label: 'Invite only',
|
||||
description: 'Admins create invite links. Good default for a shared instance.',
|
||||
},
|
||||
{
|
||||
value: 'closed',
|
||||
label: 'Closed',
|
||||
description: 'No sign-ups. Only you use this instance.',
|
||||
},
|
||||
];
|
||||
|
||||
// Tint of the chosen option. UnoCSS can't put an opacity on a CSS variable, so
|
||||
// "bg-[var(--color-accent)]/10" comes out as the solid accent colour.
|
||||
const SELECTED_STYLE = {
|
||||
backgroundColor: 'color-mix(in srgb, var(--color-accent) 10%, transparent)',
|
||||
};
|
||||
|
||||
definePageMeta({
|
||||
layout: 'auth',
|
||||
});
|
||||
|
||||
const { isAdmin } = useAuth();
|
||||
|
||||
// the rest of first-run setup belongs to the admin who just made the account
|
||||
if (!isAdmin.value) {
|
||||
await navigateTo('/');
|
||||
}
|
||||
|
||||
const { data: instance } = await useFetch<InstanceSettings>('/api/admin/instance', {
|
||||
key: 'admin_instance',
|
||||
});
|
||||
|
||||
const registration = ref<RegistrationMode>(instance.value?.registration ?? 'invite');
|
||||
const locked = computed(() => !!instance.value?.registrationLocked);
|
||||
|
||||
const saving = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
const submit = async () => {
|
||||
if (saving.value) return;
|
||||
|
||||
// nothing to save when the environment decides, or nothing changed
|
||||
if (locked.value || instance.value?.registration === registration.value) {
|
||||
return navigateTo('/setup/provider');
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
error.value = '';
|
||||
|
||||
try {
|
||||
instance.value = await $fetch<InstanceSettings>('/api/admin/instance', {
|
||||
method: 'PUT',
|
||||
body: { registration: registration.value },
|
||||
});
|
||||
// the sign in pages read this to decide whether to offer registering
|
||||
clearNuxtData('instance');
|
||||
} catch (err) {
|
||||
console.error('Failed to save registration:', err);
|
||||
error.value = 'That could not be saved. Try again.';
|
||||
saving.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = false;
|
||||
|
||||
return navigateTo('/setup/provider');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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">
|
||||
<div class="flex flex-col items-center gap-1 text-center">
|
||||
<h1 class="text-3xl font-bold tracking-tight">Veridian</h1>
|
||||
<p class="text-[var(--text-secondary)]">Set up this instance</p>
|
||||
</div>
|
||||
|
||||
<OnboardingSetupSteps :current="2"></OnboardingSetupSteps>
|
||||
|
||||
<form class="flex flex-col gap-4" @submit.prevent="submit">
|
||||
<fieldset class="flex flex-col gap-1.5" :disabled="saving || locked">
|
||||
<legend
|
||||
class="mb-1.5 ml-1 text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)]">
|
||||
Who can register
|
||||
</legend>
|
||||
|
||||
<label v-for="option in REGISTRATION_OPTIONS" :key="option.value"
|
||||
class="flex items-start gap-3 px-3 py-2.5 rounded-lg border transition-colors duration-200"
|
||||
:style="registration === option.value ? SELECTED_STYLE : undefined" :class="[
|
||||
registration === option.value
|
||||
? 'border-[var(--color-accent)]'
|
||||
: 'border-[var(--color-border)]',
|
||||
locked ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer',
|
||||
]">
|
||||
<!-- a plain radio: the auth layout sizes every input as a text field -->
|
||||
<input v-model="registration" type="radio" name="registration" :value="option.value"
|
||||
class="!w-auto !p-0 mt-0.75 flex-shrink-0" style="accent-color: var(--color-accent)" />
|
||||
<span class="flex flex-col gap-0.5">
|
||||
<span class="font-medium">{{ option.label }}</span>
|
||||
<span class="text-[13px] text-[var(--text-secondary)]">
|
||||
{{ option.description }}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<p v-if="locked" class="flex gap-2 text-xs leading-normal text-[var(--text-secondary)]">
|
||||
<span class="i-mynaui-lock text-4 mt-0.25 flex-shrink-0" aria-hidden="true"></span>
|
||||
<span>
|
||||
Registration is turned off by the <code class="font-mono">DISABLE_SIGNUP</code>
|
||||
environment variable. Remove it and restart the server to choose here.
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p v-if="error" role="alert" class="text-sm text-red-400">{{ error }}</p>
|
||||
|
||||
<button type="submit" :disabled="saving"
|
||||
class="accent w-full h-11 flex items-center justify-center font-semibold">
|
||||
<span v-if="saving" class="i-svg-spinners-90-ring-with-bg text-xl"></span>
|
||||
<span v-else>Continue</span>
|
||||
</button>
|
||||
|
||||
<p class="text-xs text-center text-[var(--text-secondary)]">
|
||||
This can be changed later in Settings → Instance.
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: 'auth',
|
||||
});
|
||||
|
||||
const { isAdmin } = useAuth();
|
||||
|
||||
// the rest of first-run setup belongs to the admin who just made the account
|
||||
if (!isAdmin.value) {
|
||||
await navigateTo('/');
|
||||
}
|
||||
|
||||
const finish = () => navigateTo('/');
|
||||
const back = () => navigateTo('/setup/instance');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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">
|
||||
<div class="flex flex-col items-center gap-1 text-center">
|
||||
<h1 class="text-3xl font-bold tracking-tight">Veridian</h1>
|
||||
<p class="text-[var(--text-secondary)]">Connect your first AI provider</p>
|
||||
</div>
|
||||
|
||||
<OnboardingSetupSteps :current="3"></OnboardingSetupSteps>
|
||||
|
||||
<OnboardingProviderForm submit-label="Fetch models and finish" :show-back="true" @done="finish"
|
||||
@back="back">
|
||||
<template #note>
|
||||
<p class="text-xs leading-normal text-[var(--text-secondary)]">
|
||||
Providers are per account. Other people on this instance add their own keys, and nobody,
|
||||
including admins, can read yours.
|
||||
</p>
|
||||
</template>
|
||||
</OnboardingProviderForm>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: 'auth',
|
||||
});
|
||||
|
||||
const to = useRoute().query.to as string | undefined;
|
||||
|
||||
const finish = () => navigateTo(to ?? '/');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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">
|
||||
<div class="flex flex-col items-center gap-1 text-center">
|
||||
<h1 class="text-3xl font-bold tracking-tight">Veridian</h1>
|
||||
<p class="text-[var(--text-secondary)]">One more thing: connect an AI provider</p>
|
||||
</div>
|
||||
|
||||
<p class="text-sm leading-normal text-center text-[var(--text-secondary)]">
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,29 @@
|
||||
const AVATAR_COLORS = [
|
||||
'#d12655',
|
||||
'#c42685',
|
||||
'#a334c2',
|
||||
'#853de0',
|
||||
'#6548db',
|
||||
'#485ddd',
|
||||
'#2670cf',
|
||||
'#087caa',
|
||||
'#008579',
|
||||
'#148348',
|
||||
'#7846c7',
|
||||
'#d13243',
|
||||
];
|
||||
|
||||
export const avatarColor = (seed: string): string => {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
hash = (hash * 31 + seed.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
|
||||
return AVATAR_COLORS[hash % AVATAR_COLORS.length]!;
|
||||
};
|
||||
|
||||
export const avatarInitial = (name: string | null | undefined): string => {
|
||||
const first = Array.from((name ?? '').trim())[0];
|
||||
|
||||
return first ? first.toUpperCase() : '?';
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
const DAY_MS = 24 * HOUR_MS;
|
||||
|
||||
/** How long until a date, in whole days or hours: "6 days", "3 hours". */
|
||||
export const timeLeft = (until: string | Date): string => {
|
||||
const ms = new Date(until).getTime() - Date.now();
|
||||
|
||||
if (ms >= DAY_MS) {
|
||||
const days = Math.ceil(ms / DAY_MS);
|
||||
return `${days} ${days === 1 ? 'day' : 'days'}`;
|
||||
}
|
||||
|
||||
const hours = Math.max(1, Math.ceil(ms / HOUR_MS));
|
||||
return `${hours} ${hours === 1 ? 'hour' : 'hours'}`;
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE "two_factors" (
|
||||
"id" text PRIMARY KEY,
|
||||
"secret" text NOT NULL,
|
||||
"backup_codes" text NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"verified" boolean DEFAULT true,
|
||||
"failed_verification_count" integer DEFAULT 0,
|
||||
"locked_until" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD COLUMN "two_factor_enabled" boolean DEFAULT false;--> statement-breakpoint
|
||||
CREATE INDEX "two_factors_secret_idx" ON "two_factors" ("secret");--> statement-breakpoint
|
||||
CREATE INDEX "two_factors_userId_idx" ON "two_factors" ("user_id");--> statement-breakpoint
|
||||
ALTER TABLE "two_factors" ADD CONSTRAINT "two_factors_user_id_users_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
ALTER TABLE "sessions" ADD COLUMN "impersonated_by" text;--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD COLUMN "role" text DEFAULT 'user';--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD COLUMN "banned" boolean DEFAULT false;--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD COLUMN "ban_reason" text;--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD COLUMN "ban_expires" timestamp with time zone;--> statement-breakpoint
|
||||
-- Accounts were all equal until now, so the oldest one becomes the admin.
|
||||
UPDATE "users" SET "role" = 'admin' WHERE "id" = (SELECT "id" FROM "users" ORDER BY "created_at", "id" LIMIT 1);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
CREATE TABLE "instance_settings" (
|
||||
"id" text PRIMARY KEY,
|
||||
"registration" text DEFAULT 'open' NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE "invites" (
|
||||
"id" text PRIMARY KEY,
|
||||
"token" text NOT NULL UNIQUE,
|
||||
"created_by" text NOT NULL,
|
||||
"max_uses" integer DEFAULT 1 NOT NULL,
|
||||
"uses" integer DEFAULT 0 NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "invites" ADD CONSTRAINT "invites_created_by_users_id_fkey" FOREIGN KEY ("created_by") REFERENCES "users"("id") ON DELETE CASCADE;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE "recovery_links" (
|
||||
"id" text PRIMARY KEY,
|
||||
"token_hash" text NOT NULL UNIQUE,
|
||||
"user_id" text NOT NULL,
|
||||
"reset_security" boolean DEFAULT true NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "recovery_links_userId_idx" ON "recovery_links" ("user_id");--> statement-breakpoint
|
||||
ALTER TABLE "recovery_links" ADD CONSTRAINT "recovery_links_user_id_users_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,19 @@ export enum ToolCallType {
|
||||
Json = 'json',
|
||||
}
|
||||
|
||||
export const REGISTRATION_MODES = ['open', 'invite', 'closed'] as const;
|
||||
export type RegistrationMode = typeof REGISTRATION_MODES[number];
|
||||
|
||||
export const INSTANCE_SETTINGS_ID = 'instance';
|
||||
|
||||
// Settings of the whole instance, changed by admins. There is only ever the
|
||||
// one row with the id above.
|
||||
export const instanceSettings = pgTable('instance_settings', {
|
||||
id: text('id').primaryKey(),
|
||||
registration: text('registration').$type<RegistrationMode>().notNull().default('open'),
|
||||
updatedAt: timestamptz('updated_at').notNull().defaultNow().$onUpdate(() => /* @__PURE__ */ new Date()),
|
||||
});
|
||||
|
||||
export const settings = pgTable('settings', {
|
||||
id: text('id').primaryKey().$defaultFn(nanoid),
|
||||
userId: text('user_id').notNull(),
|
||||
@@ -253,6 +266,12 @@ export const users = pgTable("users", {
|
||||
email: text("email").notNull().unique(),
|
||||
emailVerified: boolean("email_verified").default(false).notNull(),
|
||||
image: text("image"),
|
||||
twoFactorEnabled: boolean("two_factor_enabled").default(false),
|
||||
// 'admin' or 'user', the first account of an instance is its admin
|
||||
role: text("role").default("user"),
|
||||
banned: boolean("banned").default(false),
|
||||
banReason: text("ban_reason"),
|
||||
banExpires: timestamptz("ban_expires"),
|
||||
createdAt: timestamptz("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamptz("updated_at")
|
||||
.defaultNow()
|
||||
@@ -260,6 +279,25 @@ export const users = pgTable("users", {
|
||||
.notNull(),
|
||||
});
|
||||
|
||||
export const twoFactors = pgTable(
|
||||
"two_factors",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
secret: text("secret").notNull(),
|
||||
backupCodes: text("backup_codes").notNull(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
verified: boolean("verified").default(true),
|
||||
failedVerificationCount: integer("failed_verification_count").default(0),
|
||||
lockedUntil: timestamptz("locked_until"),
|
||||
},
|
||||
(table) => [
|
||||
index("two_factors_secret_idx").on(table.secret),
|
||||
index("two_factors_userId_idx").on(table.userId),
|
||||
],
|
||||
);
|
||||
|
||||
export const sessions = pgTable(
|
||||
"sessions",
|
||||
{
|
||||
@@ -275,6 +313,7 @@ export const sessions = pgTable(
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
impersonatedBy: text("impersonated_by"),
|
||||
},
|
||||
(table) => [index("sessions_userId_idx").on(table.userId)],
|
||||
);
|
||||
@@ -318,3 +357,39 @@ export const verifications = pgTable(
|
||||
},
|
||||
(table) => [index("verifications_identifier_idx").on(table.identifier)],
|
||||
);
|
||||
|
||||
// Links admins hand out so someone can register while the instance is invite
|
||||
// only. The token is the secret part of the link.
|
||||
export const invites = pgTable(
|
||||
"invites",
|
||||
{
|
||||
id: text("id").primaryKey().$defaultFn(nanoid),
|
||||
token: text("token").notNull().unique(),
|
||||
createdBy: text("created_by")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
maxUses: integer("max_uses").notNull().default(1),
|
||||
uses: integer("uses").notNull().default(0),
|
||||
expiresAt: timestamptz("expires_at").notNull(),
|
||||
createdAt: timestamptz("created_at").defaultNow().notNull(),
|
||||
},
|
||||
);
|
||||
|
||||
// One-time links that let someone who forgot their password set a new one.
|
||||
// Only a hash of the token is kept, the link itself is shown once when made.
|
||||
export const recoveryLinks = pgTable(
|
||||
"recovery_links",
|
||||
{
|
||||
id: text("id").primaryKey().$defaultFn(nanoid),
|
||||
tokenHash: text("token_hash").notNull().unique(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
// also turn two-factor off when it is used, every device is signed out
|
||||
// either way
|
||||
resetSecurity: boolean("reset_security").notNull().default(true),
|
||||
expiresAt: timestamptz("expires_at").notNull(),
|
||||
createdAt: timestamptz("created_at").defaultNow().notNull(),
|
||||
},
|
||||
(table) => [index("recovery_links_userId_idx").on(table.userId)],
|
||||
);
|
||||
|
||||
+131
-3
@@ -1,10 +1,41 @@
|
||||
import { drizzleAdapter } from "@better-auth/drizzle-adapter/relations-v2";
|
||||
import { db } from "../server/lib/db";
|
||||
import { APIError, createAuthMiddleware, getSessionFromCtx, isAPIError } from 'better-auth/api';
|
||||
import { betterAuth } from 'better-auth/minimal';
|
||||
import { createAccessControl } from 'better-auth/plugins/access';
|
||||
import { admin } from 'better-auth/plugins/admin';
|
||||
import { defaultStatements } from 'better-auth/plugins/admin/access';
|
||||
import { twoFactor } from 'better-auth/plugins/two-factor';
|
||||
import * as schema from '../drizzle/schema';
|
||||
import { getRegistrationMode } from '../server/utils/instance';
|
||||
import { hasUsableAdministrator } from '../server/lib/account-security';
|
||||
import { authDatabase, currentTransaction } from '../server/lib/auth-transaction';
|
||||
import { claimInvite, INVITE_TOKEN_HEADER } from '../server/utils/invites';
|
||||
import { isSetupNeeded } from '../server/utils/setup';
|
||||
|
||||
const MIN_PASSWORD_LENGTH = 8;
|
||||
|
||||
const ADMIN_MUTATIONS = new Set(['/admin/set-role', '/admin/ban-user', '/admin/unban-user', '/admin/update-user']);
|
||||
|
||||
const transactionRequired = () => new APIError('INTERNAL_SERVER_ERROR', {
|
||||
message: 'This has to run in a transaction',
|
||||
code: 'TRANSACTION_REQUIRED',
|
||||
});
|
||||
|
||||
const accessControl = createAccessControl(defaultStatements);
|
||||
|
||||
const roles = {
|
||||
admin: accessControl.newRole({
|
||||
user: ['set-role', 'ban'],
|
||||
session: ['revoke'],
|
||||
}),
|
||||
user: accessControl.newRole({
|
||||
user: [],
|
||||
session: [],
|
||||
}),
|
||||
};
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: drizzleAdapter(db, {
|
||||
database: drizzleAdapter(authDatabase, {
|
||||
provider: "pg",
|
||||
schema,
|
||||
usePlural: true,
|
||||
@@ -15,13 +46,110 @@ export const auth = betterAuth({
|
||||
updateAge: 60 * 60 * 24 * 1, // 1 day
|
||||
cookieCache: {
|
||||
enabled: true,
|
||||
maxAge: 5 * 60,
|
||||
maxAge: 60,
|
||||
},
|
||||
},
|
||||
|
||||
baseURL: process.env.NUXT_PUBLIC_URL ?? 'http://localhost:3000',
|
||||
trustedOrigins: [process.env.NUXT_PUBLIC_URL ?? 'http://localhost:3000'],
|
||||
|
||||
hooks: {
|
||||
before: createAuthMiddleware(async (ctx) => {
|
||||
if (ctx.path === "/change-password" && ctx.request) {
|
||||
throw new APIError("FORBIDDEN", {
|
||||
message: "Use the account password endpoint",
|
||||
code: "USE_ACCOUNT_PASSWORD_ENDPOINT",
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.path === '/sign-up/email') {
|
||||
if (await isSetupNeeded()) {
|
||||
throw new APIError('FORBIDDEN', {
|
||||
message: 'This instance has not been set up yet',
|
||||
code: 'SETUP_NEEDED',
|
||||
});
|
||||
}
|
||||
|
||||
const mode = await getRegistrationMode();
|
||||
|
||||
if (mode === 'closed') {
|
||||
throw new APIError('FORBIDDEN', {
|
||||
message: 'Registration is closed on this instance',
|
||||
code: 'REGISTRATION_CLOSED',
|
||||
});
|
||||
}
|
||||
|
||||
if (mode === 'invite') {
|
||||
const tx = currentTransaction();
|
||||
if (!tx) throw transactionRequired();
|
||||
|
||||
const token = ctx.headers?.get(INVITE_TOKEN_HEADER);
|
||||
|
||||
if (!token || !await claimInvite(tx, token)) {
|
||||
throw new APIError('FORBIDDEN', {
|
||||
message: 'This invite link is not valid anymore',
|
||||
code: 'INVITE_INVALID',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ctx.path === '/admin/create-user' && ctx.body?.password !== undefined) {
|
||||
const password = ctx.body.password;
|
||||
if (typeof password !== 'string' || password.length < MIN_PASSWORD_LENGTH) {
|
||||
throw new APIError('BAD_REQUEST', {
|
||||
message: 'Password is too short',
|
||||
code: 'PASSWORD_TOO_SHORT',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!ADMIN_MUTATIONS.has(ctx.path)) return;
|
||||
|
||||
if (!currentTransaction()) throw transactionRequired();
|
||||
|
||||
if (ctx.path !== '/admin/set-role') return;
|
||||
|
||||
const role = ctx.body?.role;
|
||||
if (role === undefined) return;
|
||||
|
||||
const roleList = (Array.isArray(role) ? role : String(role).split(',')).map((r) => String(r).trim());
|
||||
if (roleList.includes('admin')) return;
|
||||
|
||||
const session = await getSessionFromCtx(ctx);
|
||||
if (session && String(ctx.body?.userId) === session.user.id) {
|
||||
throw new APIError('BAD_REQUEST', {
|
||||
message: 'You cannot remove your own admin role',
|
||||
code: 'YOU_CANNOT_DEMOTE_YOURSELF',
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
after: createAuthMiddleware(async (ctx) => {
|
||||
if (!ADMIN_MUTATIONS.has(ctx.path) || isAPIError(ctx.context.returned)) return;
|
||||
|
||||
const tx = currentTransaction();
|
||||
if (!tx) throw transactionRequired();
|
||||
|
||||
if (!await hasUsableAdministrator(tx)) {
|
||||
throw new APIError('CONFLICT', {
|
||||
message: 'The instance must keep a usable administrator',
|
||||
code: 'LAST_ADMIN',
|
||||
});
|
||||
}
|
||||
}),
|
||||
},
|
||||
|
||||
plugins: [
|
||||
twoFactor({
|
||||
issuer: 'Veridian',
|
||||
}),
|
||||
admin({
|
||||
ac: accessControl,
|
||||
roles,
|
||||
}),
|
||||
],
|
||||
|
||||
emailAndPassword: {
|
||||
// if DISABLE_LOCAL_AUTH is set to "1" or "true" then local auth is disabled
|
||||
enabled: process.env.DISABLE_LOCAL_AUTH?.toLowerCase() !== 'true' && process.env.DISABLE_LOCAL_AUTH !== '1',
|
||||
|
||||
+2
-2
@@ -119,6 +119,7 @@ export default defineNuxtConfig({
|
||||
'@nuxt/hints/runtime/hydration/component',
|
||||
'@tanstack/vue-virtual',
|
||||
'better-auth/vue',
|
||||
'uqr',
|
||||
'nanoid',
|
||||
'drizzle-orm/pg-core'
|
||||
],
|
||||
@@ -166,7 +167,6 @@ export default defineNuxtConfig({
|
||||
},
|
||||
|
||||
public: {
|
||||
disableSignup: process.env.DISABLE_SIGNUP === 'true' || process.env.DISABLE_SIGNUP === '1',
|
||||
publicUrl: process.env.NUXT_PUBLIC_URL ?? 'http://localhost:3000',
|
||||
}
|
||||
},
|
||||
@@ -181,4 +181,4 @@ export default defineNuxtConfig({
|
||||
sourcemap: {
|
||||
client: 'hidden',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
+7
-5
@@ -7,7 +7,8 @@
|
||||
"dev": "nuxt dev",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare"
|
||||
"postinstall": "nuxt prepare",
|
||||
"recover": "bun scripts/recover.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^3.0.71",
|
||||
@@ -20,7 +21,7 @@
|
||||
"@ai-sdk/openai-compatible": "^2.0.41",
|
||||
"@aws-sdk/client-s3": "^3.1028.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1028.0",
|
||||
"@better-auth/drizzle-adapter": "https://pkg.pr.new/better-auth/better-auth/@better-auth/drizzle-adapter@9489",
|
||||
"@better-auth/drizzle-adapter": "^1.7.7",
|
||||
"@floating-ui/vue": "^1.1.11",
|
||||
"@iconify-json/mynaui": "^1.2.17",
|
||||
"@nuxt/fonts": "0.14.0",
|
||||
@@ -32,10 +33,10 @@
|
||||
"@tanstack/vue-virtual": "^3.13.23",
|
||||
"ai": "^6.0.182",
|
||||
"ai-sdk-ollama": "^3.8.3",
|
||||
"better-auth": "^1.6.2",
|
||||
"better-auth": "^1.7.7",
|
||||
"comark": "0.6.2",
|
||||
"comlink": "^4.4.2",
|
||||
"drizzle-orm": "1.0.0-rc.4-5d5b77c",
|
||||
"drizzle-orm": "1.0.0-rc.4",
|
||||
"glob": "^13.0.6",
|
||||
"katex": "0.17.0",
|
||||
"longcat-ai-sdk-provider": "^0.0.5",
|
||||
@@ -43,6 +44,7 @@
|
||||
"nuxt": "^4.4.2",
|
||||
"pg": "^8.20.0",
|
||||
"shiki": "^3.23.0",
|
||||
"uqr": "^0.1.3",
|
||||
"vue": "^3.6.0-beta.10",
|
||||
"vue-router": "^5.0.4",
|
||||
"zod": "^4.3.6"
|
||||
@@ -54,7 +56,7 @@
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@unocss/nuxt": "^66.6.8",
|
||||
"drizzle-kit": "^1.0.0-beta.21",
|
||||
"drizzle-kit": "1.0.0-rc.4",
|
||||
"typescript": "^5.9.3",
|
||||
"unocss": "^66.6.8"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
||||
import { Pool } from "pg";
|
||||
|
||||
const LIFETIME_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
// Standalone so operators can recover an account without running the server.
|
||||
const main = async (): Promise<number> => {
|
||||
const email = process.argv[2]?.trim().toLowerCase();
|
||||
if (!email) {
|
||||
console.error("Usage: bun run recover <email>");
|
||||
return 1;
|
||||
}
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL || "postgres://postgres:password@localhost:5432/veridian",
|
||||
});
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
// Same user-row lock as the web issuance and redemption endpoints.
|
||||
const found = await client.query<{ id: string; name: string }>(
|
||||
"SELECT id, name FROM users WHERE lower(email) = $1 FOR UPDATE", [email],
|
||||
);
|
||||
const user = found.rows[0];
|
||||
if (!user) {
|
||||
await client.query("ROLLBACK");
|
||||
console.error(`No account with the email ${email}.`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const token = randomBytes(32).toString("base64url");
|
||||
const tokenHash = createHash("sha256").update(token).digest("hex");
|
||||
const expiresAt = new Date(Date.now() + LIFETIME_MS);
|
||||
|
||||
await client.query("DELETE FROM recovery_links WHERE user_id = $1", [user.id]);
|
||||
await client.query(
|
||||
"INSERT INTO recovery_links (id, token_hash, user_id, reset_security, expires_at) VALUES ($1, $2, $3, true, $4)",
|
||||
[randomUUID(), tokenHash, user.id, expiresAt],
|
||||
);
|
||||
await client.query("COMMIT");
|
||||
|
||||
const baseUrl = (process.env.NUXT_PUBLIC_URL ?? "http://localhost:3000").replace(/\/$/, "");
|
||||
console.log(`Recovery link for ${user.name} <${email}>:`);
|
||||
console.log("");
|
||||
console.log(` ${baseUrl}/auth/recover/${token}`);
|
||||
console.log("");
|
||||
console.log("It works once and expires in 24 hours. Using it deletes the saved API keys");
|
||||
console.log("of this account, turns two-factor off and signs every device out.");
|
||||
return 0;
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK");
|
||||
console.error("Failed to create recovery link:", error);
|
||||
return 1;
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
};
|
||||
|
||||
process.exit(await main());
|
||||
@@ -0,0 +1,128 @@
|
||||
import * as z from 'zod';
|
||||
import { auth } from '~~/lib/auth';
|
||||
import { hasUsableAdministrator } from '~~/server/lib/account-security';
|
||||
import { db } from '~~/server/lib/db';
|
||||
|
||||
/**
|
||||
* Deletes the signed in user's account and everything that belongs to it,
|
||||
* after they proved it is really them.
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
await protectRoute(event);
|
||||
|
||||
const userId = event.context.user!.id as string;
|
||||
|
||||
const result = await readValidatedBody(event, (body) =>
|
||||
z
|
||||
.object({
|
||||
password: z.string().min(1),
|
||||
// required when two-factor is on: a code from the authenticator
|
||||
// app, or one of the backup codes
|
||||
code: z.string().min(1).optional(),
|
||||
backupCode: z.boolean().optional(),
|
||||
})
|
||||
.safeParse(body),
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: result.error.issues[0]!.message,
|
||||
});
|
||||
}
|
||||
|
||||
const { password, code, backupCode } = result.data;
|
||||
|
||||
// read from the database, the session can lag behind by a few minutes
|
||||
const account = await db.query.users.findFirst({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
const twoFactorEnabled = !!account?.twoFactorEnabled;
|
||||
|
||||
// Checked again when the account is deleted. Doing it here as well keeps
|
||||
// a deletion that is refused anyway from using up a backup code.
|
||||
if (account?.role === 'admin' && !await db.transaction(tx => hasUsableAdministrator(tx, userId))) {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
statusMessage: 'The last usable admin cannot be deleted',
|
||||
data: {
|
||||
code: 'LAST_ADMIN',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (twoFactorEnabled && code === undefined) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Two-factor code required',
|
||||
data: {
|
||||
code: 'TWO_FACTOR_REQUIRED',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await auth.api.verifyPassword({
|
||||
body: {
|
||||
password,
|
||||
},
|
||||
headers: event.headers,
|
||||
});
|
||||
|
||||
if (twoFactorEnabled && backupCode) {
|
||||
await auth.api.verifyBackupCode({
|
||||
body: {
|
||||
code: code!,
|
||||
},
|
||||
headers: event.headers,
|
||||
});
|
||||
} else if (twoFactorEnabled) {
|
||||
await auth.api.verifyTOTP({
|
||||
body: {
|
||||
code: code!,
|
||||
},
|
||||
headers: event.headers,
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
const errorCode = error?.body?.code as string | undefined;
|
||||
|
||||
if (errorCode === undefined) {
|
||||
console.error('Failed to verify credentials:', error);
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: errorCode === undefined ? 500 : 400,
|
||||
statusMessage: 'Failed to delete account',
|
||||
data: {
|
||||
code: errorCode ?? 'INTERNAL_ERROR',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const deleted = await deleteAccount(userId);
|
||||
if (!deleted.ok) {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
statusMessage: "The last usable admin cannot be deleted",
|
||||
data: { code: deleted.error },
|
||||
});
|
||||
}
|
||||
|
||||
// the session is gone, but the browser would keep trusting its cached
|
||||
// copy for a few minutes unless the cookies go too
|
||||
const { authCookies } = await auth.$context;
|
||||
|
||||
for (const cookie of Object.values(authCookies)) {
|
||||
deleteCookie(event, cookie.name, {
|
||||
path: cookie.attributes.path,
|
||||
secure: cookie.attributes.secure,
|
||||
httpOnly: cookie.attributes.httpOnly,
|
||||
});
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { db } from '~~/server/lib/db';
|
||||
|
||||
/**
|
||||
* Reports when the password was last changed, or null if it is still the one
|
||||
* the account was created with. The credential account row is only written
|
||||
* when the account is created and when its password changes, so its
|
||||
* updatedAt is the time of the last change.
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
await protectRoute(event);
|
||||
|
||||
const userId = event.context.user!.id as string;
|
||||
|
||||
const account = await db.query.accounts.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
providerId: 'credential',
|
||||
},
|
||||
});
|
||||
|
||||
if (!account) {
|
||||
return { changedAt: null };
|
||||
}
|
||||
|
||||
// the two timestamps are written a moment apart on sign up
|
||||
const changed = account.updatedAt.getTime() - account.createdAt.getTime() > 1000;
|
||||
|
||||
return { changedAt: changed ? account.updatedAt : null };
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import * as z from "zod";
|
||||
import { providers, recoveryLinks } from "~~/drizzle/schema";
|
||||
import { lockAccount, revokeTwoFactorTrust } from "~~/server/lib/account-security";
|
||||
import { auth } from "~~/lib/auth";
|
||||
import { authTransaction } from "~~/server/lib/auth-transaction";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { user } = await protectRoute(event);
|
||||
const result = await readValidatedBody(event, (body) => z.object({
|
||||
currentPassword: z.string().min(1).max(128),
|
||||
newPassword: z.string().min(8).max(128),
|
||||
providerKeys: z.array(z.object({
|
||||
id: z.string(),
|
||||
originalApiKey: z.string().min(1),
|
||||
apiKey: z.string().min(1),
|
||||
})),
|
||||
}).safeParse(body));
|
||||
|
||||
if (!result.success) {
|
||||
throw createError({ statusCode: 400, message: result.error.issues[0]?.message });
|
||||
}
|
||||
|
||||
const { currentPassword, newPassword, providerKeys } = result.data;
|
||||
const replacements = new Map(providerKeys.map(key => [key.id, key]));
|
||||
let cookies: string[];
|
||||
|
||||
try {
|
||||
cookies = await authTransaction(async (tx) => {
|
||||
await lockAccount(tx, user.id);
|
||||
const existing = await tx.select().from(providers).where(eq(providers.userId, user.id));
|
||||
const withKey = existing.filter(provider => !!provider.config.apiKey);
|
||||
const complete = replacements.size === providerKeys.length
|
||||
&& replacements.size === withKey.length
|
||||
&& withKey.every(provider => replacements.get(provider.id)?.originalApiKey === provider.config.apiKey);
|
||||
|
||||
if (!complete) {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
statusMessage: "Provider keys out of date",
|
||||
data: { code: "PROVIDER_KEYS_OUT_OF_DATE" },
|
||||
});
|
||||
}
|
||||
|
||||
const { headers } = await auth.api.changePassword({
|
||||
body: { currentPassword, newPassword, revokeOtherSessions: true },
|
||||
headers: event.headers,
|
||||
returnHeaders: true,
|
||||
});
|
||||
|
||||
await tx.delete(recoveryLinks).where(eq(recoveryLinks.userId, user.id));
|
||||
await revokeTwoFactorTrust(tx, user.id);
|
||||
|
||||
// Invalidate pending config writes even for providers without a key.
|
||||
const updatedAt = new Date(Math.max(Date.now(), ...existing.map(provider => provider.updatedAt.getTime() + 1)));
|
||||
await tx.update(providers).set({ updatedAt }).where(eq(providers.userId, user.id));
|
||||
|
||||
for (const key of providerKeys) {
|
||||
await tx.update(providers).set({
|
||||
config: sql`jsonb_set(${providers.config}, '{apiKey}', ${JSON.stringify(key.apiKey)}::jsonb)`,
|
||||
updatedAt,
|
||||
}).where(and(eq(providers.id, key.id), eq(providers.userId, user.id)));
|
||||
}
|
||||
|
||||
return headers.getSetCookie();
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error && "body" in error) {
|
||||
const body = z.object({ code: z.string(), message: z.string().optional() }).safeParse(error.body);
|
||||
if (body.success) {
|
||||
throw createError({ statusCode: 400, message: body.data.message, data: { code: body.data.code } });
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// No replacement session reaches the browser until its transaction commits.
|
||||
for (const cookie of cookies) {
|
||||
appendResponseHeader(event, "set-cookie", cookie);
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { sessions } from '~~/drizzle/schema';
|
||||
import { db } from '~~/server/lib/db';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const sessionData = await protectRoute(event);
|
||||
|
||||
const id = getRouterParam(event, 'id')!;
|
||||
|
||||
// signing out the current device goes through the normal sign out, which
|
||||
// also clears its cookies
|
||||
if (id === sessionData.session.id) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Cannot revoke the current session',
|
||||
});
|
||||
}
|
||||
|
||||
const deleted = await db.delete(sessions)
|
||||
.where(and(eq(sessions.id, id), eq(sessions.userId, sessionData.user.id)))
|
||||
.returning({ id: sessions.id });
|
||||
|
||||
if (deleted.length === 0) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'Not Found',
|
||||
message: 'Session not found',
|
||||
});
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { db } from '~~/server/lib/db';
|
||||
|
||||
/**
|
||||
* Lists the signed in devices for the current user. Session tokens are left
|
||||
* out on purpose: the client only ever needs the id to sign a device out.
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const sessionData = await protectRoute(event);
|
||||
|
||||
const result = await db.query.sessions.findMany({
|
||||
where: {
|
||||
userId: sessionData.user.id,
|
||||
expiresAt: {
|
||||
gt: new Date(),
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
updatedAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
return result.map(session => ({
|
||||
id: session.id,
|
||||
createdAt: session.createdAt,
|
||||
updatedAt: session.updatedAt,
|
||||
ipAddress: session.ipAddress,
|
||||
userAgent: session.userAgent,
|
||||
current: session.id === sessionData.session.id,
|
||||
}));
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
await protectAdminRoute(event);
|
||||
|
||||
return {
|
||||
registration: await getRegistrationMode(),
|
||||
// set when DISABLE_SIGNUP in the environment overrules the setting
|
||||
registrationLocked: isRegistrationLocked(),
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import * as z from 'zod';
|
||||
import { INSTANCE_SETTINGS_ID, REGISTRATION_MODES, instanceSettings } from '~~/drizzle/schema';
|
||||
import { db } from '~~/server/lib/db';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await protectAdminRoute(event);
|
||||
|
||||
const result = await readValidatedBody(event, (body) =>
|
||||
z
|
||||
.object({
|
||||
registration: z.enum(REGISTRATION_MODES),
|
||||
})
|
||||
.safeParse(body),
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: result.error.issues[0]!.message,
|
||||
});
|
||||
}
|
||||
|
||||
if (isRegistrationLocked()) {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
statusMessage: 'Registration is locked by the environment',
|
||||
data: {
|
||||
code: 'REGISTRATION_LOCKED',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const { registration } = result.data;
|
||||
|
||||
await db.insert(instanceSettings)
|
||||
.values({ id: INSTANCE_SETTINGS_ID, registration })
|
||||
.onConflictDoUpdate({
|
||||
target: instanceSettings.id,
|
||||
set: { registration, updatedAt: new Date() },
|
||||
});
|
||||
|
||||
return { registration, registrationLocked: false };
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { invites } from '~~/drizzle/schema';
|
||||
import { db } from '~~/server/lib/db';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await protectAdminRoute(event);
|
||||
|
||||
await db.delete(invites).where(eq(invites.id, getRouterParam(event, 'id')!));
|
||||
|
||||
return { ok: true };
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { asc } from 'drizzle-orm';
|
||||
import { invites } from '~~/drizzle/schema';
|
||||
import { db } from '~~/server/lib/db';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await protectAdminRoute(event);
|
||||
|
||||
await deleteDeadInvites();
|
||||
|
||||
return await db
|
||||
.select({
|
||||
id: invites.id,
|
||||
token: invites.token,
|
||||
maxUses: invites.maxUses,
|
||||
uses: invites.uses,
|
||||
expiresAt: invites.expiresAt,
|
||||
createdAt: invites.createdAt,
|
||||
})
|
||||
.from(invites)
|
||||
.orderBy(asc(invites.createdAt));
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
import * as z from 'zod';
|
||||
import { invites } from '~~/drizzle/schema';
|
||||
import { db } from '~~/server/lib/db';
|
||||
|
||||
const TOKEN_LENGTH = 32;
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
const MAX_USES = 1000;
|
||||
const MAX_HOURS = 24 * 365;
|
||||
|
||||
/** Creates an invite link that works for the given time and number of people. */
|
||||
export default defineEventHandler(async (event) => {
|
||||
await protectAdminRoute(event);
|
||||
|
||||
const result = await readValidatedBody(event, (body) =>
|
||||
z
|
||||
.object({
|
||||
maxUses: z.int().min(1).max(MAX_USES),
|
||||
expiresInHours: z.int().min(1).max(MAX_HOURS),
|
||||
})
|
||||
.safeParse(body),
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: result.error.issues[0]!.message,
|
||||
});
|
||||
}
|
||||
|
||||
const { maxUses, expiresInHours } = result.data;
|
||||
|
||||
const [invite] = await db.insert(invites)
|
||||
.values({
|
||||
token: nanoid(TOKEN_LENGTH),
|
||||
createdBy: event.context.user!.id as string,
|
||||
maxUses,
|
||||
expiresAt: new Date(Date.now() + expiresInHours * HOUR_MS),
|
||||
})
|
||||
.returning({
|
||||
id: invites.id,
|
||||
token: invites.token,
|
||||
maxUses: invites.maxUses,
|
||||
uses: invites.uses,
|
||||
expiresAt: invites.expiresAt,
|
||||
createdAt: invites.createdAt,
|
||||
});
|
||||
|
||||
return invite!;
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { db } from '~~/server/lib/db';
|
||||
|
||||
/**
|
||||
* Deletes someone else's account and everything that belongs to it.
|
||||
*
|
||||
* Admins delete their own account from their profile, where it asks for the
|
||||
* password and keeps the last admin from leaving.
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
await protectAdminRoute(event);
|
||||
|
||||
const id = getRouterParam(event, 'id')!;
|
||||
|
||||
if (id === event.context.user!.id) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'You cannot delete your own account here',
|
||||
data: {
|
||||
code: 'CANNOT_DELETE_YOURSELF',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const account = await db.query.users.findFirst({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!account) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'User not found',
|
||||
});
|
||||
}
|
||||
|
||||
const deleted = await deleteAccount(id);
|
||||
if (!deleted.ok) {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
statusMessage: "The last usable admin cannot be deleted",
|
||||
data: { code: deleted.error },
|
||||
});
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { recoveryLinks } from '~~/drizzle/schema';
|
||||
import { db } from '~~/server/lib/db';
|
||||
|
||||
/** Cancels the recovery link of a user, if there is one. */
|
||||
export default defineEventHandler(async (event) => {
|
||||
await protectAdminRoute(event);
|
||||
|
||||
await db.delete(recoveryLinks).where(eq(recoveryLinks.userId, getRouterParam(event, 'id')!));
|
||||
|
||||
return { ok: true };
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import * as z from 'zod';
|
||||
import { db } from '~~/server/lib/db';
|
||||
|
||||
/**
|
||||
* Makes a one-time link the user can set a new password with, or their first
|
||||
* one if the account was created by an admin and never had a password. The link is
|
||||
* only returned here, afterwards nobody can read it back, admins included.
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
await protectAdminRoute(event);
|
||||
|
||||
const result = await readValidatedBody(event, (body) =>
|
||||
z
|
||||
.object({
|
||||
// also turn two-factor off, every device is signed out either way
|
||||
resetSecurity: z.boolean(),
|
||||
})
|
||||
.safeParse(body),
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: result.error.issues[0]!.message,
|
||||
});
|
||||
}
|
||||
|
||||
const id = getRouterParam(event, 'id')!;
|
||||
|
||||
const account = await db.query.users.findFirst({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!account) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'User not found',
|
||||
});
|
||||
}
|
||||
|
||||
return await createRecoveryLink(id, result.data.resetSecurity);
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { twoFactors, users } from '~~/drizzle/schema';
|
||||
import { db } from '~~/server/lib/db';
|
||||
import { lockAccount, revokeTwoFactorTrust } from "~~/server/lib/account-security";
|
||||
|
||||
/**
|
||||
* Turns two-factor sign-in off for a user who lost their authenticator app
|
||||
* and backup codes. They sign in with just their password again and can set
|
||||
* it up from scratch.
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
await protectAdminRoute(event);
|
||||
|
||||
const id = getRouterParam(event, 'id')!;
|
||||
|
||||
const account = await db.query.users.findFirst({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!account) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'User not found',
|
||||
});
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await lockAccount(tx, id);
|
||||
await revokeTwoFactorTrust(tx, id);
|
||||
await tx.delete(twoFactors).where(eq(twoFactors.userId, id));
|
||||
await tx.update(users).set({ twoFactorEnabled: false }).where(eq(users.id, id));
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { asc, eq, max, sql } from 'drizzle-orm';
|
||||
import { accounts, sessions, users } from '~~/drizzle/schema';
|
||||
import { db } from '~~/server/lib/db';
|
||||
|
||||
/**
|
||||
* Every account of the instance, for the admin user list.
|
||||
*
|
||||
* Only what is needed to manage accounts is returned. Chats, agents, files and
|
||||
* API keys of other users are never part of this.
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
await protectAdminRoute(event);
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
name: users.name,
|
||||
email: users.email,
|
||||
image: users.image,
|
||||
role: users.role,
|
||||
banned: users.banned,
|
||||
twoFactorEnabled: users.twoFactorEnabled,
|
||||
createdAt: users.createdAt,
|
||||
// sessions are touched as they are used, the newest one is the
|
||||
// closest thing there is to a last seen time
|
||||
lastActiveAt: max(sessions.updatedAt),
|
||||
// no password yet: created by an admin and still waiting for its
|
||||
// owner to open the setup link
|
||||
pending: sql<boolean>`not exists (select 1 from ${accounts} where ${accounts.userId} = ${users.id} and ${accounts.providerId} = 'credential')`,
|
||||
})
|
||||
.from(users)
|
||||
.leftJoin(sessions, eq(sessions.userId, users.id))
|
||||
.groupBy(users.id)
|
||||
.orderBy(asc(users.createdAt));
|
||||
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
role: row.role === 'admin' ? 'admin' as const : 'user' as const,
|
||||
banned: !!row.banned,
|
||||
twoFactorEnabled: !!row.twoFactorEnabled,
|
||||
}));
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import * as z from 'zod';
|
||||
import { auth } from '~~/lib/auth';
|
||||
|
||||
/**
|
||||
* Creates an account for someone, without a password. Nobody can sign in to
|
||||
* it until its owner opens the setup link returned here and picks one, so the
|
||||
* admin never knows a password of theirs. The link is only returned here.
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
await protectAdminRoute(event);
|
||||
|
||||
const result = await readValidatedBody(event, (body) =>
|
||||
z
|
||||
.object({
|
||||
name: z.string().trim().min(1).max(128),
|
||||
email: z.email(),
|
||||
})
|
||||
.safeParse(body),
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: result.error.issues[0]!.message,
|
||||
data: {
|
||||
code: 'INVALID_BODY',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const { name, email } = result.data;
|
||||
|
||||
let userId: string;
|
||||
|
||||
try {
|
||||
// called without the request, the admin check already happened above
|
||||
const { user } = await auth.api.createUser({
|
||||
body: {
|
||||
name,
|
||||
email,
|
||||
role: 'user',
|
||||
},
|
||||
});
|
||||
|
||||
userId = user.id;
|
||||
} catch (error: any) {
|
||||
const code = error?.body?.code as string | undefined;
|
||||
|
||||
if (code === undefined) {
|
||||
console.error('Failed to create user:', error);
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: code === undefined ? 500 : 400,
|
||||
statusMessage: 'Failed to create user',
|
||||
data: {
|
||||
code: code ?? 'INTERNAL_ERROR',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return await createRecoveryLink(userId, false);
|
||||
});
|
||||
@@ -1,5 +1,46 @@
|
||||
import { auth } from '~~/lib/auth';
|
||||
import { auth } from "~~/lib/auth";
|
||||
import { lockAdministration } from "~~/server/lib/account-security";
|
||||
import { authTransaction } from "~~/server/lib/auth-transaction";
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
return auth.handler(toWebRequest(event));
|
||||
const SIGN_UP = "/api/auth/sign-up/email";
|
||||
|
||||
// Requests that have to be undone as a whole when they fail. The hooks in
|
||||
// lib/auth.ts refuse to run these without a transaction, so a URL that slips
|
||||
// past this list is rejected there instead of going through unguarded.
|
||||
const ADMIN_MUTATIONS = new Set([
|
||||
"/api/auth/admin/set-role",
|
||||
"/api/auth/admin/ban-user",
|
||||
"/api/auth/admin/unban-user",
|
||||
"/api/auth/admin/update-user",
|
||||
]);
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const request = toWebRequest(event);
|
||||
const path = new URL(request.url).pathname.replace(/\/+$/, "");
|
||||
const adminMutation = ADMIN_MUTATIONS.has(path);
|
||||
|
||||
if (request.method !== "POST" || (!adminMutation && path !== SIGN_UP)) {
|
||||
return auth.handler(request);
|
||||
}
|
||||
|
||||
try {
|
||||
return await authTransaction(async (tx) => {
|
||||
if (adminMutation) {
|
||||
// Deletion, banning and demotion all wait for each other here.
|
||||
await lockAdministration(tx);
|
||||
}
|
||||
|
||||
const response = await auth.handler(request);
|
||||
if (!response.ok) {
|
||||
// The auth handler returns errors as responses; roll back any writes.
|
||||
throw response;
|
||||
}
|
||||
return response;
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Response) {
|
||||
return error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -22,8 +22,11 @@ export default defineEventHandler(async (event) => {
|
||||
// Path 2: Session auth (for client-side access)
|
||||
if (!authorized) {
|
||||
try {
|
||||
const sessionData = await auth.api.getSession(event);
|
||||
if (sessionData) {
|
||||
const sessionData = await auth.api.getSession({
|
||||
headers: event.headers,
|
||||
query: { disableCookieCache: true },
|
||||
});
|
||||
if (sessionData && !sessionData.user.banned) {
|
||||
event.context.user = sessionData.user;
|
||||
authorized = true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
// Public on purpose: the sign in pages ask this before anyone is signed in.
|
||||
export default defineEventHandler(async () => {
|
||||
const [setupNeeded, registration] = await Promise.all([isSetupNeeded(), getRegistrationMode()]);
|
||||
|
||||
return { setupNeeded, registration };
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { generateFileToken } from '~~/server/utils/file-token';
|
||||
|
||||
const FILE_PREFIX = '/api/files/';
|
||||
|
||||
// Uploaded avatars are served to signed in people only, and whoever opens an
|
||||
// invite is not signed in yet. A short-lived signed URL lets them see it.
|
||||
const publicImage = (image: string | null): string | null => {
|
||||
const secret = process.env.BETTER_AUTH_SECRET;
|
||||
|
||||
if (!image || !image.startsWith(FILE_PREFIX) || !secret) return image;
|
||||
|
||||
const { exp, sig } = generateFileToken(image.slice(FILE_PREFIX.length), secret);
|
||||
|
||||
return `${image}?exp=${exp}&sig=${sig}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tells the register page whether an invite link still works and who it is
|
||||
* from. Public on purpose, the person opening the link has no account yet.
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const invite = await findUsableInvite(getRouterParam(event, 'token')!);
|
||||
|
||||
if (!invite) {
|
||||
return { valid: false as const };
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true as const,
|
||||
expiresAt: invite.expiresAt,
|
||||
singleUse: invite.maxUses === 1,
|
||||
inviter: {
|
||||
id: invite.inviterId,
|
||||
name: invite.inviterName,
|
||||
image: publicImage(invite.inviterImage),
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -1,68 +1,63 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { providers } from "~~/drizzle/schema";
|
||||
import { and, eq, gt } from "drizzle-orm";
|
||||
import * as z from "zod";
|
||||
import { providers, sessions } from "~~/drizzle/schema";
|
||||
import { lockAccount } from "~~/server/lib/account-security";
|
||||
import { db } from "~~/server/lib/db";
|
||||
import * as z from 'zod';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await protectRoute(event);
|
||||
|
||||
const userId = event.context.user!.id;
|
||||
|
||||
const result = await readValidatedBody(event, (body) =>
|
||||
z
|
||||
.object({
|
||||
config: z.object({
|
||||
apiKey: z.string().optional(),
|
||||
apiProxyUrl: z.string().optional(),
|
||||
}).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
})
|
||||
.safeParse(body),
|
||||
);
|
||||
const { user, session } = await protectRoute(event);
|
||||
const result = await readValidatedBody(event, (body) => z.object({
|
||||
config: z.object({
|
||||
apiKey: z.string().optional(),
|
||||
apiProxyUrl: z.string().optional(),
|
||||
}).optional(),
|
||||
expectedUpdatedAt: z.iso.datetime().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
}).refine(body => body.config === undefined || body.expectedUpdatedAt !== undefined, {
|
||||
message: "Provider config updates require a revision",
|
||||
}).safeParse(body));
|
||||
|
||||
if (!result.success) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: result.error.issues[0]!.message,
|
||||
});
|
||||
throw createError({ statusCode: 400, message: result.error.issues[0]?.message });
|
||||
}
|
||||
|
||||
const providerId = getRouterParam(event, 'providerId')!;
|
||||
|
||||
const { config, enabled } = result.data;
|
||||
|
||||
const existing = await db.query.providers.findFirst({
|
||||
where: {
|
||||
id: providerId,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Invalid provider',
|
||||
});
|
||||
const providerId = getRouterParam(event, "providerId");
|
||||
if (!providerId) {
|
||||
throw createError({ statusCode: 400, statusMessage: "Invalid provider" });
|
||||
}
|
||||
const { config, enabled, expectedUpdatedAt } = result.data;
|
||||
|
||||
const res = await db.update(providers)
|
||||
.set({
|
||||
config,
|
||||
return await db.transaction(async (tx) => {
|
||||
await lockAccount(tx, user.id);
|
||||
// A request may have waited behind a password rotation or recovery.
|
||||
const [activeSession] = await tx.select({ id: sessions.id }).from(sessions).where(and(
|
||||
eq(sessions.id, session.id), eq(sessions.userId, user.id), gt(sessions.expiresAt, new Date()),
|
||||
));
|
||||
if (!activeSession) {
|
||||
throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
|
||||
}
|
||||
|
||||
const [existing] = await tx.select().from(providers).where(and(
|
||||
eq(providers.id, providerId), eq(providers.userId, user.id),
|
||||
));
|
||||
if (!existing) {
|
||||
throw createError({ statusCode: 404, statusMessage: "Invalid provider" });
|
||||
}
|
||||
if (config !== undefined && existing.updatedAt.getTime() !== new Date(expectedUpdatedAt ?? "").getTime()) {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
statusMessage: "Provider config out of date",
|
||||
data: { code: "PROVIDER_KEYS_OUT_OF_DATE" },
|
||||
});
|
||||
}
|
||||
|
||||
const updatedAt = new Date(Math.max(Date.now(), existing.updatedAt.getTime() + 1));
|
||||
await tx.update(providers).set({
|
||||
config: config === undefined ? undefined : { ...existing.config, ...config },
|
||||
enabled,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(providers.id, providerId),
|
||||
eq(providers.userId, userId),
|
||||
)
|
||||
);
|
||||
updatedAt,
|
||||
}).where(and(eq(providers.id, providerId), eq(providers.userId, user.id)));
|
||||
|
||||
if (res.rowCount === 0) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Invalid provider',
|
||||
});
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
});
|
||||
return { ok: true, updatedAt };
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { and, eq, sql } from 'drizzle-orm';
|
||||
import { providers } from '~~/drizzle/schema';
|
||||
import { db } from '~~/server/lib/db';
|
||||
|
||||
/**
|
||||
* Tells the recovery page whether a link still works and what using it will
|
||||
* do: set the first password of a new account, or replace a forgotten one. Public on purpose: whoever opens it cannot sign in, that is the point.
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const link = await findRecoveryLink(getRouterParam(event, 'token')!);
|
||||
|
||||
if (!link) {
|
||||
return { valid: false as const };
|
||||
}
|
||||
|
||||
// an account that never had a password is being set up, not recovered
|
||||
if (!await hasPassword(link.userId)) {
|
||||
return {
|
||||
valid: true as const,
|
||||
newAccount: true as const,
|
||||
email: link.email,
|
||||
name: link.name,
|
||||
};
|
||||
}
|
||||
|
||||
const withKey = await db
|
||||
.select({ name: providers.name })
|
||||
.from(providers)
|
||||
.where(and(eq(providers.userId, link.userId), sql`coalesce(${providers.config}->>'apiKey', '') <> ''`));
|
||||
|
||||
return {
|
||||
valid: true as const,
|
||||
newAccount: false as const,
|
||||
email: link.email,
|
||||
// the providers whose saved key is lost when the link is used
|
||||
keyProviders: withKey.map(p => p.name),
|
||||
resetSecurity: link.resetSecurity,
|
||||
twoFactorEnabled: !!link.twoFactorEnabled,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { and, eq, gt, sql } from 'drizzle-orm';
|
||||
import * as z from 'zod';
|
||||
import { accounts, providers, recoveryLinks, sessions, twoFactors, users } from '~~/drizzle/schema';
|
||||
import { lockAccount, revokeTwoFactorTrust } from "~~/server/lib/account-security";
|
||||
import { authTransaction } from "~~/server/lib/auth-transaction";
|
||||
import { auth } from '~~/lib/auth';
|
||||
|
||||
/**
|
||||
* Sets a password with a one-time link.
|
||||
*
|
||||
* For an account an admin just created this is its first password, and there
|
||||
* is nothing else to do. For a forgotten password it is a recovery:
|
||||
*
|
||||
* The saved API keys are encrypted with a key derived from the old password.
|
||||
* Nobody has that password anymore, so the keys can never be read again and
|
||||
* are deleted here. Chats, agents, files and everything else stay.
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const result = await readValidatedBody(event, (body) =>
|
||||
z
|
||||
.object({
|
||||
password: z.string().min(8).max(128),
|
||||
})
|
||||
.safeParse(body),
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: result.error.issues[0]!.message,
|
||||
data: {
|
||||
code: 'INVALID_BODY',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const token = getRouterParam(event, 'token')!;
|
||||
const tokenHash = hashRecoveryToken(token);
|
||||
const { password } = result.data;
|
||||
|
||||
const invalidLink = () => createError({
|
||||
statusCode: 410,
|
||||
statusMessage: 'This recovery link is not valid anymore',
|
||||
data: {
|
||||
code: 'RECOVERY_LINK_INVALID',
|
||||
},
|
||||
});
|
||||
|
||||
// Hashing a password is slow on purpose and anyone can call this, so a
|
||||
// link that does not work is turned away before any of that is done.
|
||||
const candidate = await findRecoveryLink(token);
|
||||
if (!candidate) {
|
||||
throw invalidLink();
|
||||
}
|
||||
|
||||
const { password: hasher, internalAdapter } = await auth.$context;
|
||||
const passwordHash = await hasher.hash(password);
|
||||
|
||||
const recovered = await authTransaction(async (tx) => {
|
||||
await lockAccount(tx, candidate.userId);
|
||||
// deleting the link is what makes it work once: of two requests with
|
||||
// the same link only one gets a row back
|
||||
const [link] = await tx.delete(recoveryLinks)
|
||||
.where(and(eq(recoveryLinks.tokenHash, tokenHash), gt(recoveryLinks.expiresAt, new Date())))
|
||||
.returning({ userId: recoveryLinks.userId, resetSecurity: recoveryLinks.resetSecurity });
|
||||
|
||||
if (!link) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// if either of these fails the link comes back with the rest of the
|
||||
// transaction
|
||||
const [credential] = await tx.select({ id: accounts.id }).from(accounts)
|
||||
.where(and(eq(accounts.userId, link.userId), eq(accounts.providerId, 'credential')));
|
||||
if (!credential) {
|
||||
await internalAdapter.linkAccount({
|
||||
providerId: 'credential',
|
||||
accountId: link.userId,
|
||||
password: passwordHash,
|
||||
userId: link.userId,
|
||||
});
|
||||
|
||||
return 'created';
|
||||
}
|
||||
|
||||
await internalAdapter.updatePassword(link.userId, passwordHash);
|
||||
|
||||
await tx.update(providers).set({
|
||||
updatedAt: sql`greatest(date_trunc('milliseconds', clock_timestamp()), date_trunc('milliseconds', ${providers.updatedAt}) + interval '1 millisecond')`,
|
||||
}).where(eq(providers.userId, link.userId));
|
||||
|
||||
// A provider without its key can't be used, so it is switched off
|
||||
// until the key is entered again. The provider and its models stay.
|
||||
await tx.update(providers)
|
||||
.set({
|
||||
config: sql`${providers.config} - 'apiKey'`,
|
||||
enabled: false,
|
||||
updatedAt: sql`${providers.updatedAt}`,
|
||||
})
|
||||
.where(and(eq(providers.userId, link.userId), sql`coalesce(${providers.config}->>'apiKey', '') <> ''`));
|
||||
|
||||
// Every device is signed out, always: the ones still signed in hold
|
||||
// an encryption key derived from the old password.
|
||||
await tx.delete(sessions).where(eq(sessions.userId, link.userId));
|
||||
|
||||
await revokeTwoFactorTrust(tx, link.userId);
|
||||
|
||||
if (link.resetSecurity) {
|
||||
await tx.delete(twoFactors).where(eq(twoFactors.userId, link.userId));
|
||||
await tx.update(users).set({ twoFactorEnabled: false }).where(eq(users.id, link.userId));
|
||||
}
|
||||
|
||||
return 'recovered';
|
||||
});
|
||||
|
||||
// used or expired between the check above and here
|
||||
if (recovered === null) {
|
||||
throw invalidLink();
|
||||
}
|
||||
|
||||
return { newAccount: recovered === 'created' };
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import * as z from 'zod';
|
||||
import { INSTANCE_SETTINGS_ID, instanceSettings, users } from '~~/drizzle/schema';
|
||||
import { auth } from '~~/lib/auth';
|
||||
import { authTransaction } from '~~/server/lib/auth-transaction';
|
||||
|
||||
// any fixed number works, it only has to be the same for every request
|
||||
const SETUP_LOCK_ID = 7_261_008;
|
||||
|
||||
/**
|
||||
* Creates the first account of the instance as its admin.
|
||||
*
|
||||
* Public, because nobody can be signed in yet, and only works while there are
|
||||
* no accounts at all. It does not go through sign up, so it also works when
|
||||
* registration is turned off.
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const result = await readValidatedBody(event, (body) =>
|
||||
z
|
||||
.object({
|
||||
name: z.string().trim().min(1).max(128),
|
||||
email: z.email(),
|
||||
password: z.string().min(8).max(128),
|
||||
})
|
||||
.safeParse(body),
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: result.error.issues[0]!.message,
|
||||
data: {
|
||||
code: 'INVALID_BODY',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const { name, email, password } = result.data;
|
||||
|
||||
let alreadyDone = false;
|
||||
|
||||
try {
|
||||
await authTransaction(async (tx) => {
|
||||
// two people submitting the form at the same moment wait here, and
|
||||
// the second one then finds the first one's account
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(${SETUP_LOCK_ID})`);
|
||||
|
||||
const existing = await tx.select({ id: users.id }).from(users).limit(1);
|
||||
if (existing.length > 0) {
|
||||
alreadyDone = true;
|
||||
return;
|
||||
}
|
||||
|
||||
await auth.api.createUser({
|
||||
body: {
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
role: 'admin',
|
||||
},
|
||||
});
|
||||
|
||||
// A new instance starts invite only. The next setup step lets the
|
||||
// admin pick, and if they never get there it is not left open.
|
||||
await tx.insert(instanceSettings)
|
||||
.values({ id: INSTANCE_SETTINGS_ID, registration: 'invite' })
|
||||
.onConflictDoNothing();
|
||||
});
|
||||
} catch (error: any) {
|
||||
const code = error?.body?.code as string | undefined;
|
||||
|
||||
if (code === undefined) {
|
||||
console.error('Failed to create the admin account:', error);
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: code === undefined ? 500 : 400,
|
||||
statusMessage: 'Failed to set up the instance',
|
||||
message: error?.body?.message ?? 'Failed to set up the instance',
|
||||
data: {
|
||||
code: code ?? 'INTERNAL_ERROR',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (alreadyDone) {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
statusMessage: 'This instance is already set up',
|
||||
data: {
|
||||
code: 'SETUP_ALREADY_DONE',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Transaction } from "./auth-transaction";
|
||||
import { and, eq, isNotNull, ne, or, isNull, sql, like } from "drizzle-orm";
|
||||
import { accounts, users, verifications } from "../../drizzle/schema";
|
||||
|
||||
const ADMIN_LOCK_ID = 7_261_009;
|
||||
|
||||
// Deletion, banning and demotion must all hold this lock until commit.
|
||||
export const lockAdministration = async (tx: Transaction): Promise<void> => {
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(${ADMIN_LOCK_ID})`);
|
||||
};
|
||||
|
||||
export const hasUsableAdministrator = async (tx: Transaction, excludedUserId?: string): Promise<boolean> => {
|
||||
const rows = await tx.select({ id: users.id })
|
||||
.from(users)
|
||||
.innerJoin(accounts, eq(accounts.userId, users.id))
|
||||
.where(and(
|
||||
eq(users.role, "admin"),
|
||||
excludedUserId === undefined ? undefined : ne(users.id, excludedUserId),
|
||||
or(eq(users.banned, false), isNull(users.banned)),
|
||||
eq(accounts.providerId, "credential"),
|
||||
isNotNull(accounts.password),
|
||||
ne(accounts.password, ""),
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
return rows.length > 0;
|
||||
};
|
||||
|
||||
// All credential, recovery-link and provider-config writes use the user row
|
||||
// as their common lock, including when no credential or recovery row exists.
|
||||
export const lockAccount = async (tx: Transaction, userId: string): Promise<boolean> => {
|
||||
const rows = await tx.select({ id: users.id }).from(users)
|
||||
.where(eq(users.id, userId)).for("update");
|
||||
|
||||
return rows.length > 0;
|
||||
};
|
||||
|
||||
export const revokeTwoFactorTrust = async (tx: Transaction, userId: string): Promise<void> => {
|
||||
await tx.delete(verifications).where(and(
|
||||
eq(verifications.value, userId),
|
||||
or(
|
||||
like(verifications.identifier, "trust-device-%"),
|
||||
like(verifications.identifier, "2fa-%"),
|
||||
),
|
||||
));
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import { db } from "./db";
|
||||
|
||||
export type Transaction = Parameters<Parameters<typeof db.transaction>[0]>[0];
|
||||
|
||||
const storage = new AsyncLocalStorage<Transaction>();
|
||||
|
||||
/** The transaction opened by authTransaction() around the running code, if any. */
|
||||
export const currentTransaction = (): Transaction | undefined => storage.getStore();
|
||||
|
||||
// What the one auth instance is given as its database. Every access goes to
|
||||
// the transaction opened by authTransaction() around the running code, or to
|
||||
// the pool when there is none, so no second auth instance is needed to make
|
||||
// Better Auth write on a transaction's connection.
|
||||
export const authDatabase = new Proxy(db, {
|
||||
get(_target, property) {
|
||||
const target = storage.getStore() ?? db;
|
||||
const value = Reflect.get(target, property, target);
|
||||
|
||||
return typeof value === "function" ? value.bind(target) : value;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Runs the callback in a transaction that everything the auth instance does
|
||||
* inside it joins: its endpoints, its hooks and its internal adapter all
|
||||
* commit or roll back together with the callback's own writes.
|
||||
*/
|
||||
export const authTransaction = <T>(callback: (tx: Transaction) => Promise<T>): Promise<T> =>
|
||||
db.transaction(tx => storage.run(tx, () => callback(tx)));
|
||||
@@ -0,0 +1,50 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import {
|
||||
agents,
|
||||
files,
|
||||
models,
|
||||
providers,
|
||||
settings,
|
||||
toolCalls,
|
||||
topics,
|
||||
users,
|
||||
} from '~~/drizzle/schema';
|
||||
import { db } from '~~/server/lib/db';
|
||||
import { hasUsableAdministrator, lockAccount, lockAdministration } from "~~/server/lib/account-security";
|
||||
import { Err, Ok } from "~~/types/result";
|
||||
|
||||
/**
|
||||
* Deletes an account and everything that belongs to it.
|
||||
*
|
||||
* The app tables only store a plain user id, nothing cascades from the users
|
||||
* table, so every one of them is cleared here. It all happens in one
|
||||
* transaction: either the whole account is gone or nothing changed.
|
||||
*
|
||||
* Objects in file storage are left alone on purpose.
|
||||
*/
|
||||
export const deleteAccount = async (userId: string) => {
|
||||
return await db.transaction(async (tx) => {
|
||||
await lockAdministration(tx);
|
||||
await lockAccount(tx, userId);
|
||||
|
||||
const [account] = await tx.select({ role: users.role }).from(users).where(eq(users.id, userId));
|
||||
if (account?.role === "admin" && !await hasUsableAdministrator(tx, userId)) {
|
||||
return Err("LAST_ADMIN" as const);
|
||||
}
|
||||
|
||||
// messages, their parts, generations and attachments cascade from topics
|
||||
await tx.delete(topics).where(eq(topics.userId, userId));
|
||||
await tx.delete(agents).where(eq(agents.userId, userId));
|
||||
await tx.delete(models).where(eq(models.userId, userId));
|
||||
await tx.delete(providers).where(eq(providers.userId, userId));
|
||||
// nothing points a tool call back at its message, so these never cascade
|
||||
await tx.delete(toolCalls).where(eq(toolCalls.userId, userId));
|
||||
// embeddings go with their file
|
||||
await tx.delete(files).where(eq(files.userId, userId));
|
||||
await tx.delete(settings).where(eq(settings.userId, userId));
|
||||
|
||||
// sessions, accounts and two-factor rows cascade from the user
|
||||
await tx.delete(users).where(eq(users.id, userId));
|
||||
return Ok(undefined);
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { H3Event } from 'h3';
|
||||
|
||||
/**
|
||||
* Like protectRoute(), but only lets admins through.
|
||||
*
|
||||
* protectRoute() reads the user from the database on every request, so someone
|
||||
* who just stopped being an admin loses the access right away.
|
||||
*/
|
||||
export const protectAdminRoute = async (event: H3Event) => {
|
||||
const sessionData = await protectRoute(event);
|
||||
|
||||
if (sessionData.user.role !== 'admin') {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: 'Forbidden',
|
||||
});
|
||||
}
|
||||
|
||||
return sessionData;
|
||||
};
|
||||
@@ -2,9 +2,12 @@ import type { H3Event } from 'h3';
|
||||
import { auth } from '~~/lib/auth';
|
||||
|
||||
export const protectRoute = async (event: H3Event) => {
|
||||
const sessionData = await auth.api.getSession(event);
|
||||
const sessionData = await auth.api.getSession({
|
||||
headers: event.headers,
|
||||
query: { disableCookieCache: true },
|
||||
});
|
||||
|
||||
if (sessionData === null) {
|
||||
if (sessionData === null || sessionData.user.banned) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'Unauthorized',
|
||||
@@ -12,4 +15,5 @@ export const protectRoute = async (event: H3Event) => {
|
||||
}
|
||||
|
||||
event.context.user = sessionData.user;
|
||||
return sessionData;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { INSTANCE_SETTINGS_ID, REGISTRATION_MODES, type RegistrationMode } from '../../drizzle/schema';
|
||||
import { db } from '../lib/db';
|
||||
|
||||
/**
|
||||
* DISABLE_SIGNUP in the environment wins over whatever admins pick in the UI,
|
||||
* so a deployment that was locked down this way stays locked down.
|
||||
*/
|
||||
export const isRegistrationLocked = (): boolean => {
|
||||
const value = process.env.DISABLE_SIGNUP?.toLowerCase();
|
||||
|
||||
return value === 'true' || value === '1';
|
||||
};
|
||||
|
||||
export const getRegistrationMode = async (): Promise<RegistrationMode> => {
|
||||
if (isRegistrationLocked()) return 'closed';
|
||||
|
||||
const row = await db.query.instanceSettings.findFirst({
|
||||
where: {
|
||||
id: INSTANCE_SETTINGS_ID,
|
||||
},
|
||||
});
|
||||
|
||||
const mode = row?.registration;
|
||||
|
||||
// instances that never touched the setting keep taking sign ups like before
|
||||
return mode !== undefined && REGISTRATION_MODES.includes(mode) ? mode : 'open';
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import { and, eq, gt, lt, lte, or, sql } from 'drizzle-orm';
|
||||
import { invites, users } from '../../drizzle/schema';
|
||||
import type { Transaction } from '../lib/auth-transaction';
|
||||
import { db } from '../lib/db';
|
||||
|
||||
export const INVITE_TOKEN_HEADER = 'x-invite-token';
|
||||
|
||||
// still has uses left and has not run out
|
||||
const usable = () => and(lt(invites.uses, invites.maxUses), gt(invites.expiresAt, new Date()));
|
||||
|
||||
/**
|
||||
* Takes one use of an invite. It is a single statement, so two people
|
||||
* registering with the same single use link at once cannot both get in. It
|
||||
* runs on the transaction of the registration, a failed one gives the use back
|
||||
* by rolling back.
|
||||
*/
|
||||
export const claimInvite = async (tx: Transaction, token: string): Promise<boolean> => {
|
||||
const claimed = await tx.update(invites)
|
||||
.set({ uses: sql`${invites.uses} + 1` })
|
||||
.where(and(eq(invites.token, token), usable()))
|
||||
.returning({ id: invites.id });
|
||||
|
||||
return claimed.length > 0;
|
||||
};
|
||||
|
||||
export const findUsableInvite = async (token: string) => {
|
||||
const rows = await db
|
||||
.select({
|
||||
expiresAt: invites.expiresAt,
|
||||
maxUses: invites.maxUses,
|
||||
uses: invites.uses,
|
||||
inviterId: users.id,
|
||||
inviterName: users.name,
|
||||
inviterImage: users.image,
|
||||
})
|
||||
.from(invites)
|
||||
.innerJoin(users, eq(users.id, invites.createdBy))
|
||||
.where(and(eq(invites.token, token), usable()))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
};
|
||||
|
||||
/** Used up and expired invites are of no use to anyone, so they are dropped. */
|
||||
export const deleteDeadInvites = async () => {
|
||||
await db.delete(invites).where(or(lte(invites.expiresAt, new Date()), sql`${invites.uses} >= ${invites.maxUses}`));
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { and, eq, gt, lte } from 'drizzle-orm';
|
||||
import { accounts, recoveryLinks, users } from '~~/drizzle/schema';
|
||||
import { db } from '~~/server/lib/db';
|
||||
import { lockAccount } from "~~/server/lib/account-security";
|
||||
|
||||
export const RECOVERY_LINK_LIFETIME_MS = 24 * 60 * 60 * 1000;
|
||||
// a new person may take a while to get round to it
|
||||
export const SETUP_LINK_LIFETIME_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* An account an admin created has no password until its owner sets one with
|
||||
* a setup link. Until then nobody can sign in to it.
|
||||
*/
|
||||
export const hasPassword = async (userId: string): Promise<boolean> => {
|
||||
const rows = await db
|
||||
.select({ id: accounts.id })
|
||||
.from(accounts)
|
||||
.where(and(eq(accounts.userId, userId), eq(accounts.providerId, 'credential')))
|
||||
.limit(1);
|
||||
|
||||
return rows.length > 0;
|
||||
};
|
||||
|
||||
// scripts/recover.ts makes links the same way, keep the two in step
|
||||
export const hashRecoveryToken = (token: string) => createHash('sha256').update(token).digest('hex');
|
||||
|
||||
/**
|
||||
* Makes a one-time link a user can set a password with and returns its token:
|
||||
* a recovery link for someone who forgot theirs, a setup link for an account
|
||||
* that never had one. Only one link works at a time, making a new one cancels
|
||||
* the ones before it.
|
||||
*/
|
||||
export const createRecoveryLink = async (userId: string, resetSecurity: boolean) => {
|
||||
const token = randomBytes(32).toString('base64url');
|
||||
const lifetime = await hasPassword(userId) ? RECOVERY_LINK_LIFETIME_MS : SETUP_LINK_LIFETIME_MS;
|
||||
const expiresAt = new Date(Date.now() + lifetime);
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await lockAccount(tx, userId);
|
||||
await tx.delete(recoveryLinks).where(eq(recoveryLinks.userId, userId));
|
||||
// nothing else ever looks at a link that ran out, so they go here
|
||||
await tx.delete(recoveryLinks).where(lte(recoveryLinks.expiresAt, new Date()));
|
||||
await tx.insert(recoveryLinks).values({
|
||||
tokenHash: hashRecoveryToken(token),
|
||||
userId,
|
||||
resetSecurity,
|
||||
expiresAt,
|
||||
});
|
||||
});
|
||||
|
||||
return { token, expiresAt };
|
||||
};
|
||||
|
||||
export const findRecoveryLink = async (token: string) => {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: recoveryLinks.id,
|
||||
userId: recoveryLinks.userId,
|
||||
resetSecurity: recoveryLinks.resetSecurity,
|
||||
expiresAt: recoveryLinks.expiresAt,
|
||||
email: users.email,
|
||||
name: users.name,
|
||||
twoFactorEnabled: users.twoFactorEnabled,
|
||||
})
|
||||
.from(recoveryLinks)
|
||||
.innerJoin(users, eq(users.id, recoveryLinks.userId))
|
||||
.where(and(eq(recoveryLinks.tokenHash, hashRecoveryToken(token)), gt(recoveryLinks.expiresAt, new Date())))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { users } from '../../drizzle/schema';
|
||||
import { db } from '../lib/db';
|
||||
|
||||
/**
|
||||
* An instance needs setting up for as long as it has no accounts. The first
|
||||
* account is created by the setup page and becomes the admin.
|
||||
*/
|
||||
export const isSetupNeeded = async (): Promise<boolean> => {
|
||||
const existing = await db.select({ id: users.id }).from(users).limit(1);
|
||||
|
||||
return existing.length === 0;
|
||||
};
|
||||
Reference in New Issue
Block a user