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
85
admin/components/AuditLog.vue
Normal file
85
admin/components/AuditLog.vue
Normal 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>
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
213
admin/components/MembersPanel.vue
Normal file
213
admin/components/MembersPanel.vue
Normal 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>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue