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 TwoFactor from './TwoFactor.vue';
|
||||
import DeleteAccount from './DeleteAccount.vue';
|
||||
import Confirm from './Confirm.vue';
|
||||
import CreateUser from './CreateUser.vue';
|
||||
|
||||
import { DialogType } from '~/composables/useDialog';
|
||||
|
||||
@@ -69,6 +71,8 @@ onUnmounted(() => {
|
||||
<TwoFactor v-else-if="page === DialogType.TwoFactor" :action="data.action" @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>
|
||||
</Transition>
|
||||
|
||||
@@ -3,10 +3,21 @@ import { providerIcons } from '~/utils/model-mapping';
|
||||
|
||||
const route = useRoute();
|
||||
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',
|
||||
adminOnly: false,
|
||||
items: [
|
||||
{ to: '/settings/profile', name: 'Profile', icon: 'i-mynaui-user' },
|
||||
{ to: '/settings/security', name: 'Security', icon: 'i-mynaui-shield' },
|
||||
@@ -14,6 +25,7 @@ const groups = [
|
||||
},
|
||||
{
|
||||
label: 'App',
|
||||
adminOnly: false,
|
||||
items: [
|
||||
{ to: '/settings/general', name: 'General', icon: 'i-mynaui-cog-four' },
|
||||
{ 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'));
|
||||
|
||||
// enabled providers first, same order the provider list uses
|
||||
@@ -35,8 +49,13 @@ const sortedProviders = computed(() => [
|
||||
<template>
|
||||
<nav aria-label="Settings" class="flex flex-col gap-1 overflow-auto">
|
||||
<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 }}
|
||||
<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>
|
||||
|
||||
<template v-for="item in group.items" :key="item.to">
|
||||
|
||||
Reference in New Issue
Block a user