feat(admin): add frontend admin shell, views, services, and routes
This commit is contained in:
parent
23c82bd5fa
commit
7df5f127ca
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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}})
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
@ -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>
|
||||
Loading…
Reference in New Issue