feat: basic admin interface

This commit is contained in:
Zoe
2026-10-08 13:41:02 -05:00
parent 7b0800882a
commit f6e1bc1f47
27 changed files with 4095 additions and 49 deletions
+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>
+155
View File
@@ -0,0 +1,155 @@
<script setup lang="ts">
const MIN_PASSWORD_LENGTH = 8;
const GENERATED_PASSWORD_LENGTH = 16;
// no look-alikes (0/O, 1/l/I), this gets read out or typed by hand
const PASSWORD_ALPHABET = 'abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789';
const emit = defineEmits(['confirm', 'cancel']);
const { client: authClient } = useAuth();
const form = reactive({
name: '',
email: '',
password: '',
});
const busy = ref(false);
const error = ref('');
const nameInputEl = ref<HTMLInputElement | null>(null);
onMounted(() => {
nameInputEl.value?.focus();
});
const ready = computed(() =>
form.name.trim() !== '' && form.email.trim() !== '' && form.password.length >= MIN_PASSWORD_LENGTH);
const generatePassword = () => {
// values past the last full run of the alphabet are skipped, so every
// character stays equally likely
const limit = 256 - (256 % PASSWORD_ALPHABET.length);
let password = '';
while (password.length < GENERATED_PASSWORD_LENGTH) {
for (const value of crypto.getRandomValues(new Uint8Array(GENERATED_PASSWORD_LENGTH))) {
if (value >= limit || password.length === GENERATED_PASSWORD_LENGTH) continue;
password += PASSWORD_ALPHABET[value % PASSWORD_ALPHABET.length];
}
}
form.password = password;
};
const submit = async () => {
if (busy.value || !ready.value) return;
busy.value = true;
error.value = '';
const { error: createError } = await authClient.admin.createUser({
name: form.name.trim(),
email: form.email.trim(),
password: form.password,
role: 'user',
});
busy.value = false;
if (createError) {
switch (createError.code) {
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 'PASSWORD_TOO_SHORT':
error.value = `The password needs at least ${MIN_PASSWORD_LENGTH} characters.`;
break;
case 'PASSWORD_TOO_LONG':
error.value = 'That password is too long.';
break;
default:
console.error('Failed to create user:', createError);
error.value = 'The account could not be created. Try again.';
break;
}
return;
}
emit('confirm', true);
};
</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">Create user</h2>
<p class="text-sm text-[var(--text-secondary)]">
Creates the account right away. Nothing is sent to them, so pass the password on yourself.
</p>
</header>
<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>
<div class="flex flex-col gap-1.5">
<label for="create-user-password"
class="text-[13px] font-medium uppercase tracking-wider text-[var(--text-secondary)]">
Temporary password
</label>
<div class="flex gap-2">
<!-- shown in the clear, the admin has to read it to pass it on -->
<input id="create-user-password" v-model="form.password" type="text" required
:minlength="MIN_PASSWORD_LENGTH" maxlength="128" :disabled="busy" autocomplete="off"
autocapitalize="off" spellcheck="false"
class="flex-1 min-w-0 h-9 px-2.5 text-sm font-mono rounded-md bg-[var(--bg-container)]" />
<button type="button" :disabled="busy" @click="generatePassword"
class="flex items-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">
Generate
</button>
</div>
<p class="text-xs leading-normal text-[var(--text-secondary)]">
At least {{ MIN_PASSWORD_LENGTH }} characters. They can change it under Settings → Security
after signing in.
</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 || !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>
</form>
</template>
+4
View File
@@ -4,6 +4,8 @@ import QuickSwitcher from './QuickSwitcher.vue';
import ChangePassword from './ChangePassword.vue'; import ChangePassword from './ChangePassword.vue';
import TwoFactor from './TwoFactor.vue'; import TwoFactor from './TwoFactor.vue';
import DeleteAccount from './DeleteAccount.vue'; import DeleteAccount from './DeleteAccount.vue';
import Confirm from './Confirm.vue';
import CreateUser from './CreateUser.vue';
import { DialogType } from '~/composables/useDialog'; import { DialogType } from '~/composables/useDialog';
@@ -69,6 +71,8 @@ onUnmounted(() => {
<TwoFactor v-else-if="page === DialogType.TwoFactor" :action="data.action" @confirm="confirm" <TwoFactor v-else-if="page === DialogType.TwoFactor" :action="data.action" @confirm="confirm"
@cancel="close" /> @cancel="close" />
<DeleteAccount v-else-if="page === DialogType.DeleteAccount" @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" />
<Confirm v-else-if="page === DialogType.Confirm" v-bind="data" @confirm="confirm" @cancel="close" />
</div> </div>
</div> </div>
</Transition> </Transition>
+21 -2
View File
@@ -3,10 +3,21 @@ import { providerIcons } from '~/utils/model-mapping';
const route = useRoute(); const route = useRoute();
const { providers } = await useModels(); const { providers } = await useModels();
const { isAdmin } = useAuth();
const groups = [ 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', label: 'Account',
adminOnly: false,
items: [ items: [
{ to: '/settings/profile', name: 'Profile', icon: 'i-mynaui-user' }, { to: '/settings/profile', name: 'Profile', icon: 'i-mynaui-user' },
{ to: '/settings/security', name: 'Security', icon: 'i-mynaui-shield' }, { to: '/settings/security', name: 'Security', icon: 'i-mynaui-shield' },
@@ -14,6 +25,7 @@ const groups = [
}, },
{ {
label: 'App', label: 'App',
adminOnly: false,
items: [ items: [
{ to: '/settings/general', name: 'General', icon: 'i-mynaui-cog-four' }, { to: '/settings/general', name: 'General', icon: 'i-mynaui-cog-four' },
{ to: '/settings/appearance', name: 'Appearance', icon: 'i-tabler-palette' }, { to: '/settings/appearance', name: 'Appearance', icon: 'i-tabler-palette' },
@@ -23,6 +35,8 @@ const groups = [
}, },
]; ];
const groups = computed(() => isAdmin.value ? [...BASE_GROUPS, ADMIN_GROUP] : BASE_GROUPS);
const inProviders = computed(() => route.path.startsWith('/settings/providers')); const inProviders = computed(() => route.path.startsWith('/settings/providers'));
// enabled providers first, same order the provider list uses // enabled providers first, same order the provider list uses
@@ -35,8 +49,13 @@ const sortedProviders = computed(() => [
<template> <template>
<nav aria-label="Settings" class="flex flex-col gap-1 overflow-auto"> <nav aria-label="Settings" class="flex flex-col gap-1 overflow-auto">
<template v-for="group in groups" :key="group.label"> <template v-for="group in groups" :key="group.label">
<div class="px-2 pt-3 pb-0.5 text-sm md:text-xs font-medium uppercase tracking-wider"> <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 }} {{ 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> </div>
<template v-for="item in group.items" :key="item.to"> <template v-for="item in group.items" :key="item.to">
+2 -2
View File
@@ -1,5 +1,5 @@
import type { BetterAuthClientOptions, InferSessionFromClient, InferUserFromClient, User } from 'better-auth/client'; import type { BetterAuthClientOptions, InferSessionFromClient, InferUserFromClient, User } from 'better-auth/client';
import { twoFactorClient } from 'better-auth/client/plugins'; 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';
@@ -31,7 +31,7 @@ export type SignInResult =
export const useAuth = () => { export const useAuth = () => {
const authClient = createAuthClient({ const authClient = createAuthClient({
plugins: [twoFactorClient()], plugins: [twoFactorClient(), adminClient()],
}); });
const session = useState<InferSessionFromClient<BetterAuthClientOptions> | null>('auth:session', () => null); const session = useState<InferSessionFromClient<BetterAuthClientOptions> | null>('auth:session', () => null);
const user = useState<AuthUser | null>('auth:user', () => null); const user = useState<AuthUser | null>('auth:user', () => null);
+1
View File
@@ -7,6 +7,7 @@ export enum DialogType {
ChangePassword = 'change-password', ChangePassword = 'change-password',
TwoFactor = 'two-factor', TwoFactor = 'two-factor',
DeleteAccount = 'delete-account', DeleteAccount = 'delete-account',
CreateUser = 'create-user',
} }
interface DialogOptions { interface DialogOptions {
+1 -1
View File
@@ -31,7 +31,7 @@ export default defineNuxtRouteMiddleware(async (to) => {
// once an account exists the answer never changes back // once an account exists the answer never changes back
if (setupNeeded.value !== false) { if (setupNeeded.value !== false) {
try { try {
setupNeeded.value = (await $fetch<{ needed: boolean }>('/api/setup')).needed; setupNeeded.value = (await $fetch<{ setupNeeded: boolean }>('/api/instance')).setupNeeded;
} catch (error) { } catch (error) {
console.error('Failed to check instance setup:', error); console.error('Failed to check instance setup:', error);
setupNeeded.value = null; setupNeeded.value = null;
+7 -2
View File
@@ -14,7 +14,8 @@ 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: '',
@@ -110,6 +111,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}`);
@@ -303,7 +308,7 @@ const submitTwoFactor = async () => {
</form> </form>
</div> </div>
<p v-if="!config.public.disableSignup && step === 'credentials'" class="text-sm text-center mt-3 text-[var(--text-secondary)]"> <p v-if="instance?.registration === 'open' && step === 'credentials'" class="text-sm text-center mt-3 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">
+7 -2
View File
@@ -12,9 +12,10 @@ 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 && instance.value.registration !== 'open') {
await navigateTo(to ? `/auth/login?to=${to}` : '/auth/login'); await navigateTo(to ? `/auth/login?to=${to}` : '/auth/login');
} }
@@ -121,6 +122,10 @@ 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 '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);
+3
View File
@@ -10,6 +10,7 @@ const TITLES: Record<string, string> = {
appearance: 'Appearance', appearance: 'Appearance',
providers: 'AI Providers', providers: 'AI Providers',
assistants: 'System Assistants', assistants: 'System Assistants',
instance: 'Instance',
}; };
const title = computed(() => { const title = computed(() => {
@@ -20,6 +21,8 @@ const title = computed(() => {
return provider ? provider.name : 'Unknown Provider'; return provider ? provider.name : 'Unknown Provider';
} }
if (section === 'instance' && id === 'users') return 'Users';
return TITLES[section ?? ''] ?? 'Settings'; return TITLES[section ?? ''] ?? 'Settings';
}); });
</script> </script>
+104
View File
@@ -0,0 +1,104 @@
<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 instance can create an account.',
},
{
value: 'invite',
label: 'Invite only',
description: 'New accounts need an invite link from an admin. Invite links are not available yet, so until then nobody can register.',
},
{
value: 'closed',
label: 'Closed',
description: 'Nobody can register. Admins create accounts under Users.',
},
];
const { isAdmin } = useAuth();
if (!isAdmin.value) {
await navigateTo('/settings/profile');
}
const { data: instance } = await useFetch<InstanceSettings>('/api/admin/instance', {
key: 'admin_instance',
});
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;
};
</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>
</section>
</div>
</div>
</template>
+348
View File
@@ -0,0 +1,348 @@
<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;
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. They never see anyone else\'s chats, files or API keys.',
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 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;
}));
const openCreateUser = () => {
openDialog(DialogType.CreateUser, async (result) => {
if (result.ok === false) return;
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>
<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>
</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' : 'text-green-400'">
<span aria-hidden="true" class="w-1.75 h-1.75 rounded-full bg-current"></span>
{{ u.banned ? 'Disabled' : '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>
<!-- your own account is managed from Profile and Security -->
<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 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)]">
Admins manage accounts, not content: chats, agents, files and API keys of other users are never
shown here. Disabled accounts can't sign in and nothing of theirs is deleted. Your own account is
managed from Profile and Security, and you can't remove your own admin role.
</p>
</div>
</template>
@@ -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
+13
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(),
+29 -4
View File
@@ -7,15 +7,20 @@ import { admin } from 'better-auth/plugins/admin';
import { defaultStatements } from 'better-auth/plugins/admin/access'; import { defaultStatements } from 'better-auth/plugins/admin/access';
import { twoFactor } from 'better-auth/plugins/two-factor'; 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';
const MIN_PASSWORD_LENGTH = 8;
const accessControl = createAccessControl(defaultStatements); const accessControl = createAccessControl(defaultStatements);
// Admins manage accounts, not content. Signing in as someone else and setting // Admins manage accounts, not content. Signing in as someone else and setting
// another user's password or email are left out on purpose: the first would // another user's password or email are left out on purpose: the first would
// show their chats, the others would lock them out of their encrypted API keys. // show their chats, the others would lock them out of their encrypted API keys.
// Deleting is left out too, because Better Auth would only remove the account
// and leave its agents, topics and files behind. /api/admin/users does that.
const roles = { const roles = {
admin: accessControl.newRole({ admin: accessControl.newRole({
user: ['create', 'list', 'set-role', 'ban', 'delete', 'get', 'update'], user: ['create', 'list', 'set-role', 'ban', 'get', 'update'],
session: ['list', 'revoke', 'delete'], session: ['list', 'revoke', 'delete'],
}), }),
user: accessControl.newRole({ user: accessControl.newRole({
@@ -44,10 +49,30 @@ export const auth = betterAuth({
trustedOrigins: [process.env.NUXT_PUBLIC_URL ?? 'http://localhost:3000'], trustedOrigins: [process.env.NUXT_PUBLIC_URL ?? 'http://localhost:3000'],
hooks: { hooks: {
// The admin endpoints already stop admins from removing or disabling
// themselves. Giving up their own admin role is the one way left to
// end up with an instance nobody can manage, so that is refused too.
before: createAuthMiddleware(async (ctx) => { before: createAuthMiddleware(async (ctx) => {
// Who may register is an instance setting admins change at runtime.
// Invite links do not exist yet, so only open instances take sign ups.
if (ctx.path === '/sign-up/email' && await getRegistrationMode() !== 'open') {
throw new APIError('FORBIDDEN', {
message: 'Registration is closed on this instance',
code: 'REGISTRATION_CLOSED',
});
}
// creating a user as an admin skips the sign up password rules
if (ctx.path === '/admin/create-user') {
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',
});
}
}
// The admin endpoints already stop admins from removing or disabling
// themselves. Giving up their own admin role is the one way left to
// end up with an instance nobody can manage, so that is refused too.
if (ctx.path !== '/admin/set-role' && ctx.path !== '/admin/update-user') return; if (ctx.path !== '/admin/set-role' && ctx.path !== '/admin/update-user') return;
const role = ctx.path === '/admin/set-role' ? ctx.body?.role : ctx.body?.data?.role; const role = ctx.path === '/admin/set-role' ? ctx.body?.role : ctx.body?.data?.role;
+4 -32
View File
@@ -1,26 +1,12 @@
import { and, eq, ne } from 'drizzle-orm'; import { and, eq, ne } from 'drizzle-orm';
import * as z from 'zod'; import * as z from 'zod';
import { import { users } from '~~/drizzle/schema';
agents,
files,
models,
providers,
settings,
toolCalls,
topics,
users,
} from '~~/drizzle/schema';
import { auth } from '~~/lib/auth'; import { auth } from '~~/lib/auth';
import { db } from '~~/server/lib/db'; import { db } from '~~/server/lib/db';
/** /**
* Deletes the signed in user's account and everything that belongs to it. * Deletes the signed in user's account and everything that belongs to it,
* * after they proved it is really them.
* 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 default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
await protectRoute(event); await protectRoute(event);
@@ -125,21 +111,7 @@ export default defineEventHandler(async (event) => {
}); });
} }
await db.transaction(async (tx) => { await deleteAccount(userId);
// 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));
});
// the session is gone, but the browser would keep trusting its cached // the session is gone, but the browser would keep trusting its cached
// copy for a few minutes unless the cookies go too // copy for a few minutes unless the cookies go too
+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 };
});
@@ -0,0 +1,40 @@
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',
});
}
await deleteAccount(id);
return { ok: true };
});
@@ -0,0 +1,34 @@
import { eq } from 'drizzle-orm';
import { twoFactors, users } from '~~/drizzle/schema';
import { db } from '~~/server/lib/db';
/**
* 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 tx.delete(twoFactors).where(eq(twoFactors.userId, id));
await tx.update(users).set({ twoFactorEnabled: false }).where(eq(users.id, id));
});
return { ok: true };
});
+39
View File
@@ -0,0 +1,39 @@
import { asc, eq, max } from 'drizzle-orm';
import { 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),
})
.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,
}));
});
+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 };
});
-4
View File
@@ -1,4 +0,0 @@
// Public on purpose: the sign in pages ask this before anyone has an account.
export default defineEventHandler(async () => {
return { needed: await isSetupNeeded() };
});
+39
View File
@@ -0,0 +1,39 @@
import { eq } from 'drizzle-orm';
import {
agents,
files,
models,
providers,
settings,
toolCalls,
topics,
users,
} from '~~/drizzle/schema';
import { db } from '~~/server/lib/db';
/**
* 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) => {
await db.transaction(async (tx) => {
// 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));
});
};
+26
View File
@@ -0,0 +1,26 @@
import type { H3Event } from 'h3';
import { db } from '~~/server/lib/db';
/**
* Like protectRoute(), but only lets admins through.
*
* The role is read from the database: the session cookie keeps a copy of the
* user for a few minutes, and someone who just stopped being an admin should
* not keep the access for that long.
*/
export const protectAdminRoute = async (event: H3Event) => {
await protectRoute(event);
const account = await db.query.users.findFirst({
where: {
id: event.context.user!.id as string,
},
});
if (account?.role !== 'admin' || account.banned) {
throw createError({
statusCode: 403,
statusMessage: 'Forbidden',
});
}
};
+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';
};