feat(admin): add frontend admin shell, views, services, and routes

This commit is contained in:
kolaente 2026-04-20 18:58:09 +02:00 committed by kolaente
parent 23c82bd5fa
commit 7df5f127ca
11 changed files with 1222 additions and 0 deletions

View File

@ -87,6 +87,12 @@
<DropdownItem :to="{ name: 'user.settings' }">
{{ $t('user.settings.title') }}
</DropdownItem>
<DropdownItem
v-if="adminPanelEnabled && authStore.info?.isAdmin"
:to="{ name: 'admin.overview' }"
>
{{ $t('admin.title') }}
</DropdownItem>
<DropdownItem
v-if="imprintUrl"
:href="imprintUrl"
@ -150,6 +156,7 @@ const authStore = useAuthStore()
const configStore = useConfigStore()
const imprintUrl = computed(() => configStore.legal.imprintUrl)
const privacyPolicyUrl = computed(() => configStore.legal.privacyPolicyUrl)
const adminPanelEnabled = computed(() => configStore.isProFeatureEnabled('admin_panel'))
</script>
<style lang="scss" scoped>

View File

@ -73,6 +73,7 @@ import {
faTimes,
faTrashAlt,
faUser,
faUserEdit,
faUsers,
faQuoteRight,
faListUl,
@ -186,6 +187,7 @@ library.add(faTimes)
library.add(faTimesCircle)
library.add(faTrashAlt)
library.add(faUser)
library.add(faUserEdit)
library.add(faUsers)
library.add(faArrowDownShortWide)
library.add(faArrowUpFromBracket)

View File

@ -1473,5 +1473,69 @@
"months": "month|months",
"years": "year|years"
}
},
"admin": {
"title": "Administration",
"labels": {
"users": "Users",
"tasks": "Tasks"
},
"overview": {
"shares": "Shares",
"linkShares": "Link shares",
"teamShares": "Team shares",
"userShares": "User shares",
"linkSharesShort": "link",
"teamSharesShort": "team",
"userSharesShort": "user",
"version": "Version",
"license": "License",
"licenseValidUntil": "Valid until",
"licenseExpiresIn": "in {days} days",
"licenseLastVerified": "Last verified",
"licenseNever": "never",
"licenseLastCheckFailed": "last check failed",
"licenseFeatures": "Features",
"licenseInstance": "Instance ID",
"licenseManage": "Manage"
},
"searchUsersPlaceholder": "Search by username or email…",
"users": {
"status": "Status",
"details": "Details",
"detailsTitle": "User: {username}",
"issuer": "Issuer",
"issuerLocal": "Local",
"issuerUrl": "Issuer URL",
"subject": "Subject",
"statusActive": "Active",
"statusEmailConfirmation": "Email confirmation required",
"statusDisabled": "Disabled",
"statusLocked": "Account locked",
"isAdminLabel": "Administrator",
"addUser": "Add user",
"createTitle": "Create user",
"nameLabel": "Name",
"skipEmailConfirm": "Skip email confirmation",
"createSubmit": "Create user",
"saveButton": "Save changes",
"createdSuccess": "User {username} created.",
"updatedSuccess": "User {username} updated.",
"deletedSuccess": "User {username} deleted.",
"deleteScheduledSuccess": "User {username} will receive a confirmation email to schedule the deletion.",
"confirmDeleteTitle": "Delete user?",
"confirmDeleteIntro": "How should user {username} be deleted?",
"deleteModeScheduled": "Schedule deletion",
"deleteModeScheduledHelp": "Schedule deletion sends the user a confirmation email, mirroring a self-initiated account deletion.",
"deleteModeNow": "Delete now",
"deleteModeNowHelp": "Delete now removes the user and all their data immediately. This cannot be undone."
},
"projects": {
"ownerLabel": "Owner",
"reassignOwner": "Reassign owner",
"reassignTitle": "Reassign {title}",
"reassignedSuccess": "Project owner reassigned.",
"newOwnerLabel": "New owner"
}
}
}

View File

