mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-23 00:21:28 +00:00
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:
parent
252f6f181a
commit
11b74aa82a
11 changed files with 805 additions and 55 deletions
35
admin/composables/useAudit.ts
Normal file
35
admin/composables/useAudit.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "分组已保存",
|
||||
|
|
|
|||
71
admin/composables/useInvites.ts
Normal file
71
admin/composables/useInvites.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue