merge: resolve docs conflict with origin/main (auto-fix)

Merged the remote lint/formatting fix; kept the group-level lang row in the zh group schema table.
This commit is contained in:
RhenCloud 2026-08-12 00:50:28 +08:00
commit 13ba1e019e
No known key found for this signature in database
GPG key ID: A574A617378C4E0B
12 changed files with 151 additions and 88 deletions

View file

@ -73,11 +73,7 @@
<section v-if="!groupRoutesLoading && !groupRoutes.length" class="empty">
<p>{{ t("routes.emptyGroup") }}</p>
<button
v-if="canEditRoutes(selectedGroup.id)"
class="btn btn-accent"
@click="openNew"
>
<button v-if="canEditRoutes(selectedGroup.id)" class="btn btn-accent" @click="openNew">
{{ t("routes.createFirst") }}
</button>
</section>
@ -134,7 +130,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>
<span v-if="roleOf(g.id)" class="badge lang">{{
t("role.badge", { role: t("roles." + roleOf(g.id)) })
}}</span>
</div>
<div v-if="canEditGroup(g.id)" class="card-actions" @click.stop>
<button
@ -150,7 +148,9 @@
<div class="target">
<span
><b>{{ t("groups.members") }}</b
><code>{{ (g.members ?? []).length || (g.adminIds || []).length || "—" }}</code></span
><code>{{
(g.members ?? []).length || (g.adminIds || []).length || "—"
}}</code></span
>
<span
><b>{{ t("groups.owners") }}</b
@ -249,7 +249,12 @@ if (view.value === null) {
}
const { logs, loading: logsLoading, load: loadLogs } = useSendLogs();
const { entries: auditEntries, loading: auditLoading, error: auditError, load: loadAudit } = useAuditApi();
const {
entries: auditEntries,
loading: auditLoading,
error: auditError,
load: loadAudit,
} = useAuditApi();
const {
groups,
isSuper,

View file

@ -127,7 +127,12 @@ import type { Group } from "~/types";
const { t } = useI18n();
const props = defineProps<{ open: boolean; group: Group | null; saving: boolean; superAdmin?: boolean }>();
const props = defineProps<{
open: boolean;
group: Group | null;
saving: boolean;
superAdmin?: boolean;
}>();
const emit = defineEmits<{
(e: "close"): void;
(e: "save", group: Group): void;

View file

@ -38,7 +38,11 @@
>
</button>
<button class="icon-btn" :title="t('routeEditor.editTitle')" @click="$emit('edit', route)">
<button
class="icon-btn"
:title="t('routeEditor.editTitle')"
@click="$emit('edit', route)"
>
</button>
<button
@ -87,7 +91,12 @@ import { fmtMatch } from "~/types";
const { t } = useI18n();
const props = defineProps<{ route: Route; atFirst?: boolean; atLast?: boolean; readonly?: 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

@ -70,5 +70,17 @@ export function useGroupsApi() {
groups.value = next;
}
return { groups, isSuper, roles, roleOf, canEditGroup, canEditRoutes, loading, needLogin, error, load, save };
return {
groups,
isSuper,
roles,
roleOf,
canEditGroup,
canEditRoutes,
loading,
needLogin,
error,
load,
save,
};
}

View file

@ -59,7 +59,7 @@ WebHooker ships with a built-in config console at `/admin` for managing routes i
The console is served as an SPA at `/admin`; its tabs are deep-linkable via the URL path (`/admin/groups`, `/admin/logs`, `/admin/audit`). URLs outside `/admin` that do not match an endpoint below return a plain `404` instead of the console.
| Endpoint | Description |
| ----------------------------------------- | -------------------------------------------- |
| ------------------------------------ | -------------------------------------------- |
| `GET /admin` | Config console UI |
| `GET /admin/login` | Start GitHub OAuth sign-in |
| `GET /admin/logout` | Destroy session |

View file

@ -59,7 +59,7 @@ WebHooker 内置了位于 `/admin` 的配置控制台,可在浏览器中管理
控制台以 SPA 形式挂在 `/admin`,各标签页可通过 URL 路径直达(`/admin/groups``/admin/logs``/admin/audit`)。`/admin` 之外且未匹配下方端点的 URL 直接返回 `404`,不会再被吞进控制台。
| 端点 | 说明 |
| ------------------------------------- | ------------------------------------ |
| ------------------------------------ | -------------------------------------- |
| `GET /admin` | 配置控制台页面 |
| `GET /admin/login` | 开始 GitHub OAuth 登录 |
| `GET /admin/logout` | 销毁会话 |
@ -201,7 +201,7 @@ WebHooker 内置了位于 `/admin` 的配置控制台,可在浏览器中管理
每个分组成员拥有三种角色之一。超级管理员(`ADMIN_USER_IDS`)始终绕过角色限制。
| 角色 | 查看路由/日志 | 编辑路由 | 管理成员与邀请 | 编辑分组设置 |
| -------- | ------------- | -------- | -------------- | ------------ |
| -------- | ------------- | -------- | -------------- | ------------------ |
| `owner` | ✓ | ✓ | ✓ | ✓(`owners` 除外) |
| `admin` | ✓ | ✓ | ✗ | ✗ |
| `viewer` | ✓(只读) | ✗ | ✗ | ✗ |

View file

@ -57,22 +57,42 @@ describe("invites", () => {
});
it("lists pending invites of a group only", async () => {
await createInvite(kv, { groupId: "team", role: "viewer", expiresAt: Date.now() + 86400_000, createdBy: "boss" });
await createInvite(kv, { groupId: "other", role: "viewer", expiresAt: Date.now() + 86400_000, createdBy: "boss" });
await createInvite(kv, {
groupId: "team",
role: "viewer",
expiresAt: Date.now() + 86400_000,
createdBy: "boss",
});
await createInvite(kv, {
groupId: "other",
role: "viewer",
expiresAt: Date.now() + 86400_000,
createdBy: "boss",
});
const invites = await listInvites(kv, "team");
expect(invites).toHaveLength(1);
expect(invites[0]!.groupId).toBe("team");
});
it("revokes an invite", async () => {
const token = await createInvite(kv, { groupId: "team", role: "viewer", expiresAt: Date.now() + 86400_000, createdBy: "boss" });
const token = await createInvite(kv, {
groupId: "team",
role: "viewer",
expiresAt: Date.now() + 86400_000,
createdBy: "boss",
});
await revokeInvite(kv, token);
expect(await getInvite(kv, token)).toBeNull();
});
it("accept adds the user as a member and consumes the token", async () => {
await saveGroups(kv, [group]);
const token = await createInvite(kv, { groupId: "team", role: "admin", expiresAt: Date.now() + 86400_000, createdBy: "boss" });
const token = await createInvite(kv, {
groupId: "team",
role: "admin",
expiresAt: Date.now() + 86400_000,
createdBy: "boss",
});
const result = await acceptInvite(kv, token, "777", "newbie");
expect(result).toEqual({ ok: true, groupId: "team", role: "admin" });
expect(await getInvite(kv, token)).toBeNull();
@ -84,9 +104,20 @@ describe("invites", () => {
it("upgrades an existing viewer to admin", async () => {
await saveGroups(kv, [
{ ...group, members: [{ login: "boss", role: "owner" }, { login: "newbie", role: "viewer" }] },
{
...group,
members: [
{ login: "boss", role: "owner" },
{ login: "newbie", role: "viewer" },
],
},
]);
const token = await createInvite(kv, { groupId: "team", role: "admin", expiresAt: Date.now() + 86400_000, createdBy: "boss" });
const token = await createInvite(kv, {
groupId: "team",
role: "admin",
expiresAt: Date.now() + 86400_000,
createdBy: "boss",
});
await acceptInvite(kv, token, "777", "newbie");
const groups = await loadGroups(kv);
expect(groups[0]!.members).toContainEqual({ login: "newbie", role: "admin" });
@ -94,14 +125,24 @@ describe("invites", () => {
it("rejects expired or unknown invites", async () => {
await saveGroups(kv, [group]);
const token = await createInvite(kv, { groupId: "team", role: "viewer", expiresAt: Date.now() - 1000, createdBy: "boss" });
const token = await createInvite(kv, {
groupId: "team",
role: "viewer",
expiresAt: Date.now() - 1000,
createdBy: "boss",
});
expect(await acceptInvite(kv, token, "1", "x")).toEqual({ ok: false, reason: "invalid" });
expect(await acceptInvite(kv, "deadbeef", "1", "x")).toEqual({ ok: false, reason: "invalid" });
});
it("rejects invites for missing groups", async () => {
await saveGroups(kv, []);
const token = await createInvite(kv, { groupId: "ghost", role: "viewer", expiresAt: Date.now() + 86400_000, createdBy: "boss" });
const token = await createInvite(kv, {
groupId: "ghost",
role: "viewer",
expiresAt: Date.now() + 86400_000,
createdBy: "boss",
});
expect(await acceptInvite(kv, token, "1", "x")).toEqual({ ok: false, reason: "group-missing" });
});
});

View file

@ -15,7 +15,8 @@ export interface AuditEntry {
ip?: string;
}
const COLUMNS = "id, ts, actor_id, actor_login, action, target_type, target_id, group_id, detail, ip";
const COLUMNS =
"id, ts, actor_id, actor_login, action, target_type, target_id, group_id, detail, ip";
interface AuditRow {
id: number;

View file

@ -148,14 +148,7 @@ export function createActionRoutes(): Hono<AuthEnv> {
repo: body.repo,
issue_number: body.issueNumber,
content: body.reaction as
| "+1"
| "-1"
| "laugh"
| "confused"
| "heart"
| "hooray"
| "rocket"
| "eyes",
"+1" | "-1" | "laugh" | "confused" | "heart" | "hooray" | "rocket" | "eyes",
});
} catch (err) {
log.error({ err }, "Failed to create reaction");

View file

@ -292,10 +292,7 @@ function ownerCount(members: GroupMember[]): number {
}
/** Route params are always present for matched paths; keeps Hono's loose typing honest. */
function param(
c: { req: { param: (name: string) => string | undefined } },
name: string,
): string {
function param(c: { req: { param: (name: string) => string | undefined } }, name: string): string {
return c.req.param(name) ?? "";
}
@ -329,7 +326,9 @@ export function createAdminRoutes(): Hono<AuthEnv> {
if (!token) return c.redirect("/admin");
const auth = c.get("auth");
if (!auth) {
return c.redirect(`/auth/github?redirect=${encodeURIComponent(`/admin/invite?token=${token}`)}`);
return c.redirect(
`/auth/github?redirect=${encodeURIComponent(`/admin/invite?token=${token}`)}`,
);
}
const result = await acceptInvite(c.env.KV, token, auth.session.userId, auth.session.login);
if (result.ok) {
@ -411,10 +410,7 @@ export function createAdminRoutes(): Hono<AuthEnv> {
);
const otherOwner = ownerCount(members) > 1;
if (!stillMine && !otherOwner) {
return c.json(
{ error: `group "${g.id}" would be left without an owner by you` },
403,
);
return c.json({ error: `group "${g.id}" would be left without an owner by you` }, 403);
}
}
nextAll = [
@ -453,7 +449,8 @@ export function createAdminRoutes(): Hono<AuthEnv> {
if (prev.emoji !== g.emoji) fields.push("emoji");
if (!deepEqual(prev.providers ?? [], g.providers ?? [])) fields.push("providers");
if (!deepEqual(prev.owners ?? [], g.owners ?? [])) fields.push("owners");
if (!deepEqual(prev.members ?? normalizeGroupMembers(prev), g.members)) fields.push("members");
if (!deepEqual(prev.members ?? normalizeGroupMembers(prev), g.members))
fields.push("members");
if (fields.length > 0) {
await recordAudit(c.env.DB, {
ts: Date.now(),

View file

@ -62,9 +62,7 @@ export function currentAuth(c: Context<AuthEnv>): AuthContext {
return c.get(AUTH_KEY);
}
export type GroupAccess =
| { ok: true; group: Group }
| { ok: false; status: 403 | 404 };
export type GroupAccess = { ok: true; group: Group } | { ok: false; status: 403 | 404 };
/** Resolves the group and checks the user can at least view it. */
export function requireGroup(c: Context<AuthEnv>, groupId: string): GroupAccess {
@ -95,7 +93,9 @@ export function requireGroupRole(
export { canEditGroup, canEditRoutes, roleAt };
/** Best-effort client IP for audit entries (Cloudflare header first). */
export function clientIp(c: { req: { header: (n: string) => string | undefined } }): string | undefined {
export function clientIp(c: {
req: { header: (n: string) => string | undefined };
}): string | undefined {
return c.req.header("cf-connecting-ip") ?? c.req.header("x-forwarded-for")?.split(",")[0]?.trim();
}