More things

This commit is contained in:
2026-10-01 02:29:21 +02:00
parent e1df4d3ae1
commit 13b659662e
54 changed files with 4409 additions and 911 deletions

View File

@@ -0,0 +1,274 @@
<script setup lang="ts">
/* The wiki's back office: every account it holds, in one list, together with
what each of them may do. The sidebar offers the door to an admin alone;
whoever else reaches this address is turned away before anything is asked —
a member sees the same refusal whether or not the list would have answered.
The changes members propose are kept on their own page beside this one. */
import type { AuthRole } from '~/composables/useAuth'
export interface AdminAccount {
id: string
username: string
avatar: string
role: AuthRole
createdAt: string
}
/* matches the home page's transition so the two surfaces slide past
each other the way the reader does (see main.scss) */
definePageMeta({ transition: { name: 'page', mode: 'out-in' } })
const api = `${useRuntimeConfig().public.apiBase}/api`
const { user, loggedIn, isAdmin, authHeaders, signOut } = useAuth()
const { show: openSignIn } = useSignInPopup()
const { t, locale } = useI18n()
/* What the last request could not do, in the words to show for it. '' when
there is nothing to report: a dead session is signed out of instead of
explained, the same way the profile popup handles one. Shared state rather
than a ref, so the first draw in the browser agrees with the one the server
already wrote out. */
const failure = useState('admin:failure', () => '')
function describeFailure(err: any): string {
const code = err?.statusCode ?? err?.response?.status
if (code === 401) {
signOut()
if (import.meta.client) openSignIn({ notice: t('admin.sessionExpired') })
return ''
}
return err?.data?.message || t('common.apiUnreachable')
}
/* Only an admin's session is answered here, so nothing is asked until the
session is known to be one — signing in over this page brings the list with
it, which is what the watch below is for. */
const {
data: accounts,
status,
refresh,
} = await useAsyncData(
'admin-accounts',
async () => {
failure.value = ''
if (!isAdmin.value) return []
try {
const { accounts: list } = await $fetch<{ accounts: AdminAccount[] }>(
`${api}/admin/users`,
{ headers: authHeaders() }
)
return list
} catch (err: any) {
failure.value = describeFailure(err)
return []
}
},
{ default: () => [] }
)
watch(isAdmin, (admin) => {
if (admin) void refresh()
})
const loading = computed(() => status.value === 'pending')
const adminCount = computed(() => accounts.value.filter((a) => a.role === 'admin').length)
/* --- handing the role on, taking it back, closing an account ---
One change at a time: while a request is in flight the menu waits, so two
clicks cannot disagree about what an account is. */
const changingId = ref('')
const removing = ref(false)
const actionError = ref('')
async function setRole(account: AdminAccount, role: AuthRole) {
if (changingId.value || removing.value) return
actionError.value = ''
changingId.value = account.id
try {
const { account: updated } = await $fetch<{ account: AdminAccount }>(
`${api}/admin/users/${account.id}/role`,
{ method: 'PUT', headers: authHeaders(), body: { role } }
)
/* the whole list is written again rather than the one row changed in
place: useAsyncData keeps it shallow, so a row would not notice */
accounts.value = accounts.value.map((a) => (a.id === updated.id ? updated : a))
} catch (err: any) {
actionError.value = describeFailure(err) || t('admin.roleChangeFailed')
} finally {
changingId.value = ''
}
}
/* Deleting is the one thing on this page that does not come back, so the menu
hands it to the yes/no popup first: the account awaiting an answer, null
when nothing is. */
const removalTarget = ref<AdminAccount | null>(null)
async function removeAccount() {
const account = removalTarget.value
if (!account || removing.value || changingId.value) return
actionError.value = ''
removing.value = true
try {
await $fetch(`${api}/admin/users/${account.id}`, {
method: 'DELETE',
headers: authHeaders()
})
accounts.value = accounts.value.filter((a) => a.id !== account.id)
removalTarget.value = null
} catch (err: any) {
actionError.value = describeFailure(err) || t('admin.deleteFailed')
} finally {
removing.value = false
}
}
/* esc answers the yes/no popup the way its Cancel button does: the dashboard has
no other use for the key. */
function onKeydown(e: KeyboardEvent) {
if (e.key !== 'Escape' || !removalTarget.value || removing.value) return
removalTarget.value = null
}
onMounted(() => window.addEventListener('keydown', onKeydown))
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
/* "12 Mar 2026", in the interface language and read in UTC — the server and
the browser may run on different clocks, and the first render has to say the
same day on both. */
function joined(iso: string): string {
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return ''
return new Intl.DateTimeFormat(locale.value, {
timeZone: 'UTC',
day: 'numeric',
month: 'short',
year: 'numeric',
}).format(date)
}
useSeoMeta({
title: () => t('admin.metaTitle'),
description: () => t('admin.metaDescription'),
})
</script>
<template>
<div class="home-main">
<!-- Nobody but an admin is given the list: a visitor without an account is
offered the way in, a signed-in member is told the room is not theirs. -->
<AuthGate
v-if="!loggedIn"
:heading="t('admin.gateHeading')"
:text="t('admin.gateText')"
/>
<section v-else-if="!isAdmin" class="auth-gate" aria-labelledby="admin-no-access">
<span class="auth-gate-mark" aria-hidden="true">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 22s8-4.5 8-10V5.5L12 2 4 5.5V12c0 5.5 8 10 8 10z" />
<path d="m9 12 2 2 4-4" />
</svg>
</span>
<h1 id="admin-no-access" class="auth-gate-title">{{ t('admin.forAdmins') }}</h1>
<p class="auth-gate-text">
{{ t('admin.forAdminsText') }}
</p>
<div class="auth-gate-actions">
<NuxtLink to="/articles" class="btn btn-ghost">{{ t('admin.backToArticles') }}</NuxtLink>
</div>
</section>
<template v-else>
<section class="hero">
<h1 v-html="t('admin.hero')"></h1>
</section>
<!-- Every account the wiki holds and what each may do. The requests
members send live on their own page, reached from the sidebar. -->
<section class="section">
<div v-if="failure" class="banner">
<span class="glyph">!</span>
<span>{{ failure }}</span>
</div>
<template v-else>
<div class="results-bar">
<h2>
<template v-if="loading">{{ t('common.loading') }}</template>
<template v-else>
{{ t('admin.accountCount', accounts.length) }}
</template>
</h2>
<span v-if="!loading && accounts.length" class="topic-tally">
{{ t('admin.adminTally', adminCount) }} ·
{{ t('admin.memberTally', accounts.length - adminCount) }}
</span>
</div>
<p v-if="actionError" class="form-error">{{ actionError }}</p>
<div v-if="loading" class="account-list">
<div v-for="i in 4" :key="i" class="skeleton skeleton-row" />
</div>
<ul v-else-if="accounts.length" class="account-list">
<li v-for="account in accounts" :key="account.id" class="account-entry">
<span class="account-avatar" aria-hidden="true">
<img v-if="account.avatar" :src="account.avatar" alt="" />
<template v-else>{{ account.username.charAt(0).toUpperCase() }}</template>
</span>
<span class="account-entry-name">
{{ account.username }}
<span v-if="account.id === user?.id" class="account-entry-you">{{ t('admin.you') }}</span>
</span>
<span class="role-pill" :class="{ 'is-admin': account.role === 'admin' }">
{{ account.role === 'admin' ? t('admin.roleAdmin') : t('admin.roleMember') }}
</span>
<span class="account-entry-since">{{ t('admin.joined', { date: joined(account.createdAt) }) }}</span>
<!-- an admin changing their own role, or closing their own account,
is what the API will not do — so the row says it where the ⋮
would go, instead of offering a menu that could only fail -->
<span v-if="account.id === user?.id" class="account-entry-note">
{{ t('admin.selfNote') }}
</span>
<AccountActionsMenu
v-else
class="account-entry-kebab"
:account-id="account.id"
:username="account.username"
:role="account.role"
:busy="changingId === account.id || removing"
@set-role="setRole(account, $event)"
@remove="removalTarget = account"
/>
</li>
</ul>
<div v-else class="empty">
<div class="glyph">∅</div>
<h3>{{ t('admin.emptyTitle') }}</h3>
<p>{{ t('admin.emptyText') }}</p>
</div>
</template>
</section>
</template>
<ConfirmModal
:open="removalTarget !== null"
:title="t('admin.confirmDeleteTitle')"
:message="removalTarget
? t('admin.confirmDeleteMessage', { username: removalTarget.username })
: ''"
:confirm-label="t('admin.confirmDeleteButton')"
:cancel-label="t('admin.confirmKeepButton')"
@confirm="removeAccount"
@cancel="removalTarget = null"
/>
</div>
</template>