Files
veridian/app/pages/settings/instance/users.vue
T
2026-10-15 10:22:06 -05:00

384 lines
18 KiB
Vue

<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>