9 Commits

Author SHA1 Message Date
zoeissleeping e5b0e5bf98 fix: forking of a regeneration sometimes copied messages after the root 2026-10-15 10:58:20 -05:00
zoeissleeping ff7797d3f8 feat: admin controls 2026-10-15 10:22:06 -05:00
zoeissleeping b6f4d85ed7 feat: complete admin invite and user creation and recovery flows 2026-10-08 14:24:47 -05:00
zoeissleeping 61f9bb74b4 feat: complete onboarding flow 2026-10-08 14:04:29 -05:00
zoeissleeping fae05ebc4c feat: instance invites 2026-10-08 13:50:44 -05:00
zoeissleeping f6e1bc1f47 feat: basic admin interface 2026-10-08 13:41:02 -05:00
zoeissleeping 7b0800882a feat: setup and roles 2026-10-08 13:08:47 -05:00
zoeissleeping 4bba529ba8 feat: improve account security
Support TOTP 2fa, allow for changing password, add session management
and account removal.
2026-10-08 12:43:22 -05:00
zoeissleeping ff33e9c573 feat: replace settings with a full page 2026-10-08 08:06:06 -05:00
97 changed files with 21891 additions and 847 deletions
+3
View File
@@ -26,6 +26,9 @@ COPY --from=build /app/drizzle/migrations /app/drizzle/migrations
# Copy entrypoint script # Copy entrypoint script
COPY --from=build /app/scripts/start.ts /app/scripts/start.ts 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 package.json and lockfile for production dependency install
COPY --from=build /app/package.json /app/package.json COPY --from=build /app/package.json /app/package.json
COPY --from=build /app/bun.lock* ./ COPY --from=build /app/bun.lock* ./
+9 -4
View File
@@ -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 = () => { const handleSubmit = () => {
if (noModels.value) return;
if (isUploading.value || hasFailedUploads.value) { if (isUploading.value || hasFailedUploads.value) {
return; return;
} }
@@ -454,8 +459,8 @@ onUnmounted(() => {
<div class="flex items-center gap-1 shrink-0"> <div class="flex items-center gap-1 shrink-0">
<FileSelector ref="fileSelectorRef" :selected-model="selectedModel" v-model="files"></FileSelector> <FileSelector ref="fileSelectorRef" :selected-model="selectedModel" v-model="files"></FileSelector>
<div v-if="allowManualRole" class="flex items-center"> <div v-if="allowManualRole" class="flex items-center">
<button aria-label="Send message" @click="handleSubmit" <button :aria-label="noModels ? 'Send (add a provider first)' : 'Send message'" @click="handleSubmit"
:disabled="((!inputValue.content.trim() && files.length === 0) && !loading) || isUploading || hasFailedUploads" :disabled="((!inputValue.content.trim() && files.length === 0) && !loading) || isUploading || hasFailedUploads || noModels"
:class="[ :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', '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 (inputValue.content.trim() || files.length > 0) && !loading && !isUploading && !hasFailedUploads
@@ -496,8 +501,8 @@ onUnmounted(() => {
</template> </template>
</Dropdown> </Dropdown>
</div> </div>
<button v-else aria-label="Send message" @click="handleSubmit" <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" :disabled="((!inputValue.content.trim() && files.length === 0) && !loading) || isUploading || hasFailedUploads || noModels"
:class="[ :class="[
'h-8 w-8 rounded-xl transition-all duration-200 flex items-center justify-center disabled:cursor-not-allowed disabled:bg-transparent', '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 (inputValue.content.trim() || files.length > 0) && !loading && !isUploading && !hasFailedUploads
+106
View File
@@ -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>
+243
View File
@@ -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>
+34
View File
@@ -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>
+141
View File
@@ -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>
+159
View File
@@ -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>
+155
View File
@@ -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>
+176
View File
@@ -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>
-138
View File
@@ -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>
+359
View File
@@ -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>
+20 -5
View File
@@ -1,7 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import Textbox from './Textbox.vue'; import Textbox from './Textbox.vue';
import Settings from './Settings.vue';
import QuickSwitcher from './QuickSwitcher.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'; import { DialogType } from '~/composables/useDialog';
@@ -11,7 +17,7 @@ const { addShortcut } = useKeyboardShortcuts();
const unbindSettingsShortcut = addShortcut(['ctrl', ','], (event) => { const unbindSettingsShortcut = addShortcut(['ctrl', ','], (event) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
openDialog(DialogType.Settings); navigateTo('/settings');
}); });
const unbindQuickSwitcherShortcut = addShortcut(['ctrl', 'k'], (event) => { const unbindQuickSwitcherShortcut = addShortcut(['ctrl', 'k'], (event) => {
@@ -57,12 +63,21 @@ onUnmounted(() => {
<div <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"> 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> <KeepAlive>
<Settings v-if="page === DialogType.Settings" :page="data.page" :params="data.params" /> <Textbox v-if="page === DialogType.Textbox" v-bind="data" @confirm="confirm" @cancel="close" />
<Textbox v-else-if="page === DialogType.Textbox" v-bind="data" @confirm="confirm" @cancel="close" />
<QuickSwitcher v-else-if="page === DialogType.QuickSwitcher" @close="close" /> <QuickSwitcher v-else-if="page === DialogType.QuickSwitcher" @close="close" />
</KeepAlive> </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>
</div> </div>
</Transition> </Transition>
+30 -8
View File
@@ -1,12 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'; import { ref, computed, watch, nextTick } from 'vue';
import { useFloating, offset, flip, shift, autoUpdate, size, hide } from '@floating-ui/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 type { Model, ModelWithProvider, Provider, ProviderWithModels } from '~/composables/useModels';
import { sortByReleaseDate } from '~/utils/sort'; import { sortByReleaseDate } from '~/utils/sort';
import RowVirtualizerFixed from './RowVirtualizerFixed.vue'; import RowVirtualizerFixed from './RowVirtualizerFixed.vue';
const { openDialog } = useDialog();
const { allModels } = await useModels(); const { allModels } = await useModels();
const { addShortcut } = useKeyboardShortcuts(); const { addShortcut } = useKeyboardShortcuts();
@@ -17,7 +15,16 @@ const props = defineProps<{
const selectedModel = defineModel<ModelWithProvider | null>(); const selectedModel = defineModel<ModelWithProvider | null>();
const openProviderSettings = async (providerId?: string) => {
closeDropdown();
await navigateTo(providerId ? `/settings/providers/${providerId}` : '/settings/providers');
};
const isOpen = ref(false); 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 dropdownButton = ref<HTMLElement | null>(null);
const dropdownContent = ref<HTMLElement | null>(null); const dropdownContent = ref<HTMLElement | null>(null);
const searchInputRef = ref<HTMLInputElement | null>(null); const searchInputRef = ref<HTMLInputElement | null>(null);
@@ -217,11 +224,13 @@ onUnmounted(() => {
isOpen isOpen
? 'bg-[var(--color-hover)] text-[var(--text-primary)]' ? 'bg-[var(--color-hover)] text-[var(--text-primary)]'
: 'text-[var(--text-secondary)] @hover:text-[var(--text-primary)] @hover:bg-[var(--color-hover)]', : '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" /> :model-id="selectedModel.externalId" size="22" />
<span class="max-w-full truncate"> <span class="max-w-full truncate">
{{ selectedModel ? selectedModel.name : 'Select a model' }} {{ noModels ? 'No models yet' : selectedModel ? selectedModel.name : 'Select a model' }}
</span> </span>
<span class="i-mynaui-chevron-down text-3.5 transition-transform duration-200" <span class="i-mynaui-chevron-down text-3.5 transition-transform duration-200"
:class="{ 'rotate-180': isOpen }"></span> :class="{ 'rotate-180': isOpen }"></span>
@@ -242,9 +251,21 @@ onUnmounted(() => {
? 'hidden' ? 'hidden'
: 'visible', : '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, noModels ? 'max-w-80' : 'max-w-[420px]']"
:class="transformOrigin"> 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"> <div class="relative">
<span <span
class="i-mynaui-search absolute left-3 top-1/2 -translate-y-1/2 text-4 text-[var(--text-secondary)]"></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"> 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 }} {{ item.provider.name }}
<button <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)]"> 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> <span class="i-mynaui-cog-four text-4"></span>
</button> </button>
@@ -295,12 +316,13 @@ onUnmounted(() => {
<div class="p-1 border-t border-[var(--color-border)]"> <div class="p-1 border-t border-[var(--color-border)]">
<button <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" 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 class="i-mynaui-cog-four text-4.5"></span>
<span>Manage Providers</span> <span>Manage Providers</span>
<span class="i-mynaui-arrow-right text-4 ml-auto"></span> <span class="i-mynaui-arrow-right text-4 ml-auto"></span>
</button> </button>
</div> </div>
</template>
</div> </div>
</KeepAlive> </KeepAlive>
</Transition> </Transition>
+246
View File
@@ -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>
+28
View File
@@ -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>
+8 -10
View File
@@ -1,6 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { DialogType } from '~/composables/useDialog';
const { user, signOut } = useAuth(); const { user, signOut } = useAuth();
// to prevent the user details from going blank for a // to prevent the user details from going blank for a
@@ -16,10 +14,14 @@ watch(user, () => {
cachedUser.value = user.value; cachedUser.value = user.value;
}) })
const { openDialog } = useDialog();
const { isHovered } = useSidenavContext(); const { isHovered } = useSidenavContext();
const openSettings = async () => {
dropdownOpen.value = false;
await navigateTo('/settings');
};
const handleLogout = async () => { const handleLogout = async () => {
await signOut(); await signOut();
@@ -31,11 +33,7 @@ const handleLogout = async () => {
<header class="flex items-center justify-between overflow-hidden"> <header class="flex items-center justify-between overflow-hidden">
<button @click="dropdownOpen = !dropdownOpen" <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)]"> 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 <UserAvatar :user="cachedUser" class="w-9 h-9 md:w-7 md:h-7 rounded-lg md:text-sm" />
: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>
<span <span
class="md:text-sm font-medium text-ellipsis overflow-hidden text-[var(--text-primary)] whitespace-nowrap"> class="md:text-sm font-medium text-ellipsis overflow-hidden text-[var(--text-primary)] whitespace-nowrap">
{{ cachedUser?.name }} {{ cachedUser?.name }}
@@ -55,7 +53,7 @@ const handleLogout = async () => {
leave-from-class="opacity-100 scale-100" leave-to-class="opacity-0 scale-95"> leave-from-class="opacity-100 scale-100" leave-to-class="opacity-0 scale-95">
<div v-if="dropdownOpen" v-click-outside="() => dropdownOpen = false" <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"> 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)]"> 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 class="i-mynaui-cog-four text-4.5"></span>
<span>Settings</span> <span>Settings</span>
+16
View File
@@ -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>
+83
View File
@@ -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>
+17 -7
View File
@@ -1,6 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { DialogType } from '~/composables/useDialog';
const { close: closeSidebar, open, sidebarWidth, resize, saveWidth } = useSidebar(); const { close: closeSidebar, open, sidebarWidth, resize, saveWidth } = useSidebar();
const route = useRoute(); const route = useRoute();
@@ -37,8 +35,6 @@ const onFocusOut = (e: FocusEvent) => {
} }
}; };
const { openDialog } = useDialog();
const onResizeStart = (event: MouseEvent) => { const onResizeStart = (event: MouseEvent) => {
isResizing.value = true; isResizing.value = true;
startX.value = event.clientX; startX.value = event.clientX;
@@ -97,8 +93,18 @@ const navKind = computed(() => {
if (route.path === '/') return 'home'; if (route.path === '/') return 'home';
if (route.path.startsWith('/agent/')) return 'agent'; if (route.path.startsWith('/agent/')) return 'agent';
if (route.path === '/resources') return 'resources'; if (route.path === '/resources') return 'resources';
if (route.path.startsWith('/settings')) return 'settings';
return null; 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> </script>
<template> <template>
@@ -126,6 +132,7 @@ const navKind = computed(() => {
<SidenavHeader v-if="navKind === 'home'" /> <SidenavHeader v-if="navKind === 'home'" />
<SidenavHeaderResources v-else-if="navKind === 'resources'" /> <SidenavHeaderResources v-else-if="navKind === 'resources'" />
<SidenavHeaderAgent v-else-if="navKind === 'agent'" /> <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="flex items-center justify-end text-[var(--text-secondary)] gap-0.5">
<div :class="[ <div :class="[
@@ -158,15 +165,18 @@ const navKind = computed(() => {
<SidenavNavHome v-if="navKind === 'home'" /> <SidenavNavHome v-if="navKind === 'home'" />
<SidenavNavAgent v-else-if="navKind === 'agent'" /> <SidenavNavAgent v-else-if="navKind === 'agent'" />
<SidenavNavResources v-else-if="navKind === 'resources'" /> <SidenavNavResources v-else-if="navKind === 'resources'" />
<SidenavNavSettings v-else-if="navKind === 'settings'" />
</div> </div>
<div class="flex justify-between pt-2 z-25 bg-[var(--bg-base)] pb-2"> <div class="flex justify-between pt-2 z-25 bg-[var(--bg-base)] pb-2">
<div class="flex"> <div class="flex">
<Tooltip :hotkey="['ctrl', ',']"> <Tooltip :hotkey="['ctrl', ',']">
<button @click="openDialog(DialogType.Settings)" <NuxtLink to="/settings" aria-label="Settings" :class="[
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)]"> '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> <span class="i-mynaui-cog-four text-6 md:text-5"></span>
</button> </NuxtLink>
</Tooltip> </Tooltip>
</div> </div>
+29
View File
@@ -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
View File
@@ -1,4 +1,5 @@
import type { BetterAuthClientOptions, InferSessionFromClient, InferUserFromClient, User } from 'better-auth/client'; 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 { createAuthClient } from 'better-auth/vue';
import { Err, Ok, type Result } from '~~/types/result'; import { Err, Ok, type Result } from '~~/types/result';
import { assert } from '~~/utils/assert'; import { assert } from '~~/utils/assert';
@@ -8,19 +9,34 @@ export enum AuthError {
SignInFailed = 'SIGN_IN_FAILED', SignInFailed = 'SIGN_IN_FAILED',
SignUpFailed = 'SIGN_UP_FAILED', SignUpFailed = 'SIGN_UP_FAILED',
SignOutFailed = 'SIGN_OUT_FAILED', SignOutFailed = 'SIGN_OUT_FAILED',
UpdateFailed = 'UPDATE_FAILED',
NetworkError = 'NETWORK_ERROR', NetworkError = 'NETWORK_ERROR',
} }
type sessionData = { export type AuthUser = InferUserFromClient<BetterAuthClientOptions> & {
session: InferSessionFromClient<BetterAuthClientOptions> | null; twoFactorEnabled?: boolean | null;
user: InferUserFromClient<BetterAuthClientOptions> | 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 = () => { export const useAuth = () => {
const authClient = createAuthClient({}); const authClient = createAuthClient({
plugins: [twoFactorClient(), adminClient()],
});
const session = useState<InferSessionFromClient<BetterAuthClientOptions> | null>('auth:session', () => null); 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 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; let sessionPromise: Promise<Result<sessionData, AuthError>> | null = null;
@@ -35,7 +51,7 @@ export const useAuth = () => {
sessionPromise = promise; sessionPromise = promise;
let data: { let data: {
session: InferSessionFromClient<BetterAuthClientOptions>; session: InferSessionFromClient<BetterAuthClientOptions>;
user: InferUserFromClient<BetterAuthClientOptions>; user: AuthUser;
} | null = null; } | null = null;
try { try {
@@ -44,7 +60,7 @@ export const useAuth = () => {
( (
await useFetch<{ await useFetch<{
session: InferSessionFromClient<BetterAuthClientOptions>; session: InferSessionFromClient<BetterAuthClientOptions>;
user: InferUserFromClient<BetterAuthClientOptions>; user: AuthUser;
}>('/api/auth/get-session') }>('/api/auth/get-session')
).data.value ?? null; ).data.value ?? null;
} else { } else {
@@ -74,7 +90,7 @@ export const useAuth = () => {
const signIn = async ( const signIn = async (
email: string, email: string,
password: string password: string
): Promise<Result<{ user: User; token: string }, { error: AuthError, data?: any }>> => { ): Promise<Result<SignInResult, { error: AuthError, data?: any }>> => {
sessionFetching.value = true; sessionFetching.value = true;
try { try {
@@ -92,11 +108,16 @@ export const useAuth = () => {
return Err({ error: AuthError.SignInFailed }); 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; user.value = data.user;
clearNuxtData(); clearNuxtData();
return Ok({ user: data.user, token: data.token }); return Ok({ twoFactorRequired: false, user: data.user, token: data.token });
} catch (err) { } catch (err) {
console.error('Sign in error:', err); console.error('Sign in error:', err);
return Err({ error: AuthError.NetworkError }); 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 ( const signUp = async (
email: string, email: string,
password: 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 }>> => { ): Promise<Result<{ user: User; token: string }, { error: AuthError, data?: any }>> => {
sessionFetching.value = true; sessionFetching.value = true;
@@ -117,6 +173,8 @@ export const useAuth = () => {
email, email,
password, password,
name, name,
}, {
headers: inviteToken ? { 'x-invite-token': inviteToken } : undefined,
}); });
if (error) { 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 { return {
client: authClient, client: authClient,
session, session,
user, user,
isAdmin,
loggedIn: computed(() => !!session.value), loggedIn: computed(() => !!session.value),
signIn, signIn,
verifyTwoFactor,
signUp, signUp,
signOut, signOut,
updateProfile,
fetchSession, fetchSession,
}; };
}; };
+6 -1
View File
@@ -1,10 +1,15 @@
import { Err, Ok, type Result } from "~~/types/result"; import { Err, Ok, type Result } from "~~/types/result";
export enum DialogType { export enum DialogType {
Settings = 'settings',
Textbox = 'textbox', Textbox = 'textbox',
QuickSwitcher = 'quick-switcher', QuickSwitcher = 'quick-switcher',
Confirm = 'confirm', Confirm = 'confirm',
ChangePassword = 'change-password',
TwoFactor = 'two-factor',
DeleteAccount = 'delete-account',
CreateUser = 'create-user',
CreateInvite = 'create-invite',
RecoverAccount = 'recover-account',
} }
interface DialogOptions { interface DialogOptions {
+8 -3
View File
@@ -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); const provider = providers.value.find(p => p.id === id);
if (!provider) return; if (!provider) return;
@@ -142,10 +142,14 @@ export const useModels = async () => {
await $fetch(`/api/provider/${id}`, { await $fetch(`/api/provider/${id}`, {
method: 'PATCH', method: 'PATCH',
body: updates, body: {
...updates,
expectedUpdatedAt: expectedUpdatedAt ?? new Date(original.updatedAt).toISOString(),
},
onRequest() { onRequest() {
// a config update only carries the fields that changed
providers.value = providers.value.map(p => providers.value = providers.value.map(p =>
p.id === id ? { ...p, ...updates } : p p.id === id ? { ...p, ...updates, config: { ...p.config, ...updates.config } } : p
); );
}, },
onRequestError() { onRequestError() {
@@ -166,6 +170,7 @@ export const useModels = async () => {
return { return {
providers, providers,
refresh,
allModels, allModels,
getModel, getModel,
getProvider, getProvider,
+25
View File
@@ -22,6 +22,31 @@ export default defineNuxtRouteMiddleware(async (to) => {
return await navigateTo((to.query.to as string) ?? "/"); 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 not authenticated, and not on a signin/signup page, redirect to login page
if (!loggedIn.value && !isAuthPage) { if (!loggedIn.value && !isAuthPage) {
return await navigateTo(`/auth/login?to=${to.path}`); return await navigateTo(`/auth/login?to=${to.path}`);
+194 -7
View File
@@ -1,7 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import CodeInput from '~/components/CodeInput.vue';
import { deriveKey } from '~/utils/crypto'; import { deriveKey } from '~/utils/crypto';
const { client: authClient, signIn, session } = useAuth(); const { client: authClient, signIn, verifyTwoFactor, session } = useAuth();
definePageMeta({ definePageMeta({
layout: 'auth', layout: 'auth',
@@ -9,11 +10,16 @@ definePageMeta({
const to = useRoute().query.to as string | undefined; 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) { if (session.value !== null) {
await navigateTo(to ?? '/'); 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({ const form = reactive({
email: '', email: '',
@@ -21,6 +27,27 @@ const form = reactive({
}); });
const loading = ref(false); 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 emailInputEl = ref<HTMLInputElement | null>(null);
const passwordInputEl = 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!.setCustomValidity(res.error.data.message!);
emailInputEl.value!.reportValidity(); emailInputEl.value!.reportValidity();
break; break;
case 'BANNED_USER':
emailInputEl.value!.setCustomValidity('This account has been disabled by an admin of this instance.');
emailInputEl.value!.reportValidity();
break;
default: default:
console.log(res.error); console.log(res.error);
alert(`Something went wrong. ${res.error.data.message}`); alert(`Something went wrong. ${res.error.data.message}`);
@@ -97,11 +128,94 @@ const submit = async () => {
return; 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)); localStorage.setItem('encryptionKey', JSON.stringify(key));
return navigateTo(to ?? '/'); 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> </script>
<template> <template>
@@ -117,15 +231,35 @@ const submit = async () => {
<div class="z-10 max-w-sm w-full"> <div class="z-10 max-w-sm w-full">
<div class="flex flex-col items-center mb-4 text-center"> <div class="flex flex-col items-center mb-4 text-center">
<h1 class="text-3xl font-bold tracking-tight">Veridian</h1> <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>
<div class="rounded-2xl"> <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"> <div class="flex flex-col gap-1.5">
<label for="email" <label for="email"
class="text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)] ml-1">Email</label> 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" 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" /> class="focus:ring-2 focus:ring-[var(--color-accent)]/20 border border-[var(--color-border)] transition-all" />
</div> </div>
@@ -144,9 +278,62 @@ const submit = async () => {
<span v-else>Sign In</span> <span v-else>Sign In</span>
</button> </button>
</form> </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> </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? New to Veridian?
<NuxtLink :to="to ? `/auth/register?to=${to}` : '/auth/register'" <NuxtLink :to="to ? `/auth/register?to=${to}` : '/auth/register'"
class="text-[var(--color-accent)] font-medium @hover:underline"> class="text-[var(--color-accent)] font-medium @hover:underline">
+239
View File
@@ -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>
+72
View File
@@ -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 &lt;email&gt;</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>
+76 -6
View File
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { deriveKey } from '~/utils/crypto'; import { deriveKey } from '~/utils/crypto';
import { timeLeft } from '~/utils/time';
const { client: authClient, signUp, session } = useAuth(); const { client: authClient, signUp, session } = useAuth();
@@ -12,12 +13,38 @@ if (session.value !== null) {
await navigateTo(to ?? '/'); 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'); 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({ const form = reactive({
name: '', name: '',
email: '', email: '',
@@ -55,6 +82,9 @@ onMounted(() => {
form.confirmPassword = tempForm.confirmPassword; form.confirmPassword = tempForm.confirmPassword;
hydrated.value = true; hydrated.value = true;
// there is no form when the invite does not work
if (blocked.value) return;
nameInputEl.value!.addEventListener('input', () => { nameInputEl.value!.addEventListener('input', () => {
nameInputEl.value!.setCustomValidity(''); nameInputEl.value!.setCustomValidity('');
}); });
@@ -94,7 +124,7 @@ const submit = async () => {
loading.value = true; 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; loading.value = false;
if (!res.ok) { if (!res.ok) {
if (!res.error.data) { if (!res.error.data) {
@@ -121,6 +151,14 @@ const submit = async () => {
passwordInputEl.value!.setCustomValidity(res.error.data.message!); passwordInputEl.value!.setCustomValidity(res.error.data.message!);
passwordInputEl.value!.reportValidity(); passwordInputEl.value!.reportValidity();
break; 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: default:
console.log(res.error); console.log(res.error);
alert('Something went wrong: ' + res.error.data.message); 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); const key = await deriveKey(form.password, res.data.user.id);
localStorage.setItem('encryptionKey', JSON.stringify(key)); 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> </script>
@@ -153,7 +192,30 @@ const submit = async () => {
<p class="text-[var(--text-secondary)]">Create your account</p> <p class="text-[var(--text-secondary)]">Create your account</p>
</div> </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"> <form class="flex flex-col gap-2" @submit.prevent="submit">
<div class="flex flex-col gap-1.5"> <div class="flex flex-col gap-1.5">
<label for="name" <label for="name"
@@ -166,7 +228,7 @@ const submit = async () => {
<div class="flex flex-col gap-1.5"> <div class="flex flex-col gap-1.5">
<label for="email" <label for="email"
class="text-sm font-medium uppercase tracking-wider text-[var(--text-secondary)] ml-1">Email</label> 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" 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" /> class="focus:ring-2 focus:ring-[var(--color-accent)]/20 border border-[var(--color-border)] transition-all" />
</div> </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" /> class="focus:ring-2 focus:ring-[var(--color-accent)]/20 border border-[var(--color-border)] transition-all" />
</div> </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" <button :disabled="!hydrated"
class="accent w-full mt-2 h-11 flex items-center justify-center font-semibold" type="submit"> 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-if="loading" class="i-svg-spinners-90-ring-with-bg text-xl"></span>
+235
View File
@@ -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>
+46
View File
@@ -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); const hinting = parseInt((e.target as HTMLInputElement).value);
updateSettings({ appearance: { hinting } }); updateSettings({ appearance: { hinting } });
}; };
defineEmits(['navigate']);
</script> </script>
<template> <template>
<div <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"> <div class="flex flex-row items-center justify-between gap-2">
<h4 class="font-medium">Theme</h4> <h4 class="font-medium">Theme</h4>
<div class="flex gap-2"> <div class="flex gap-2">
@@ -33,8 +33,6 @@ const getModel = (id: string | null | undefined) => {
return allModels.value.find(m => m.id === id); return allModels.value.find(m => m.id === id);
} }
defineEmits(['navigate']);
</script> </script>
<template> <template>
+5
View File
@@ -0,0 +1,5 @@
<template>
<div class="py-4 text-sm text-[var(--text-secondary)]">
Nothing to configure here yet.
</div>
</template>
+9
View File
@@ -0,0 +1,9 @@
<script setup lang="ts">
definePageMeta({
redirect: '/settings/profile',
});
</script>
<template>
<div></div>
</template>
+204
View File
@@ -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>
+383
View File
@@ -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>
+275
View File
@@ -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>
@@ -2,20 +2,19 @@
import { sortByReleaseDate } from '~/utils/sort'; import { sortByReleaseDate } from '~/utils/sort';
import { encryptData, decrypt, uint8ArrayToBase64, base64ToUint8Array } from '~/utils/crypto'; import { encryptData, decrypt, uint8ArrayToBase64, base64ToUint8Array } from '~/utils/crypto';
import { type Model } from '~/types/model'; import { type Model } from '~/types/model';
import ModelItem from './ModelItem.vue'; import ModelItem from '~/components/Settings/ModelItem.vue';
import RowVirtualizerDynamic from '../RowVirtualizerDynamic.vue'; import RowVirtualizerDynamic from '~/components/RowVirtualizerDynamic.vue';
const props = defineProps<{ const route = useRoute();
params?: string; const providerId = computed(() => route.params.id as string | undefined);
}>();
const { providers, updateProvider, createModel, updateModel } = await useModels(); const { providers, updateProvider, createModel, updateModel } = await useModels();
const scrollContainerRef = ref<HTMLDivElement | null>(null); const scrollContainerRef = ref<HTMLDivElement | null>(null);
const provider = computed(() => { const provider = computed(() => {
if (props.params === undefined) return null; if (providerId.value === undefined) return null;
return providers.value!.find(p => p.id === props.params); return providers.value!.find(p => p.id === providerId.value);
}); });
watch(provider, async () => { watch(provider, async () => {
@@ -29,8 +28,8 @@ const apiKey = ref('');
const apiProxyUrl = ref(provider.value?.config.apiProxyUrl ?? ''); const apiProxyUrl = ref(provider.value?.config.apiProxyUrl ?? '');
const modelSearch = ref(''); const modelSearch = ref('');
watch(() => props.params, () => { watch(() => providerId.value, () => {
if (props.params === undefined) return; if (providerId.value === undefined) return;
apiKey.value = provider.value?.config.apiKey ?? ''; apiKey.value = provider.value?.config.apiKey ?? '';
apiProxyUrl.value = provider.value?.config.apiProxyUrl ?? ''; apiProxyUrl.value = provider.value?.config.apiProxyUrl ?? '';
modelSearch.value = ''; 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) => { // Config saves run one after the other, and each reads the revision only when
if (!provider.value) return; // 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( const saveConfig = (
"jwk", id: string,
JSON.parse(window.localStorage.getItem("encryptionKey")!), failure: string,
"AES-GCM", config: () => Promise<{ apiKey?: string; apiProxyUrl?: string }>,
false, expectedEncryptionKey?: string,
["encrypt", "decrypt"] ) => {
); configSaves = configSaves.then(async () => {
const encypted = await encryptData(key, value); // 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) { const updateApiKey = (value: string) => {
clearTimeout(apiKeyTimeout); const snapshot = provider.value;
const storedKey = localStorage.getItem("encryptionKey");
if (!snapshot || !storedKey) {
return;
} }
clearTimeout(apiKeyTimeout);
apiKeyTimeout = setTimeout(async () => { apiKeyTimeout = setTimeout(() => {
await updateProvider(provider.value!.id, { saveConfig(snapshot.id, "The key could not be saved. Reload this page before trying again.", async () => {
config: { const key = await crypto.subtle.importKey("jwk", JSON.parse(storedKey), "AES-GCM", false, ["encrypt", "decrypt"]);
...provider.value!.config, const encrypted = await encryptData(key, value);
apiKey: uint8ArrayToBase64(encypted), return { apiKey: uint8ArrayToBase64(encrypted) };
}, }, storedKey);
});
}, 700); }, 700);
}; };
let proxyUrlTimeout: NodeJS.Timeout | undefined; const updateProxyUrl = (value: string) => {
const snapshot = provider.value;
const updateProxyUrl = async (value: string) => { if (!snapshot) {
if (!provider.value) return; return;
}
apiProxyUrl.value = value; apiProxyUrl.value = value;
clearTimeout(proxyUrlTimeout);
if (proxyUrlTimeout) { proxyUrlTimeout = setTimeout(() => {
clearTimeout(proxyUrlTimeout); saveConfig(snapshot.id, "The address could not be saved. Reload this page before trying again.", async () => ({ apiProxyUrl: value }));
}
proxyUrlTimeout = setTimeout(async () => {
await updateProvider(provider.value!.id, {
config: {
...provider.value!.config,
apiProxyUrl: value,
},
});
}, 700); }, 700);
}; };
onUnmounted(() => {
clearTimeout(apiKeyTimeout);
clearTimeout(proxyUrlTimeout);
});
const fetchingModels = ref(false); const fetchingModels = ref(false);
const fetchModels = async () => { const fetchModels = async () => {
@@ -373,8 +390,6 @@ const formatMoney = (value: string) => {
const isFormValid = computed(() => { const isFormValid = computed(() => {
return formData.value.name.trim() && formData.value.externalId.trim(); return formData.value.name.trim() && formData.value.externalId.trim();
}); });
defineEmits(['navigate']);
</script> </script>
<template> <template>
@@ -410,6 +425,8 @@ defineEmits(['navigate']);
</div> </div>
</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"> <div class="flex flex-row justify-center text-xs">
<p class="text-[var(--text-secondary)]"> <p class="text-[var(--text-secondary)]">
<span class="i-mynaui-lock inline-block"></span> Your API key is encrypted using <span class="i-mynaui-lock inline-block"></span> Your API key is encrypted using
@@ -3,10 +3,6 @@ import { providerIcons } from '~/utils/model-mapping';
const { providers, updateProvider } = await useModels(); const { providers, updateProvider } = await useModels();
const props = defineProps<{
params?: string;
}>();
if (providers.value === undefined) throw new Error('Providers not loaded'); if (providers.value === undefined) throw new Error('Providers not loaded');
const toggleProvider = async (id: string) => { 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> </script>
<template> <template>
@@ -31,8 +27,7 @@ defineEmits(['navigate']);
</h2> </h2>
<div <div
class="grid gap-4 grid-cols-[repeat(auto-fill,_minmax(max(240px,_calc((100%_-_16px_*_(3_-_1))_/_3)),_1fr))]"> 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)" <button @click="openProvider(p.id)" v-for="p in providers?.filter(p => p.enabled)" :key="p.id"
: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)]"> 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 flex-col flex-grow">
<div class="flex items-center gap-2 mb-2"> <div class="flex items-center gap-2 mb-2">
@@ -55,8 +50,7 @@ defineEmits(['navigate']);
</h2> </h2>
<div <div
class="grid gap-4 grid-cols-[repeat(auto-fill,_minmax(max(240px,_calc((100%_-_16px_*_(3_-_1))_/_3)),_1fr))]"> 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)" <button @click="openProvider(p.id)" v-for="p in providers?.filter(p => !p.enabled)" :key="p.id"
: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)]"> 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 flex-col flex-grow">
<div class="flex items-center gap-2 mb-2"> <div class="flex items-center gap-2 mb-2">
+283
View File
@@ -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>
+155
View File
@@ -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>
+49
View File
@@ -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>
+35
View File
@@ -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>
+29
View File
@@ -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() : '?';
};
+15
View File
@@ -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'}`;
};
+84 -445
View File
File diff suppressed because it is too large Load Diff
@@ -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
+75
View File
@@ -25,6 +25,19 @@ export enum ToolCallType {
Json = 'json', 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', { export const settings = pgTable('settings', {
id: text('id').primaryKey().$defaultFn(nanoid), id: text('id').primaryKey().$defaultFn(nanoid),
userId: text('user_id').notNull(), userId: text('user_id').notNull(),
@@ -253,6 +266,12 @@ export const users = pgTable("users", {
email: text("email").notNull().unique(), email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").default(false).notNull(), emailVerified: boolean("email_verified").default(false).notNull(),
image: text("image"), 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(), createdAt: timestamptz("created_at").defaultNow().notNull(),
updatedAt: timestamptz("updated_at") updatedAt: timestamptz("updated_at")
.defaultNow() .defaultNow()
@@ -260,6 +279,25 @@ export const users = pgTable("users", {
.notNull(), .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( export const sessions = pgTable(
"sessions", "sessions",
{ {
@@ -275,6 +313,7 @@ export const sessions = pgTable(
userId: text("user_id") userId: text("user_id")
.notNull() .notNull()
.references(() => users.id, { onDelete: "cascade" }), .references(() => users.id, { onDelete: "cascade" }),
impersonatedBy: text("impersonated_by"),
}, },
(table) => [index("sessions_userId_idx").on(table.userId)], (table) => [index("sessions_userId_idx").on(table.userId)],
); );
@@ -318,3 +357,39 @@ export const verifications = pgTable(
}, },
(table) => [index("verifications_identifier_idx").on(table.identifier)], (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
View File
@@ -1,10 +1,41 @@
import { drizzleAdapter } from "@better-auth/drizzle-adapter/relations-v2"; 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 { 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 * 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({ export const auth = betterAuth({
database: drizzleAdapter(db, { database: drizzleAdapter(authDatabase, {
provider: "pg", provider: "pg",
schema, schema,
usePlural: true, usePlural: true,
@@ -15,13 +46,110 @@ export const auth = betterAuth({
updateAge: 60 * 60 * 24 * 1, // 1 day updateAge: 60 * 60 * 24 * 1, // 1 day
cookieCache: { cookieCache: {
enabled: true, enabled: true,
maxAge: 5 * 60, maxAge: 60,
}, },
}, },
baseURL: process.env.NUXT_PUBLIC_URL ?? 'http://localhost:3000', baseURL: process.env.NUXT_PUBLIC_URL ?? 'http://localhost:3000',
trustedOrigins: [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: { emailAndPassword: {
// if DISABLE_LOCAL_AUTH is set to "1" or "true" then local auth is disabled // 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', enabled: process.env.DISABLE_LOCAL_AUTH?.toLowerCase() !== 'true' && process.env.DISABLE_LOCAL_AUTH !== '1',
+2 -2
View File
@@ -119,6 +119,7 @@ export default defineNuxtConfig({
'@nuxt/hints/runtime/hydration/component', '@nuxt/hints/runtime/hydration/component',
'@tanstack/vue-virtual', '@tanstack/vue-virtual',
'better-auth/vue', 'better-auth/vue',
'uqr',
'nanoid', 'nanoid',
'drizzle-orm/pg-core' 'drizzle-orm/pg-core'
], ],
@@ -166,7 +167,6 @@ export default defineNuxtConfig({
}, },
public: { public: {
disableSignup: process.env.DISABLE_SIGNUP === 'true' || process.env.DISABLE_SIGNUP === '1',
publicUrl: process.env.NUXT_PUBLIC_URL ?? 'http://localhost:3000', publicUrl: process.env.NUXT_PUBLIC_URL ?? 'http://localhost:3000',
} }
}, },
@@ -181,4 +181,4 @@ export default defineNuxtConfig({
sourcemap: { sourcemap: {
client: 'hidden', client: 'hidden',
}, },
}); });
+7 -5
View File
@@ -7,7 +7,8 @@
"dev": "nuxt dev", "dev": "nuxt dev",
"generate": "nuxt generate", "generate": "nuxt generate",
"preview": "nuxt preview", "preview": "nuxt preview",
"postinstall": "nuxt prepare" "postinstall": "nuxt prepare",
"recover": "bun scripts/recover.ts"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "^3.0.71", "@ai-sdk/anthropic": "^3.0.71",
@@ -20,7 +21,7 @@
"@ai-sdk/openai-compatible": "^2.0.41", "@ai-sdk/openai-compatible": "^2.0.41",
"@aws-sdk/client-s3": "^3.1028.0", "@aws-sdk/client-s3": "^3.1028.0",
"@aws-sdk/s3-request-presigner": "^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", "@floating-ui/vue": "^1.1.11",
"@iconify-json/mynaui": "^1.2.17", "@iconify-json/mynaui": "^1.2.17",
"@nuxt/fonts": "0.14.0", "@nuxt/fonts": "0.14.0",
@@ -32,10 +33,10 @@
"@tanstack/vue-virtual": "^3.13.23", "@tanstack/vue-virtual": "^3.13.23",
"ai": "^6.0.182", "ai": "^6.0.182",
"ai-sdk-ollama": "^3.8.3", "ai-sdk-ollama": "^3.8.3",
"better-auth": "^1.6.2", "better-auth": "^1.7.7",
"comark": "0.6.2", "comark": "0.6.2",
"comlink": "^4.4.2", "comlink": "^4.4.2",
"drizzle-orm": "1.0.0-rc.4-5d5b77c", "drizzle-orm": "1.0.0-rc.4",
"glob": "^13.0.6", "glob": "^13.0.6",
"katex": "0.17.0", "katex": "0.17.0",
"longcat-ai-sdk-provider": "^0.0.5", "longcat-ai-sdk-provider": "^0.0.5",
@@ -43,6 +44,7 @@
"nuxt": "^4.4.2", "nuxt": "^4.4.2",
"pg": "^8.20.0", "pg": "^8.20.0",
"shiki": "^3.23.0", "shiki": "^3.23.0",
"uqr": "^0.1.3",
"vue": "^3.6.0-beta.10", "vue": "^3.6.0-beta.10",
"vue-router": "^5.0.4", "vue-router": "^5.0.4",
"zod": "^4.3.6" "zod": "^4.3.6"
@@ -54,7 +56,7 @@
"@types/node": "^25.6.0", "@types/node": "^25.6.0",
"@types/pg": "^8.20.0", "@types/pg": "^8.20.0",
"@unocss/nuxt": "^66.6.8", "@unocss/nuxt": "^66.6.8",
"drizzle-kit": "^1.0.0-beta.21", "drizzle-kit": "1.0.0-rc.4",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"unocss": "^66.6.8" "unocss": "^66.6.8"
}, },
+60
View File
@@ -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());
+128
View File
@@ -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 };
});
+29
View File
@@ -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 };
});
+82
View File
@@ -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 };
});
+30
View File
@@ -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,
}));
});
+9
View File
@@ -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(),
};
});
+43
View File
@@ -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 };
});
+11
View File
@@ -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 };
});
+21
View File
@@ -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));
});
+50
View File
@@ -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 };
});
+42
View File
@@ -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,
}));
});
+63
View File
@@ -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);
});
+44 -3
View File
@@ -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) => { const SIGN_UP = "/api/auth/sign-up/email";
return auth.handler(toWebRequest(event));
// 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;
}
}); });
+5 -2
View File
@@ -22,8 +22,11 @@ export default defineEventHandler(async (event) => {
// Path 2: Session auth (for client-side access) // Path 2: Session auth (for client-side access)
if (!authorized) { if (!authorized) {
try { try {
const sessionData = await auth.api.getSession(event); const sessionData = await auth.api.getSession({
if (sessionData) { headers: event.headers,
query: { disableCookieCache: true },
});
if (sessionData && !sessionData.user.banned) {
event.context.user = sessionData.user; event.context.user = sessionData.user;
authorized = true; authorized = true;
} }
+6
View File
@@ -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 };
});
+38
View File
@@ -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),
},
};
});
+52 -57
View File
@@ -1,68 +1,63 @@
import { and, eq } from "drizzle-orm"; import { and, eq, gt } from "drizzle-orm";
import { providers } from "~~/drizzle/schema"; 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 { db } from "~~/server/lib/db";
import * as z from 'zod';
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
await protectRoute(event); const { user, session } = await protectRoute(event);
const result = await readValidatedBody(event, (body) => z.object({
const userId = event.context.user!.id; config: z.object({
apiKey: z.string().optional(),
const result = await readValidatedBody(event, (body) => apiProxyUrl: z.string().optional(),
z }).optional(),
.object({ expectedUpdatedAt: z.iso.datetime().optional(),
config: z.object({ enabled: z.boolean().optional(),
apiKey: z.string().optional(), }).refine(body => body.config === undefined || body.expectedUpdatedAt !== undefined, {
apiProxyUrl: z.string().optional(), message: "Provider config updates require a revision",
}).optional(), }).safeParse(body));
enabled: z.boolean().optional(),
})
.safeParse(body),
);
if (!result.success) { if (!result.success) {
throw createError({ throw createError({ statusCode: 400, message: result.error.issues[0]?.message });
statusCode: 400,
message: result.error.issues[0]!.message,
});
} }
const providerId = getRouterParam(event, 'providerId')!; const providerId = getRouterParam(event, "providerId");
if (!providerId) {
const { config, enabled } = result.data; throw createError({ statusCode: 400, statusMessage: "Invalid provider" });
const existing = await db.query.providers.findFirst({
where: {
id: providerId,
userId,
},
});
if (!existing) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid provider',
});
} }
const { config, enabled, expectedUpdatedAt } = result.data;
const res = await db.update(providers) return await db.transaction(async (tx) => {
.set({ await lockAccount(tx, user.id);
config, // 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, enabled,
}) updatedAt,
.where( }).where(and(eq(providers.id, providerId), eq(providers.userId, user.id)));
and(
eq(providers.id, providerId),
eq(providers.userId, userId),
)
);
if (res.rowCount === 0) { return { ok: true, updatedAt };
throw createError({ });
statusCode: 400, });
statusMessage: 'Invalid provider',
});
}
return { ok: true };
});
+40
View File
@@ -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,
};
});
+122
View File
@@ -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' };
});
+97
View File
@@ -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 };
});
+14 -8
View File
@@ -85,15 +85,21 @@ export default defineEventHandler(async (event) => {
current = current.parentMessageId ? messageMap.get(current.parentMessageId) : undefined; current = current.parentMessageId ? messageMap.get(current.parentMessageId) : undefined;
} }
const forkIndex = topicMessages.findIndex(m => m.id === messageId); // A regeneration can be created after later roots, so the fork
// point has to be located by its root rather than by its own createdAt.
let forkRoot = forkMessage;
while (forkRoot.parentMessageId && messageMap.has(forkRoot.parentMessageId)) {
forkRoot = messageMap.get(forkRoot.parentMessageId)!;
}
const rootMessages = topicMessages.filter(m => !m.parentMessageId);
const forkIndex = rootMessages.findIndex(m => m.id === forkRoot.id);
for (let i = 0; i <= forkIndex; i++) { for (let i = 0; i <= forkIndex; i++) {
const msg = topicMessages[i]!; const msg = rootMessages[i]!;
if (!msg.parentMessageId) { messageIdsToCopy.add(msg.id);
messageIdsToCopy.add(msg.id); const children = childrenMap.get(msg.id) || [];
const children = childrenMap.get(msg.id) || []; for (const child of children) {
for (const child of children) { messageIdsToCopy.add(child.id);
messageIdsToCopy.add(child.id);
}
} }
} }
+46
View File
@@ -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-%"),
),
));
};
+30
View File
@@ -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)));
+50
View File
@@ -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);
});
};
+20
View File
@@ -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;
};
+6 -2
View File
@@ -2,9 +2,12 @@ import type { H3Event } from 'h3';
import { auth } from '~~/lib/auth'; import { auth } from '~~/lib/auth';
export const protectRoute = async (event: H3Event) => { 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({ throw createError({
statusCode: 401, statusCode: 401,
statusMessage: 'Unauthorized', statusMessage: 'Unauthorized',
@@ -12,4 +15,5 @@ export const protectRoute = async (event: H3Event) => {
} }
event.context.user = sessionData.user; event.context.user = sessionData.user;
return sessionData;
}; };
+27
View File
@@ -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';
};
+47
View File
@@ -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}`));
};
+72
View File
@@ -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;
};
+12
View File
@@ -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;
};