From d3c1349d001c01c7799a21688ccfe4ad49a3a565 Mon Sep 17 00:00:00 2001 From: RhenCloud Date: Sun, 2 Aug 2026 08:39:46 +0800 Subject: [PATCH] feat(admin): group-centric console navigation and group-scoped routes API Add GET/PUT /api/groups/:groupId/routes endpoints that filter and merge routes by group (backend forces groupId from the path param). Rework the console so the top level lists groups; entering a group shows and manages only that group's routes. Drop the route-level group selector and the now-redundant group badge on route cards. --- admin/components/RouteCard.vue | 21 +- admin/components/RouteEditor.vue | 78 +++----- admin/composables/useGroupRoutes.ts | 51 +++++ admin/pages/admin.vue | 292 +++++++++++++++------------- src/admin-routes.ts | 71 +++++++ 5 files changed, 327 insertions(+), 186 deletions(-) create mode 100644 admin/composables/useGroupRoutes.ts diff --git a/admin/components/RouteCard.vue b/admin/components/RouteCard.vue index f2767d3..3c74c32 100644 --- a/admin/components/RouteCard.vue +++ b/admin/components/RouteCard.vue @@ -6,35 +6,36 @@ - {{ route.name || "(untitled)" }} + {{ route.name || t("route.untitled") }} {{ route.id }} {{ route.lang }} - {{ groupName }}
- - + +
- {{ f.exclude ? "NOT " : "" }}{{ FILTER_LABELS[f.type] || f.type }} + {{ f.exclude ? t("routeEditor.not") + " " : "" }}{{ t("filter." + f.type) }} {{ fmtMatch(f.match) }} - no filters + {{ t("route.noFilters") }}
- CHANNEL{{ route.target.channelId }} - THREAD{{ route.target.threadId }} + {{ t("route.channel") }}{{ route.target.channelId }} + {{ t("route.thread") }}{{ route.target.threadId }}
diff --git a/src/admin-routes.ts b/src/admin-routes.ts index f710b4d..0ded8aa 100644 --- a/src/admin-routes.ts +++ b/src/admin-routes.ts @@ -259,5 +259,76 @@ export function createAdminRoutes(): Hono<{ Bindings: Env }> { return c.json({ ok: true, count: nextAll.length }); }); + // Routes scoped to a single group. The group is the container: the console + // enters a group and then lists / edits only that group's routes. + function groupAccess( + s: { scope: AccessScope; groups: Group[] }, + groupId: string, + ): { ok: true; group: Group } | { ok: false; status: 403 | 404 } { + const group = s.groups.find((g) => g.id === groupId); + if (!group) return { ok: false, status: 404 }; + if (!s.scope.isSuper && !s.scope.groupIds.has(groupId)) return { ok: false, status: 403 }; + return { ok: true, group }; + } + + app.get("/api/groups/:groupId/routes", async (c) => { + const s = await loadScope(c); + if (!s) return c.json({ error: "Unauthorized" }, 401); + const groupId = c.req.param("groupId"); + const access = groupAccess(s, groupId); + if (!access.ok) { + return c.json({ error: access.status === 404 ? "Group not found" : "Forbidden" }, access.status); + } + const all = await loadRoutes(c.env.KV); + return c.json({ group: access.group, routes: all.filter((r) => r.groupId === groupId) }); + }); + + app.put("/api/groups/:groupId/routes", async (c) => { + const s = await loadScope(c); + if (!s) return c.json({ error: "Unauthorized" }, 401); + const groupId = c.req.param("groupId"); + const access = groupAccess(s, groupId); + if (!access.ok) { + return c.json({ error: access.status === 404 ? "Group not found" : "Forbidden" }, access.status); + } + + let body: unknown; + try { + body = await c.req.json(); + } catch { + return c.json({ error: "Invalid JSON body" }, 400); + } + + // Force every submitted route into this group so the client never has to + // carry a groupId; the path parameter is the single source of truth. + const submitted = (body as { routes?: unknown })?.routes; + const scoped = Array.isArray(submitted) + ? submitted.map((r) => ({ ...(r as Record), groupId })) + : submitted; + const result = validateRoutes(scoped); + if (!result.ok) return c.json({ error: result.error }, 400); + + const existing = await loadRoutes(c.env.KV); + // Replace only this group's routes; every other group is preserved untouched. + const others = existing.filter((r) => r.groupId !== groupId); + const nextAll = [...others, ...result.routes]; + + // Guard against ids colliding with routes in other groups. + const ids = new Set(); + for (const r of nextAll) { + if (ids.has(r.id)) return c.json({ error: `duplicate route id "${r.id}"` }, 400); + ids.add(r.id); + } + + try { + await saveRoutes(c.env.KV, nextAll); + } catch (err) { + log.error({ err }, "Failed to save routes"); + return c.json({ error: "Failed to save routes" }, 500); + } + log.info({ groupId, count: result.routes.length }, "Group routes updated via admin UI"); + return c.json({ ok: true, count: result.routes.length }); + }); + return app; }