feat(admin): GitHub OAuth login, admin whitelist and Nuxt WebUI

Add an admin WebUI (Nuxt static SPA in admin/, served via the ASSETS
binding) for managing routes and viewing send logs. Access is gated by
GitHub OAuth plus an ADMIN_USER_IDS whitelist with cookie sessions
(admin-session.ts). Expose routes/logs CRUD API (admin-routes.ts), add a
discord-link mapping in token-store.ts, and mount admin routes with an
SPA assets fallback in the server.
This commit is contained in:
RhenCloud 2026-08-02 06:50:41 +08:00
parent cfdf37e273
commit 407514f3c9
No known key found for this signature in database
GPG key ID: A574A617378C4E0B
20 changed files with 3242 additions and 13 deletions

View file

@ -0,0 +1,13 @@
<template>
<div class="toasts">
<TransitionGroup name="toast">
<div v-for="t in toasts" :key="t.id" class="toast" :class="t.kind" @click="dismiss(t.id)">
{{ t.msg }}
</div>
</TransitionGroup>
</div>
</template>
<script setup lang="ts">
const { toasts, dismiss } = useToasts();
</script>

View file

@ -0,0 +1,47 @@
<template>
<article class="card" :class="{ disabled: !route.enabled }">
<div class="card-head">
<div class="card-title">
<label class="switch" @click.prevent>
<input type="checkbox" :checked="route.enabled" @change="onToggle" />
<span class="track"></span>
</label>
<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>
</div>
<div class="card-actions">
<button class="icon-btn" title="Edit" @click="$emit('edit', route)"></button>
<button class="icon-btn danger" title="Delete" @click="$emit('delete', route)"></button>
</div>
</div>
<div class="chips">
<span v-for="(f, i) in route.filters" :key="i" class="chip" :class="{ exclude: f.exclude }">
<span class="f-type">{{ f.exclude ? "NOT " : "" }}{{ FILTER_LABELS[f.type] || f.type }}</span>
<span class="f-val">{{ fmtMatch(f.match) }}</span>
</span>
<span v-if="!route.filters.length" class="chip"><span class="f-type">no filters</span></span>
</div>
<div class="target">
<span><b>CHANNEL</b><code>{{ route.target.channelId }}</code></span>
<span v-if="route.target.threadId"><b>THREAD</b><code>{{ route.target.threadId }}</code></span>
</div>
</article>
</template>
<script setup lang="ts">
import type { Route } from "~/types";
import { FILTER_LABELS, fmtMatch } from "~/types";
const props = defineProps<{ route: Route }>();
const emit = defineEmits<{
(e: "toggle", route: Route): void;
(e: "edit", route: Route): void;
(e: "delete", route: Route): void;
}>();
function onToggle(event: Event): void {
const next = { ...props.route, enabled: (event.target as HTMLInputElement).checked };
emit("toggle", next);
}
</script>

View file

