diff --git a/admin/assets/css/main.css b/admin/assets/css/main.css
index 2256283..51a816e 100644
--- a/admin/assets/css/main.css
+++ b/admin/assets/css/main.css
@@ -339,6 +339,12 @@ main {
color: var(--accent-text);
}
+.badge.group {
+ background: var(--ok-dim);
+ color: var(--ok);
+ border: 1px solid transparent;
+}
+
.card-actions {
display: flex;
gap: 6px;
diff --git a/admin/components/GroupEditor.vue b/admin/components/GroupEditor.vue
new file mode 100644
index 0000000..6dd2962
--- /dev/null
+++ b/admin/components/GroupEditor.vue
@@ -0,0 +1,112 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/admin/components/RouteCard.vue b/admin/components/RouteCard.vue
index 5b542e8..f2767d3 100644
--- a/admin/components/RouteCard.vue
+++ b/admin/components/RouteCard.vue
@@ -9,6 +9,7 @@
{{ route.name || "(untitled)" }}
{{ route.id }}
{{ route.lang }}
+ {{ groupName }}
@@ -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;
diff --git a/admin/components/RouteEditor.vue b/admin/components/RouteEditor.vue
index b9f2088..2d07e74 100644
--- a/admin/components/RouteEditor.vue
+++ b/admin/components/RouteEditor.vue
@@ -14,6 +14,14 @@
+
+
+
+
Create a group first (Groups tab).
+
@@ -68,14 +76,14 @@
diff --git a/admin/composables/useGroups.ts b/admin/composables/useGroups.ts
new file mode 100644
index 0000000..f608918
--- /dev/null
+++ b/admin/composables/useGroups.ts
@@ -0,0 +1,53 @@
+import type { Group } from "~/types";
+
+export function useGroupsApi() {
+ const groups = ref([]);
+ const isSuper = ref(false);
+ const loading = ref(false);
+ const needLogin = ref(false);
+ const error = ref("");
+
+ async function load(): Promise {
+ 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 {
+ 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 };
+}
diff --git a/admin/composables/useMe.ts b/admin/composables/useMe.ts
new file mode 100644
index 0000000..6e2ff39
--- /dev/null
+++ b/admin/composables/useMe.ts
@@ -0,0 +1,30 @@
+import type { Me } from "~/types";
+
+export function useMeApi() {
+ const me = ref(null);
+ const loading = ref(false);
+ const needLogin = ref(false);
+
+ async function load(): Promise {
+ 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 };
+}
diff --git a/admin/pages/admin.vue b/admin/pages/admin.vue
index 2923e7c..e262d1e 100644
--- a/admin/pages/admin.vue
+++ b/admin/pages/admin.vue
@@ -9,7 +9,16 @@
-
+
+
Sign out
@@ -18,7 +27,9 @@
Config Console
-
Access denied — this GitHub account is not in ADMIN_USER_IDS.
+
+ Access denied — this GitHub account is not an admin or group admin.
+
Sign in with GitHub to manage routes.
Sign in with GitHub
@@ -29,6 +40,14 @@
+
@@ -53,6 +72,7 @@
v-for="(r, i) in routes"
:key="r.id"
:route="r"
+ :group-name="groupName(r.groupId)"
:style="{ animationDelay: i * 45 + 'ms' }"
@toggle="onToggle"
@edit="openEdit"
@@ -66,6 +86,51 @@
+
+
+
+ {{ groupsError }}
+
+
+
+
+
+ {{ g.name || "(untitled)" }}
+ {{ g.id }}
+
+
+
+
+
+
+
+ ADMINS{{ g.adminIds.length ? g.adminIds.join(", ") : "—" }}
+ OWNERS{{ g.owners && g.owners.length ? g.owners.join(", ") : "any" }}
+
+
+
+
+
+ No groups yet. Groups scope routes by org/user and delegate access.
+
+
+
+
diff --git a/admin/types.ts b/admin/types.ts
index 05ef82d..9daa259 100644
--- a/admin/types.ts
+++ b/admin/types.ts
@@ -14,6 +14,21 @@ export interface Route {
threadId?: 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;