mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-22 16:11:29 +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
|
|
@ -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;
|
||||
|
|
|
|||
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;
|
||||
|
|
|
|||
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 };
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue