From e59b10f7392d90014499163a48d9e42380834a4e Mon Sep 17 00:00:00 2001 From: RhenCloud Date: Fri, 14 Aug 2026 07:18:55 +0800 Subject: [PATCH] refactor(admin): unify webui fetch/copy/format helpers; fix audit tab load and delete-group count - apiFetch + shared needLogin (any 401 shows the login card) replaces six duplicated fetch/401/error-handling blocks in the admin composables - useCopy composable: clipboard + execCommand fallback with timed reset, used by MembersPanel and WebhookPanel - utils/format.ts (fmtTime/splitList/parseMatch) removes inline duplicates across SendLogs, AuditLog, RouteEditor and GroupEditor - fix: load the audit log when the audit tab becomes active (client-side navigation never remounted the page, leaving the list empty on first visit) - fix: fetch the real route count for a group before confirming deletion instead of reusing the currently open group's route list --- app/components/AuditLog.vue | 4 --- app/components/ConsolePage.vue | 23 +++++++++++++++-- app/components/GroupEditor.vue | 7 ------ app/components/MembersPanel.vue | 29 +++------------------- app/components/RouteEditor.vue | 9 ------- app/components/SendLogs.vue | 4 --- app/components/WebhookPanel.vue | 27 +++----------------- app/composables/useApi.ts | 30 ++++++++++++++++++++++ app/composables/useAudit.ts | 15 +++-------- app/composables/useCopy.ts | 33 +++++++++++++++++++++++++ app/composables/useGroupRoutes.ts | 29 +++++----------------- app/composables/useGroups.ts | 41 +++++-------------------------- app/composables/useInvites.ts | 33 ++++++------------------- app/composables/useLogs.ts | 26 +++----------------- app/composables/useWebhook.ts | 11 +-------- app/utils/format.ts | 18 ++++++++++++++ 16 files changed, 137 insertions(+), 202 deletions(-) create mode 100644 app/composables/useApi.ts create mode 100644 app/composables/useCopy.ts create mode 100644 app/utils/format.ts diff --git a/app/components/AuditLog.vue b/app/components/AuditLog.vue index 0f794a2..91f7da4 100644 --- a/app/components/AuditLog.vue +++ b/app/components/AuditLog.vue @@ -70,10 +70,6 @@ const emit = defineEmits<{ (e: "update:selectedGroupId", value: string): void; }>(); -function fmtTime(ts: number): string { - return new Date(ts).toLocaleString(); -} - function refresh(): void { emit("refresh"); } diff --git a/app/components/ConsolePage.vue b/app/components/ConsolePage.vue index 950e63c..94c9acb 100644 --- a/app/components/ConsolePage.vue +++ b/app/components/ConsolePage.vue @@ -311,9 +311,18 @@ onMounted(() => { } loadGroups(); loadLogs(50, logFilterGroup.value || undefined); - if (view.value === "audit") loadAudit(50, auditFilterGroup.value || undefined); }); +// Tab switches are client-side navigations (no remount), so load the audit +// log whenever the audit view becomes active — including direct deep links. +watch( + view, + (v) => { + if (v === "audit") loadAudit(50, auditFilterGroup.value || undefined); + }, + { immediate: true }, +); + function switchView(next: "groups" | "logs" | "audit"): void { const path = next === "groups" ? "/admin" : `/admin/${next}`; if (route.path !== path) router.replace(path); @@ -469,7 +478,17 @@ async function onSaveGroup(group: Group): Promise { } async function onDeleteGroup(group: Group): Promise { - const used = groupRoutes.value.filter((r) => r.groupId === group.id).length; + // Fetch the real route count for this group instead of relying on the + // routes of whichever group happens to be open in the detail view. + let used = 0; + try { + const data = await apiFetch<{ routes?: Route[] }>( + `/admin/api/groups/${encodeURIComponent(group.id)}/routes`, + ); + used = (data.routes ?? []).length; + } catch { + // Count is best-effort; proceed without the warning. + } const warn = used ? t("confirm.deleteGroupWarn", { n: used }) : ""; if (!window.confirm(t("confirm.deleteGroup", { name: group.name || group.id }) + warn)) return; try { diff --git a/app/components/GroupEditor.vue b/app/components/GroupEditor.vue index 3512224..bc9366d 100644 --- a/app/components/GroupEditor.vue +++ b/app/components/GroupEditor.vue @@ -216,13 +216,6 @@ const form = reactive({ logTopicId: "", }); -function splitList(text: string): string[] { - return text - .split(",") - .map((s) => s.trim()) - .filter(Boolean); -} - function toggleProvider(p: "github" | "gitea", e: Event): void { const checked = (e.target as HTMLInputElement).checked; form.providers = checked diff --git a/app/components/MembersPanel.vue b/app/components/MembersPanel.vue index ef0cc7a..af56139 100644 --- a/app/components/MembersPanel.vue +++ b/app/components/MembersPanel.vue @@ -72,7 +72,7 @@ {{ t("roles." + inv.role) }} {{ fmtExp(inv.expiresAt) }} @@ -101,8 +101,8 @@ const newLogin = ref(""); const newRole = ref("admin"); const inviteRole = ref<"admin" | "viewer">("admin"); const inviting = ref(false); -const copiedToken = ref(""); const formError = ref(""); +const { copied, copy } = useCopy(2000); watch( () => props.group, @@ -175,11 +175,7 @@ async function createInvite(): Promise { ...invites.value, ]; } - copyText(`${window.location.origin}${url}`); - copiedToken.value = token; - window.setTimeout(() => { - copiedToken.value = ""; - }, 2000); + copy(`${window.location.origin}${url}`, token); await loadInvites(); } catch (err) { formError.value = err instanceof Error ? err.message : String(err); @@ -190,11 +186,7 @@ async function createInvite(): Promise { async function copyInvite(inv: GroupInvite): Promise { const url = `${window.location.origin}/admin/invite?token=${inv.token}`; - copyText(url); - copiedToken.value = inv.token; - window.setTimeout(() => { - copiedToken.value = ""; - }, 2000); + await copy(url, inv.token); } async function revoke(inv: GroupInvite): Promise { @@ -206,19 +198,6 @@ async function revoke(inv: GroupInvite): Promise { } } -async function copyText(text: string): Promise { - try { - await navigator.clipboard.writeText(text); - } catch { - const ta = document.createElement("textarea"); - ta.value = text; - document.body.appendChild(ta); - ta.select(); - document.execCommand("copy"); - document.body.removeChild(ta); - } -} - function shortToken(token: string): string { return token.slice(0, 8) + "…"; } diff --git a/app/components/RouteEditor.vue b/app/components/RouteEditor.vue index 6e75bcf..7ce1227 100644 --- a/app/components/RouteEditor.vue +++ b/app/components/RouteEditor.vue @@ -210,15 +210,6 @@ const form = reactive({ 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: "" }; } diff --git a/app/components/SendLogs.vue b/app/components/SendLogs.vue index 10b7861..f5f9eac 100644 --- a/app/components/SendLogs.vue +++ b/app/components/SendLogs.vue @@ -137,10 +137,6 @@ const detailRows = computed(() => { return rows; }); -function fmtTime(ts: number): string { - return new Date(ts).toLocaleString(); -} - function refresh(): void { emit("refresh"); } diff --git a/app/components/WebhookPanel.vue b/app/components/WebhookPanel.vue index ea27b4d..c0156ea 100644 --- a/app/components/WebhookPanel.vue +++ b/app/components/WebhookPanel.vue @@ -12,7 +12,7 @@ {{ t("webhook.url") }} {{ info.url }} @@ -26,7 +26,7 @@ class="btn btn-ghost btn-sm" @click="copy(info.secret!, 'secret')" > - {{ copiedSecret ? t("webhook.copied") : t("webhook.copy") }} + {{ copied === "secret" ? t("webhook.copied") : t("webhook.copy") }}

