feat(admin-ui): members panel, invite links, audit tab and role-aware console

- New MembersPanel: role switching, add/remove members, invite-link
  generation with copy/revoke, pending invite list
- New Audit tab (AuditLog.vue) with group filter
- Role-aware UI: viewer hides route/group edit actions (RouteCard readonly),
  owner-only controls, role badge + member count on group cards
- GroupEditor: members managed via panel, owners field super-admin only
- en/zh i18n and CSS for the new panels
This commit is contained in:
RhenCloud 2026-08-11 23:38:33 +08:00
parent 252f6f181a
commit 11b74aa82a
No known key found for this signature in database
GPG key ID: A574A617378C4E0B
11 changed files with 805 additions and 55 deletions

View file

@ -1130,6 +1130,135 @@ main {
white-space: pre-wrap;
}
/* ---- Members panel ---- */
.members-panel {
margin-top: 28px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px 20px;
}
.panel-head {
display: flex;
align-items: baseline;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.panel-head h3 {
font-size: 14px;
letter-spacing: 0.3px;
}
.members-panel h4 {
font-size: 12px;
margin: 14px 0 8px;
}
.member-list {
list-style: none;
margin: 0 0 12px;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.member-row,
.invite-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
padding: 8px 12px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.member-login {
font-weight: 600;
font-size: 13px;
flex: 1;
min-width: 120px;
}
.role-pill {
font-size: 11px;
font-weight: 700;
border-radius: 999px;
padding: 2px 10px;
letter-spacing: 0.5px;
text-transform: uppercase;
}
.role-pill.owner {
color: var(--accent);
background: var(--accent-dim, rgba(88, 101, 242, 0.12));
}
.role-pill.admin {
color: var(--ok);
background: var(--ok-dim);
}
.role-pill.viewer {
color: var(--faint);
background: var(--surface);
border: 1px solid var(--border);
}
.member-add,
.invite-create {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.text-input {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-size: 13px;
padding: 6px 10px;
min-width: 200px;
outline: none;
}
.text-input:focus {
border-color: var(--accent);
}
.invite-box {
margin-top: 14px;
border-top: 1px dashed var(--border);
padding-top: 6px;
}
.invite-list {
list-style: none;
margin: 10px 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.invite-token {
font-family: var(--mono);
font-size: 12px;
color: var(--muted);
}
.invite-exp {
font-size: 12px;
color: var(--faint);
}
@media (max-width: 720px) {
.filter-row {
grid-template-columns: 100px 1fr auto;

View file

@ -0,0 +1,85 @@
<template>
<div>
<div class="log-toolbar">
<span class="kpi-label">{{ t("audit.title") }}</span>
<div class="log-filters">
<label class="filter-label">{{ t("audit.filterGroup") }}</label>
<select
class="filter-select"
:value="selectedGroupId"
:disabled="loading"
@change="onGroupFilter"
>
<option value="">{{ t("audit.allGroups") }}</option>
<option v-for="g in groups" :key="g.id" :value="g.id">
{{ g.name || g.id }}
</option>
</select>
</div>
<button class="btn btn-ghost btn-sm" :disabled="loading" @click="refresh">
{{ t("audit.refresh") }}
</button>
</div>
<p v-if="error" class="err">{{ error }}</p>
<p v-else-if="!loading && !entries.length" class="empty-log">{{ t("audit.empty") }}</p>
<div class="log-list">
<article v-for="e in entries" :key="e.id ?? e.ts" class="log-entry">
<div class="log-head">
<span class="dot ok"></span>
<span class="log-route">{{ e.action }}</span>
<span class="log-event">{{ e.actorLogin || e.actorId || "-" }}</span>
<span class="log-status ok">{{ e.targetType || "-" }}</span>
<span class="log-time">{{ fmtTime(e.ts) }}</span>
</div>
<div class="log-meta">
<span
><b>{{ t("audit.target") }}</b
><code>{{ e.targetId || "-" }}</code></span
>
<span
><b>{{ t("audit.group") }}</b
><code>{{ e.groupId || "-" }}</code></span
>
<span v-if="e.detail && Object.keys(e.detail).length > 0"
><b>{{ t("audit.detail") }}</b
><code>{{ JSON.stringify(e.detail) }}</code></span
>
</div>
</article>
</div>
</div>
</template>
<script setup lang="ts">
import type { AuditEntry, Group } from "~/types";
const { t } = useI18n();
defineProps<{
entries: AuditEntry[];
loading: boolean;
error: string;
groups: Group[];
selectedGroupId: string;
}>();
const emit = defineEmits<{
(e: "refresh"): void;
(e: "filter"): void;
(e: "update:selectedGroupId", value: string): void;
}>();
function fmtTime(ts: number): string {
return new Date(ts).toLocaleString();
}
function refresh(): void {
emit("refresh");
}
function onGroupFilter(e: Event): void {
emit("update:selectedGroupId", (e.target as HTMLSelectElement).value);
emit("filter");
}
</script>

View file

@ -34,17 +34,14 @@
</div>
<div class="field">
<label
>{{ t("groupEditor.admins") }}
<span class="lbl-note">{{ t("groupEditor.adminsNote") }}</span></label
>{{ t("groupEditor.membersNote") }}
<span class="lbl-note">{{ t("groupEditor.membersHint") }}</span></label
>
<input
v-model="form.adminIds"
type="text"
:placeholder="t('groupEditor.adminsPlaceholder')"
/>
<div class="hint">{{ t("groupEditor.adminsHint") }}</div>
<p class="hint">
{{ t("groupEditor.membersGoPanel") }}
</p>
</div>
<div class="field">
<div v-if="superAdmin" class="field">
<label
>{{ t("groupEditor.owners") }}
<span class="lbl-note">{{ t("groupEditor.ownersNote") }}</span></label
@ -56,6 +53,13 @@
/>
<div class="hint">{{ t("groupEditor.ownersHint") }}</div>
</div>
<div v-else class="field">
<label
>{{ t("groupEditor.owners") }}
<span class="lbl-note">{{ t("groupEditor.ownersSuperOnly") }}</span></label
>
<input v-model="ownersReadonly" type="text" disabled />
</div>
<div class="field">
<label
>{{ t("groupEditor.providers") }}
@ -106,7 +110,7 @@ import type { Group } from "~/types";
const { t } = useI18n();
const props = defineProps<{ open: boolean; group: Group | null; saving: boolean }>();
const props = defineProps<{ open: boolean; group: Group | null; saving: boolean; superAdmin?: boolean }>();
const emit = defineEmits<{
(e: "close"): void;
(e: "save", group: Group): void;
@ -114,11 +118,11 @@ const emit = defineEmits<{
const isEdit = computed(() => props.group != null);
const formError = ref("");
const ownersReadonly = computed(() => (props.group?.owners ?? []).join(", "));
const form = reactive({
id: "",
name: "",
adminIds: "",
owners: "",
providers: [] as ("github" | "gitea")[],
emoji: true,
@ -145,7 +149,6 @@ watch(
const g = props.group;
form.id = g?.id ?? "";
form.name = g?.name ?? "";
form.adminIds = (g?.adminIds ?? []).join(", ");
form.owners = (g?.owners ?? []).join(", ");
form.providers = (g?.providers ?? []).filter(
(p): p is "github" | "gitea" => p === "github" || p === "gitea",
@ -172,11 +175,17 @@ function save(): void {
return;
}
const owners = splitList(form.owners);
const members = props.group?.members
? props.group.members.map((m) => ({ ...m }))
: props.group?.adminIds?.length
? props.group.adminIds.map((login) => ({ login, role: "owner" as const }))
: [];
emit("save", {
id,
name,
adminIds: splitList(form.adminIds),
owners: owners.length ? owners : undefined,
members,
adminIds: members.filter((m) => m.role === "owner").map((m) => m.login),
owners: props.superAdmin ? (owners.length ? owners : undefined) : props.group?.owners,
providers: form.providers.length ? form.providers : undefined,
emoji: form.emoji,
});

View file

@ -0,0 +1,213 @@
<template>
<section class="members-panel">
<div class="panel-head">
<h3>{{ t("members.title") }}</h3>
<span class="lbl-note">{{ t("members.note") }}</span>
</div>
<p v-if="!members.length" class="empty-log">{{ t("members.empty") }}</p>
<ul class="member-list">
<li v-for="m in members" :key="m.login.toLowerCase()" class="member-row">
<span class="member-login">{{ m.login }}</span>
<select
v-if="canEdit"
class="filter-select"
:value="m.role"
:disabled="saving"
@change="onRoleChange(m, $event)"
>
<option value="owner">{{ t("roles.owner") }}</option>
<option value="admin">{{ t("roles.admin") }}</option>
<option value="viewer">{{ t("roles.viewer") }}</option>
</select>
<span v-else class="role-pill" :class="m.role">{{ t("roles." + m.role) }}</span>
<button
v-if="canEdit"
class="icon-btn danger"
:disabled="saving"
:title="t('members.remove')"
@click="removeMember(m)"
>
</button>
</li>
</ul>
<div v-if="canEdit" class="member-add">
<input
v-model="newLogin"
type="text"
:placeholder="t('members.addPlaceholder')"
class="text-input"
@keydown.enter.prevent="addMember"
/>
<select v-model="newRole" class="filter-select">
<option value="owner">{{ t("roles.owner") }}</option>
<option value="admin">{{ t("roles.admin") }}</option>
<option value="viewer">{{ t("roles.viewer") }}</option>
</select>
<button class="btn btn-accent btn-sm" :disabled="saving" @click="addMember">
{{ t("members.addBtn") }}
</button>
</div>
<div v-if="canEdit" class="invite-box">
<h4>
{{ t("members.invites") }}
<span class="lbl-note">{{ t("members.inviteNote") }}</span>
</h4>
<div class="invite-create">
<select v-model="inviteRole" class="filter-select">
<option value="admin">{{ t("roles.admin") }}</option>
<option value="viewer">{{ t("roles.viewer") }}</option>
</select>
<button class="btn btn-accent btn-sm" :disabled="saving || inviting" @click="createInvite">
{{ t("members.createInvite") }}
</button>
</div>
<ul v-if="invites.length" class="invite-list">
<li v-for="inv in invites" :key="inv.token" class="invite-row">
<code class="invite-token">{{ shortToken(inv.token) }}</code>
<span class="role-pill" :class="inv.role">{{ t("roles." + inv.role) }}</span>
<span class="invite-exp">{{ fmtExp(inv.expiresAt) }}</span>
<button class="btn btn-ghost btn-sm" @click="copyInvite(inv)">
{{ copiedToken === inv.token ? t("members.copied") : t("members.copyLink") }}
</button>
<button class="icon-btn danger" @click="revoke(inv)">{{ t("members.revoke") }}</button>
</li>
</ul>
<p v-else class="empty-log">{{ t("members.noInvites") }}</p>
</div>
<div class="err">{{ formError }}</div>
</section>
</template>
<script setup lang="ts">
import { ref, watch } from "vue";
import type { Group, GroupInvite, GroupMember, GroupRole } from "~/types";
import { useInvitesApi } from "~/composables/useInvites";
const { t } = useI18n();
const props = defineProps<{ group: Group; canEdit: boolean; saving: boolean }>();
const emit = defineEmits<{ (e: "save", group: Group): void }>();
const invitesApi = useInvitesApi();
const members = ref<GroupMember[]>([]);
const invites = ref<GroupInvite[]>([]);
const newLogin = ref("");
const newRole = ref<GroupRole>("admin");
const inviteRole = ref<"admin" | "viewer">("admin");
const inviting = ref(false);
const copiedToken = ref("");
const formError = ref("");
watch(
() => props.group,
(g) => {
members.value = (g.members ?? []).map((m) => ({ ...m }));
formError.value = "";
if (props.canEdit) loadInvites();
},
{ immediate: true },
);
async function loadInvites(): Promise<void> {
invites.value = await invitesApi.list(props.group.id);
}
function ownerCount(): number {
return members.value.filter((m) => m.role === "owner").length;
}
function onRoleChange(m: GroupMember, e: Event): void {
const role = (e.target as HTMLSelectElement).value as GroupRole;
if (role === "viewer" && m.role === "owner" && ownerCount() === 1) {
formError.value = t("members.errLastOwner");
return;
}
m.role = role;
emitSave();
}
function addMember(): void {
const login = newLogin.value.trim();
if (!login) return;
if (members.value.some((m) => m.login.toLowerCase() === login.toLowerCase())) {
formError.value = t("members.errOwner");
return;
}
members.value.push({ login, role: newRole.value });
newLogin.value = "";
emitSave();
}
function removeMember(m: GroupMember): void {
if (m.role === "owner" && ownerCount() === 1) {
formError.value = t("members.errLastOwner");
return;
}
members.value = members.value.filter((x) => x !== m);
emitSave();
}
function emitSave(): void {
emit("save", { ...props.group, members: members.value.map((m) => ({ ...m })) });
}
async function createInvite(): Promise<void> {
inviting.value = true;
formError.value = "";
try {
const url = await invitesApi.create(props.group.id, inviteRole.value);
await loadInvites();
copyText(url);
copiedToken.value = "";
} catch (err) {
formError.value = err instanceof Error ? err.message : String(err);
} finally {
inviting.value = false;
}
}
async function copyInvite(inv: GroupInvite): Promise<void> {
const url = `${window.location.origin}/admin/invite?token=${inv.token}`;
copyText(url);
copiedToken.value = inv.token;
window.setTimeout(() => {
copiedToken.value = "";
}, 2000);
}
async function revoke(inv: GroupInvite): Promise<void> {
try {
await invitesApi.revoke(inv.token);
invites.value = invites.value.filter((i) => i.token !== inv.token);
} catch (err) {
formError.value = err instanceof Error ? err.message : String(err);
}
}
async function copyText(text: string): Promise<void> {
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) + "…";
}
function fmtExp(ts: number): string {
return new Date(ts).toLocaleDateString();
}
</script>

View file

@ -2,10 +2,11 @@
<article class="card" :class="{ disabled: !route.enabled }">
<div class="card-head">
<div class="card-title">
<label class="switch">
<label v-if="!readonly" class="switch">
<input type="checkbox" :checked="route.enabled" @change="onToggle" />
<span class="track"></span>
</label>
<span v-if="readonly" class="dot" :class="route.enabled ? 'ok' : 'bad'"></span>
<span class="route-name">{{ route.name || t("route.untitled") }}</span>
<span class="route-id">{{ route.id }}</span>
<span v-if="route.lang" class="badge lang">{{ route.lang }}</span>
@ -21,32 +22,34 @@
<span v-if="route.discordRoleIds?.length" class="badge lang">@roles</span>
</div>
<div class="card-actions">
<button
class="icon-btn"
:disabled="atFirst"
:title="t('route.moveUp')"
@click="$emit('move', route, -1)"
>
</button>
<button
class="icon-btn"
:disabled="atLast"
:title="t('route.moveDown')"
@click="$emit('move', route, 1)"
>
</button>
<button class="icon-btn" :title="t('routeEditor.editTitle')" @click="$emit('edit', route)">
</button>
<button
class="icon-btn danger"
:title="t('routeEditor.close')"
@click="$emit('delete', route)"
>
</button>
<template v-if="!readonly">
<button
class="icon-btn"
:disabled="atFirst"
:title="t('route.moveUp')"
@click="$emit('move', route, -1)"
>
</button>
<button
class="icon-btn"
:disabled="atLast"
:title="t('route.moveDown')"
@click="$emit('move', route, 1)"
>
</button>
<button class="icon-btn" :title="t('routeEditor.editTitle')" @click="$emit('edit', route)">
</button>
<button
class="icon-btn danger"
:title="t('routeEditor.close')"
@click="$emit('delete', route)"
>
</button>
</template>
</div>
</div>
<div class="chips">
@ -85,7 +88,7 @@ import { fmtMatch } from "~/types";
const { t } = useI18n();
const props = defineProps<{ route: Route; atFirst?: boolean; atLast?: boolean }>();
const props = defineProps<{ route: Route; atFirst?: boolean; atLast?: boolean; readonly?: boolean }>();
const emit = defineEmits<{
(e: "toggle", route: Route): void;
(e: "edit", route: Route): void;

View file

@ -0,0 +1,35 @@
import type { AuditEntry } from "~/types";
export function useAuditApi() {
const entries = ref<AuditEntry[]>([]);
const loading = ref(false);
const needLogin = ref(false);
const error = ref("");
async function load(limit = 50, groupId?: string): Promise<void> {
loading.value = true;
error.value = "";
needLogin.value = false;
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[] };
entries.value = data.audit ?? [];
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);
} finally {
loading.value = false;
}
}
return { entries, loading, needLogin, error, load };
}

View file

@ -1,12 +1,28 @@
import type { Group } from "~/types";
import type { Group, GroupRole } from "~/types";
export function useGroupsApi() {
const groups = ref<Group[]>([]);
const isSuper = ref(false);
/** groupId → role of the signed-in user (absent for super admins). */
const roles = ref<Record<string, GroupRole>>({});
const loading = ref(false);
const needLogin = ref(false);
const error = ref("");
function roleOf(groupId: string): GroupRole | undefined {
return isSuper.value ? "owner" : roles.value[groupId];
}
function canEditGroup(groupId: string): boolean {
const r = roleOf(groupId);
return isSuper.value || r === "owner";
}
function canEditRoutes(groupId: string): boolean {
const r = roleOf(groupId);
return isSuper.value || r === "owner" || r === "admin";
}
async function load(): Promise<void> {
loading.value = true;
error.value = "";
@ -21,9 +37,14 @@ export function useGroupsApi() {
return;
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = (await res.json()) as { groups?: Group[]; isSuper?: boolean };
const data = (await res.json()) as {
groups?: Group[];
isSuper?: boolean;
roles?: Record<string, GroupRole>;
};
groups.value = data.groups ?? [];
isSuper.value = data.isSuper ?? false;
roles.value = data.roles ?? {};
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);
} finally {
@ -49,5 +70,5 @@ export function useGroupsApi() {
groups.value = next;
}
return { groups, isSuper, loading, needLogin, error, load, save };
return { groups, isSuper, roles, roleOf, canEditGroup, canEditRoutes, loading, needLogin, error, load, save };
}

View file

@ -16,14 +16,45 @@ const en: Dict = {
"login.button": "Sign in with GitHub",
"tab.groups": "Groups",
"tab.logs": "Send Logs",
"tab.audit": "Audit Log",
"status.loading": "loading",
"status.connected": "connected",
"kpi.groups": "GROUPS",
"kpi.routes": "ROUTES",
"role.owner": "Owner",
"role.admin": "Admin",
"role.viewer": "Viewer",
"role.badge": "You: {role}",
"roles.owner": "Owner",
"roles.admin": "Admin",
"roles.viewer": "Viewer",
"members.title": "Members",
"members.note": "(role: owner = full control · admin = edit routes · viewer = read-only)",
"members.empty": "No members yet — add the first owner or share an invite link.",
"members.add": "Add member",
"members.addPlaceholder": "GitHub login",
"members.addBtn": "Add",
"members.role": "Role",
"members.you": "(you)",
"members.remove": "Remove",
"members.invites": "Invite links",
"members.inviteNote": "(single-use · expires in 7 days)",
"members.inviteRole": "Invite as",
"members.createInvite": "Create invite link",
"members.copyLink": "Copy",
"members.copied": "Copied!",
"members.revoke": "Revoke",
"members.noInvites": "No pending invites.",
"members.errSelfOwner": "You must remain an owner of this group.",
"members.errOwner": "A group needs at least one owner.",
"members.errLastOwner": "Cannot remove the last owner.",
"members.inviteOk": "Invite accepted — welcome!",
"members.inviteBad": "This invite is invalid or expired.",
"groups.empty": "No groups yet. Groups scope routes by org/user and delegate access.",
"groups.createFirst": "Create your first group",
"groups.admins": "ADMINS",
"groups.owners": "OWNERS",
"groups.members": "MEMBERS",
"groups.any": "any",
"groups.open": "Open →",
"group.back": "← Groups",
@ -123,9 +154,13 @@ const en: Dict = {
"groupEditor.adminsHint": "These users can view logs and edit this group's routes.",
"groupEditor.owners": "Owner scope",
"groupEditor.ownersNote": "(GitHub org / user logins, comma-separated)",
"groupEditor.ownersSuperOnly": "(only super admins may change)",
"groupEditor.ownersPlaceholder": "my-org, some-user",
"groupEditor.ownersHint":
"Only webhook events from these orgs/users enter this group's routes. Leave empty for no restriction.",
"groupEditor.membersNote": "Members",
"groupEditor.membersHint": "(owner / admin / viewer)",
"groupEditor.membersGoPanel": "Manage members and invite links from the group page.",
"groupEditor.providers": "Source platforms",
"groupEditor.providersNote": "(leave both unchecked = all)",
"groupEditor.providersHint":
@ -162,6 +197,17 @@ const en: Dict = {
"logs.detail": "Extra Info",
"logs.filterGroup": "Group",
"logs.allGroups": "All groups",
"audit.title": "AUDIT LOG",
"audit.empty": "No audit entries yet.",
"audit.time": "Time",
"audit.actor": "Actor",
"audit.action": "Action",
"audit.target": "Target",
"audit.group": "Group",
"audit.detail": "Detail",
"audit.filterGroup": "Group",
"audit.allGroups": "All groups",
"audit.refresh": "⟳ Refresh",
"toast.routesSaved": "Routes saved",
"toast.routeDeleted": "Route deleted",
"toast.groupSaved": "Groups saved",
@ -187,14 +233,43 @@ const zh: Dict = {
"login.button": "使用 GitHub 登录",
"tab.groups": "分组",
"tab.logs": "发送日志",
"tab.audit": "审计日志",
"status.loading": "加载中",
"status.connected": "已连接",
"kpi.groups": "分组",
"kpi.routes": "路由",
"groups.empty": "还没有分组。分组按组织/用户限定路由并委派访问权限。",
"groups.createFirst": "创建第一个分组",
"role.owner": "拥有者",
"role.admin": "管理员",
"role.viewer": "只读",
"role.badge": "我的角色:{role}",
"roles.owner": "拥有者",
"roles.admin": "管理员",
"roles.viewer": "只读",
"members.title": "成员",
"members.note": "角色owner = 完全控制 · admin = 编辑路由 · viewer = 只读)",
"members.empty": "还没有成员 —— 添加第一位 owner 或分享邀请链接。",
"members.add": "添加成员",
"members.addPlaceholder": "GitHub 登录名",
"members.addBtn": "添加",
"members.role": "角色",
"members.you": "(我)",
"members.remove": "移除",
"members.invites": "邀请链接",
"members.inviteNote": "(一次性 · 7 天内有效)",
"members.inviteRole": "邀请为",
"members.createInvite": "生成邀请链接",
"members.copyLink": "复制",
"members.copied": "已复制!",
"members.revoke": "撤销",
"members.noInvites": "暂无待接受的邀请。",
"members.errSelfOwner": "你必须保留本组的 owner 身份。",
"members.errOwner": "分组至少需要一位 owner。",
"members.errLastOwner": "不能移除最后一位 owner。",
"members.inviteOk": "邀请已接受 —— 欢迎!",
"members.inviteBad": "该邀请无效或已过期。",
"groups.admins": "管理员",
"groups.owners": "来源",
"groups.members": "成员",
"groups.any": "任意",
"groups.open": "进入 →",
"group.back": "← 返回分组",
@ -293,9 +368,13 @@ const zh: Dict = {
"groupEditor.adminsHint": "这些用户可以查看日志并编辑本分组的路由。",
"groupEditor.owners": "来源限定",
"groupEditor.ownersNote": "GitHub 组织 / 用户登录名,逗号分隔)",
"groupEditor.ownersSuperOnly": "(仅超级管理员可修改)",
"groupEditor.ownersPlaceholder": "my-org, some-user",
"groupEditor.ownersHint":
"只有来自这些组织/用户的 webhook 事件才会进入本分组的路由。留空表示不限制。",
"groupEditor.membersNote": "成员",
"groupEditor.membersHint": "owner / admin / viewer",
"groupEditor.membersGoPanel": "请在分组页面管理成员与邀请链接。",
"groupEditor.providers": "来源平台",
"groupEditor.providersNote": "(都不勾选 = 全部来源)",
"groupEditor.providersHint":
@ -332,6 +411,17 @@ const zh: Dict = {
"logs.detail": "附加信息",
"logs.filterGroup": "分组",
"logs.allGroups": "全部分组",
"audit.title": "审计日志",
"audit.empty": "暂无审计记录。",
"audit.time": "时间",
"audit.actor": "操作者",
"audit.action": "动作",
"audit.target": "对象",
"audit.group": "分组",
"audit.detail": "详情",
"audit.filterGroup": "分组",
"audit.allGroups": "全部分组",
"audit.refresh": "⟳ 刷新",
"toast.routesSaved": "路由已保存",
"toast.routeDeleted": "路由已删除",
"toast.groupSaved": "分组已保存",

View file

@ -0,0 +1,71 @@
import type { GroupInvite } from "~/types";
export function useInvitesApi() {
const loading = ref(false);
const error = ref("");
async function create(groupId: string, role: "admin" | "viewer"): Promise<string> {
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 };
return data.url ?? "";
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);
throw err;
} finally {
loading.value = false;
}
}
async function list(groupId: string): Promise<GroupInvite[]> {
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[] };
return data.invites ?? [];
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);
return [];
} finally {
loading.value = false;
}
}
async function revoke(token: string): Promise<void> {
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}`);
}
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);
throw err;
} finally {
loading.value = false;
}
}
return { loading, error, create, list, revoke };
}

View file

@ -10,7 +10,11 @@
</div>
<div class="head-actions">
<button class="btn btn-ghost btn-sm" @click="toggle">{{ t("app.langToggle") }}</button>
<button v-if="!needLogin && selectedGroup" class="btn btn-accent" @click="openNew">
<button
v-if="!needLogin && selectedGroup && canEditRoutes(selectedGroup.id)"
class="btn btn-accent"
@click="openNew"
>
{{ t("app.newRoute") }}
</button>
<button
@ -58,6 +62,7 @@
:route="r"
:at-first="i === 0"
:at-last="i === groupRoutes.length - 1"
:readonly="!canEditRoutes(selectedGroup.id)"
:style="{ animationDelay: i * 45 + 'ms' }"
@toggle="onToggle"
@edit="openEdit"
@ -68,8 +73,21 @@
<section v-if="!groupRoutesLoading && !groupRoutes.length" class="empty">
<p>{{ t("routes.emptyGroup") }}</p>
<button class="btn btn-accent" @click="openNew">{{ t("routes.createFirst") }}</button>
<button
v-if="canEditRoutes(selectedGroup.id)"
class="btn btn-accent"
@click="openNew"
>
{{ t("routes.createFirst") }}
</button>
</section>
<MembersPanel
:group="selectedGroup"
:can-edit="canEditGroup(selectedGroup.id)"
:saving="savingGroup"
@save="onSaveGroupFromPanel"
/>
</template>
<!-- Top-level views -->
@ -85,6 +103,9 @@
<button class="tab" :class="{ active: view === 'logs' }" @click="switchView('logs')">
{{ t("tab.logs") }}
</button>
<button class="tab" :class="{ active: view === 'audit' }" @click="switchView('audit')">
{{ t("tab.audit") }}
</button>
</nav>
<template v-if="view === 'groups'">
@ -113,8 +134,9 @@
<div class="card-title">
<span class="route-name">{{ g.name || t("route.untitled") }}</span>
<span class="route-id">{{ g.id }}</span>
<span v-if="roleOf(g.id)" class="badge lang">{{ t("role.badge", { role: t("roles." + roleOf(g.id)) }) }}</span>
</div>
<div v-if="isSuper" class="card-actions" @click.stop>
<div v-if="canEditGroup(g.id)" class="card-actions" @click.stop>
<button
class="icon-btn"
:title="t('groupEditor.editTitle')"
@ -127,8 +149,8 @@
</div>
<div class="target">
<span
><b>{{ t("groups.admins") }}</b
><code>{{ g.adminIds.length ? g.adminIds.join(", ") : "—" }}</code></span
><b>{{ t("groups.members") }}</b
><code>{{ (g.members ?? []).length || (g.adminIds || []).length || "—" }}</code></span
>
<span
><b>{{ t("groups.owners") }}</b
@ -149,7 +171,7 @@
</section>
</template>
<template v-else>
<template v-else-if="view === 'logs'">
<section class="toolbar">
<div class="status">
<span class="dot" :class="logsLoading ? '' : 'ok'"></span>
@ -166,6 +188,19 @@
@update:selected-group-id="logFilterGroup = $event"
/>
</template>
<template v-else-if="view === 'audit'">
<AuditLog
:entries="auditEntries"
:loading="auditLoading"
:error="auditError"
:groups="groups"
:selected-group-id="auditFilterGroup"
@refresh="loadAudit(50, auditFilterGroup || undefined)"
@filter="loadAudit(50, auditFilterGroup || undefined)"
@update:selected-group-id="auditFilterGroup = $event"
/>
</template>
</template>
</template>
</main>
@ -182,6 +217,7 @@
:open="groupEditorOpen"
:group="editingGroup"
:saving="savingGroup"
:super-admin="isSuper"
@close="groupEditorOpen = false"
@save="onSaveGroup"
/>
@ -190,13 +226,19 @@
<script setup lang="ts">
import type { Group, Route } from "~/types";
import { useAuditApi } from "~/composables/useAudit";
const { t, toggle } = useI18n();
const { push } = useToasts();
const { logs, loading: logsLoading, load: loadLogs } = useSendLogs();
const { entries: auditEntries, loading: auditLoading, error: auditError, load: loadAudit } = useAuditApi();
const {
groups,
isSuper,
roles,
roleOf,
canEditGroup,
canEditRoutes,
loading: groupsLoading,
needLogin,
error: groupsError,
@ -215,7 +257,7 @@ const editorOpen = ref(false);
const editing = ref<Route | null>(null);
const saving = ref(false);
const forbidden = ref(false);
const view = ref<"groups" | "logs">("groups");
const view = ref<"groups" | "logs" | "audit">("groups");
const selectedGroup = ref<Group | null>(null);
const groupEditorOpen = ref(false);
@ -223,20 +265,25 @@ const editingGroup = ref<Group | null>(null);
const savingGroup = ref(false);
const logFilterGroup = ref("");
const auditFilterGroup = ref("");
onMounted(() => {
if (typeof window !== "undefined") {
const params = new URLSearchParams(window.location.search);
forbidden.value = params.get("error") === "forbidden";
const invite = params.get("invite");
if (invite === "ok") push(t("members.inviteOk"));
else if (invite != null && invite !== "ok") push(t("members.inviteBad"), "bad");
}
loadGroups();
loadLogs(50, logFilterGroup.value || undefined);
});
function switchView(next: "groups" | "logs"): void {
function switchView(next: "groups" | "logs" | "audit"): void {
view.value = next;
if (next === "groups") loadGroups();
if (next === "logs") loadLogs(50, logFilterGroup.value || undefined);
if (next === "audit") loadAudit(50, auditFilterGroup.value || undefined);
}
function enterGroup(group: Group): void {
@ -339,6 +386,23 @@ function openEditGroup(group: Group): void {
groupEditorOpen.value = true;
}
async function onSaveGroupFromPanel(group: Group): Promise<void> {
savingGroup.value = true;
try {
const next = groups.value.map((g) => (g.id === group.id ? group : g));
await saveGroups(next);
selectedGroup.value = group;
push(t("toast.groupSaved"));
} catch (err) {
push(t("toast.saveFailed", { msg: err instanceof Error ? err.message : String(err) }), "bad");
if (selectedGroup.value) {
loadGroups();
}
} finally {
savingGroup.value = false;
}
}
async function onSaveGroup(group: Group): Promise<void> {
savingGroup.value = true;
try {

View file

@ -25,15 +25,45 @@ export interface Route {
discordRoleIds?: string[];
}
export type GroupRole = "owner" | "admin" | "viewer";
export interface GroupMember {
login: string;
role: GroupRole;
}
export interface Group {
id: string;
name: string;
adminIds: string[];
members?: GroupMember[];
owners?: string[];
providers?: ("github" | "gitea" | "gitlab")[];
emoji?: boolean;
}
export interface GroupInvite {
token: string;
groupId: string;
role: "admin" | "viewer";
expiresAt: number;
createdBy: string;
note?: string;
}
export interface AuditEntry {
id?: number;
ts: number;
actorId?: string;
actorLogin?: string;
action: string;
targetType?: string;
targetId?: string;
groupId?: string;
detail?: Record<string, unknown>;
ip?: string;
}
export interface RouteTemplate {
id: string;
nameKey: string;