More things
This commit is contained in:
274
frontend/app/pages/admin/index.vue
Normal file
274
frontend/app/pages/admin/index.vue
Normal 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>
|
||||
231
frontend/app/pages/admin/proposals.vue
Normal file
231
frontend/app/pages/admin/proposals.vue
Normal file
@@ -0,0 +1,231 @@
|
||||
<script setup lang="ts">
|
||||
/* The changes members want, kept on their own page: every request that waits
|
||||
for whoever holds the role to apply it or refuse it, and the record of the
|
||||
ones already answered. A member's save does not write — it arrives here.
|
||||
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 pile would have answered. */
|
||||
|
||||
import type { AdminProposal } from '~/components/ProposalRow.vue'
|
||||
|
||||
/* 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 { loggedIn, isAdmin, authHeaders, signOut } = useAuth()
|
||||
const { report: reportPending } = usePendingRequests()
|
||||
const { show: openSignIn } = useSignInPopup()
|
||||
const { t } = 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 account page 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('requests: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')
|
||||
}
|
||||
|
||||
/* --- edit requests ------------------------------------------------------
|
||||
A member's save does not write: it waits here for whoever holds the role to
|
||||
apply it or refuse it. Decided requests stay as the record of the call. 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 pile with it, which is
|
||||
what the watch below is for. */
|
||||
const {
|
||||
data: proposals,
|
||||
status: proposalsStatus,
|
||||
refresh: refreshProposals,
|
||||
} = await useAsyncData(
|
||||
'admin-proposals',
|
||||
async () => {
|
||||
failure.value = ''
|
||||
if (!isAdmin.value) return []
|
||||
try {
|
||||
const { proposals: list } = await $fetch<{ proposals: AdminProposal[] }>(
|
||||
`${api}/admin/proposals`,
|
||||
{ headers: authHeaders() }
|
||||
)
|
||||
/* The desk holds the whole pile, so it lays the waiting count on the
|
||||
sidebar's door too — approving or refusing moves it in step, without
|
||||
the API being asked twice. */
|
||||
reportPending(list.filter((p) => p.status === 'pending').length)
|
||||
return list
|
||||
} catch (err: any) {
|
||||
failure.value = describeFailure(err)
|
||||
return []
|
||||
}
|
||||
},
|
||||
{ default: () => [] }
|
||||
)
|
||||
|
||||
watch(isAdmin, (admin) => {
|
||||
if (admin) void refreshProposals()
|
||||
})
|
||||
|
||||
const proposalsLoading = computed(() => proposalsStatus.value === 'pending')
|
||||
const pendingProposals = computed(() =>
|
||||
proposals.value.filter((p) => p.status === 'pending')
|
||||
)
|
||||
const decidedProposals = computed(() =>
|
||||
proposals.value.filter((p) => p.status !== 'pending')
|
||||
)
|
||||
|
||||
/* deciding, one request at a time — while a request is in flight the buttons
|
||||
wait, so two clicks cannot decide the same request twice */
|
||||
const decidingId = ref('')
|
||||
const proposalError = ref('')
|
||||
const expandedProposal = ref('')
|
||||
const historyOpen = ref(false)
|
||||
|
||||
async function decideProposal(p: AdminProposal, action: 'approve' | 'reject') {
|
||||
if (decidingId.value) return
|
||||
proposalError.value = ''
|
||||
decidingId.value = p.id
|
||||
try {
|
||||
await $fetch(`${api}/admin/proposals/${p.id}/${action}`, {
|
||||
method: 'PUT',
|
||||
headers: authHeaders(),
|
||||
})
|
||||
await refreshProposals()
|
||||
} catch (err: any) {
|
||||
proposalError.value =
|
||||
describeFailure(err) ||
|
||||
t(action === 'approve' ? 'admin.approveFailed' : 'admin.rejectFailed')
|
||||
} finally {
|
||||
decidingId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
useSeoMeta({
|
||||
title: () => t('requests.metaTitle'),
|
||||
description: () => t('requests.metaDescription'),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="home-main">
|
||||
<!-- Nobody but an admin is given the pile: 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('requests.gateHeading')"
|
||||
:text="t('requests.gateText')"
|
||||
/>
|
||||
|
||||
<section v-else-if="!isAdmin" class="auth-gate" aria-labelledby="requests-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="requests-no-access" class="auth-gate-title">{{ t('admin.forAdmins') }}</h1>
|
||||
<p class="auth-gate-text">
|
||||
{{ t('requests.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('requests.hero')"></h1>
|
||||
</section>
|
||||
|
||||
<!-- what members want changed: the waiting pile first, then the history
|
||||
of what was answered. Details open the change as a pair of pages —
|
||||
how it read when sent, how it would read if applied. -->
|
||||
<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 id="proposals-heading">
|
||||
<template v-if="proposalsLoading">{{ t('common.loading') }}</template>
|
||||
<template v-else>{{ t('admin.proposalsHeading') }}</template>
|
||||
</h2>
|
||||
<span v-if="!proposalsLoading && pendingProposals.length" class="topic-tally">
|
||||
{{ t('admin.proposalWaitingTally', pendingProposals.length) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p v-if="proposalError" class="form-error">{{ proposalError }}</p>
|
||||
|
||||
<div v-if="proposalsLoading" class="proposal-list">
|
||||
<div v-for="i in 2" :key="i" class="skeleton proposal-skeleton" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="!proposals.length" class="empty">
|
||||
<div class="glyph">✎</div>
|
||||
<h3>{{ t('admin.proposalsEmptyTitle') }}</h3>
|
||||
<p>{{ t('admin.proposalsEmptyText') }}</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<ul v-if="pendingProposals.length" class="proposal-list">
|
||||
<li v-for="p in pendingProposals" :key="p.id" class="proposal-entry is-pending">
|
||||
<ProposalRow
|
||||
:proposal="p"
|
||||
:expanded="expandedProposal === p.id"
|
||||
:busy="!!decidingId"
|
||||
@toggle="expandedProposal = expandedProposal === p.id ? '' : p.id"
|
||||
@decide="decideProposal(p, $event)"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<template v-if="decidedProposals.length">
|
||||
<button
|
||||
type="button"
|
||||
class="proposal-history-toggle"
|
||||
:aria-expanded="String(historyOpen)"
|
||||
@click="historyOpen = !historyOpen"
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.4"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
:class="{ turned: historyOpen }"
|
||||
>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
{{ t('admin.decidedHeading', decidedProposals.length) }}
|
||||
</button>
|
||||
<ul v-if="historyOpen" class="proposal-list">
|
||||
<li v-for="p in decidedProposals" :key="p.id" class="proposal-entry" :class="`is-${p.status}`">
|
||||
<ProposalRow
|
||||
:proposal="p"
|
||||
:expanded="expandedProposal === p.id"
|
||||
:busy="false"
|
||||
@toggle="expandedProposal = expandedProposal === p.id ? '' : p.id"
|
||||
@decide="decideProposal(p, $event)"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user