386 lines
18 KiB
Vue
386 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. 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;
|
|
});
|
|
|
|
// The dialog makes the link and shows it, nothing in the list changes. For an
|
|
// account that has no password yet it is a new setup link instead.
|
|
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>
|
|
|
|
<!-- 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 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)]">
|
|
Admins manage accounts, not content: chats, agents, files and API keys of other users are never
|
|
shown here. Invited accounts have no password until their owner opens the setup link. 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>
|