feat: basic admin interface
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user