mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-22 16:11:29 +00:00
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:
parent
537f4cbb84
commit
62985a2f3f
8 changed files with 382 additions and 9 deletions
|
|
@ -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;
|
||||||
|
|
|
||||||
112
admin/components/GroupEditor.vue
Normal file
112
admin/components/GroupEditor.vue
Normal 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>
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
53
admin/composables/useGroups.ts
Normal file
53
admin/composables/useGroups.ts
Normal 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 };
|
||||||
|
}
|
||||||
30
admin/composables/useMe.ts
Normal file
30
admin/composables/useMe.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue