From f4959eebf85e215cc415493787b615c5731abfeb Mon Sep 17 00:00:00 2001 From: RhenCloud Date: Thu, 13 Aug 2026 09:58:02 +0800 Subject: [PATCH] feat(groups): allow renaming a group id Add PUT /admin/api/groups/:id/rename (owner role) which re-points the group's routes, migrates the per-group webhook secret (tenant:{id}) and pending invites, and records an audit entry. The group editor's id field is now editable and the console renames first, then persists remaining edits under the new id. --- admin/components/ConsolePage.vue | 14 ++- admin/components/GroupEditor.vue | 5 +- admin/composables/useGroups.ts | 19 ++++ admin/composables/useI18n.ts | 3 + docs/api/overview.md | 1 + docs/guide/configuration.md | 49 ++++----- docs/zh/api/overview.md | 1 + docs/zh/guide/configuration.md | 49 ++++----- src/__tests__/admin-api.test.ts | 177 +++++++++++++++++++++++++++++++ src/web/admin-routes.ts | 78 +++++++++++++- src/web/invites.ts | 29 +++++ 11 files changed, 372 insertions(+), 53 deletions(-) create mode 100644 src/__tests__/admin-api.test.ts diff --git a/admin/components/ConsolePage.vue b/admin/components/ConsolePage.vue index 2e36705..cef9573 100644 --- a/admin/components/ConsolePage.vue +++ b/admin/components/ConsolePage.vue @@ -274,6 +274,7 @@ const { error: groupsError, load: loadGroups, save: saveGroups, + rename: groupsRename, } = useGroupsApi(); const { routes: groupRoutes, @@ -435,9 +436,17 @@ async function onSaveGroupFromPanel(group: Group): Promise { async function onSaveGroup(group: Group): Promise { savingGroup.value = true; try { + const editing = editingGroup.value; let next: Group[]; - if (editingGroup.value) { - next = groups.value.map((g) => (g.id === editingGroup.value!.id ? group : g)); + if (editing) { + if (group.id !== editing.id) { + // Id changed: rename first so routes/webhook secret/invites follow, + // then persist the remaining edits under the new id. + await groupsRename(editing.id, group.id); + next = [...groups.value.filter((g) => g.id !== editing.id), group]; + } else { + next = groups.value.map((g) => (g.id === editing.id ? group : g)); + } } else { if (groups.value.some((g) => g.id === group.id)) { push(t("toast.groupIdExists"), "bad"); @@ -448,6 +457,7 @@ async function onSaveGroup(group: Group): Promise { await saveGroups(next); groupEditorOpen.value = false; push(t("toast.groupSaved")); + await loadGroups(); } catch (err) { push(t("toast.saveFailed", { msg: err instanceof Error ? err.message : String(err) }), "bad"); } finally { diff --git a/admin/components/GroupEditor.vue b/admin/components/GroupEditor.vue index 8aaba00..e92b91f 100644 --- a/admin/components/GroupEditor.vue +++ b/admin/components/GroupEditor.vue @@ -26,10 +26,11 @@ v-model="form.id" type="text" :placeholder="t('groupEditor.idPlaceholder')" - :disabled="isEdit" required /> -
{{ t("groupEditor.idHint") }}
+
+ {{ isEdit ? t("groupEditor.renameHint") : t("groupEditor.idHint") }} +
diff --git a/admin/composables/useGroups.ts b/admin/composables/useGroups.ts index d3f01ad..4767f82 100644 --- a/admin/composables/useGroups.ts +++ b/admin/composables/useGroups.ts @@ -70,6 +70,24 @@ export function useGroupsApi() { groups.value = next; } + /** Rename a group; routes, webhook secret and invites follow automatically. */ + async function rename(oldId: string, newId: string): Promise { + const res = await fetch(`/admin/api/groups/${encodeURIComponent(oldId)}/rename`, { + method: "PUT", + headers: { "content-type": "application/json" }, + credentials: "same-origin", + body: JSON.stringify({ newId }), + }); + if (res.status === 401) { + needLogin.value = true; + throw new Error("unauthorized"); + } + if (!res.ok) { + const data = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(data.error ?? `HTTP ${res.status}`); + } + } + return { groups, isSuper, @@ -82,5 +100,6 @@ export function useGroupsApi() { error, load, save, + rename, }; } diff --git a/admin/composables/useI18n.ts b/admin/composables/useI18n.ts index 10aeb3b..26aa103 100644 --- a/admin/composables/useI18n.ts +++ b/admin/composables/useI18n.ts @@ -167,6 +167,8 @@ const en: Dict = { "groupEditor.id": "ID", "groupEditor.idPlaceholder": "my-team", "groupEditor.idHint": "Unique, use a-z / 0-9 / dashes", + "groupEditor.renameHint": + "Changing the id also updates routes, webhook secrets and invites that reference this group.", "groupEditor.language": "Message language", "groupEditor.langPlaceholder": "en", "groupEditor.langHint": "en or zh; custom via KV i18n:", @@ -416,6 +418,7 @@ const zh: Dict = { "groupEditor.id": "ID", "groupEditor.idPlaceholder": "my-team", "groupEditor.idHint": "唯一,使用 a-z / 0-9 / 短横线", + "groupEditor.renameHint": "修改 id 会同步更新引用该分组的路由、webhook secret 与邀请链接。", "groupEditor.language": "消息语言", "groupEditor.langPlaceholder": "zh", "groupEditor.langHint": "en 或 zh;可通过 KV i18n: 自定义", diff --git a/docs/api/overview.md b/docs/api/overview.md index d346e21..7e52f88 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -32,6 +32,7 @@ https://your-worker.workers.dev | `PUT` | `/admin/api/groups` | Admin session | Replace groups (super) | | `GET` | `/admin/api/groups/:id/routes` | Admin session | List a group's routes | | `PUT` | `/admin/api/groups/:id/routes` | Admin session | Replace a group's routes | +| `PUT` | `/admin/api/groups/:id/rename` | Admin session | Rename a group (owner); routes/secret/invites follow | | `GET` | `/admin/api/me` | Admin session | Current session info | | `GET` | `/admin/api/logs` | Admin session | Send logs (scoped) | | `GET` | `/admin/api/logs/:id` | Admin session | Single send-log entry (scoped) | diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 45c9f25..adc5305 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -58,28 +58,29 @@ 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 | -| `GET /admin/invite?token=…` | Accept a group invite (browser page) | -| `GET /admin/api/me` | Current session, scope, groups, and roles | -| `GET /admin/api/routes` | List routes (scoped to access) | -| `PUT /admin/api/routes` | Replace routes (owner/admin per group) | -| `GET /admin/api/groups` | List groups + the signed-in user's role each | -| `PUT /admin/api/groups` | Replace groups (super: all; owner: own only) | -| `GET /admin/api/groups/:id/routes` | List a group's routes | -| `PUT /admin/api/groups/:id/routes` | Replace a group's routes (owner/admin) | -| `GET /admin/api/logs` | Send logs (scoped to accessible routes) | -| `GET /admin/api/logs/:id` | Single send-log entry (scoped) | -| `POST /admin/api/groups/:id/invites` | Create an invite link (owner) | -| `GET /admin/api/groups/:id/invites` | List pending invites (owner) | -| `DELETE /admin/api/invites/:token` | Revoke an invite (owner) | -| `GET /admin/api/audit` | Audit log (scoped to accessible groups) | -| `GET /admin/api/groups/:id/webhook` | Group webhook endpoint info (owner) | -| `POST /admin/api/groups/:id/webhook/regenerate` | Generate/regenerate the group webhook secret (owner) | -| `DELETE /admin/api/groups/:id/webhook` | Disable the group webhook ingress (owner) | +| Endpoint | Description | +| ----------------------------------------------- | ----------------------------------------------------------------- | +| `GET /admin` | Config console UI | +| `GET /admin/login` | Start GitHub OAuth sign-in | +| `GET /admin/logout` | Destroy session | +| `GET /admin/invite?token=…` | Accept a group invite (browser page) | +| `GET /admin/api/me` | Current session, scope, groups, and roles | +| `GET /admin/api/routes` | List routes (scoped to access) | +| `PUT /admin/api/routes` | Replace routes (owner/admin per group) | +| `GET /admin/api/groups` | List groups + the signed-in user's role each | +| `PUT /admin/api/groups` | Replace groups (super: all; owner: own only) | +| `GET /admin/api/groups/:id/routes` | List a group's routes | +| `PUT /admin/api/groups/:id/routes` | Replace a group's routes (owner/admin) | +| `PUT /admin/api/groups/:id/rename` | Rename a group (owner); routes, webhook secret and invites follow | +| `GET /admin/api/logs` | Send logs (scoped to accessible routes) | +| `GET /admin/api/logs/:id` | Single send-log entry (scoped) | +| `POST /admin/api/groups/:id/invites` | Create an invite link (owner) | +| `GET /admin/api/groups/:id/invites` | List pending invites (owner) | +| `DELETE /admin/api/invites/:token` | Revoke an invite (owner) | +| `GET /admin/api/audit` | Audit log (scoped to accessible groups) | +| `GET /admin/api/groups/:id/webhook` | Group webhook endpoint info (owner) | +| `POST /admin/api/groups/:id/webhook/regenerate` | Generate/regenerate the group webhook secret (owner) | +| `DELETE /admin/api/groups/:id/webhook` | Disable the group webhook ingress (owner) | The console lets you add, edit, delete, and toggle routes. Saved routes are written to KV `config:routes` immediately and the config cache is invalidated so the webhook pipeline picks them up on the next run. @@ -249,7 +250,7 @@ Routes belong to groups. Groups scope admin access and can restrict which events | Field | Type | Required | Description | | ---------------- | -------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `id` | string | Yes | Lowercase id (`a-z0-9`, `-`); referenced by each route's `groupId` | +| `id` | string | Yes | Lowercase id (`a-z0-9`, `-`); referenced by each route's `groupId`. Editable: renaming a group re-points its routes, per-group webhook secret and pending invites | | `name` | string | Yes | Human-readable group name | | `members` | object[] | No | `{ login, role }` entries; role is `owner`, `admin`, or `viewer` | | `adminIds` | string[] | No | Deprecated legacy field; treated as `members` with role `owner` when present | @@ -273,7 +274,7 @@ Every group member has one of three roles. Super admins (`ADMIN_USER_IDS`) alway ### Access Model - **Super admins** (`ADMIN_USER_IDS`) see and edit every group and all routes; only they can edit a group's `owners` list. -- **Owners** manage their group's routes, members, invites, name, `emoji`, and `providers`. They cannot remove the last owner or demote themselves when no other owner remains. +- **Owners** manage their group's routes, members, invites, name, id, `emoji`, and `providers`. They cannot remove the last owner or demote themselves when no other owner remains. - **Admins** edit routes inside their groups and view logs; **viewers** get a read-only console. - Group admin endpoints operate on a single group at a time via `/admin/api/groups/:id/routes`; `groupId` is forced from the path parameter. - The `owners` list restricts which event actors (sender logins) the group's routes will dispatch at all. diff --git a/docs/zh/api/overview.md b/docs/zh/api/overview.md index 9872e19..63d8a9f 100644 --- a/docs/zh/api/overview.md +++ b/docs/zh/api/overview.md @@ -32,6 +32,7 @@ https://your-worker.workers.dev | `PUT` | `/admin/api/groups` | 管理员会话 | 替换分组(仅超级管理员) | | `GET` | `/admin/api/groups/:id/routes` | 管理员会话 | 列出某分组的路由 | | `PUT` | `/admin/api/groups/:id/routes` | 管理员会话 | 替换某分组的路由 | +| `PUT` | `/admin/api/groups/:id/rename` | 管理员会话 | 重命名分组(owner);路由/secret/邀请自动跟随 | | `GET` | `/admin/api/me` | 管理员会话 | 当前会话信息 | | `GET` | `/admin/api/logs` | 管理员会话 | 发送日志(按权限过滤) | | `GET` | `/admin/api/logs/:id` | 管理员会话 | 单条发送日志(按权限过滤) | diff --git a/docs/zh/guide/configuration.md b/docs/zh/guide/configuration.md index d800081..415a710 100644 --- a/docs/zh/guide/configuration.md +++ b/docs/zh/guide/configuration.md @@ -58,28 +58,29 @@ WebHooker 内置了位于 `/admin` 的配置控制台,可在浏览器中管理 控制台以 SPA 形式挂在 `/admin`,各标签页可通过 URL 路径直达(`/admin/groups`、`/admin/logs`、`/admin/audit`)。`/admin` 之外且未匹配下方端点的 URL 直接返回 `404`,不会再被吞进控制台。 -| 端点 | 说明 | -| ----------------------------------------------- | ----------------------------------------- | -| `GET /admin` | 配置控制台页面 | -| `GET /admin/login` | 开始 GitHub OAuth 登录 | -| `GET /admin/logout` | 销毁会话 | -| `GET /admin/invite?token=…` | 接受分组邀请(浏览器页面) | -| `GET /admin/api/me` | 当前会话、权限范围、分组和角色 | -| `GET /admin/api/routes` | 列出路由(按权限过滤) | -| `PUT /admin/api/routes` | 替换路由(按分组 owner/admin 权限) | -| `GET /admin/api/groups` | 列出分组 + 当前用户在各组的角色 | -| `PUT /admin/api/groups` | 替换分组(超管全量;owner 仅自己的组) | -| `GET /admin/api/groups/:id/routes` | 列出某分组的路由 | -| `PUT /admin/api/groups/:id/routes` | 替换某分组的路由(owner/admin) | -| `GET /admin/api/logs` | 发送日志(按可访问路由过滤) | -| `GET /admin/api/logs/:id` | 单条发送日志(按权限过滤) | -| `POST /admin/api/groups/:id/invites` | 创建邀请链接(owner) | -| `GET /admin/api/groups/:id/invites` | 列出待接受邀请(owner) | -| `DELETE /admin/api/invites/:token` | 撤销邀请(owner) | -| `GET /admin/api/audit` | 审计日志(按可访问分组过滤) | -| `GET /admin/api/groups/:id/webhook` | 分组 webhook 入口信息(owner) | -| `POST /admin/api/groups/:id/webhook/regenerate` | 生成/重新生成分组 webhook secret(owner) | -| `DELETE /admin/api/groups/:id/webhook` | 停用分组 webhook 入口(owner) | +| 端点 | 说明 | +| ----------------------------------------------- | -------------------------------------------------------- | +| `GET /admin` | 配置控制台页面 | +| `GET /admin/login` | 开始 GitHub OAuth 登录 | +| `GET /admin/logout` | 销毁会话 | +| `GET /admin/invite?token=…` | 接受分组邀请(浏览器页面) | +| `GET /admin/api/me` | 当前会话、权限范围、分组和角色 | +| `GET /admin/api/routes` | 列出路由(按权限过滤) | +| `PUT /admin/api/routes` | 替换路由(按分组 owner/admin 权限) | +| `GET /admin/api/groups` | 列出分组 + 当前用户在各组的角色 | +| `PUT /admin/api/groups` | 替换分组(超管全量;owner 仅自己的组) | +| `GET /admin/api/groups/:id/routes` | 列出某分组的路由 | +| `PUT /admin/api/groups/:id/routes` | 替换某分组的路由(owner/admin) | +| `PUT /admin/api/groups/:id/rename` | 重命名分组(owner);路由、webhook secret 与邀请自动跟随 | +| `GET /admin/api/logs` | 发送日志(按可访问路由过滤) | +| `GET /admin/api/logs/:id` | 单条发送日志(按权限过滤) | +| `POST /admin/api/groups/:id/invites` | 创建邀请链接(owner) | +| `GET /admin/api/groups/:id/invites` | 列出待接受邀请(owner) | +| `DELETE /admin/api/invites/:token` | 撤销邀请(owner) | +| `GET /admin/api/audit` | 审计日志(按可访问分组过滤) | +| `GET /admin/api/groups/:id/webhook` | 分组 webhook 入口信息(owner) | +| `POST /admin/api/groups/:id/webhook/regenerate` | 生成/重新生成分组 webhook secret(owner) | +| `DELETE /admin/api/groups/:id/webhook` | 停用分组 webhook 入口(owner) | 控制台支持新增、编辑、删除和开关路由。保存后立即写入 KV `config:routes` 并使配置缓存失效,下一次 webhook 处理即会生效。 @@ -249,7 +250,7 @@ GitHub App 安装后,**所有**安装方的事件都会到达全局端点。 | 字段 | 类型 | 必需 | 说明 | | ---------------- | -------- | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `id` | string | 是 | 小写 id(`a-z0-9`、`-`),由每条路由的 `groupId` 引用 | +| `id` | string | 是 | 小写 id(`a-z0-9`、`-`),由每条路由的 `groupId` 引用。可修改:重命名分组会同步更新其路由、分组级 webhook secret 与待接受邀请 | | `name` | string | 是 | 可读的分组名称 | | `members` | object[] | 否 | `{ login, role }` 列表;角色为 `owner`、`admin` 或 `viewer` | | `adminIds` | string[] | 否 | 已废弃的旧字段;存在时按 role 为 `owner` 的成员处理 | @@ -273,7 +274,7 @@ GitHub App 安装后,**所有**安装方的事件都会到达全局端点。 ### 权限模型 - **超级管理员**(`ADMIN_USER_IDS`)可查看和编辑所有分组及全部路由;只有他们能修改分组的 `owners` 列表。 -- **owner** 管理本组的路由、成员、邀请、名称、`emoji` 与 `providers`;不能移除最后一位 owner,也没有其他 owner 时不能把自己降级。 +- **owner** 管理本组的路由、成员、邀请、名称、id、`emoji` 与 `providers`;不能移除最后一位 owner,也没有其他 owner 时不能把自己降级。 - **admin** 可编辑本组路由并查看日志;**viewer** 只读控制台。 - 分组管理端点通过 `/admin/api/groups/:id/routes` 一次只操作一个分组;`groupId` 由路径参数强制指定。 - `owners` 列表限定哪些事件参与者(发送者登录名)的事件会被该分组的路由投递。 diff --git a/src/__tests__/admin-api.test.ts b/src/__tests__/admin-api.test.ts new file mode 100644 index 0000000..ec7da02 --- /dev/null +++ b/src/__tests__/admin-api.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect } from "bun:test"; +import { createAdminRoutes } from "../web/admin-routes"; +import { createAdminSession, adminCookie } from "../web/session"; +import { loadGroups } from "../web/groups"; +import { loadRoutes } from "../config"; +import { createInvite, listInvites } from "../web/invites"; +import { getTenantSecret, setTenantSecret } from "../web/tenants"; +import type { Env, Route } from "../types"; + +function createMockKV(): KVNamespace { + const store = new Map(); + return { + get: async (key: string, type?: string) => { + const v = store.get(key); + if (v == null) return null; + if (type === "json") return JSON.parse(v); + return v; + }, + put: async (key: string, value: string) => { + store.set(key, value); + }, + delete: async (key: string) => { + store.delete(key); + }, + list: async () => ({ + keys: [...store.keys()].map((k) => ({ name: k })), + list_complete: true, + cacheStatus: null, + }), + } as unknown as KVNamespace; +} + +function createMockDB(): D1Database { + return { + prepare: () => ({ + bind: () => ({ + run: async () => ({ success: true }), + all: async () => ({ results: [] }), + }), + }), + } as unknown as D1Database; +} + +function createEnv(overrides: Partial = {}): Env { + return { + GITHUB_WEBHOOK_SECRET: "secret", + KV: createMockKV(), + DB: createMockDB(), + ...overrides, + }; +} + +describe("PUT /admin/api/groups/:id/rename", () => { + const app = createAdminRoutes(); + + async function setupGroups(kv: KVNamespace, groups: unknown[]): Promise { + await kv.put("config:groups", JSON.stringify(groups)); + } + + function renameReq(env: Env, cookie: string, groupId: string, newId: string): Promise { + return app.request( + `/api/groups/${encodeURIComponent(groupId)}/rename`, + { + method: "PUT", + headers: { cookie, "content-type": "application/json" }, + body: JSON.stringify({ newId }), + }, + env, + ); + } + + it("renames an owned group and follows routes, secret and invites", async () => { + const kv = createMockKV(); + await setupGroups(kv, [ + { + id: "old-team", + name: "Old Team", + adminIds: [], + members: [{ login: "alice", role: "owner" }], + }, + ]); + await kv.put( + "config:routes", + JSON.stringify([ + { + id: "r1", + name: "R1", + enabled: true, + groupId: "old-team", + filters: [{ type: "event", match: "push" }], + targets: [{ channelId: "111" }], + }, + ] as Route[]), + ); + await setTenantSecret(kv, "old-team"); + const originalSecret = (await getTenantSecret(kv, "old-team"))!; + await createInvite(kv, { + groupId: "old-team", + role: "viewer", + expiresAt: Date.now() + 86400_000, + createdBy: "alice", + }); + const env = createEnv({ KV: kv }); + const sessionId = await createAdminSession(kv, "1001", "alice"); + + const res = await renameReq(env, adminCookie(sessionId), "old-team", "new-team"); + expect(res.status).toBe(200); + + const groups = await loadGroups(kv); + expect(groups).toHaveLength(1); + expect(groups[0]!.id).toBe("new-team"); + expect(groups[0]!.name).toBe("Old Team"); + + const routes = await loadRoutes(kv); + expect(routes[0]!.groupId).toBe("new-team"); + + expect(await getTenantSecret(kv, "new-team")).toBe(originalSecret); + expect(await getTenantSecret(kv, "old-team")).toBeNull(); + + const invites = await listInvites(kv, "new-team"); + expect(invites).toHaveLength(1); + expect(invites[0]!.groupId).toBe("new-team"); + expect(await listInvites(kv, "old-team")).toHaveLength(0); + }); + + it("forbids non-owner members from renaming", async () => { + const kv = createMockKV(); + await setupGroups(kv, [ + { id: "team", name: "Team", adminIds: [], members: [{ login: "bob", role: "admin" }] }, + ]); + const env = createEnv({ KV: kv }); + const sessionId = await createAdminSession(kv, "2002", "bob"); + + const res = await renameReq(env, adminCookie(sessionId), "team", "new-team"); + expect(res.status).toBe(403); + }); + + it("rejects invalid, duplicate or unchanged ids", async () => { + const kv = createMockKV(); + await setupGroups(kv, [ + { id: "team", name: "Team", adminIds: [], members: [{ login: "alice", role: "owner" }] }, + { id: "other", name: "Other", adminIds: [] }, + ]); + const env = createEnv({ KV: kv }); + const sessionId = await createAdminSession(kv, "1001", "alice"); + const cookie = adminCookie(sessionId); + + expect((await renameReq(env, cookie, "team", "Bad ID!")).status).toBe(400); + expect((await renameReq(env, cookie, "team", "team")).status).toBe(400); + expect((await renameReq(env, cookie, "team", "other")).status).toBe(400); + }); + + it("returns 401 without a session", async () => { + const env = createEnv(); + const res = await app.request( + "/api/groups/team/rename", + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ newId: "new-team" }), + }, + env, + ); + expect(res.status).toBe(401); + }); + + it("returns 404 for an unknown group", async () => { + const kv = createMockKV(); + await setupGroups(kv, [ + { id: "mine", name: "Mine", adminIds: [], members: [{ login: "alice", role: "owner" }] }, + ]); + const env = createEnv({ KV: kv }); + const sessionId = await createAdminSession(kv, "1001", "alice"); + const res = await renameReq(env, adminCookie(sessionId), "nope", "new-team"); + expect(res.status).toBe(404); + }); +}); diff --git a/src/web/admin-routes.ts b/src/web/admin-routes.ts index 3dfbdb2..4936916 100644 --- a/src/web/admin-routes.ts +++ b/src/web/admin-routes.ts @@ -15,7 +15,14 @@ import { } from "./auth"; import { getSendLog, getSendLogById } from "../lib/send-log"; import { getAuditLog, recordAudit } from "../lib/audit"; -import { createInvite, listInvites, revokeInvite, getInvite, acceptInvite } from "./invites"; +import { + createInvite, + listInvites, + revokeInvite, + getInvite, + acceptInvite, + migrateInvites, +} from "./invites"; import { getTenantSecret, setTenantSecret, deleteTenantSecret } from "./tenants"; import { log } from "../lib/log"; @@ -756,6 +763,75 @@ export function createAdminRoutes(): Hono { return c.json({ ok: true }); }); + // Rename a group (owner +). References follow: routes, the per-group + // webhook secret (tenant:{id}) and pending invites are re-pointed. + app.put("/api/groups/:groupId/rename", requireAnyAccess(), async (c) => { + const groupId = param(c, "groupId"); + const access = requireGroupRole(c, groupId, "owner"); + if (!access.ok) { + return c.json( + { error: access.status === 404 ? "Group not found" : "Forbidden" }, + access.status, + ); + } + let body: unknown; + try { + body = await c.req.json(); + } catch { + return c.json({ error: "Invalid JSON body" }, 400); + } + const newId = String((body as { newId?: unknown })?.newId ?? "").trim(); + if (!ID_RE.test(newId)) { + return c.json({ error: "newId is invalid" }, 400); + } + if (newId === groupId) { + return c.json({ error: "newId must differ from the current id" }, 400); + } + const auth = currentAuth(c); + const existing = await loadGroups(c.env.KV); + if (existing.some((g) => g.id === newId)) { + return c.json({ error: `group id "${newId}" already exists` }, 400); + } + + const next = existing.map((g) => (g.id === groupId ? { ...g, id: newId } : g)); + try { + await saveGroups(c.env.KV, next); + } catch (err) { + log.error({ err }, "Failed to save groups on rename"); + return c.json({ error: "Failed to save groups" }, 500); + } + + // Re-point routes, the tenant webhook secret and pending invites. + const routes = await loadRoutes(c.env.KV); + const touched = routes.filter((r) => r.groupId === groupId); + if (touched.length > 0) { + await saveRoutes( + c.env.KV, + routes.map((r) => (r.groupId === groupId ? { ...r, groupId: newId } : r)), + ); + } + const secret = await getTenantSecret(c.env.KV, groupId); + if (secret) { + await c.env.KV.put(`tenant:${newId}`, secret); + await c.env.KV.delete(`tenant:${groupId}`); + } + await migrateInvites(c.env.KV, groupId, newId); + + await recordAudit(c.env.DB, { + ts: Date.now(), + actorId: auth.session.userId, + actorLogin: auth.session.login, + action: "group.rename", + targetType: "group", + targetId: newId, + groupId: newId, + detail: { from: groupId, to: newId, routes: touched.length }, + ip: clientIp(c), + }); + log.info({ from: groupId, to: newId }, "Group renamed via admin UI"); + return c.json({ ok: true, id: newId }); + }); + // ---- Group webhook ingress (owner +) ---- app.get("/api/groups/:groupId/webhook", requireAnyAccess(), async (c) => { const groupId = param(c, "groupId"); diff --git a/src/web/invites.ts b/src/web/invites.ts index 45b1965..5009adf 100644 --- a/src/web/invites.ts +++ b/src/web/invites.ts @@ -120,6 +120,35 @@ export async function revokeInvite(kv: KVNamespace, token: string): Promise { + try { + const tokens = await readIndex(kv, from); + if (tokens.length === 0) { + await kv.delete(indexKey(from)); + return; + } + const moved: string[] = []; + for (const token of tokens) { + const invite = await kv.get(inviteKey(token), "json"); + if (invite && invite.groupId === from) { + await kv.put(inviteKey(token), JSON.stringify({ ...invite, groupId: to }), { + expirationTtl: INVITE_TTL, + }); + moved.push(token); + } + } + await kv.put(indexKey(to), JSON.stringify(moved)); + await kv.delete(indexKey(from)); + } catch (err) { + log.warn({ err, from, to }, "Failed to migrate invites on group rename"); + } +} + /** * Adds the accepting user to the invited group (or upgrades their role when * they are already a viewer) and consumes the invite. The invited role is