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.
This commit is contained in:
RhenCloud 2026-08-13 09:58:02 +08:00
parent 39bb639883
commit f4959eebf8
No known key found for this signature in database
GPG key ID: A574A617378C4E0B
11 changed files with 372 additions and 53 deletions

View file

@ -274,6 +274,7 @@ const {
error: groupsError, error: groupsError,
load: loadGroups, load: loadGroups,
save: saveGroups, save: saveGroups,
rename: groupsRename,
} = useGroupsApi(); } = useGroupsApi();
const { const {
routes: groupRoutes, routes: groupRoutes,
@ -435,9 +436,17 @@ async function onSaveGroupFromPanel(group: Group): Promise<void> {
async function onSaveGroup(group: Group): Promise<void> { async function onSaveGroup(group: Group): Promise<void> {
savingGroup.value = true; savingGroup.value = true;
try { try {
const editing = editingGroup.value;
let next: Group[]; let next: Group[];
if (editingGroup.value) { if (editing) {
next = groups.value.map((g) => (g.id === editingGroup.value!.id ? group : g)); 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 { } else {
if (groups.value.some((g) => g.id === group.id)) { if (groups.value.some((g) => g.id === group.id)) {
push(t("toast.groupIdExists"), "bad"); push(t("toast.groupIdExists"), "bad");
@ -448,6 +457,7 @@ async function onSaveGroup(group: Group): Promise<void> {
await saveGroups(next); await saveGroups(next);
groupEditorOpen.value = false; groupEditorOpen.value = false;
push(t("toast.groupSaved")); push(t("toast.groupSaved"));
await loadGroups();
} catch (err) { } catch (err) {
push(t("toast.saveFailed", { msg: err instanceof Error ? err.message : String(err) }), "bad"); push(t("toast.saveFailed", { msg: err instanceof Error ? err.message : String(err) }), "bad");
} finally { } finally {

View file

@ -26,10 +26,11 @@
v-model="form.id" v-model="form.id"
type="text" type="text"
:placeholder="t('groupEditor.idPlaceholder')" :placeholder="t('groupEditor.idPlaceholder')"
:disabled="isEdit"
required required
/> />
<div class="hint">{{ t("groupEditor.idHint") }}</div> <div class="hint">
{{ isEdit ? t("groupEditor.renameHint") : t("groupEditor.idHint") }}
</div>
</div> </div>
</div> </div>
<div class="row2"> <div class="row2">

View file

@ -70,6 +70,24 @@ export function useGroupsApi() {
groups.value = next; groups.value = next;
} }
/** Rename a group; routes, webhook secret and invites follow automatically. */
async function rename(oldId: string, newId: string): Promise<void> {
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 { return {
groups, groups,
isSuper, isSuper,
@ -82,5 +100,6 @@ export function useGroupsApi() {
error, error,
load, load,
save, save,
rename,
}; };
} }

View file

@ -167,6 +167,8 @@ const en: Dict = {
"groupEditor.id": "ID", "groupEditor.id": "ID",
"groupEditor.idPlaceholder": "my-team", "groupEditor.idPlaceholder": "my-team",
"groupEditor.idHint": "Unique, use a-z / 0-9 / dashes", "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.language": "Message language",
"groupEditor.langPlaceholder": "en", "groupEditor.langPlaceholder": "en",
"groupEditor.langHint": "en or zh; custom via KV i18n:<lang>", "groupEditor.langHint": "en or zh; custom via KV i18n:<lang>",
@ -416,6 +418,7 @@ const zh: Dict = {
"groupEditor.id": "ID", "groupEditor.id": "ID",
"groupEditor.idPlaceholder": "my-team", "groupEditor.idPlaceholder": "my-team",
"groupEditor.idHint": "唯一,使用 a-z / 0-9 / 短横线", "groupEditor.idHint": "唯一,使用 a-z / 0-9 / 短横线",
"groupEditor.renameHint": "修改 id 会同步更新引用该分组的路由、webhook secret 与邀请链接。",
"groupEditor.language": "消息语言", "groupEditor.language": "消息语言",
"groupEditor.langPlaceholder": "zh", "groupEditor.langPlaceholder": "zh",
"groupEditor.langHint": "en 或 zh可通过 KV i18n:<lang> 自定义", "groupEditor.langHint": "en 或 zh可通过 KV i18n:<lang> 自定义",

View file

@ -32,6 +32,7 @@ https://your-worker.workers.dev
| `PUT` | `/admin/api/groups` | Admin session | Replace groups (super) | | `PUT` | `/admin/api/groups` | Admin session | Replace groups (super) |
| `GET` | `/admin/api/groups/:id/routes` | Admin session | List a group's routes | | `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/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/me` | Admin session | Current session info |
| `GET` | `/admin/api/logs` | Admin session | Send logs (scoped) | | `GET` | `/admin/api/logs` | Admin session | Send logs (scoped) |
| `GET` | `/admin/api/logs/:id` | Admin session | Single send-log entry (scoped) | | `GET` | `/admin/api/logs/:id` | Admin session | Single send-log entry (scoped) |

View file

@ -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. 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 |
| `GET /admin/invite?token=…` | Accept a group invite (browser page) | | `GET /admin/invite?token=…` | Accept a group invite (browser page) |
| `GET /admin/api/me` | Current session, scope, groups, and roles | | `GET /admin/api/me` | Current session, scope, groups, and roles |
| `GET /admin/api/routes` | List routes (scoped to access) | | `GET /admin/api/routes` | List routes (scoped to access) |
| `PUT /admin/api/routes` | Replace routes (owner/admin per group) | | `PUT /admin/api/routes` | Replace routes (owner/admin per group) |
| `GET /admin/api/groups` | List groups + the signed-in user's role each | | `GET /admin/api/groups` | List groups + the signed-in user's role each |
| `PUT /admin/api/groups` | Replace groups (super: all; owner: own only) | | `PUT /admin/api/groups` | Replace groups (super: all; owner: own only) |
| `GET /admin/api/groups/:id/routes` | List a group's routes | | `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/routes` | Replace a group's routes (owner/admin) |
| `GET /admin/api/logs` | Send logs (scoped to accessible routes) | | `PUT /admin/api/groups/:id/rename` | Rename a group (owner); routes, webhook secret and invites follow |
| `GET /admin/api/logs/:id` | Single send-log entry (scoped) | | `GET /admin/api/logs` | Send logs (scoped to accessible routes) |
| `POST /admin/api/groups/:id/invites` | Create an invite link (owner) | | `GET /admin/api/logs/:id` | Single send-log entry (scoped) |
| `GET /admin/api/groups/:id/invites` | List pending invites (owner) | | `POST /admin/api/groups/:id/invites` | Create an invite link (owner) |
| `DELETE /admin/api/invites/:token` | Revoke an invite (owner) | | `GET /admin/api/groups/:id/invites` | List pending invites (owner) |
| `GET /admin/api/audit` | Audit log (scoped to accessible groups) | | `DELETE /admin/api/invites/:token` | Revoke an invite (owner) |
| `GET /admin/api/groups/:id/webhook` | Group webhook endpoint info (owner) | | `GET /admin/api/audit` | Audit log (scoped to accessible groups) |
| `POST /admin/api/groups/:id/webhook/regenerate` | Generate/regenerate the group webhook secret (owner) | | `GET /admin/api/groups/:id/webhook` | Group webhook endpoint info (owner) |
| `DELETE /admin/api/groups/:id/webhook` | Disable the group webhook ingress (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. 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 | | 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 | | `name` | string | Yes | Human-readable group name |
| `members` | object[] | No | `{ login, role }` entries; role is `owner`, `admin`, or `viewer` | | `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 | | `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 ### Access Model
- **Super admins** (`ADMIN_USER_IDS`) see and edit every group and all routes; only they can edit a group's `owners` list. - **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. - **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. - 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. - The `owners` list restricts which event actors (sender logins) the group's routes will dispatch at all.

View file

@ -32,6 +32,7 @@ https://your-worker.workers.dev
| `PUT` | `/admin/api/groups` | 管理员会话 | 替换分组(仅超级管理员) | | `PUT` | `/admin/api/groups` | 管理员会话 | 替换分组(仅超级管理员) |
| `GET` | `/admin/api/groups/:id/routes` | 管理员会话 | 列出某分组的路由 | | `GET` | `/admin/api/groups/:id/routes` | 管理员会话 | 列出某分组的路由 |
| `PUT` | `/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/me` | 管理员会话 | 当前会话信息 |
| `GET` | `/admin/api/logs` | 管理员会话 | 发送日志(按权限过滤) | | `GET` | `/admin/api/logs` | 管理员会话 | 发送日志(按权限过滤) |
| `GET` | `/admin/api/logs/:id` | 管理员会话 | 单条发送日志(按权限过滤) | | `GET` | `/admin/api/logs/:id` | 管理员会话 | 单条发送日志(按权限过滤) |

View file

@ -58,28 +58,29 @@ 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` | 销毁会话 |
| `GET /admin/invite?token=…` | 接受分组邀请(浏览器页面) | | `GET /admin/invite?token=…` | 接受分组邀请(浏览器页面) |
| `GET /admin/api/me` | 当前会话、权限范围、分组和角色 | | `GET /admin/api/me` | 当前会话、权限范围、分组和角色 |
| `GET /admin/api/routes` | 列出路由(按权限过滤) | | `GET /admin/api/routes` | 列出路由(按权限过滤) |
| `PUT /admin/api/routes` | 替换路由(按分组 owner/admin 权限) | | `PUT /admin/api/routes` | 替换路由(按分组 owner/admin 权限) |
| `GET /admin/api/groups` | 列出分组 + 当前用户在各组的角色 | | `GET /admin/api/groups` | 列出分组 + 当前用户在各组的角色 |
| `PUT /admin/api/groups` | 替换分组超管全量owner 仅自己的组) | | `PUT /admin/api/groups` | 替换分组超管全量owner 仅自己的组) |
| `GET /admin/api/groups/:id/routes` | 列出某分组的路由 | | `GET /admin/api/groups/:id/routes` | 列出某分组的路由 |
| `PUT /admin/api/groups/:id/routes` | 替换某分组的路由owner/admin | | `PUT /admin/api/groups/:id/routes` | 替换某分组的路由owner/admin |
| `GET /admin/api/logs` | 发送日志(按可访问路由过滤) | | `PUT /admin/api/groups/:id/rename` | 重命名分组owner路由、webhook secret 与邀请自动跟随 |
| `GET /admin/api/logs/:id` | 单条发送日志(按权限过滤) | | `GET /admin/api/logs` | 发送日志(按可访问路由过滤) |
| `POST /admin/api/groups/:id/invites` | 创建邀请链接owner | | `GET /admin/api/logs/:id` | 单条发送日志(按权限过滤) |
| `GET /admin/api/groups/:id/invites` | 列出待接受邀请owner | | `POST /admin/api/groups/:id/invites` | 创建邀请链接owner |
| `DELETE /admin/api/invites/:token` | 撤销邀请owner | | `GET /admin/api/groups/:id/invites` | 列出待接受邀请owner |
| `GET /admin/api/audit` | 审计日志(按可访问分组过滤) | | `DELETE /admin/api/invites/:token` | 撤销邀请owner |
| `GET /admin/api/groups/:id/webhook` | 分组 webhook 入口信息owner | | `GET /admin/api/audit` | 审计日志(按可访问分组过滤) |
| `POST /admin/api/groups/:id/webhook/regenerate` | 生成/重新生成分组 webhook secretowner | | `GET /admin/api/groups/:id/webhook` | 分组 webhook 入口信息owner |
| `DELETE /admin/api/groups/:id/webhook` | 停用分组 webhook 入口owner | | `POST /admin/api/groups/:id/webhook/regenerate` | 生成/重新生成分组 webhook secretowner |
| `DELETE /admin/api/groups/:id/webhook` | 停用分组 webhook 入口owner |
控制台支持新增、编辑、删除和开关路由。保存后立即写入 KV `config:routes` 并使配置缓存失效,下一次 webhook 处理即会生效。 控制台支持新增、编辑、删除和开关路由。保存后立即写入 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 | 是 | 可读的分组名称 | | `name` | string | 是 | 可读的分组名称 |
| `members` | object[] | 否 | `{ login, role }` 列表;角色为 `owner``admin``viewer` | | `members` | object[] | 否 | `{ login, role }` 列表;角色为 `owner``admin``viewer` |
| `adminIds` | string[] | 否 | 已废弃的旧字段;存在时按 role 为 `owner` 的成员处理 | | `adminIds` | string[] | 否 | 已废弃的旧字段;存在时按 role 为 `owner` 的成员处理 |
@ -273,7 +274,7 @@ GitHub App 安装后,**所有**安装方的事件都会到达全局端点。
### 权限模型 ### 权限模型
- **超级管理员**`ADMIN_USER_IDS`)可查看和编辑所有分组及全部路由;只有他们能修改分组的 `owners` 列表。 - **超级管理员**`ADMIN_USER_IDS`)可查看和编辑所有分组及全部路由;只有他们能修改分组的 `owners` 列表。
- **owner** 管理本组的路由、成员、邀请、名称、`emoji``providers`;不能移除最后一位 owner也没有其他 owner 时不能把自己降级。 - **owner** 管理本组的路由、成员、邀请、名称、id、`emoji``providers`;不能移除最后一位 owner也没有其他 owner 时不能把自己降级。
- **admin** 可编辑本组路由并查看日志;**viewer** 只读控制台。 - **admin** 可编辑本组路由并查看日志;**viewer** 只读控制台。
- 分组管理端点通过 `/admin/api/groups/:id/routes` 一次只操作一个分组;`groupId` 由路径参数强制指定。 - 分组管理端点通过 `/admin/api/groups/:id/routes` 一次只操作一个分组;`groupId` 由路径参数强制指定。
- `owners` 列表限定哪些事件参与者(发送者登录名)的事件会被该分组的路由投递。 - `owners` 列表限定哪些事件参与者(发送者登录名)的事件会被该分组的路由投递。

View file

@ -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<string, string>();
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> = {}): 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<void> {
await kv.put("config:groups", JSON.stringify(groups));
}
function renameReq(env: Env, cookie: string, groupId: string, newId: string): Promise<Response> {
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);
});
});

View file

@ -15,7 +15,14 @@ import {
} from "./auth"; } from "./auth";
import { getSendLog, getSendLogById } from "../lib/send-log"; import { getSendLog, getSendLogById } from "../lib/send-log";
import { getAuditLog, recordAudit } from "../lib/audit"; 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 { getTenantSecret, setTenantSecret, deleteTenantSecret } from "./tenants";
import { log } from "../lib/log"; import { log } from "../lib/log";
@ -756,6 +763,75 @@ export function createAdminRoutes(): Hono<AuthEnv> {
return c.json({ ok: true }); 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 +) ---- // ---- Group webhook ingress (owner +) ----
app.get("/api/groups/:groupId/webhook", requireAnyAccess(), async (c) => { app.get("/api/groups/:groupId/webhook", requireAnyAccess(), async (c) => {
const groupId = param(c, "groupId"); const groupId = param(c, "groupId");

View file

@ -120,6 +120,35 @@ export async function revokeInvite(kv: KVNamespace, token: string): Promise<void
if (raw) await removeFromIndex(kv, raw.groupId, token); if (raw) await removeFromIndex(kv, raw.groupId, token);
} }
/**
* Re-point every pending invite of a group to its new id (group rename).
* Best-effort: a failure leaves the old invites in place (they will be
* rejected as group-missing after the rename).
*/
export async function migrateInvites(kv: KVNamespace, from: string, to: string): Promise<void> {
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<Invite>(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 * 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 * they are already a viewer) and consumes the invite. The invited role is