{{ t("webhook.secretHidden") }}

@@ -68,10 +68,9 @@ const props = defineProps<{ groupId: string; canEdit: boolean }>(); const api = useWebhookApi(); const info = ref(null); -const copiedUrl = ref(false); -const copiedSecret = ref(false); const busy = ref(false); const error = ref(""); +const { copied, copy } = useCopy(); const maskedSecret = "••••••••••••••••"; const customExample = [ @@ -112,7 +111,6 @@ async function onRegenerate(): Promise { error.value = ""; try { info.value = await api.regenerate(props.groupId); - copiedSecret.value = false; } catch (err) { error.value = err instanceof Error ? err.message : String(err); } finally { @@ -132,23 +130,4 @@ async function onDisable(): Promise { busy.value = false; } } - -async function copy(text: string, which: "url" | "secret"): Promise { - try { - await navigator.clipboard.writeText(text); - if (which === "url") { - copiedUrl.value = true; - window.setTimeout(() => { - copiedUrl.value = false; - }, 1500); - } else { - copiedSecret.value = true; - window.setTimeout(() => { - copiedSecret.value = false; - }, 1500); - } - } catch { - // ignore clipboard failures - } -} diff --git a/app/composables/useApi.ts b/app/composables/useApi.ts new file mode 100644 index 0000000..d8b0e77 --- /dev/null +++ b/app/composables/useApi.ts @@ -0,0 +1,30 @@ +/** + * Shared "session expired" flag: any 401 from the admin API flips it and the + * console swaps to the login card. One source of truth for every composable. + */ +export function useAuthState() { + const needLogin = useState("wh-need-login", () => false); + return { needLogin }; +} + +/** + * JSON fetch for same-origin admin API calls: sends credentials, treats 401 + * as "not logged in" (flips the shared flag and throws), and surfaces the + * server's `{ error }` message on failure. + */ +export async function apiFetch(path: string, init?: RequestInit): Promise { + const res = await fetch(path, { + credentials: "same-origin", + ...init, + headers: { accept: "application/json", ...(init?.headers ?? {}) }, + }); + if (res.status === 401) { + useAuthState().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}`); + } + return (await res.json()) as T; +} diff --git a/app/composables/useAudit.ts b/app/composables/useAudit.ts index ec913a0..da08cd0 100644 --- a/app/composables/useAudit.ts +++ b/app/composables/useAudit.ts @@ -1,9 +1,9 @@ import type { AuditEntry } from "~/types"; export function useAuditApi() { + const { needLogin } = useAuthState(); const entries = ref([]); const loading = ref(false); - const needLogin = ref(false); const error = ref(""); async function load(limit = 50, groupId?: string): Promise { @@ -13,19 +13,10 @@ export function useAuditApi() { try { let url = `/admin/api/audit?limit=${limit}`; if (groupId) url += `&groupId=${encodeURIComponent(groupId)}`; - const res = await fetch(url, { - 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 { audit?: AuditEntry[] }; + const data = await apiFetch<{ audit?: AuditEntry[] }>(url); entries.value = data.audit ?? []; } catch (err) { - error.value = err instanceof Error ? err.message : String(err); + if (!needLogin.value) error.value = err instanceof Error ? err.message : String(err); } finally { loading.value = false; } diff --git a/app/composables/useCopy.ts b/app/composables/useCopy.ts new file mode 100644 index 0000000..18a362b --- /dev/null +++ b/app/composables/useCopy.ts @@ -0,0 +1,33 @@ +/** + * Clipboard helper with execCommand fallback and a "just copied" flag keyed + * by whatever identifies the copied value (a token, "url", "secret", ...). + * The flag resets after `duration` ms. + */ +export function useCopy(duration = 1500) { + const copied = ref(""); + + async function copy(text: string, key: string): Promise { + try { + await navigator.clipboard.writeText(text); + } catch { + try { + const ta = document.createElement("textarea"); + ta.value = text; + ta.style.position = "fixed"; + ta.style.opacity = "0"; + document.body.appendChild(ta); + ta.select(); + document.execCommand("copy"); + document.body.removeChild(ta); + } catch { + return; + } + } + copied.value = key; + window.setTimeout(() => { + if (copied.value === key) copied.value = ""; + }, duration); + } + + return { copied, copy }; +} diff --git a/app/composables/useGroupRoutes.ts b/app/composables/useGroupRoutes.ts index 995282c..f2fbf4a 100644 --- a/app/composables/useGroupRoutes.ts +++ b/app/composables/useGroupRoutes.ts @@ -1,9 +1,9 @@ import type { Route } from "~/types"; export function useGroupRoutesApi() { + const { needLogin } = useAuthState(); const routes = ref([]); const loading = ref(false); - const needLogin = ref(false); const error = ref(""); async function load(groupId: string): Promise { @@ -11,39 +11,22 @@ export function useGroupRoutesApi() { error.value = ""; needLogin.value = false; try { - const res = await fetch(`/admin/api/groups/${encodeURIComponent(groupId)}/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[] }; + const data = await apiFetch<{ routes?: Route[] }>( + `/admin/api/groups/${encodeURIComponent(groupId)}/routes`, + ); routes.value = data.routes ?? []; } catch (err) { - error.value = err instanceof Error ? err.message : String(err); + if (!needLogin.value) error.value = err instanceof Error ? err.message : String(err); } finally { loading.value = false; } } async function save(groupId: string, next: Route[]): Promise { - const res = await fetch(`/admin/api/groups/${encodeURIComponent(groupId)}/routes`, { + await apiFetch(`/admin/api/groups/${encodeURIComponent(groupId)}/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; } diff --git a/app/composables/useGroups.ts b/app/composables/useGroups.ts index 4767f82..95e6139 100644 --- a/app/composables/useGroups.ts +++ b/app/composables/useGroups.ts @@ -1,12 +1,12 @@ import type { Group, GroupRole } from "~/types"; export function useGroupsApi() { + const { needLogin } = useAuthState(); const groups = ref([]); const isSuper = ref(false); /** groupId → role of the signed-in user (absent for super admins). */ const roles = ref>({}); const loading = ref(false); - const needLogin = ref(false); const error = ref(""); function roleOf(groupId: string): GroupRole | undefined { @@ -28,64 +28,35 @@ export function useGroupsApi() { 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 { + const data = await apiFetch<{ groups?: Group[]; isSuper?: boolean; roles?: Record; - }; + }>("/admin/api/groups"); groups.value = data.groups ?? []; isSuper.value = data.isSuper ?? false; roles.value = data.roles ?? {}; } catch (err) { - error.value = err instanceof Error ? err.message : String(err); + if (!needLogin.value) 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", { + await apiFetch("/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; } /** Rename a group; routes, webhook secret and invites follow automatically. */ async function rename(oldId: string, newId: string): Promise { - const res = await fetch(`/admin/api/groups/${encodeURIComponent(oldId)}/rename`, { + await apiFetch(`/admin/api/groups/${encodeURIComponent(oldId)}/rename`, { method: "PUT", - headers: { "content-type": "application/json" }, - credentials: "same-origin", body: JSON.stringify({ newId }), }); - 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}`); - } } return { diff --git a/app/composables/useInvites.ts b/app/composables/useInvites.ts index 9dc5edc..983c895 100644 --- a/app/composables/useInvites.ts +++ b/app/composables/useInvites.ts @@ -8,17 +8,10 @@ export function useInvitesApi() { loading.value = true; error.value = ""; try { - const res = await fetch(`/admin/api/groups/${encodeURIComponent(groupId)}/invites`, { - method: "POST", - headers: { "content-type": "application/json" }, - credentials: "same-origin", - body: JSON.stringify({ role }), - }); - if (!res.ok) { - const data = (await res.json().catch(() => ({}))) as { error?: string }; - throw new Error(data.error ?? `HTTP ${res.status}`); - } - const data = (await res.json()) as { url?: string }; + const data = await apiFetch<{ url?: string }>( + `/admin/api/groups/${encodeURIComponent(groupId)}/invites`, + { method: "POST", body: JSON.stringify({ role }) }, + ); return data.url ?? ""; } catch (err) { error.value = err instanceof Error ? err.message : String(err); @@ -32,12 +25,9 @@ export function useInvitesApi() { loading.value = true; error.value = ""; try { - const res = await fetch(`/admin/api/groups/${encodeURIComponent(groupId)}/invites`, { - headers: { accept: "application/json" }, - credentials: "same-origin", - }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const data = (await res.json()) as { invites?: GroupInvite[] }; + const data = await apiFetch<{ invites?: GroupInvite[] }>( + `/admin/api/groups/${encodeURIComponent(groupId)}/invites`, + ); return data.invites ?? []; } catch (err) { error.value = err instanceof Error ? err.message : String(err); @@ -51,14 +41,7 @@ export function useInvitesApi() { loading.value = true; error.value = ""; try { - const res = await fetch(`/admin/api/invites/${encodeURIComponent(token)}`, { - method: "DELETE", - credentials: "same-origin", - }); - if (!res.ok) { - const data = (await res.json().catch(() => ({}))) as { error?: string }; - throw new Error(data.error ?? `HTTP ${res.status}`); - } + await apiFetch(`/admin/api/invites/${encodeURIComponent(token)}`, { method: "DELETE" }); } catch (err) { error.value = err instanceof Error ? err.message : String(err); throw err; diff --git a/app/composables/useLogs.ts b/app/composables/useLogs.ts index c1311c6..ed7222e 100644 --- a/app/composables/useLogs.ts +++ b/app/composables/useLogs.ts @@ -1,9 +1,9 @@ import type { SendRecord } from "~/types"; export function useSendLogs() { + const { needLogin } = useAuthState(); const logs = ref([]); const loading = ref(false); - const needLogin = ref(false); const error = ref(""); async function load(limit = 50, groupId?: string): Promise { @@ -13,35 +13,17 @@ export function useSendLogs() { try { let url = `/admin/api/logs?limit=${limit}`; if (groupId) url += `&groupId=${encodeURIComponent(groupId)}`; - const res = await fetch(url, { - 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[] }; + const data = await apiFetch<{ logs?: SendRecord[] }>(url); logs.value = data.logs ?? []; } catch (err) { - error.value = err instanceof Error ? err.message : String(err); + if (!needLogin.value) error.value = err instanceof Error ? err.message : String(err); } finally { loading.value = false; } } async function loadById(id: number): Promise { - const res = await fetch(`/admin/api/logs/${id}`, { - headers: { accept: "application/json" }, - credentials: "same-origin", - }); - if (res.status === 401) { - needLogin.value = true; - return null; - } - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const data = (await res.json()) as { log?: SendRecord }; + const data = await apiFetch<{ log?: SendRecord }>(`/admin/api/logs/${id}`); return data.log ?? null; } diff --git a/app/composables/useWebhook.ts b/app/composables/useWebhook.ts index eae9628..9bf5afc 100644 --- a/app/composables/useWebhook.ts +++ b/app/composables/useWebhook.ts @@ -12,16 +12,7 @@ export function useWebhookApi() { loading.value = true; error.value = ""; try { - const res = await fetch(path, { - headers: { accept: "application/json" }, - credentials: "same-origin", - ...init, - }); - if (!res.ok) { - const data = (await res.json().catch(() => ({}))) as { error?: string }; - throw new Error(data.error ?? `HTTP ${res.status}`); - } - return (await res.json()) as T; + return await apiFetch(path, init); } catch (err) { error.value = err instanceof Error ? err.message : String(err); throw err; diff --git a/app/utils/format.ts b/app/utils/format.ts new file mode 100644 index 0000000..8ed4153 --- /dev/null +++ b/app/utils/format.ts @@ -0,0 +1,18 @@ +/** Split a comma-separated input into trimmed non-empty parts. */ +export function splitList(text: string): string[] { + return text + .split(",") + .map((s) => s.trim()) + .filter(Boolean); +} + +/** Comma-separated filter match → single value, list, or null when empty. */ +export function parseMatch(text: string): string | string[] | null { + const parts = splitList(text); + if (!parts.length) return null; + return parts.length === 1 ? parts[0]! : parts; +} + +export function fmtTime(ts: number): string { + return new Date(ts).toLocaleString(); +}