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,33 @@
import type { SendRecord } from "~/types";
export function useSendLogs() {
const logs = ref<SendRecord[]>([]);
const loading = ref(false);
const needLogin = ref(false);
const error = ref("");
async function load(limit = 50): Promise<void> {
loading.value = true;
error.value = "";
needLogin.value = false;
try {
const res = await fetch(`/admin/api/logs?limit=${limit}`, {
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 { logs?: SendRecord[] };
logs.value = data.logs ?? [];
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);
} finally {
loading.value = false;
}
}
return { logs, loading, needLogin, error, load };
}

View file

@ -0,0 +1,51 @@
import type { Route } from "~/types";
export function useRoutesApi() {
const routes = ref<Route[]>([]);
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/routes", {
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 { routes?: Route[] };
routes.value = data.routes ?? [];
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);
} finally {
loading.value = false;
}
}
async function save(next: Route[]): Promise<void> {
const res = await fetch("/admin/api/routes", {
method: "PUT",
headers: { "content-type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ routes: 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}`);
}
routes.value = next;
}
return { routes, loading, needLogin, error, load, save };
}

View file

@ -0,0 +1,23 @@
export interface Toast {
id: number;
msg: string;
kind: "ok" | "bad";
}
export function useToasts() {
const toasts = useState<Toast[]>("app-toasts", () => []);
function push(msg: string, kind: "ok" | "bad" = "ok"): void {
const id = Date.now() + Math.floor(Math.random() * 1000);
toasts.value.push({ id, msg, kind });
setTimeout(() => {
toasts.value = toasts.value.filter((t) => t.id !== id);
}, 2600);
}
function dismiss(id: number): void {
toasts.value = toasts.value.filter((t) => t.id !== id);
}
return { toasts, push, dismiss };
}