CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 17s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 37s
#2533. theme.css claimed "removing this block is a rename sweep across the
components, tracked separately" — written in 67a529a, never filed, which made
the comment itself an instance of the survey's presence-without-reference
pattern. This is that sweep.
73 alias declarations deleted; 69 files rewritten; every --color-*-style name
now references its --fs-* token directly. Mechanical by construction: the map
IS the alias block, applied longest-name-first with a boundary guard so
--color-text never matched inside --color-text-muted. Zero survivors outside
theme.css, verified by grep rather than assumed.
One deliberate survivor: --color-shadow stays DECLARED, because it was never
an alias — it is a literal value the design system has no token for. Marked
in place as a recorded gap: promote it to an --fs-* token when a second app
needs it, don't copy the line.
Nothing is lost mode-wise: the aliases' resolve-at-use-time trick (which
absorbed 48 dark-mode overrides) lives one layer down in the --fs-* tokens'
own derivations, which is why the sweep is a pure rename. Both CSS checkers
green.
Why now rather than never: check_snippets_against_design_system reports every
--color-* reference as "unknown — renders as NOTHING", and nine recipe
snippets recorded from components.css carried the deprecated names, making
them prior art pointing the wrong way. With the sweep in, the checker's
report over re-recorded snippets should be EMPTY — the acceptance test that
proves the checker was right all along (#2517's correction).
Refs #2533
459 lines
12 KiB
Vue
459 lines
12 KiB
Vue
<script setup lang="ts">
|
|
import { ref, onMounted } from "vue";
|
|
import { apiGet, apiPost, apiPut, apiDelete } from "@/api/client";
|
|
import { useAuthStore } from "@/stores/auth";
|
|
import { useToastStore } from "@/stores/toast";
|
|
import type { User } from "@/types/auth";
|
|
|
|
interface Invitation {
|
|
id: number;
|
|
email: string;
|
|
created_at: string;
|
|
expires_at: string;
|
|
}
|
|
|
|
const authStore = useAuthStore();
|
|
const toastStore = useToastStore();
|
|
|
|
const users = ref<User[]>([]);
|
|
const registrationOpen = ref(false);
|
|
const loading = ref(true);
|
|
const toggling = ref(false);
|
|
const confirmDeleteId = ref<number | null>(null);
|
|
const deleting = ref<number | null>(null);
|
|
|
|
const inviteEmail = ref("");
|
|
const sendingInvite = ref(false);
|
|
const invitations = ref<Invitation[]>([]);
|
|
const revokingId = ref<number | null>(null);
|
|
|
|
onMounted(async () => {
|
|
await Promise.all([fetchUsers(), fetchRegistration(), fetchInvitations()]);
|
|
loading.value = false;
|
|
});
|
|
|
|
async function fetchUsers() {
|
|
try {
|
|
const data = await apiGet<{ users: User[] }>("/api/admin/users");
|
|
users.value = data.users;
|
|
} catch {
|
|
toastStore.show("Failed to load users", "error");
|
|
}
|
|
}
|
|
|
|
async function fetchRegistration() {
|
|
try {
|
|
const data = await apiGet<{ open: boolean }>("/api/admin/registration");
|
|
registrationOpen.value = data.open;
|
|
} catch {
|
|
// Ignore — will default to false
|
|
}
|
|
}
|
|
|
|
async function fetchInvitations() {
|
|
try {
|
|
const data = await apiGet<{ invitations: Invitation[] }>("/api/admin/invitations");
|
|
invitations.value = data.invitations;
|
|
} catch {
|
|
// Ignore
|
|
}
|
|
}
|
|
|
|
async function sendInvite() {
|
|
const email = inviteEmail.value.trim().toLowerCase();
|
|
if (!email) return;
|
|
sendingInvite.value = true;
|
|
try {
|
|
await apiPost("/api/admin/invitations", { email });
|
|
toastStore.show(`Invitation sent to ${email}`);
|
|
inviteEmail.value = "";
|
|
await fetchInvitations();
|
|
} catch (e: unknown) {
|
|
if (e && typeof e === "object" && "body" in e) {
|
|
const body = (e as { body?: { error?: string } }).body;
|
|
toastStore.show(body?.error || "Failed to send invitation", "error");
|
|
} else {
|
|
toastStore.show("Failed to send invitation", "error");
|
|
}
|
|
} finally {
|
|
sendingInvite.value = false;
|
|
}
|
|
}
|
|
|
|
async function revokeInvitation(id: number) {
|
|
revokingId.value = id;
|
|
try {
|
|
await apiDelete(`/api/admin/invitations/${id}`);
|
|
invitations.value = invitations.value.filter((inv) => inv.id !== id);
|
|
toastStore.show("Invitation revoked");
|
|
} catch {
|
|
toastStore.show("Failed to revoke invitation", "error");
|
|
} finally {
|
|
revokingId.value = null;
|
|
}
|
|
}
|
|
|
|
async function toggleRegistration() {
|
|
toggling.value = true;
|
|
try {
|
|
const data = await apiPut<{ open: boolean }>("/api/admin/registration", {
|
|
open: !registrationOpen.value,
|
|
});
|
|
registrationOpen.value = data.open;
|
|
toastStore.show(data.open ? "Registration opened" : "Registration closed");
|
|
} catch {
|
|
toastStore.show("Failed to update registration setting", "error");
|
|
} finally {
|
|
toggling.value = false;
|
|
}
|
|
}
|
|
|
|
function confirmDelete(userId: number) {
|
|
if (confirmDeleteId.value === userId) {
|
|
deleteUser(userId);
|
|
} else {
|
|
confirmDeleteId.value = userId;
|
|
}
|
|
}
|
|
|
|
function cancelDelete() {
|
|
confirmDeleteId.value = null;
|
|
}
|
|
|
|
async function deleteUser(userId: number) {
|
|
confirmDeleteId.value = null;
|
|
deleting.value = userId;
|
|
try {
|
|
await apiDelete(`/api/admin/users/${userId}`);
|
|
users.value = users.value.filter((u) => u.id !== userId);
|
|
toastStore.show("User deleted");
|
|
} catch (e: unknown) {
|
|
if (e && typeof e === "object" && "body" in e) {
|
|
const body = (e as { body?: { error?: string } }).body;
|
|
toastStore.show(body?.error || "Failed to delete user", "error");
|
|
} else {
|
|
toastStore.show("Failed to delete user", "error");
|
|
}
|
|
} finally {
|
|
deleting.value = null;
|
|
}
|
|
}
|
|
|
|
function formatDate(iso: string): string {
|
|
return new Date(iso).toLocaleDateString(undefined, {
|
|
year: "numeric",
|
|
month: "short",
|
|
day: "numeric",
|
|
});
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<main class="users-page">
|
|
<h1>User Management</h1>
|
|
|
|
<section class="settings-section">
|
|
<h2>Registration</h2>
|
|
<div class="registration-row">
|
|
<div class="registration-info">
|
|
<p class="registration-status">
|
|
Registration is currently
|
|
<strong :class="registrationOpen ? 'text-success' : 'text-muted'">
|
|
{{ registrationOpen ? "open" : "closed" }}
|
|
</strong>
|
|
</p>
|
|
<p class="field-hint">
|
|
When closed, new users can only be added by an administrator.
|
|
</p>
|
|
</div>
|
|
<button
|
|
class="btn-primary btn-toggle"
|
|
:class="registrationOpen ? 'btn-toggle-close' : 'btn-toggle-open'"
|
|
@click="toggleRegistration"
|
|
:disabled="toggling"
|
|
>
|
|
{{ toggling ? "Updating..." : registrationOpen ? "Close Registration" : "Open Registration" }}
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="settings-section">
|
|
<h2>Invite User</h2>
|
|
<form class="invite-form" @submit.prevent="sendInvite">
|
|
<input
|
|
v-model="inviteEmail"
|
|
type="email"
|
|
placeholder="Email address"
|
|
class="input invite-input"
|
|
required
|
|
:disabled="sendingInvite"
|
|
/>
|
|
<button
|
|
type="submit"
|
|
class="btn-primary"
|
|
:disabled="sendingInvite || !inviteEmail.trim()"
|
|
>
|
|
{{ sendingInvite ? "Sending..." : "Send Invite" }}
|
|
</button>
|
|
</form>
|
|
<p class="field-hint">Send an invitation link to allow someone to register, even when public registration is closed.</p>
|
|
|
|
<div v-if="invitations.length > 0" class="invite-list">
|
|
<h3>Pending Invitations</h3>
|
|
<table class="users-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Email</th>
|
|
<th class="hide-mobile">Sent</th>
|
|
<th class="hide-mobile">Expires</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="inv in invitations" :key="inv.id">
|
|
<td class="cell-email">{{ inv.email }}</td>
|
|
<td class="hide-mobile cell-date">{{ formatDate(inv.created_at) }}</td>
|
|
<td class="hide-mobile cell-date">{{ formatDate(inv.expires_at) }}</td>
|
|
<td class="cell-actions">
|
|
<button
|
|
class="btn-ghost btn-compact"
|
|
@click="revokeInvitation(inv.id)"
|
|
:disabled="revokingId !== null"
|
|
>
|
|
{{ revokingId === inv.id ? "Revoking..." : "Revoke" }}
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="settings-section">
|
|
<h2>Users</h2>
|
|
|
|
<div v-if="loading" class="loading-msg">Loading users...</div>
|
|
|
|
<div v-else-if="users.length === 0" class="empty-msg">No users found.</div>
|
|
|
|
<table v-else class="users-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Username</th>
|
|
<th class="hide-mobile">Email</th>
|
|
<th>Role</th>
|
|
<th class="hide-mobile">Joined</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="u in users" :key="u.id">
|
|
<td class="cell-username">{{ u.username }}</td>
|
|
<td class="hide-mobile cell-email">{{ u.email || "—" }}</td>
|
|
<td>
|
|
<span class="role-badge" :class="u.role === 'admin' ? 'role-admin' : 'role-user'">
|
|
{{ u.role }}
|
|
</span>
|
|
</td>
|
|
<td class="hide-mobile cell-date">{{ formatDate(u.created_at) }}</td>
|
|
<td class="cell-actions">
|
|
<template v-if="u.id === authStore.user?.id">
|
|
<span class="you-label">You</span>
|
|
</template>
|
|
<template v-else-if="confirmDeleteId === u.id">
|
|
<button
|
|
class="btn-danger btn-compact"
|
|
@click="confirmDelete(u.id)"
|
|
:disabled="deleting !== null"
|
|
>
|
|
{{ deleting === u.id ? "Deleting..." : "Confirm" }}
|
|
</button>
|
|
<button class="btn-ghost btn-compact" @click="cancelDelete">Cancel</button>
|
|
</template>
|
|
<template v-else>
|
|
<button
|
|
class="btn-ghost btn-compact"
|
|
@click="confirmDelete(u.id)"
|
|
:disabled="deleting !== null"
|
|
>
|
|
Delete
|
|
</button>
|
|
</template>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</section>
|
|
</main>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.users-page {
|
|
max-width: 1200px;
|
|
margin: 2rem auto;
|
|
padding: 0 1rem;
|
|
}
|
|
.users-page h1 {
|
|
margin: 0 0 1.5rem;
|
|
}
|
|
.settings-section {
|
|
background: var(--fs-surface-raised);
|
|
border: 1px solid var(--fs-border-color);
|
|
border-radius: var(--fs-radius-lg);
|
|
padding: 1.25rem;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
.settings-section h2 {
|
|
margin: 0 0 0.75rem;
|
|
font-size: 1.1rem;
|
|
}
|
|
|
|
/* Invite form */
|
|
.invite-form {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
.invite-input {
|
|
flex: 1;
|
|
padding: 0.5rem 0.75rem;
|
|
border: 1px solid var(--fs-border-color);
|
|
border-radius: var(--fs-radius-sm);
|
|
font-size: 0.95rem;
|
|
background: var(--fs-surface-page);
|
|
color: var(--fs-text-primary);
|
|
box-sizing: border-box;
|
|
}
|
|
.invite-input:focus {
|
|
outline: none;
|
|
border-color: var(--fs-accent);
|
|
}
|
|
.invite-list {
|
|
margin-top: 1rem;
|
|
}
|
|
.invite-list h3 {
|
|
margin: 0 0 0.5rem;
|
|
font-size: 0.95rem;
|
|
color: var(--fs-text-secondary);
|
|
}
|
|
|
|
/* Registration toggle */
|
|
.registration-row {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 1rem;
|
|
}
|
|
.registration-info {
|
|
flex: 1;
|
|
}
|
|
.registration-status {
|
|
margin: 0;
|
|
font-size: 0.95rem;
|
|
}
|
|
.text-success {
|
|
color: var(--fs-success);
|
|
}
|
|
.text-muted {
|
|
color: var(--fs-text-tertiary);
|
|
}
|
|
.field-hint {
|
|
margin: 0.35rem 0 0;
|
|
font-size: 0.8rem;
|
|
color: var(--fs-text-tertiary);
|
|
}
|
|
/* The one genuine override: 'close registration' must NOT read as the
|
|
primary action it sits on. Scoped, so it beats the shared variant. */
|
|
.btn-toggle-close {
|
|
background: var(--fs-surface-raised);
|
|
color: var(--fs-text-primary);
|
|
border: 1px solid var(--fs-border-color);
|
|
}
|
|
.btn-toggle-close:hover:not(:disabled) {
|
|
border-color: var(--fs-warning);
|
|
color: var(--fs-warning);
|
|
}
|
|
|
|
/* Users table */
|
|
.loading-msg,
|
|
.empty-msg {
|
|
text-align: center;
|
|
color: var(--fs-text-tertiary);
|
|
font-size: 0.9rem;
|
|
padding: 1rem 0;
|
|
}
|
|
.users-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
}
|
|
.users-table th {
|
|
text-align: left;
|
|
font-size: 0.8rem;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
color: var(--fs-text-tertiary);
|
|
padding: 0.5rem 0.75rem;
|
|
border-bottom: 1px solid var(--fs-border-color);
|
|
}
|
|
.users-table td {
|
|
padding: 0.65rem 0.75rem;
|
|
border-bottom: 1px solid var(--fs-border-color);
|
|
font-size: 0.9rem;
|
|
}
|
|
.users-table tbody tr:last-child td {
|
|
border-bottom: none;
|
|
}
|
|
.cell-username {
|
|
font-weight: 600;
|
|
}
|
|
.cell-email {
|
|
color: var(--fs-text-secondary);
|
|
}
|
|
.cell-date {
|
|
color: var(--fs-text-tertiary);
|
|
font-size: 0.85rem;
|
|
}
|
|
.cell-actions {
|
|
white-space: nowrap;
|
|
}
|
|
|
|
/* Role badges */
|
|
.role-badge {
|
|
display: inline-block;
|
|
font-size: 0.7rem;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
padding: 0.15rem 0.4rem;
|
|
border-radius: var(--fs-radius-sm);
|
|
}
|
|
.role-admin {
|
|
color: var(--fs-accent);
|
|
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
|
}
|
|
.role-user {
|
|
color: var(--fs-text-tertiary);
|
|
background: var(--fs-surface-raised);
|
|
}
|
|
|
|
/* Action buttons */
|
|
.you-label {
|
|
font-size: 0.8rem;
|
|
color: var(--fs-text-tertiary);
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.registration-row {
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
}
|
|
.btn-toggle {
|
|
width: 100%;
|
|
}
|
|
.invite-form {
|
|
flex-direction: column;
|
|
}
|
|
}
|
|
</style>
|