feat: basic admin interface
This commit is contained in:
@@ -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>
|
||||||
@@ -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,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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
@@ -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(),
|
||||||
|
|||||||
+27
-2
@@ -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: {
|
||||||
|
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
|
// The admin endpoints already stop admins from removing or disabling
|
||||||
// themselves. Giving up their own admin role is the one way left to
|
// 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.
|
// end up with an instance nobody can manage, so that is refused too.
|
||||||
before: createAuthMiddleware(async (ctx) => {
|
|
||||||
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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
}));
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
});
|
||||||
@@ -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() };
|
|
||||||
});
|
|
||||||
@@ -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));
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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';
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user