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

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

View file

@ -14,6 +14,14 @@
<label>Name</label>
<input v-model="form.name" type="text" placeholder="My Route" required />
</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="field">
<label>ID</label>
@ -68,14 +76,14 @@
<script setup lang="ts">
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";
interface FilterForm extends Filter {
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<{
(e: "close"): void;
(e: "save", route: Route): void;
@ -92,6 +100,7 @@ const form = reactive({
enabled: true,
channelId: "",
threadId: "",
groupId: "",
filters: [] as FilterForm[],
});
@ -124,6 +133,7 @@ watch(
form.enabled = r?.enabled ?? true;
form.channelId = r?.target.channelId ?? "";
form.threadId = r?.target.threadId ?? "";
form.groupId = r?.groupId ?? (props.groups.length === 1 ? props.groups[0]!.id : "");
form.filters = (r && r.filters.length
? r.filters
: [{ type: "event", match: "", exclude: false }]
@ -158,6 +168,7 @@ function collect(): Route | null {
name: form.name.trim(),
enabled: form.enabled,
lang: form.lang.trim() || undefined,
groupId: form.groupId || undefined,
filters,
target: {
channelId: form.channelId.trim(),
@ -182,6 +193,10 @@ function save(): void {
formError.value = "Channel ID is required";
return;
}
if (!route.groupId) {
formError.value = "Please select a group";
return;
}
emit("save", route);
}
</script>