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
+7 -2
View File
@@ -14,7 +14,8 @@ if (session.value !== null) {
await navigateTo(to ?? '/');
}
const config = useRuntimeConfig();
// who may register is an instance setting, admins change it while it runs
const { data: instance } = await useFetch('/api/instance', { key: 'instance' });
const form = reactive({
email: '',
@@ -110,6 +111,10 @@ const submit = async () => {
emailInputEl.value!.setCustomValidity(res.error.data.message!);
emailInputEl.value!.reportValidity();
break;
case 'BANNED_USER':
emailInputEl.value!.setCustomValidity('This account has been disabled by an admin of this instance.');
emailInputEl.value!.reportValidity();
break;
default:
console.log(res.error);
alert(`Something went wrong. ${res.error.data.message}`);
@@ -303,7 +308,7 @@ const submitTwoFactor = async () => {
</form>
</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?
<NuxtLink :to="to ? `/auth/register?to=${to}` : '/auth/register'"
class="text-[var(--color-accent)] font-medium @hover:underline">
+7 -2
View File
@@ -12,9 +12,10 @@ if (session.value !== null) {
await navigateTo(to ?? '/');
}
const config = useRuntimeConfig();
// who may register is an instance setting, admins change it while it runs
const { data: instance } = await useFetch('/api/instance', { key: 'instance' });
if (config.public.disableSignup) {
if (instance.value && instance.value.registration !== 'open') {
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!.reportValidity();
break;
case 'REGISTRATION_CLOSED':
// an admin closed registration while this page was open
alert('Registration is closed on this instance. Ask an admin for an account.');
break;
default:
console.log(res.error);
alert('Something went wrong: ' + res.error.data.message);
+3
View File
@@ -10,6 +10,7 @@ const TITLES: Record<string, string> = {
appearance: 'Appearance',
providers: 'AI Providers',
assistants: 'System Assistants',
instance: 'Instance',
};
const title = computed(() => {
@@ -20,6 +21,8 @@ const title = computed(() => {
return provider ? provider.name : 'Unknown Provider';
}
if (section === 'instance' && id === 'users') return 'Users';
return TITLES[section ?? ''] ?? 'Settings';
});
</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>