@ -9,6 +9,8 @@ import {LINK_SHARE_HASH_PREFIX} from '@/constants/linkShareHash'
import {AUTH_ROUTE_NAMES} from '@/constants/authRouteNames'
import {useAuthStore} from '@/stores/auth'
import {useBaseStore} from '@/stores/base'
import {useConfigStore} from '@/stores/config'
import Login from '@/views/user/Login.vue'
import Register from '@/views/user/Register.vue'
@ -421,6 +423,31 @@ const router = createRouter({
name: 'about',
component: () => import('@/views/About.vue'),
},
{
path: '/admin',
component: () => import('@/views/admin/AdminShell.vue'),
meta: {
requiresAdminPanel: true,
adminMode: true,
},
children: [
{
path: '',
name: 'admin.overview',
component: () => import('@/views/admin/OverviewView.vue'),
},
{
path: 'users',
name: 'admin.users',
component: () => import('@/views/admin/UsersView.vue'),
},
{
path: 'projects',
name: 'admin.projects',
component: () => import('@/views/admin/ProjectsView.vue'),
},
],
},
],
})
@ -475,6 +502,24 @@ router.beforeEach(async (to, from) => {
await authStore.checkAuth()
if (to.meta?.requiresAdminPanel) {
// Await config/auth hydration so the license check doesn't race the empty default
// on direct /admin navigation. appReady resolves without waiting on router.isReady(),
// so awaiting it here doesn't deadlock the initial navigation.
const baseStore = useBaseStore()
await baseStore.appReady
const configStore = useConfigStore()
const featureOn = configStore.isProFeatureEnabled('admin_panel')
// isAdmin comes from /user, not the JWT; force-fetch in case checkAuth() was debounced.
if (authStore.info?.isAdmin === undefined) {
await authStore.refreshUserInfo()
}
const isAdmin = authStore.info?.isAdmin === true
if (!featureOn || !isAdmin) {
return {name: 'not-found'}
}
}
if(from.hash && from.hash.startsWith(LINK_SHARE_HASH_PREFIX)) {
to.hash = from.hash
}

View File

@ -0,0 +1,14 @@
import AbstractService from '@/services/abstractService'
import AdminOverviewModel from '@/models/adminOverview'
import type {IAdminOverview} from '@/modelTypes/IAdminOverview'
export default class AdminOverviewService extends AbstractService<IAdminOverview> {
modelFactory(data: Partial<IAdminOverview>) {
return new AdminOverviewModel(data)
}
async getOverview() {
const {data} = await this.http.get('/admin/overview')
return this.modelGetFactory(data)
}
}

View File

@ -0,0 +1,20 @@
import AbstractService from '@/services/abstractService'
import ProjectModel from '@/models/project'
import type {IProject} from '@/modelTypes/IProject'
export default class AdminProjectService extends AbstractService<IProject> {
constructor() {
super({
getAll: '/admin/projects',
})
}
modelFactory(data: Partial<IProject>) {
return new ProjectModel(data)
}
async reassignOwner(projectId: IProject['id'], newOwnerId: IProject['owner']['id']) {
const {data} = await this.http.patch(`/admin/projects/${projectId}/owner`, {owner_id: newOwnerId})
return this.modelUpdateFactory(data)
}
}

View File

@ -0,0 +1,46 @@
import AbstractService from '@/services/abstractService'
import AdminUserModel from '@/models/adminUser'
import type {IAdminUser} from '@/modelTypes/IAdminUser'
export interface CreateAdminUserBody {
username: string
email: string
password: string
name?: string
language?: string
isAdmin?: boolean
skipEmailConfirm?: boolean
}
export type DeleteUserMode = 'now' | 'scheduled'
export default class AdminUserService extends AbstractService<IAdminUser> {
constructor() {
super({
getAll: '/admin/users',
})
}
modelFactory(data: Partial<IAdminUser>) {
return new AdminUserModel(data)
}
async setAdmin(id: IAdminUser['id'], isAdmin: boolean) {
const {data} = await this.http.patch(`/admin/users/${id}/admin`, {is_admin: isAdmin})
return this.modelUpdateFactory(data)
}
async setStatus(id: IAdminUser['id'], status: number) {
const {data} = await this.http.patch(`/admin/users/${id}/status`, {status})
return this.modelUpdateFactory(data)
}
async createUser(body: CreateAdminUserBody) {
const {data} = await this.http.post('/admin/users', body)
return this.modelCreateFactory(data)
}
async deleteUser(id: IAdminUser['id'], mode: DeleteUserMode) {
await this.http.delete(`/admin/users/${id}`, {params: {mode}})
}
}

View File

@ -0,0 +1,32 @@
<template>
<SideNavShell
:navigation-items="navigationItems"
exact
/>
</template>
<script setup lang="ts">
import {computed} from 'vue'
import {useI18n} from 'vue-i18n'
import {useTitle} from '@/composables/useTitle'
import SideNavShell from '@/components/misc/SideNavShell.vue'
const {t} = useI18n({useScope: 'global'})
useTitle(() => t('admin.title'))
const navigationItems = computed(() => [
{
title: t('navigation.overview'),
routeName: 'admin.overview',
},
{
title: t('admin.labels.users'),
routeName: 'admin.users',
},
{
title: t('project.projects'),
routeName: 'admin.projects',
},
])
</script>

View File

@ -0,0 +1,264 @@
<template>
<Card :title="$t('navigation.overview')">
<div class="admin-overview">
<p v-if="loading">
{{ $t('misc.loading') }}
</p>
<div
v-else-if="data"
class="admin-overview__grid"
>
<div class="admin-overview__card">
<h2 class="admin-overview__card-title">
{{ $t('admin.labels.users') }}
</h2>
<p class="admin-overview__card-value">
{{ data.users }}
</p>
</div>
<div class="admin-overview__card">
<h2 class="admin-overview__card-title">
{{ $t('project.projects') }}
</h2>
<p class="admin-overview__card-value">
{{ data.projects }}
</p>
</div>
<div class="admin-overview__card">
<h2 class="admin-overview__card-title">
{{ $t('admin.labels.tasks') }}
</h2>
<p class="admin-overview__card-value">
{{ data.tasks }}
</p>
</div>
<div class="admin-overview__card">
<h2 class="admin-overview__card-title">
{{ $t('team.title') }}
</h2>
<p class="admin-overview__card-value">
{{ data.teams }}
</p>
</div>
<div class="admin-overview__card">
<h2 class="admin-overview__card-title">
{{ $t('admin.overview.shares') }}
</h2>
<p class="admin-overview__card-value">
{{ totalShares }}
</p>
<p class="admin-overview__hint admin-overview__shares-breakdown">
{{ data.shares.linkShares }} {{ $t('admin.overview.linkSharesShort') }}
<span aria-hidden="true">·</span>
{{ data.shares.teamShares }} {{ $t('admin.overview.teamSharesShort') }}
<span aria-hidden="true">·</span>
{{ data.shares.userShares }} {{ $t('admin.overview.userSharesShort') }}
</p>
</div>
<div class="admin-overview__card admin-overview__card--version">
<h2 class="admin-overview__card-title">
{{ $t('admin.overview.version') }}
</h2>
<p class="admin-overview__card-value admin-overview__card-value--version">
{{ configStore.version }}
</p>
</div>
<div class="admin-overview__card admin-overview__card--wide">
<h2 class="admin-overview__card-title">
{{ $t('admin.overview.license') }}
</h2>
<dl class="admin-overview__kv">
<dt>{{ $t('admin.overview.licenseValidUntil') }}</dt>
<dd>
<TimeDisplay :date="data.license.expiresAt" />
<span
v-if="expiresInDays !== null"
class="admin-overview__hint"
>
({{ $t('admin.overview.licenseExpiresIn', {days: expiresInDays}) }})
</span>
</dd>
<dt>{{ $t('admin.overview.licenseLastVerified') }}</dt>
<dd>
<TimeDisplay
:date="data.license.validatedAt"
mode="relative"
:fallback="$t('admin.overview.licenseNever')"
/>
<span
v-if="data.license.lastCheckFailed"
class="has-text-danger admin-overview__hint"
>
({{ $t('admin.overview.licenseLastCheckFailed') }})
</span>
</dd>
<template v-if="data.license.features.length">
<dt>{{ $t('admin.overview.licenseFeatures') }}</dt>
<dd>{{ data.license.features.join(', ') }}</dd>
</template>
<template v-if="data.license.instanceId">
<dt>{{ $t('admin.overview.licenseInstance') }}</dt>
<dd><code>{{ data.license.instanceId }}</code></dd>
</template>
</dl>
<p class="admin-overview__card-action">
<a
href="https://console.vikunja.io"
target="_blank"
rel="noopener noreferrer"
>
{{ $t('admin.overview.licenseManage') }}
<Icon
icon="arrow-up-right-from-square"
class="admin-overview__external-icon"
/>
</a>
</p>
</div>
</div>
</div>
</Card>
</template>
<script setup lang="ts">
import {ref, computed, onMounted} from 'vue'
import dayjs from 'dayjs'
import Card from '@/components/misc/Card.vue'
import Icon from '@/components/misc/Icon'
import TimeDisplay from '@/components/misc/TimeDisplay.vue'
import AdminOverviewService from '@/services/admin/overviewService'
import type {IAdminOverview} from '@/modelTypes/IAdminOverview'
import {useConfigStore} from '@/stores/config'
import {error} from '@/message'
const adminOverviewService = new AdminOverviewService()
const configStore = useConfigStore()
const data = ref<IAdminOverview | null>(null)
const loading = ref(false)
const expiresInDays = computed<number | null>(() => {
const expiresAt = data.value?.license?.expiresAt
if (!expiresAt) return null
return Math.max(0, dayjs(expiresAt).diff(dayjs(), 'day'))
})
const totalShares = computed<number>(() => {
const shares = data.value?.shares
if (!shares) return 0
return shares.linkShares + shares.teamShares + shares.userShares
})
onMounted(async () => {
loading.value = true
try {
data.value = await adminOverviewService.getOverview()
} catch (e) {
error(e)
} finally {
loading.value = false
}
})
</script>
<style lang="scss" scoped>
.admin-overview__grid {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
}
@media screen and (min-width: $tablet) {
.admin-overview__grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media screen and (min-width: $desktop) {
.admin-overview__grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.admin-overview__card {
position: relative;
background: var(--white);
border: 1px solid var(--grey-200);
border-radius: 6px;
padding: 1.25rem;
}
.admin-overview__card:not(.admin-overview__card--wide) {
min-block-size: 7.5rem;
}
.admin-overview__card--version {
container-type: inline-size;
}
.admin-overview__card-title {
font-size: 0.85rem;
color: var(--grey-600);
text-transform: uppercase;
letter-spacing: 0.03em;
margin-block-end: 0.5rem;
}
.admin-overview__card-value {
font-size: 1.75rem;
font-weight: 600;
}
.admin-overview__card-value--version {
font-size: clamp(0.9rem, 8cqi, 1.75rem);
word-break: break-all;
overflow-wrap: anywhere;
}
.admin-overview__card--wide {
grid-column: 1 / -1;
}
.admin-overview__kv {
display: grid;
grid-template-columns: max-content 1fr;
column-gap: 1rem;
row-gap: 0.25rem;
margin-block-start: 1rem;
font-size: 0.9rem;
dt {
font-weight: 600;
color: var(--grey-700);
}
dd {
margin: 0;
}
}
.admin-overview__hint {
color: var(--grey-600);
margin-inline-start: 0.25rem;
}
.admin-overview__card-action {
margin-block-start: 1rem;
}
.admin-overview__shares-breakdown {
position: absolute;
inset-block-end: 1.25rem;
inset-inline: 1.25rem;
margin: 0;
font-size: 0.75rem;
color: var(--grey-500);
text-align: end;
}
.admin-overview__external-icon {
margin-inline-start: 0.35em;
font-size: 0.85em;
opacity: 0.7;
}
</style>

View File

@ -0,0 +1,207 @@
<template>
<Card>
<div class="admin-projects">
<p v-if="loading">
{{ $t('misc.loading') }}
</p>
<template v-else>
<table class="table has-actions is-striped is-hoverable is-fullwidth">
<thead>
<tr>
<th>{{ $t('misc.id') }}</th>
<th>{{ $t('project.title') }}</th>
<th>{{ $t('admin.projects.ownerLabel') }}</th>
<th>{{ $t('task.attributes.created') }}</th>
<th>{{ $t('task.attributes.updated') }}</th>
<th>{{ $t('navigation.settings') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="p in projects"
:key="p.id"
>
<td>{{ p.id }}</td>
<td>{{ p.title }}</td>
<td>{{ p.owner?.username ?? p.owner?.id }}</td>
<td>
<TimeDisplay :date="p.created" />
</td>
<td>
<TimeDisplay :date="p.updated" />
</td>
<td class="actions">
<ProjectSettingsDropdown
:project="p"
:force-all-actions="true"
>
<template #before-delete>
<DropdownItem
icon="user-edit"
@click="openReassign(p)"
>
{{ $t('admin.projects.reassignOwner') }}
</DropdownItem>
</template>
</ProjectSettingsDropdown>
</td>
</tr>
</tbody>
</table>
<PaginationEmit
v-if="totalPages > 1"
:total-pages="totalPages"
:current-page="currentPage"
@pageChanged="goToPage"
/>
</template>
<Modal
v-if="reassignTarget"
variant="hint-modal"
@close="reassignTarget = null"
>
<Card
class="has-no-shadow"
:title="$t('admin.projects.reassignTitle', {title: reassignTarget.title})"
>
<FormField :label="$t('admin.projects.newOwnerLabel')">
<Multiselect
v-model="selectedUser"
:loading="userSearchLoading"
:placeholder="$t('admin.searchUsersPlaceholder')"
:search-results="userResults"
label="username"
@search="searchUsers"
>
<template #searchResult="{option}">
<User
v-if="typeof option !== 'string'"
:avatar-size="24"
:show-username="true"
:user="option"
/>
</template>
</Multiselect>
</FormField>
<template #footer>
<XButton
variant="tertiary"
@click="reassignTarget = null"
>
{{ $t('misc.cancel') }}
</XButton>
<XButton
variant="primary"
:disabled="!selectedUser"
@click="doReassign()"
>
{{ $t('admin.projects.reassignOwner') }}
</XButton>
</template>
</Card>
</Modal>
</div>
</Card>
</template>
<script setup lang="ts">
import {ref, onMounted} from 'vue'
import type {IProject} from '@/modelTypes/IProject'
import type {IAdminUser} from '@/modelTypes/IAdminUser'
import AdminProjectService from '@/services/admin/projectService'
import AdminUserService from '@/services/admin/userService'
import AdminUserModel from '@/models/adminUser'
import ProjectModel from '@/models/project'
import Card from '@/components/misc/Card.vue'
import Modal from '@/components/misc/Modal.vue'
import PaginationEmit from '@/components/misc/PaginationEmit.vue'
import XButton from '@/components/input/Button.vue'
import FormField from '@/components/input/FormField.vue'
import Multiselect from '@/components/input/Multiselect.vue'
import User from '@/components/misc/User.vue'
import ProjectSettingsDropdown from '@/components/project/ProjectSettingsDropdown.vue'
import DropdownItem from '@/components/misc/DropdownItem.vue'
import TimeDisplay from '@/components/misc/TimeDisplay.vue'
import {error, success} from '@/message'
import {useI18n} from 'vue-i18n'
const {t} = useI18n({useScope: 'global'})
const adminProjectService = new AdminProjectService()
const adminUserService = new AdminUserService()
const projects = ref<IProject[]>([])
const loading = ref(false)
const currentPage = ref(1)
const totalPages = ref(1)
const reassignTarget = ref<IProject | null>(null)
const userResults = ref<IAdminUser[]>([])
const userSearchLoading = ref(false)
const selectedUser = ref<IAdminUser | null>(null)
async function load() {
loading.value = true
try {
projects.value = await adminProjectService.getAll(new ProjectModel(), {}, currentPage.value)
totalPages.value = adminProjectService.totalPages || 1
} catch (e) {
error(e)
} finally {
loading.value = false
}
}
function goToPage(page: number) {
currentPage.value = page
load()
}
function openReassign(p: IProject) {
reassignTarget.value = p
userResults.value = []
selectedUser.value = null
}
async function searchUsers(query: string) {
if (!query || query.length < 2) {
userResults.value = []
return
}
userSearchLoading.value = true
try {
userResults.value = await adminUserService.getAll(new AdminUserModel(), {s: query})
} catch (e) {
error(e)
} finally {
userSearchLoading.value = false
}
}
async function doReassign() {
if (!reassignTarget.value || !selectedUser.value) return
const target = reassignTarget.value
const newOwnerId = selectedUser.value.id
reassignTarget.value = null
try {
const updated = await adminProjectService.reassignOwner(target.id, newOwnerId)
const idx = projects.value.findIndex(x => x.id === target.id)
if (idx !== -1) projects.value[idx] = updated
success({message: t('admin.projects.reassignedSuccess')})
} catch (e) {
error(e)
}
}
onMounted(load)
</script>
<style lang="scss" scoped>
// `.table.has-actions` sets overflow: hidden which clips the dropdown menu.
.admin-projects :deep(.table.has-actions) {
overflow: visible;
}
</style>

View File

@ -0,0 +1,521 @@
<template>
<Card>
<div class="admin-users">
<div class="admin-users__toolbar">
<FormInput
v-model="searchTerm"
type="text"
:placeholder="$t('admin.searchUsersPlaceholder')"
@input="onSearch"
/>
<XButton
variant="primary"
@click="openCreate"
>
{{ $t('admin.users.addUser') }}
</XButton>
</div>
<p v-if="loading">
{{ $t('misc.loading') }}
</p>
<template v-else>
<table class="table has-actions is-striped is-hoverable is-fullwidth">
<thead>
<tr>
<th>{{ $t('misc.id') }}</th>
<th>{{ $t('user.auth.username') }}</th>
<th>{{ $t('user.auth.email') }}</th>
<th>{{ $t('admin.users.issuer') }}</th>
<th>{{ $t('admin.users.status') }}</th>
<th>{{ $t('task.attributes.created') }}</th>
<th />
</tr>
</thead>
<tbody>
<tr
v-for="u in users"
:key="u.id"
>
<td>{{ u.id }}</td>
<td>{{ u.username }}</td>
<td>{{ u.email }}</td>
<td>{{ u.authProvider || $t('admin.users.issuerLocal') }}</td>
<td>{{ statusLabel(u.status) }}</td>
<td>
<TimeDisplay :date="u.created" />
</td>
<td class="actions">
<XButton
variant="secondary"
@click="openDetails(u)"
>
{{ $t('admin.users.details') }}
</XButton>
</td>
</tr>
</tbody>
</table>
<PaginationEmit
v-if="totalPages > 1"
:total-pages="totalPages"
:current-page="currentPage"
@pageChanged="goToPage"
/>
</template>
<Modal
v-if="detailTarget && !pendingDelete"
variant="hint-modal"
@close="closeDetail"
>
<Card
class="has-no-shadow"
:title="$t('admin.users.detailsTitle', {username: detailTarget.username})"
>
<dl class="admin-users__meta">
<dt>{{ $t('misc.id') }}</dt>
<dd>{{ detailTarget.id }}</dd>
<dt>{{ $t('user.auth.email') }}</dt>
<dd>{{ detailTarget.email }}</dd>
<dt>{{ $t('admin.users.issuer') }}</dt>
<dd>
{{ detailTarget.authProvider || $t('admin.users.issuerLocal') }}
</dd>
<template v-if="detailTarget.issuer?.startsWith('http')">
<dt>{{ $t('admin.users.issuerUrl') }}</dt>
<dd class="admin-users__issuer-url-value">
{{ detailTarget.issuer }}
</dd>
</template>
<template v-if="detailTarget.subject">
<dt>{{ $t('admin.users.subject') }}</dt>
<dd class="admin-users__subject">
{{ detailTarget.subject }}
</dd>
</template>
<dt>{{ $t('task.attributes.created') }}</dt>
<dd>
<TimeDisplay :date="detailTarget.created" />
</dd>
<dt>{{ $t('task.attributes.updated') }}</dt>
<dd>
<TimeDisplay :date="detailTarget.updated" />
</dd>
</dl>
<FormCheckbox
v-model="editable.isAdmin"
:label="$t('admin.users.isAdminLabel')"
/>
<FormField :label="$t('admin.users.status')">
<template #default="{id}">
<FormSelect
:id="id"
v-model.number="editable.status"
:options="statusOptions"
/>
</template>
</FormField>
<template #footer>
<XButton
variant="tertiary"
@click="closeDetail"
>
{{ $t('misc.cancel') }}
</XButton>
<XButton
v-if="detailTarget.id !== currentUserId"
variant="secondary"
:danger="true"
@click="pendingDelete = detailTarget"
>
{{ $t('misc.delete') }}
</XButton>
<XButton
variant="primary"
:disabled="!hasChanges || saving"
:loading="saving"
@click="saveChanges"
>
{{ $t('admin.users.saveButton') }}
</XButton>
</template>
</Card>
</Modal>
<Modal
v-if="createOpen"
variant="hint-modal"
@close="closeCreate"
>
<Card
class="has-no-shadow"
:title="$t('admin.users.createTitle')"
>
<FormField :label="$t('user.auth.username')">
<template #default="{id}">
<FormInput
:id="id"
v-model="createForm.username"
type="text"
required
/>
</template>
</FormField>
<FormField :label="$t('user.auth.email')">
<template #default="{id}">
<FormInput
:id="id"
v-model="createForm.email"
type="email"
required
/>
</template>
</FormField>
<FormField :label="$t('admin.users.nameLabel')">
<template #default="{id}">
<FormInput
:id="id"
v-model="createForm.name"
type="text"
/>
</template>
</FormField>
<FormField :label="$t('user.auth.password')">
<template #default="{id}">
<FormInput
:id="id"
v-model="createForm.password"
type="password"
autocomplete="new-password"
required
/>
</template>
</FormField>
<FormField :label="$t('user.settings.general.language')">
<template #default="{id}">
<FormInput
:id="id"
v-model="createForm.language"
type="text"
/>
</template>
</FormField>
<FormCheckbox
v-model="createForm.isAdmin"
:label="$t('admin.users.isAdminLabel')"
/>
<FormCheckbox
v-model="createForm.skipEmailConfirm"
:label="$t('admin.users.skipEmailConfirm')"
/>
<template #footer>
<XButton
variant="tertiary"
@click="closeCreate"
>
{{ $t('misc.cancel') }}
</XButton>
<XButton
variant="primary"
:disabled="creating || !createForm.username || !createForm.email || !createForm.password"
:loading="creating"
@click="submitCreate"
>
{{ $t('admin.users.createSubmit') }}
</XButton>
</template>
</Card>
</Modal>
<Modal
v-if="pendingDelete"
variant="hint-modal"
@close="cancelDelete"
>
<Card
class="has-no-shadow"
:title="$t('admin.users.confirmDeleteTitle')"
>
<p>{{ $t('admin.users.confirmDeleteIntro', {username: pendingDelete.username}) }}</p>
<p>{{ $t('admin.users.deleteModeScheduledHelp') }}</p>
<p>{{ $t('admin.users.deleteModeNowHelp') }}</p>
<template #footer>
<XButton
variant="tertiary"
@click="cancelDelete"
>
{{ $t('misc.cancel') }}
</XButton>
<XButton
variant="secondary"
:loading="deleting && deleteMode === 'scheduled'"
:disabled="deleting"
@click="doDelete('scheduled')"
>
{{ $t('admin.users.deleteModeScheduled') }}
</XButton>
<XButton
variant="primary"
:danger="true"
:loading="deleting && deleteMode === 'now'"
:disabled="deleting"
@click="doDelete('now')"
>
{{ $t('admin.users.deleteModeNow') }}
</XButton>
</template>
</Card>
</Modal>
</div>
</Card>
</template>
<script setup lang="ts">
import {ref, computed, onMounted, reactive, watch} from 'vue'
import {useDebounceFn} from '@vueuse/core'
import {useI18n} from 'vue-i18n'
import {useAuthStore} from '@/stores/auth'
import AdminUserService, {type CreateAdminUserBody, type DeleteUserMode} from '@/services/admin/userService'
import AdminUserModel from '@/models/adminUser'
import type {IAdminUser} from '@/modelTypes/IAdminUser'
import {error, success} from '@/message'
import Card from '@/components/misc/Card.vue'
import Modal from '@/components/misc/Modal.vue'
import PaginationEmit from '@/components/misc/PaginationEmit.vue'
import XButton from '@/components/input/Button.vue'
import FormField from '@/components/input/FormField.vue'
import FormInput from '@/components/input/FormInput.vue'
import FormSelect from '@/components/input/FormSelect.vue'
import FormCheckbox from '@/components/input/FormCheckbox.vue'
import TimeDisplay from '@/components/misc/TimeDisplay.vue'
const {t} = useI18n({useScope: 'global'})
const authStore = useAuthStore()
const currentUserId = computed(() => authStore.info?.id)
const adminUserService = new AdminUserService()
const users = ref<IAdminUser[]>([])
const loading = ref(false)
const searchTerm = ref('')
const currentPage = ref(1)
const totalPages = ref(1)
const detailTarget = ref<IAdminUser | null>(null)
const pendingDelete = ref<IAdminUser | null>(null)
const saving = ref(false)
const deleting = ref(false)
const deleteMode = ref<DeleteUserMode | null>(null)
const createOpen = ref(false)
const creating = ref(false)
const editable = reactive({isAdmin: false, status: 0})
function emptyCreateForm(): Required<Pick<CreateAdminUserBody, 'username' | 'email'>> & CreateAdminUserBody {
return {
username: '',
email: '',
name: '',
password: '',
language: '',
isAdmin: false,
skipEmailConfirm: false,
}
}
const createForm = reactive(emptyCreateForm())
const hasChanges = computed(() => {
if (!detailTarget.value) return false
return editable.isAdmin !== !!detailTarget.value.isAdmin
|| editable.status !== detailTarget.value.status
})
watch(detailTarget, (u) => {
if (!u) return
editable.isAdmin = !!u.isAdmin
editable.status = u.status
})
function statusLabel(status: number): string {
switch (status) {
case 0: return t('admin.users.statusActive')
case 1: return t('admin.users.statusEmailConfirmation')
case 2: return t('admin.users.statusDisabled')
case 3: return t('admin.users.statusLocked')
default: return String(status)
}
}
const statusOptions = computed(() => [
{value: 0, label: t('admin.users.statusActive')},
{value: 1, label: t('admin.users.statusEmailConfirmation')},
{value: 2, label: t('admin.users.statusDisabled')},
{value: 3, label: t('admin.users.statusLocked')},
])
async function load() {
loading.value = true
try {
const params = searchTerm.value ? {s: searchTerm.value} : {}
users.value = await adminUserService.getAll(new AdminUserModel(), params, currentPage.value)
totalPages.value = adminUserService.totalPages || 1
} catch (e) {
error(e)
} finally {
loading.value = false
}
}
function goToPage(page: number) {
currentPage.value = page
load()
}
const onSearch = useDebounceFn(() => {
// Reset to page 1 so a narrower search doesn't strand the UI on an empty page.
currentPage.value = 1
load()
}, 300)
function openDetails(u: IAdminUser) {
detailTarget.value = u
}
function closeDetail() {
detailTarget.value = null
}
function openCreate() {
Object.assign(createForm, emptyCreateForm())
createOpen.value = true
}
function closeCreate() {
createOpen.value = false
}
async function submitCreate() {
creating.value = true
try {
const body: CreateAdminUserBody = {
username: createForm.username,
email: createForm.email,
password: createForm.password,
}
if (createForm.name) body.name = createForm.name
if (createForm.language) body.language = createForm.language
if (createForm.isAdmin) body.isAdmin = true
if (createForm.skipEmailConfirm) body.skipEmailConfirm = true
const created = await adminUserService.createUser(body)
users.value = [created, ...users.value]
success({message: t('admin.users.createdSuccess', {username: created.username})})
createOpen.value = false
} catch (e) {
error(e)
} finally {
creating.value = false
}
}
function replaceUser(updated: IAdminUser) {
const idx = users.value.findIndex(x => x.id === updated.id)
if (idx !== -1) users.value[idx] = updated
}
async function saveChanges() {
if (!detailTarget.value) return
const target = detailTarget.value
saving.value = true
try {
let latest: IAdminUser = target
if (editable.isAdmin !== !!target.isAdmin) {
latest = await adminUserService.setAdmin(target.id, editable.isAdmin)
}
if (editable.status !== target.status) {
latest = await adminUserService.setStatus(target.id, editable.status)
}
replaceUser(latest)
success({message: t('admin.users.updatedSuccess', {username: latest.username})})
detailTarget.value = null
} catch (e) {
error(e)
} finally {
saving.value = false
}
}
function cancelDelete() {
if (deleting.value) return
pendingDelete.value = null
deleteMode.value = null
}
async function doDelete(mode: DeleteUserMode) {
if (!pendingDelete.value || deleting.value) return
const target = pendingDelete.value
deleting.value = true
deleteMode.value = mode
try {
await adminUserService.deleteUser(target.id, mode)
if (mode === 'now') {
users.value = users.value.filter(x => x.id !== target.id)
success({message: t('admin.users.deletedSuccess', {username: target.username})})
} else {
success({message: t('admin.users.deleteScheduledSuccess', {username: target.username})})
}
pendingDelete.value = null
detailTarget.value = null
} catch (e) {
error(e)
} finally {
deleting.value = false
deleteMode.value = null
}
}
onMounted(load)
</script>
<style lang="scss" scoped>
.admin-users__toolbar {
display: flex;
gap: 0.5rem;
margin-block-end: 1rem;
}
.admin-users__meta {
display: grid;
grid-template-columns: auto 1fr;
column-gap: 1rem;
row-gap: 0.25rem;
margin-block-end: 1rem;
dt {
font-weight: 600;
color: var(--grey-700);
}
dd {
margin: 0;
}
}
.admin-users__issuer-url {
margin-inline-start: 0.35rem;
color: var(--grey-600);
font-size: 0.85rem;
word-break: break-all;
}
.admin-users__issuer-url-value,
.admin-users__subject {
font-family: monospace;
font-size: 0.85rem;
word-break: break-all;
}
</style>