chore: auto-fix lint & formatting [skip ci]

This commit is contained in:
github-actions[bot] 2026-08-11 16:06:06 +00:00
parent c73b642504
commit 89b43b8b28
12 changed files with 160 additions and 97 deletions

View file

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

View file

@ -110,7 +110,12 @@ import type { Group } from "~/types";
const { t } = useI18n(); 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<{ const emit = defineEmits<{
(e: "close"): void; (e: "close"): void;
(e: "save", group: Group): void; (e: "save", group: Group): void;

View file

@ -39,7 +39,11 @@
> >
</button> </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>
<button <button
@ -88,7 +92,12 @@ import { fmtMatch } from "~/types";
const { t } = useI18n(); 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<{ const emit = defineEmits<{
(e: "toggle", route: Route): void; (e: "toggle", route: Route): void;
(e: "edit", route: Route): void; (e: "edit", route: Route): void;

View file

@ -70,5 +70,17 @@ export function useGroupsApi() {
groups.value = next; 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. 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 | | Endpoint | Description |
| ----------------------------------------- | -------------------------------------------- | | ------------------------------------ | -------------------------------------------- |
| `GET /admin` | Config console UI | | `GET /admin` | Config console UI |
| `GET /admin/login` | Start GitHub OAuth sign-in | | `GET /admin/login` | Start GitHub OAuth sign-in |
| `GET /admin/logout` | Destroy session | | `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`,不会再被吞进控制台。 控制台以 SPA 形式挂在 `/admin`,各标签页可通过 URL 路径直达(`/admin/groups``/admin/logs``/admin/audit`)。`/admin` 之外且未匹配下方端点的 URL 直接返回 `404`,不会再被吞进控制台。
| 端点 | 说明 | | 端点 | 说明 |
| ------------------------------------- | ------------------------------------ | | ------------------------------------ | -------------------------------------- |
| `GET /admin` | 配置控制台页面 | | `GET /admin` | 配置控制台页面 |
| `GET /admin/login` | 开始 GitHub OAuth 登录 | | `GET /admin/login` | 开始 GitHub OAuth 登录 |
| `GET /admin/logout` | 销毁会话 | | `GET /admin/logout` | 销毁会话 |
@ -187,7 +187,7 @@ WebHooker 内置了位于 `/admin` 的配置控制台,可在浏览器中管理
``` ```
| 字段 | 类型 | 必需 | 说明 | | 字段 | 类型 | 必需 | 说明 |
| ----------- | -------- | ---- | ------------------------------------------------------------------ | | ----------- | -------- | ---- | ----------------------------------------------------------- |
| `id` | string | 是 | 小写 id`a-z0-9``-`),由每条路由的 `groupId` 引用 | | `id` | string | 是 | 小写 id`a-z0-9``-`),由每条路由的 `groupId` 引用 |
| `name` | string | 是 | 可读的分组名称 | | `name` | string | 是 | 可读的分组名称 |
| `members` | object[] | 否 | `{ login, role }` 列表;角色为 `owner``admin``viewer` | | `members` | object[] | 否 | `{ login, role }` 列表;角色为 `owner``admin``viewer` |
@ -201,7 +201,7 @@ WebHooker 内置了位于 `/admin` 的配置控制台,可在浏览器中管理
每个分组成员拥有三种角色之一。超级管理员(`ADMIN_USER_IDS`)始终绕过角色限制。 每个分组成员拥有三种角色之一。超级管理员(`ADMIN_USER_IDS`)始终绕过角色限制。
| 角色 | 查看路由/日志 | 编辑路由 | 管理成员与邀请 | 编辑分组设置 | | 角色 | 查看路由/日志 | 编辑路由 | 管理成员与邀请 | 编辑分组设置 |
| -------- | ------------- | -------- | -------------- | ------------ | | -------- | ------------- | -------- | -------------- | ------------------ |
| `owner` | ✓ | ✓ | ✓ | ✓(`owners` 除外) | | `owner` | ✓ | ✓ | ✓ | ✓(`owners` 除外) |
| `admin` | ✓ | ✓ | ✗ | ✗ | | `admin` | ✓ | ✓ | ✗ | ✗ |
| `viewer` | ✓(只读) | ✗ | ✗ | ✗ | | `viewer` | ✓(只读) | ✗ | ✗ | ✗ |

View file

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

View file

@ -15,7 +15,8 @@ export interface AuditEntry {
ip?: string; 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 { interface AuditRow {
id: number; id: number;

View file

@ -148,14 +148,7 @@ export function createActionRoutes(): Hono<AuthEnv> {
repo: body.repo, repo: body.repo,
issue_number: body.issueNumber, issue_number: body.issueNumber,
content: body.reaction as content: body.reaction as
| "+1" "+1" | "-1" | "laugh" | "confused" | "heart" | "hooray" | "rocket" | "eyes",
| "-1"
| "laugh"
| "confused"
| "heart"
| "hooray"
| "rocket"
| "eyes",
}); });
} catch (err) { } catch (err) {
log.error({ err }, "Failed to create reaction"); 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. */ /** Route params are always present for matched paths; keeps Hono's loose typing honest. */
function param( function param(c: { req: { param: (name: string) => string | undefined } }, name: string): string {
c: { req: { param: (name: string) => string | undefined } },
name: string,
): string {
return c.req.param(name) ?? ""; return c.req.param(name) ?? "";
} }
@ -329,7 +326,9 @@ export function createAdminRoutes(): Hono<AuthEnv> {
if (!token) return c.redirect("/admin"); if (!token) return c.redirect("/admin");
const auth = c.get("auth"); const auth = c.get("auth");
if (!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); const result = await acceptInvite(c.env.KV, token, auth.session.userId, auth.session.login);
if (result.ok) { if (result.ok) {
@ -411,10 +410,7 @@ export function createAdminRoutes(): Hono<AuthEnv> {
); );
const otherOwner = ownerCount(members) > 1; const otherOwner = ownerCount(members) > 1;
if (!stillMine && !otherOwner) { if (!stillMine && !otherOwner) {
return c.json( return c.json({ error: `group "${g.id}" would be left without an owner by you` }, 403);
{ error: `group "${g.id}" would be left without an owner by you` },
403,
);
} }
} }
nextAll = [ nextAll = [
@ -453,7 +449,8 @@ export function createAdminRoutes(): Hono<AuthEnv> {
if (prev.emoji !== g.emoji) fields.push("emoji"); if (prev.emoji !== g.emoji) fields.push("emoji");
if (!deepEqual(prev.providers ?? [], g.providers ?? [])) fields.push("providers"); if (!deepEqual(prev.providers ?? [], g.providers ?? [])) fields.push("providers");
if (!deepEqual(prev.owners ?? [], g.owners ?? [])) fields.push("owners"); 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) { if (fields.length > 0) {
await recordAudit(c.env.DB, { await recordAudit(c.env.DB, {
ts: Date.now(), ts: Date.now(),

View file

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