@ -0,0 +1,187 @@
<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 route" : "New route" }}</h2>
<button class="icon-btn" title="Close" @click="close"></button>
</div>
<form class="editor-body" @submit.prevent="save">
<div class="field">
<label>Name</label>
<input v-model="form.name" type="text" placeholder="My Route" required />
</div>
<div class="row2">
<div class="field">
<label>ID</label>
<input v-model="form.id" type="text" placeholder="my-route" required />
<div class="hint">Unique, use a-z / 0-9 / dashes</div>
</div>
<div class="field">
<label>Language</label>
<input v-model="form.lang" type="text" placeholder="en" />
<div class="hint">en or zh; custom via KV i18n:&lt;lang&gt;</div>
</div>
</div>
<div class="field inline">
<input v-model="form.enabled" type="checkbox" />
<span>Route enabled</span>
</div>
<div class="field">
<label>Filters <span class="lbl-note">(all must match · AND)</span></label>
<div v-for="(f, i) in form.filters" :key="i" class="filter-row">
<select v-model="f.type">
<option v-for="t in FILTER_TYPES" :key="t" :value="t">{{ FILTER_LABELS[t] }}</option>
</select>
<input v-model="f.matchText" type="text" placeholder="match value" />
<label class="inline" title="Invert this filter">
<input v-model="f.exclude" type="checkbox" /><span>NOT</span>
</label>
<button type="button" class="icon-btn danger" title="Remove filter" @click="form.filters.splice(i, 1)"></button>
</div>
<button type="button" class="btn btn-ghost add-filter" @click="addFilter">+ Add filter</button>
<div class="err">{{ filterError }}</div>
</div>
<div class="row2">
<div class="field">
<label>Channel ID</label>
<input v-model="form.channelId" type="text" placeholder="Discord channel ID" required />
</div>
<div class="field">
<label>Thread ID <span class="lbl-note">(optional)</span></label>
<input v-model="form.threadId" type="text" placeholder="Optional thread ID" />
</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 route</button>
</div>
</aside>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { reactive, watch } from "vue";
import type { Filter, Route } 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 emit = defineEmits<{
(e: "close"): void;
(e: "save", route: Route): void;
}>();
const isEdit = computed(() => props.route != null);
const filterError = ref("");
const formError = ref("");
const form = reactive({
id: "",
name: "",
lang: "",
enabled: true,
channelId: "",
threadId: "",
filters: [] as FilterForm[],
});
function parseMatch(text: string): string | string[] | null {
const parts = text
.split(",")
.map((s) => s.trim())
.filter(Boolean);
if (!parts.length) return null;
return parts.length === 1 ? parts[0]! : parts;
}
function blankFilter(): FilterForm {
return { type: "event", match: "", exclude: false, matchText: "" };
}
function addFilter(): void {
form.filters.push(blankFilter());
filterError.value = "";
}
watch(
() => props.open,
(open) => {
if (!open) return;
const r = props.route;
form.id = r?.id ?? "";
form.name = r?.name ?? "";
form.lang = r?.lang ?? "";
form.enabled = r?.enabled ?? true;
form.channelId = r?.target.channelId ?? "";
form.threadId = r?.target.threadId ?? "";
form.filters = (r && r.filters.length
? r.filters
: [{ type: "event", match: "", exclude: false }]
).map((f) => ({ ...f, matchText: fmtMatch(f.match) })) as FilterForm[];
filterError.value = "";
formError.value = "";
},
);
function close(): void {
emit("close");
}
function collect(): Route | null {
const filters: Filter[] = [];
for (let i = 0; i < form.filters.length; i++) {
const f = form.filters[i]!;
const match = parseMatch(f.matchText);
if (!match) {
filterError.value = `Filter ${i + 1} needs a match value`;
return null;
}
filters.push({ type: f.type, match, exclude: f.exclude });
}
filterError.value = "";
if (!filters.length) {
filterError.value = "Add at least one filter";
return null;
}
return {
id: form.id.trim(),
name: form.name.trim(),
enabled: form.enabled,
lang: form.lang.trim() || undefined,
filters,
target: {
channelId: form.channelId.trim(),
threadId: form.threadId.trim() || undefined,
},
};
}
function save(): void {
formError.value = "";
const route = collect();
if (!route) return;
if (!/^[a-z0-9][a-z0-9-]*$/.test(route.id)) {
formError.value = "ID must be a-z / 0-9 / dashes";
return;
}
if (!route.name) {
formError.value = "Name is required";
return;
}
if (!route.target.channelId) {
formError.value = "Channel ID is required";
return;
}
emit("save", route);
}
</script>

View file

@ -0,0 +1,42 @@
<template>
<div>
<div class="log-toolbar">
<span class="kpi-label">LAST {{ logs.length }} SENDS</span>
<button class="btn btn-ghost btn-sm" :disabled="loading" @click="refresh"> Refresh</button>
</div>
<p v-if="error" class="err">{{ error }}</p>
<p v-else-if="!loading && !logs.length" class="empty-log">No send records yet trigger a webhook to see results.</p>
<div class="log-list">
<article v-for="l in logs" :key="l.ts + l.routeId" class="log-entry" :class="{ fail: !l.ok }">
<div class="log-head">
<span class="dot" :class="l.ok ? 'ok' : 'bad'"></span>
<span class="log-route">{{ l.routeId }}</span>
<span class="log-event">{{ l.event }}</span>
<span class="log-time">{{ fmtTime(l.ts) }}</span>
</div>
<div class="log-meta">
<span v-if="l.repo"><b>REPO</b><code>{{ l.repo }}</code></span>
<span><b>TARGET</b><code>{{ l.target }}</code></span>
</div>
<div v-if="!l.ok && l.error" class="log-error">{{ l.error }}</div>
</article>
</div>
</div>
</template>
<script setup lang="ts">
import type { SendRecord } from "~/types";
const props = defineProps<{ logs: SendRecord[]; loading: boolean }>();
const emit = defineEmits<{ (e: "refresh"): void }>();
function fmtTime(ts: number): string {
return new Date(ts).toLocaleString();
}
function refresh(): void {
emit("refresh");
}
</script>