feat(admin): groups management UI in the console

Add a super-admin-only Groups tab to create, edit and delete groups
(name, admin ids, owner scope). Routes now require a group via a select
in the editor, group names are shown on route cards, and the new
useMe/useGroups composables back the UI.
This commit is contained in:
RhenCloud 2026-08-02 08:16:05 +08:00
parent 537f4cbb84
commit 62985a2f3f
No known key found for this signature in database
GPG key ID: A574A617378C4E0B
8 changed files with 382 additions and 9 deletions

View file

@ -339,6 +339,12 @@ main {
color: var(--accent-text); color: var(--accent-text);
} }
.badge.group {
background: var(--ok-dim);
color: var(--ok);
border: 1px solid transparent;
}
.card-actions { .card-actions {
display: flex; display: flex;
gap: 6px; gap: 6px;

View file

@ -0,0 +1,112 @@
<template>
<Teleport to="body">
<Transition name="fade">
<div v-if="open" class="overlay" @click.self="close"></div>
</Transition>
<Transition name="slide">
<aside v-if="open" class="editor" role="dialog" aria-modal="true">
<div class="editor-head">
<h2>{{ isEdit ? "Edit group" : "New group" }}</h2>
<button class="icon-btn" title="Close" @click="close"></button>
</div>
<form class="editor-body" @submit.prevent="save">
<div class="row2">
<div class="field">
<label>Name</label>
<input v-model="form.name" type="text" placeholder="My Team" required />
</div>
<div class="field">
<label>ID</label>
<input v-model="form.id" type="text" placeholder="my-team" :disabled="isEdit" required />
<div class="hint">Unique, use a-z / 0-9 / dashes</div>
</div>
</div>
<div class="field">
<label>Group admins <span class="lbl-note">(GitHub login or user id, comma-separated)</span></label>
<input v-model="form.adminIds" type="text" placeholder="octocat, 12345" />
<div class="hint">These users can view logs and edit this group's routes.</div>
</div>
<div class="field">
<label>Owner scope <span class="lbl-note">(GitHub org / user logins, comma-separated)</span></label>
<input v-model="form.owners" type="text" placeholder="my-org, some-user" />
<div class="hint">
Only webhook events from these orgs/users enter this group's routes. Leave empty for no
restriction.
</div>
</div>
<div class="err">{{ formError }}</div>
</form>
<div class="editor-foot">
<button class="btn btn-ghost" type="button" @click="close">Cancel</button>
<button class="btn btn-accent" type="button" :disabled="saving" @click="save">Save group</button>
</div>
</aside>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { reactive, watch } from "vue";
import type { Group } from "~/types";
const props = defineProps<{ open: boolean; group: Group | null; saving: boolean }>();
const emit = defineEmits<{
(e: "close"): void;
(e: "save", group: Group): void;
}>();
const isEdit = computed(() => props.group != null);
const formError = ref("");
const form = reactive({
id: "",
name: "",
adminIds: "",
owners: "",
});
function splitList(text: string): string[] {
return text
.split(",")
.map((s) => s.trim())
.filter(Boolean);
}
watch(
() => props.open,
(open) => {
if (!open) return;
const g = props.group;
form.id = g?.id ?? "";
form.name = g?.name ?? "";
form.adminIds = (g?.adminIds ?? []).join(", ");
form.owners = (g?.owners ?? []).join(", ");
formError.value = "";
},
);
function close(): void {
emit("close");
}
function save(): void {
formError.value = "";
const id = form.id.trim();
const name = form.name.trim();
if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) {
formError.value = "ID must be a-z / 0-9 / dashes";
return;
}
if (!name) {
formError.value = "Name is required";
return;
}
const owners = splitList(form.owners);
emit("save", {
id,
name,
adminIds: splitList(form.adminIds),
owners: owners.length ? owners : undefined,
});
}
</script>

View file

@ -9,6 +9,7 @@
<span class="route-name">{{ route.name || "(untitled)" }}</span> <span class="route-name">{{ route.name || "(untitled)" }}</span>
<span class="route-id">{{ route.id }}</span> <span class="route-id">{{ route.id }}</span>
<span v-if="route.lang" class="badge lang">{{ route.lang }}</span> <span v-if="route.lang" class="badge lang">{{ route.lang }}</span>
<span v-if="groupName" class="badge group">{{ groupName }}</span>
</div> </div>
<div class="card-actions"> <div class="card-actions">
<button class="icon-btn" title="Edit" @click="$emit('edit', route)"></button> <button class="icon-btn" title="Edit" @click="$emit('edit', route)"></button>
@ -33,7 +34,7 @@
import type { Route } from "~/types"; import type { Route } from "~/types";
import { FILTER_LABELS, fmtMatch } from "~/types"; import { FILTER_LABELS, fmtMatch } from "~/types";
const props = defineProps<{ route: Route }>(); const props = defineProps<{ route: Route; groupName?: string }>();
const emit = defineEmits<{ const emit = defineEmits<{
(e: "toggle", route: Route): void; (e: "toggle", route: Route): void;
(e: "edit", route: Route): void; (e: "edit", route: Route): void;

View file

@ -14,6 +14,14 @@
<label>Name</label> <label>Name</label>
<input v-model="form.name" type="text" placeholder="My Route" required /> <input v-model="form.name" type="text" placeholder="My Route" required />
</div> </div>
<div class="field">
<label>Group</label>
<select v-model="form.groupId" :disabled="!groups.length">
<option value="" disabled>{{ groups.length ? "Select a group" : "No groups yet" }}</option>
<option v-for="g in groups" :key="g.id" :value="g.id">{{ g.name }}</option>
</select>
<div v-if="!groups.length" class="hint">Create a group first (Groups tab).</div>
</div>
<div class="row2"> <div class="row2">
<div class="field"> <div class="field">
<label>ID</label> <label>ID</label>
@ -68,14 +76,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { reactive, watch } from "vue"; import { reactive, watch } from "vue";
import type { Filter, Route } from "~/types"; import type { Filter, Route, Group } from "~/types";
import { FILTER_TYPES, FILTER_LABELS, fmtMatch } from "~/types"; import { FILTER_TYPES, FILTER_LABELS, fmtMatch } from "~/types";
interface FilterForm extends Filter { interface FilterForm extends Filter {
matchText: string; matchText: string;
} }
const props = defineProps<{ open: boolean; route: Route | null; saving: boolean }>(); const props = defineProps<{ open: boolean; route: Route | null; saving: boolean; groups: Group[] }>();
const emit = defineEmits<{ const emit = defineEmits<{
(e: "close"): void; (e: "close"): void;
(e: "save", route: Route): void; (e: "save", route: Route): void;
@ -92,6 +100,7 @@ const form = reactive({
enabled: true, enabled: true,
channelId: "", channelId: "",
threadId: "", threadId: "",
groupId: "",
filters: [] as FilterForm[], filters: [] as FilterForm[],
}); });
@ -124,6 +133,7 @@ watch(
form.enabled = r?.enabled ?? true; form.enabled = r?.enabled ?? true;
form.channelId = r?.target.channelId ?? ""; form.channelId = r?.target.channelId ?? "";
form.threadId = r?.target.threadId ?? ""; form.threadId = r?.target.threadId ?? "";
form.groupId = r?.groupId ?? (props.groups.length === 1 ? props.groups[0]!.id : "");
form.filters = (r && r.filters.length form.filters = (r && r.filters.length
? r.filters ? r.filters
: [{ type: "event", match: "", exclude: false }] : [{ type: "event", match: "", exclude: false }]
@ -158,6 +168,7 @@ function collect(): Route | null {
name: form.name.trim(), name: form.name.trim(),
enabled: form.enabled, enabled: form.enabled,
lang: form.lang.trim() || undefined, lang: form.lang.trim() || undefined,
groupId: form.groupId || undefined,
filters, filters,
target: { target: {
channelId: form.channelId.trim(), channelId: form.channelId.trim(),
@ -182,6 +193,10 @@ function save(): void {
formError.value = "Channel ID is required"; formError.value = "Channel ID is required";
return; return;
} }
if (!route.groupId) {
formError.value = "Please select a group";
return;
}
emit("save", route); emit("save", route);
} }
</script> </script>

View file

@ -0,0 +1,53 @@
import type { Group } from "~/types";
export function useGroupsApi() {
const groups = ref<Group[]>([]);
const isSuper = ref(false);
const loading = ref(false);
const needLogin = ref(false);
const error = ref("");
async function load(): Promise<void> {
loading.value = true;
error.value = "";
needLogin.value = false;
try {
const res = await fetch("/admin/api/groups", {
headers: { accept: "application/json" },
credentials: "same-origin",
});
if (res.status === 401) {
needLogin.value = true;
return;
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = (await res.json()) as { groups?: Group[]; isSuper?: boolean };
groups.value = data.groups ?? [];
isSuper.value = data.isSuper ?? false;
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);
} finally {
loading.value = false;
}
}
async function save(next: Group[]): Promise<void> {
const res = await fetch("/admin/api/groups", {
method: "PUT",
headers: { "content-type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ groups: next }),
});
if (res.status === 401) {
needLogin.value = true;
throw new Error("unauthorized");
}
if (!res.ok) {
const data = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(data.error ?? `HTTP ${res.status}`);
}
groups.value = next;
}
return { groups, isSuper, loading, needLogin, error, load, save };
}

View file

@ -0,0 +1,30 @@
import type { Me } from "~/types";
export function useMeApi() {
const me = ref<Me | null>(null);
const loading = ref(false);
const needLogin = ref(false);
async function load(): Promise<void> {
loading.value = true;
needLogin.value = false;
try {
const res = await fetch("/admin/api/me", {
headers: { accept: "application/json" },
credentials: "same-origin",
});
if (res.status === 401) {
needLogin.value = true;
return;
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
me.value = (await res.json()) as Me;
} catch {
me.value = null;
} finally {
loading.value = false;
}
}
return { me, loading, needLogin, load };
}

View file

@ -9,7 +9,16 @@
</div> </div>
</div> </div>
<div class="head-actions"> <div class="head-actions">
<button v-if="!needLogin" class="btn btn-accent" @click="openNew">+ New Route</button> <button v-if="!needLogin && view === 'routes'" class="btn btn-accent" @click="openNew">
+ New Route
</button>
<button
v-if="!needLogin && view === 'groups' && isSuper"
class="btn btn-accent"
@click="openNewGroup"
>
+ New Group
</button>
<a v-if="!needLogin" class="btn btn-ghost" href="/admin/logout">Sign out</a> <a v-if="!needLogin" class="btn btn-ghost" href="/admin/logout">Sign out</a>
</div> </div>
</header> </header>
@ -18,7 +27,9 @@
<div v-if="needLogin" class="login"> <div v-if="needLogin" class="login">
<div class="login-card"> <div class="login-card">
<h2>Config Console</h2> <h2>Config Console</h2>
<p v-if="forbidden">Access denied this GitHub account is not in <code>ADMIN_USER_IDS</code>.</p> <p v-if="forbidden">
Access denied this GitHub account is not an admin or group admin.
</p>
<p v-else>Sign in with GitHub to manage routes.</p> <p v-else>Sign in with GitHub to manage routes.</p>
<a class="btn btn-accent btn-lg" href="/admin/login">Sign in with GitHub</a> <a class="btn btn-accent btn-lg" href="/admin/login">Sign in with GitHub</a>
</div> </div>
@ -29,6 +40,14 @@
<button class="tab" :class="{ active: view === 'routes' }" @click="switchView('routes')"> <button class="tab" :class="{ active: view === 'routes' }" @click="switchView('routes')">
Routes Routes
</button> </button>
<button
v-if="isSuper"
class="tab"
:class="{ active: view === 'groups' }"
@click="switchView('groups')"
>
Groups
</button>
<button class="tab" :class="{ active: view === 'logs' }" @click="switchView('logs')"> <button class="tab" :class="{ active: view === 'logs' }" @click="switchView('logs')">
Send Logs Send Logs
</button> </button>
@ -53,6 +72,7 @@
v-for="(r, i) in routes" v-for="(r, i) in routes"
:key="r.id" :key="r.id"
:route="r" :route="r"
:group-name="groupName(r.groupId)"
:style="{ animationDelay: i * 45 + 'ms' }" :style="{ animationDelay: i * 45 + 'ms' }"
@toggle="onToggle" @toggle="onToggle"
@edit="openEdit" @edit="openEdit"
@ -66,6 +86,51 @@
</section> </section>
</template> </template>
<template v-else-if="view === 'groups'">
<section class="toolbar">
<div>
<span class="kpi-label">GROUPS</span>
<span class="kpi">{{ groups.length }}</span>
</div>
<div class="status">
<span class="dot" :class="groupsLoading ? '' : 'ok'"></span>
<span>{{ groupsLoading ? "loading" : "connected" }}</span>
</div>
</section>
<p v-if="groupsError" class="err">{{ groupsError }}</p>
<section class="routes">
<article v-for="g in groups" :key="g.id" class="card">
<div class="card-head">
<div class="card-title">
<span class="route-name">{{ g.name || "(untitled)" }}</span>
<span class="route-id">{{ g.id }}</span>
</div>
<div class="card-actions">
<button class="icon-btn" title="Edit" @click="openEditGroup(g)"></button>
<button class="icon-btn danger" title="Delete" @click="onDeleteGroup(g)"></button>
</div>
</div>
<div class="target">
<span
><b>ADMINS</b
><code>{{ g.adminIds.length ? g.adminIds.join(", ") : "—" }}</code></span
>
<span
><b>OWNERS</b
><code>{{ g.owners && g.owners.length ? g.owners.join(", ") : "any" }}</code></span
>
</div>
</article>
</section>
<section v-if="!groupsLoading && !groups.length" class="empty">
<p>No groups yet. Groups scope routes by org/user and delegate access.</p>
<button class="btn btn-accent" @click="openNewGroup">Create your first group</button>
</section>
</template>
<template v-else> <template v-else>
<section class="toolbar"> <section class="toolbar">
<div class="status"> <div class="status">
@ -82,24 +147,45 @@
:open="editorOpen" :open="editorOpen"
:route="editing" :route="editing"
:saving="saving" :saving="saving"
:groups="groups"
@close="editorOpen = false" @close="editorOpen = false"
@save="onSave" @save="onSave"
/> />
<GroupEditor
:open="groupEditorOpen"
:group="editingGroup"
:saving="savingGroup"
@close="groupEditorOpen = false"
@save="onSaveGroup"
/>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Route } from "~/types"; import type { Group, Route } from "~/types";
const { routes, loading, needLogin, error, load, save } = useRoutesApi(); const { routes, loading, needLogin, error, load, save } = useRoutesApi();
const { push } = useToasts(); const { push } = useToasts();
const { logs, loading: logsLoading, load: loadLogs } = useSendLogs(); const { logs, loading: logsLoading, load: loadLogs } = useSendLogs();
const {
groups,
isSuper,
loading: groupsLoading,
error: groupsError,
load: loadGroups,
save: saveGroups,
} = useGroupsApi();
const editorOpen = ref(false); const editorOpen = ref(false);
const editing = ref<Route | null>(null); const editing = ref<Route | null>(null);
const saving = ref(false); const saving = ref(false);
const forbidden = ref(false); const forbidden = ref(false);
const view = ref<"routes" | "logs">("routes"); const view = ref<"routes" | "groups" | "logs">("routes");
const groupEditorOpen = ref(false);
const editingGroup = ref<Group | null>(null);
const savingGroup = ref(false);
onMounted(() => { onMounted(() => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
@ -108,11 +194,21 @@ onMounted(() => {
} }
load(); load();
loadLogs(); loadLogs();
loadGroups();
}); });
function switchView(next: "routes" | "logs"): void { function groupName(id?: string): string | undefined {
if (!id) return undefined;
return groups.value.find((g) => g.id === id)?.name ?? id;
}
function switchView(next: "routes" | "groups" | "logs"): void {
view.value = next; view.value = next;
if (next === "routes") load(); if (next === "routes") {
load();
loadGroups();
}
if (next === "groups") loadGroups();
if (next === "logs") loadLogs(); if (next === "logs") loadLogs();
} }
@ -167,4 +263,49 @@ async function onDelete(route: Route): Promise<void> {
push(`Delete failed: ${err instanceof Error ? err.message : err}`, "bad"); push(`Delete failed: ${err instanceof Error ? err.message : err}`, "bad");
} }
} }
function openNewGroup(): void {
editingGroup.value = null;
groupEditorOpen.value = true;
}
function openEditGroup(group: Group): void {
editingGroup.value = group;
groupEditorOpen.value = true;
}
async function onSaveGroup(group: Group): Promise<void> {
savingGroup.value = true;
try {
let next: Group[];
if (editingGroup.value) {
next = groups.value.map((g) => (g.id === editingGroup.value!.id ? group : g));
} else {
if (groups.value.some((g) => g.id === group.id)) {
push("Group ID already exists", "bad");
return;
}
next = [...groups.value, group];
}
await saveGroups(next);
groupEditorOpen.value = false;
push("Groups saved");
} catch (err) {
push(`Save failed: ${err instanceof Error ? err.message : err}`, "bad");
} finally {
savingGroup.value = false;
}
}
async function onDeleteGroup(group: Group): Promise<void> {
const used = routes.value.filter((r) => r.groupId === group.id).length;
const warn = used ? ` ${used} route(s) reference it and will lose their group.` : "";
if (!window.confirm(`Delete group "${group.name || group.id}"?${warn}`)) return;
try {
await saveGroups(groups.value.filter((g) => g.id !== group.id));
push("Group deleted");
} catch (err) {
push(`Delete failed: ${err instanceof Error ? err.message : err}`, "bad");
}
}
</script> </script>

View file

@ -14,6 +14,21 @@ export interface Route {
threadId?: string; threadId?: string;
}; };
lang?: string; lang?: string;
groupId?: string;
}
export interface Group {
id: string;
name: string;
adminIds: string[];
owners?: string[];
}
export interface Me {
login: string;
userId: string;
isSuper: boolean;
groups: Group[];
} }
export const FILTER_TYPES = ["event", "repo", "actor", "action", "branch", "keyword"] as const; export const FILTER_TYPES = ["event", "repo", "actor", "action", "branch", "keyword"] as const;