diff --git a/DESIGN.md b/DESIGN.md index 90c2538..1de262b 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -4,7 +4,7 @@ - Pokopia Wiki 是一个面向 Pokopia 游戏资料的社区 Wiki。 - 所有人都可以浏览 Wiki 内容。 -- 已注册并完成邮箱验证的用户可以创建、编辑、删除 Wiki 内容。 +- 已注册并完成邮箱验证且拥有对应权限的用户可以创建、编辑、删除 Wiki 内容。 - 前台以 Pokemon、栖息地、物品、材料单、每日 CheckList、Life、Dish、Events、Actions、Dream Island、Clothes 为主要浏览入口。 - 管理入口用于维护全局配置、语言、系统文案、列表排序和每日 CheckList。 @@ -121,6 +121,50 @@ - 更新显示名后,API 仍只返回当前用户必要字段,不返回 session、token/hash、内部审计或调试数据。 - 显示名用于编辑署名、讨论和 Life 内容作者展示。 +## 用户角色与权限 + +- Pokopia 使用 RBAC 权限模型: + - 用户通过 `user_roles` 关联到一个或多个角色。 + - 角色通过 `role_permissions` 关联到一个或多个权限。 + - 后端只按启用状态的权限 key 做访问控制;前端只用于展示或隐藏操作入口,不能作为权限边界。 +- 邮箱验证仍是所有写入能力的基础门槛;未验证用户即使拥有角色也不能执行受保护写操作。 +- 对外当前用户字段只包含必要信息: + - `id` + - `email` + - `displayName` + - `emailVerified` + - `roles`:只包含 `id`、`key`、`name`、`level` + - `permissions`:当前用户启用权限 key 列表 +- 编辑署名仍只展示用户 `id` 和 `displayName`,不展示角色、权限、邮箱、token/hash 或内部元数据。 +- 权限记录存储在 `permissions`: + - `key`:稳定权限 key,例如 `pokemon.create` + - `name` + - `description` + - `category` + - `enabled` + - `system_permission`:系统初始化权限标记,仅用于管理端识别默认权限 +- 角色记录存储在 `roles`: + - `key` + - `name` + - `description` + - `level`:用于表达管理层级,数值越大层级越高 + - `enabled` + - `system_role`:系统初始化角色标记,仅用于管理端识别默认角色 +- 初始角色包含: + - `owner`:最高层级,拥有所有系统权限。 + - `admin`:拥有内容、系统配置、用户、角色和权限管理能力。 + - `editor`:拥有主要 Wiki 内容创建、更新、排序、上传和社区互动能力,不默认拥有删除、用户、角色或权限管理能力。 + - `member`:拥有 Life、讨论发布和删除本人内容的社区能力。 + - `viewer`:无写入权限,仅用于显式只读分组。 +- Bootstrap 规则: + - 启动时若已有已验证用户但没有任何 `owner` 用户,系统自动将最早完成验证的用户加入 `owner` 角色。 + - 若系统还没有 `owner` 用户,首个完成邮箱验证的用户自动加入 `owner` 角色。 + - 系统初始化只补齐默认角色、默认权限和 Owner 关联;不覆盖管理员对默认角色/权限元数据或角色权限分配的配置。 + - 新建权限会自动关联到 `owner` 角色,确保 Owner 始终拥有可用权限全集;`owner` 角色的权限分配不能在管理端被手动删改。 + - 系统必须始终至少保留一个拥有 `admin.permissions.update` 且可管理权限的有效用户;核心 RBAC 管理权限(`admin.access`、`admin.users.*`、`admin.roles.*`、`admin.permissions.*`)不能被禁用或删除;不能删除最后一个 Owner,不能移除最后一个 Owner 的关键权限能力。 +- 权限管理能力本身也通过权限控制;只有拥有相应管理权限的用户可以查看、新增、编辑、删除权限、角色和用户角色关系。 +- 管理 API 只返回权限管理所需字段,不返回密码、session token hash、verification/reset token hash、内部审计 payload 或调试字段。 + ## Referral - Referral 是账号功能,用于让已注册用户邀请新用户加入 Pokopia Wiki。 @@ -146,7 +190,7 @@ ## Community 编辑与审计 -- 已验证用户可以通过前台或管理入口编辑 Wiki 内容。 +- 已验证且拥有对应权限的用户可以通过前台或管理入口编辑 Wiki 内容。 - 新增、修改、删除 Wiki 内容时必须写入审计信息。 - 可编辑实体包含: - Pokemon @@ -173,7 +217,7 @@ ## Wiki 图片上传 -- 已验证用户可以为以下 Wiki 实体上传图片: +- 已验证且拥有对应上传权限的用户可以为以下 Wiki 实体上传图片: - Pokemon - 物品图标 - 栖息地 @@ -203,9 +247,9 @@ - Pokemon、物品、材料单、栖息地详情页支持讨论。 - 所有人都可以浏览实体讨论。 -- 已注册并完成邮箱验证的用户可以发表评论,并回复顶层评论。 +- 已注册并完成邮箱验证且拥有 `discussions.comments.create` 权限的用户可以发表评论,并回复顶层评论。 - 讨论回复只支持一层回复,不做无限嵌套。 -- 评论作者可以删除自己的评论;删除后正文不再展示,已有回复保留在原位置。 +- 评论作者拥有 `discussions.comments.delete` 权限时可以删除自己的评论;拥有 `discussions.comments.delete-any` 权限的用户可以删除其他用户评论;删除后正文不再展示,已有回复保留在原位置。 - 被删除实体的讨论会随实体删除一并清理。 - 讨论按创建时间正序展示。 - 讨论内容是用户生成内容,正文按作者输入展示,不进入 `entity_translations`。 @@ -299,7 +343,7 @@ Pokemon 的展示 ID 在普通 Pokemon 和活动 Pokemon 之间可以重复, Pokemon 编辑表单使用标签页组织字段: - 编辑表单提供 Fetch data 功能: - - 已验证用户可输入 data identifier 或 Pokemon ID,从同一个搜索输入查询基础资料或图片候选。 + - 已验证且拥有 `pokemon.fetch` 权限的用户可输入 data identifier 或 Pokemon ID,从同一个搜索输入查询基础资料或图片候选。 - Fetch data 从仓库 `data/` CSV 查询基础资料并填入当前表单。 - Fetch 输入框提供 data 列表搜索,搜索范围包含 Pokemon ID、identifier、当前语言名称和默认语言名称;结果只展示 `#ID`、名称和 identifier。 - Fetch 搜索结果默认关闭,只在用户主动点击输入框或输入内容时展开;Escape、失焦 / 点击外部、选择结果后关闭。 @@ -310,7 +354,7 @@ Pokemon 编辑表单使用标签页组织字段: - Fetch 会自动确保 canonical Pokemon Types 存在于 `pokemon_types`,Type ID 与 `data/localized_type_name.csv` 和 `frontend/public/types` 图标文件保持一致;用户不需要为 Fetch 手工创建 Type 配置。 - Type 展示使用 `frontend/public/types/small/{typeId}.png` 图标并保留文字名称。 - 编辑表单提供 Pokemon 图片选择功能: - - 已验证用户通过 Fetch data 的同一个 data identifier / Pokemon ID 输入框,从 `https://pokesprite.tootaio.com/sprites/` 静态图片树查询对应 Pokemon 的可用图片候选。 + - 已验证且拥有 `pokemon.fetch` 权限的用户通过 Fetch data 的同一个 data identifier / Pokemon ID 输入框,从 `https://pokesprite.tootaio.com/sprites/` 静态图片树查询对应 Pokemon 的可用图片候选。 - 图片候选只使用 `/sprites/pokemon/...` 相对路径,后端按固定资源族生成候选并用 `HEAD` 校验存在性;不保存任意外部 URL。 - 图片选择不直接创建或更新 Pokemon;用户仍需通过 Save 保存,保存时沿用现有编辑审计。 - 图片选择界面使用 Pokédex 风格:上方显示当前选择的大图,大图下方显示版本、状态和描述,再下方以缩略图网格展示同一 Pokemon 的不同风格 / 版本 / 状态。 @@ -511,8 +555,8 @@ Pokemon 出现配置: 管理行为: -- 已验证用户可新增、编辑、删除 Task。 -- 已验证用户可通过 Handle 拖拽排序。 + - 已验证且拥有对应 CheckList 权限的用户可新增、编辑、删除 Task。 + - 已验证且拥有 `checklist.order` 权限的用户可通过 Handle 拖拽排序。 ## Life @@ -531,14 +575,15 @@ Life Post 可配置: - 所有人都可以浏览 Life 信息流。 - 信息流按创建时间倒序展示。 -- 已注册并完成邮箱验证的用户可以发布 Life Post。 -- 作者本人可以编辑、删除自己的 Life Post;删除 Life Post 使用软删除。 -- 已注册并完成邮箱验证的用户发布或编辑 Life Post 时可以选择一个或多个 Life 标签。 -- 已注册并完成邮箱验证的用户可以评论 Life Post,并回复顶层评论。 -- 评论作者可以删除自己的评论;删除评论后正文不再展示,已有回复保留在原位置。 +- 已注册并完成邮箱验证且拥有 `life.posts.create` 权限的用户可以发布 Life Post。 +- 作者本人拥有 `life.posts.update` / `life.posts.delete` 权限时可以编辑、删除自己的 Life Post;删除 Life Post 使用软删除。 +- 拥有 `life.posts.update-any` / `life.posts.delete-any` 权限的用户可以管理其他用户的 Life Post。 +- 已注册并完成邮箱验证且拥有 `life.posts.create` 或 `life.posts.update` 权限的用户发布或编辑 Life Post 时可以选择一个或多个 Life 标签。 +- 已注册并完成邮箱验证且拥有 `life.comments.create` 权限的用户可以评论 Life Post,并回复顶层评论。 +- 评论作者拥有 `life.comments.delete` 权限时可以删除自己的评论;拥有 `life.comments.delete-any` 权限的用户可以删除其他用户评论;删除评论后正文不再展示,已有回复保留在原位置。 - 已软删除的 Life Post 不出现在信息流、搜索或标签筛选结果中,也不能继续编辑、评论或设置 Reaction。 - 每条 Life Post 默认只展示评论入口与评论数量;评论列表、回复和评论输入默认折叠,用户点击后展开。 -- 已注册并完成邮箱验证的用户可以对每条 Life Post 选择一个 Reaction;普通点击默认设置 `like`,再次点击 `like` 会取消,当前为其他 Reaction 时普通点击会替换为 `like`。 +- 已注册并完成邮箱验证且拥有 `life.reactions.set` 权限的用户可以对每条 Life Post 选择一个 Reaction;普通点击默认设置 `like`,再次点击 `like` 会取消,当前为其他 Reaction 时普通点击会替换为 `like`。 - Life Reaction 的其他类型通过右键 / context menu 或可见展开按钮打开 Popup 选择;再次选择当前 Reaction 会取消,选择其他 Reaction 会替换原 Reaction。 - 支持按 Life Post 正文搜索;用户按 Enter 或点击 Search 按钮后提交搜索,不随输入实时请求;搜索结果仍按创建时间倒序展示并分页加载。 - Feed 使用 Tabs 展示 Life 标签筛选;包含 All 和后台配置的 Life 标签;点击标签后按该标签筛选,搜索和标签筛选可以同时生效。 @@ -555,8 +600,7 @@ API 暴露边界: - Life Post 列表 API 返回分页结果:`items`、`nextCursor`、`hasMore`;`cursor` 是不透明分页令牌。 - API 不返回邮箱、token/hash、内部调试字段或不必要的审计 payload。 - API 不返回 Life Post 的 `deleted_at`、`deleted_by_user_id` 等内部软删除字段。 -- 非作者不能编辑或删除其他用户的 Life Post。 -- 非作者不能删除其他用户的 Life Comment。 +- 非作者只有拥有对应 `*-any` 管理权限时才能编辑或删除其他用户的 Life Post 或 Life Comment。 ## 开发中入口 @@ -594,6 +638,7 @@ API 暴露边界: - Life 使用信息流顶部 New Post / 编辑按钮打开普通 Modal 发布与编辑,不使用路由驱动 Modal。 - 进入或关闭编辑 Modal 时应保留底层页面上下文,不进行不必要的滚动跳转。 - 用户界面不得展示内部字段名、调试数据、计划说明或“已修改某字段”一类实现说明。 +- 权限不足时前端可以隐藏或禁用对应操作;后端必须返回本地化 403,并且不得在 UI 暴露内部权限 key 作为普通用户提示。 ## API 概览 @@ -626,35 +671,49 @@ API 暴露边界: - `GET /api/auth/referral`:读取当前用户 Referral 摘要;需要登录;返回 `referral`,其中只包含 `code`、`url`、`verifiedReferralCount`。 - `POST /api/auth/logout` -已验证用户编辑 API: +权限管理 API: -- Pokemon、栖息地、物品、材料单的创建、更新、删除。 -- `GET /api/pokemon/fetch-options`:按搜索词返回 Pokemon CSV data 搜索结果;需要已验证用户;只返回 `id`、`identifier`、`name`。 -- `POST /api/pokemon/fetch`:按 data identifier 或 Pokemon ID 查询 CSV 资料并填充 Pokemon 编辑表单;需要已验证用户;不直接保存 Pokemon。 -- `POST /api/pokemon/image-options`:按 data identifier 或 Pokemon ID 查询 pokesprite 可用图片候选;需要已验证用户;只返回 `id`、`identifier` 和图片候选列表。 -- `POST /api/uploads/:entityType`:上传 Wiki 图片;需要已验证用户;`entityType` 支持 `pokemon`、`items`、`habitats`;返回图片历史记录项和可展示 URL。 -- Life Post 的创建,以及作者本人对 Life Post 的更新、删除。 +- `GET /api/admin/users`:需要 `admin.users.read` +- `PUT /api/admin/users/:id/roles`:需要 `admin.users.update` +- `GET /api/admin/roles`:需要 `admin.roles.read` +- `POST /api/admin/roles`:需要 `admin.roles.create` +- `PUT /api/admin/roles/:id`:需要 `admin.roles.update` +- `DELETE /api/admin/roles/:id`:需要 `admin.roles.delete` +- `PUT /api/admin/roles/:id/permissions`:需要 `admin.roles.update` +- `GET /api/admin/permissions`:需要 `admin.permissions.read` +- `POST /api/admin/permissions`:需要 `admin.permissions.create` +- `PUT /api/admin/permissions/:id`:需要 `admin.permissions.update` +- `DELETE /api/admin/permissions/:id`:需要 `admin.permissions.delete` + +受权限保护的编辑 API: + +- Pokemon、栖息地、物品、材料单的创建、更新、删除分别需要对应实体的 `create`、`update`、`delete` 权限。 +- `GET /api/pokemon/fetch-options`:按搜索词返回 Pokemon CSV data 搜索结果;需要 `pokemon.fetch`;只返回 `id`、`identifier`、`name`。 +- `POST /api/pokemon/fetch`:按 data identifier 或 Pokemon ID 查询 CSV 资料并填充 Pokemon 编辑表单;需要 `pokemon.fetch`;不直接保存 Pokemon。 +- `POST /api/pokemon/image-options`:按 data identifier 或 Pokemon ID 查询 pokesprite 可用图片候选;需要 `pokemon.fetch`;只返回 `id`、`identifier` 和图片候选列表。 +- `POST /api/uploads/:entityType`:上传 Wiki 图片;需要对应实体上传权限;`entityType` 支持 `pokemon`、`items`、`habitats`;返回图片历史记录项和可展示 URL。 +- Life Post 的创建,以及作者本人对 Life Post 的更新、删除,需要对应 `life.posts.*` 权限;管理他人内容需要对应 `*-any` 权限。 - `POST /api/life-posts` - `PUT /api/life-posts/:id` - `DELETE /api/life-posts/:id` -- Life Comment 的创建,以及作者本人对 Life Comment 的删除。 +- Life Comment 的创建,以及作者本人对 Life Comment 的删除,需要对应 `life.comments.*` 权限;管理他人内容需要对应 `*-any` 权限。 - `POST /api/life-posts/:postId/comments` - `POST /api/life-posts/:postId/comments/:commentId/replies` - `DELETE /api/life-comments/:id` -- 实体讨论评论的创建、回复,以及作者本人对评论的删除。 +- 实体讨论评论的创建、回复,以及作者本人对评论的删除,需要对应 `discussions.comments.*` 权限;管理他人内容需要对应 `*-any` 权限。 - `POST /api/discussions/:entityType/:entityId/comments` - `POST /api/discussions/:entityType/:entityId/comments/:commentId/replies` - `DELETE /api/discussions/comments/:id` - Life Reaction 的设置、替换和取消。 - `PUT /api/life-posts/:id/reaction` - `DELETE /api/life-posts/:id/reaction` -- 每日 CheckList 的创建、更新、删除、排序。 -- 全局配置项的创建、更新、删除、排序。 -- 语言的创建、更新、删除、排序。 -- 系统级文案的查看和更新。 +- 每日 CheckList 的创建、更新、删除、排序需要对应 `checklist.*` 权限。 +- 全局配置项的查看、创建、更新、删除、排序需要对应 `admin.config.*` 权限。 +- 语言的查看、创建、更新、删除、排序需要对应 `admin.languages.*` 权限。 +- 系统级文案的查看和更新需要对应 `admin.wordings.*` 权限。 - `GET /api/admin/system-wordings` - `PUT /api/admin/system-wordings/:key` -- Pokemon、物品、材料单、栖息地的列表排序。 +- Pokemon、物品、材料单、栖息地的列表排序需要对应实体的 `order` 权限。 ## 开发与验证 diff --git a/backend/db/schema.sql b/backend/db/schema.sql index be6f3ad..15349b9 100644 --- a/backend/db/schema.sql +++ b/backend/db/schema.sql @@ -100,6 +100,310 @@ CREATE UNIQUE INDEX IF NOT EXISTS users_referral_code_idx CREATE INDEX IF NOT EXISTS users_referred_by_user_id_idx ON users(referred_by_user_id); +CREATE TABLE IF NOT EXISTS roles ( + id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + key text NOT NULL UNIQUE, + name text NOT NULL, + description text NOT NULL DEFAULT '', + level integer NOT NULL DEFAULT 0 CHECK (level >= 0), + enabled boolean NOT NULL DEFAULT true, + system_role boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CHECK (key ~ '^[a-z][a-z0-9-]{1,63}$'), + CHECK (length(name) BETWEEN 1 AND 80) +); + +ALTER TABLE roles ADD COLUMN IF NOT EXISTS description text NOT NULL DEFAULT ''; +ALTER TABLE roles ADD COLUMN IF NOT EXISTS level integer NOT NULL DEFAULT 0 CHECK (level >= 0); +ALTER TABLE roles ADD COLUMN IF NOT EXISTS enabled boolean NOT NULL DEFAULT true; +ALTER TABLE roles ADD COLUMN IF NOT EXISTS system_role boolean NOT NULL DEFAULT false; + +CREATE INDEX IF NOT EXISTS roles_level_idx + ON roles(level DESC, id); + +CREATE TABLE IF NOT EXISTS permissions ( + id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + key text NOT NULL UNIQUE, + name text NOT NULL, + description text NOT NULL DEFAULT '', + category text NOT NULL DEFAULT 'General', + enabled boolean NOT NULL DEFAULT true, + system_permission boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CHECK (key ~ '^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$'), + CHECK (length(name) BETWEEN 1 AND 120), + CHECK (length(category) BETWEEN 1 AND 80) +); + +ALTER TABLE permissions ADD COLUMN IF NOT EXISTS description text NOT NULL DEFAULT ''; +ALTER TABLE permissions ADD COLUMN IF NOT EXISTS category text NOT NULL DEFAULT 'General'; +ALTER TABLE permissions ADD COLUMN IF NOT EXISTS enabled boolean NOT NULL DEFAULT true; +ALTER TABLE permissions ADD COLUMN IF NOT EXISTS system_permission boolean NOT NULL DEFAULT false; + +CREATE INDEX IF NOT EXISTS permissions_category_idx + ON permissions(category, key); + +CREATE TABLE IF NOT EXISTS role_permissions ( + role_id integer NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + permission_id integer NOT NULL REFERENCES permissions(id) ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (role_id, permission_id) +); + +CREATE INDEX IF NOT EXISTS role_permissions_permission_id_idx + ON role_permissions(permission_id, role_id); + +CREATE TABLE IF NOT EXISTS user_roles ( + user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role_id integer NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + assigned_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, + assigned_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, role_id) +); + +CREATE INDEX IF NOT EXISTS user_roles_role_id_idx + ON user_roles(role_id, user_id); + +INSERT INTO permissions (key, name, description, category, system_permission) +VALUES + ('admin.access', 'Access admin', 'Open the management area.', 'Admin', true), + ('admin.users.read', 'View users', 'View user role assignments.', 'Users', true), + ('admin.users.update', 'Manage user roles', 'Assign and remove roles from users.', 'Users', true), + ('admin.roles.read', 'View roles', 'View role configuration.', 'Roles', true), + ('admin.roles.create', 'Create roles', 'Create configurable roles.', 'Roles', true), + ('admin.roles.update', 'Update roles', 'Edit roles and role permission assignments.', 'Roles', true), + ('admin.roles.delete', 'Delete roles', 'Delete configurable roles.', 'Roles', true), + ('admin.permissions.read', 'View permissions', 'View permission configuration.', 'Permissions', true), + ('admin.permissions.create', 'Create permissions', 'Create configurable permissions.', 'Permissions', true), + ('admin.permissions.update', 'Update permissions', 'Edit permission metadata and enabled state.', 'Permissions', true), + ('admin.permissions.delete', 'Delete permissions', 'Delete configurable permissions.', 'Permissions', true), + ('admin.languages.read', 'View languages', 'View language settings.', 'Languages', true), + ('admin.languages.create', 'Create languages', 'Create languages.', 'Languages', true), + ('admin.languages.update', 'Update languages', 'Edit language settings.', 'Languages', true), + ('admin.languages.delete', 'Delete languages', 'Delete languages.', 'Languages', true), + ('admin.languages.order', 'Order languages', 'Reorder languages.', 'Languages', true), + ('admin.wordings.read', 'View system wordings', 'View system wording values.', 'System wordings', true), + ('admin.wordings.update', 'Update system wordings', 'Edit system wording values.', 'System wordings', true), + ('admin.config.read', 'View system config', 'View management configuration records.', 'System config', true), + ('admin.config.create', 'Create system config', 'Create management configuration records.', 'System config', true), + ('admin.config.update', 'Update system config', 'Edit management configuration records.', 'System config', true), + ('admin.config.delete', 'Delete system config', 'Delete management configuration records.', 'System config', true), + ('admin.config.order', 'Order system config', 'Reorder management configuration records.', 'System config', true), + ('checklist.create', 'Create checklist tasks', 'Create Daily CheckList tasks.', 'CheckList', true), + ('checklist.update', 'Update checklist tasks', 'Edit Daily CheckList tasks.', 'CheckList', true), + ('checklist.delete', 'Delete checklist tasks', 'Delete Daily CheckList tasks.', 'CheckList', true), + ('checklist.order', 'Order checklist tasks', 'Reorder Daily CheckList tasks.', 'CheckList', true), + ('pokemon.create', 'Create Pokemon', 'Create Pokemon records.', 'Pokemon', true), + ('pokemon.update', 'Update Pokemon', 'Edit Pokemon records.', 'Pokemon', true), + ('pokemon.delete', 'Delete Pokemon', 'Delete Pokemon records.', 'Pokemon', true), + ('pokemon.order', 'Order Pokemon', 'Reorder Pokemon records.', 'Pokemon', true), + ('pokemon.fetch', 'Fetch Pokemon data', 'Fetch Pokemon data and sprite candidates.', 'Pokemon', true), + ('pokemon.upload', 'Upload Pokemon images', 'Upload Pokemon images.', 'Pokemon', true), + ('habitats.create', 'Create habitats', 'Create habitat records.', 'Habitats', true), + ('habitats.update', 'Update habitats', 'Edit habitat records.', 'Habitats', true), + ('habitats.delete', 'Delete habitats', 'Delete habitat records.', 'Habitats', true), + ('habitats.order', 'Order habitats', 'Reorder habitat records.', 'Habitats', true), + ('habitats.upload', 'Upload habitat images', 'Upload habitat images.', 'Habitats', true), + ('items.create', 'Create items', 'Create item records.', 'Items', true), + ('items.update', 'Update items', 'Edit item records.', 'Items', true), + ('items.delete', 'Delete items', 'Delete item records.', 'Items', true), + ('items.order', 'Order items', 'Reorder item records.', 'Items', true), + ('items.upload', 'Upload item images', 'Upload item images.', 'Items', true), + ('recipes.create', 'Create recipes', 'Create recipe records.', 'Recipes', true), + ('recipes.update', 'Update recipes', 'Edit recipe records.', 'Recipes', true), + ('recipes.delete', 'Delete recipes', 'Delete recipe records.', 'Recipes', true), + ('recipes.order', 'Order recipes', 'Reorder recipe records.', 'Recipes', true), + ('life.posts.create', 'Create Life posts', 'Create Life posts.', 'Life', true), + ('life.posts.update', 'Update own Life posts', 'Edit own Life posts.', 'Life', true), + ('life.posts.delete', 'Delete own Life posts', 'Delete own Life posts.', 'Life', true), + ('life.posts.update-any', 'Update any Life post', 'Edit any Life post.', 'Life', true), + ('life.posts.delete-any', 'Delete any Life post', 'Delete any Life post.', 'Life', true), + ('life.comments.create', 'Create Life comments', 'Create Life comments and replies.', 'Life', true), + ('life.comments.delete', 'Delete own Life comments', 'Delete own Life comments.', 'Life', true), + ('life.comments.delete-any', 'Delete any Life comment', 'Delete any Life comment.', 'Life', true), + ('life.reactions.set', 'Set Life reactions', 'Set and remove Life reactions.', 'Life', true), + ('discussions.comments.create', 'Create discussion comments', 'Create entity discussion comments and replies.', 'Discussions', true), + ('discussions.comments.delete', 'Delete own discussion comments', 'Delete own entity discussion comments.', 'Discussions', true), + ('discussions.comments.delete-any', 'Delete any discussion comment', 'Delete any entity discussion comment.', 'Discussions', true) +ON CONFLICT (key) DO UPDATE +SET system_permission = true +WHERE permissions.system_permission = false; + +INSERT INTO roles (key, name, description, level, enabled, system_role) +VALUES + ('owner', 'Owner', 'Highest-level system owner with all permissions.', 1000, true, true), + ('admin', 'Admin', 'System manager with content, configuration and user administration permissions.', 800, true, true), + ('editor', 'Editor', 'Wiki editor with content creation, update, sorting and community permissions.', 500, true, true), + ('member', 'Member', 'Community member with Life and discussion permissions.', 100, true, true), + ('viewer', 'Viewer', 'Read-only role for explicit access grouping.', 0, true, true) +ON CONFLICT (key) DO UPDATE +SET system_role = true +WHERE roles.system_role = false; + +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +CROSS JOIN permissions p +WHERE r.key = 'owner' +ON CONFLICT DO NOTHING; + +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +JOIN permissions p ON p.key = ANY (ARRAY[ + 'admin.access', + 'admin.users.read', + 'admin.users.update', + 'admin.roles.read', + 'admin.roles.create', + 'admin.roles.update', + 'admin.roles.delete', + 'admin.permissions.read', + 'admin.permissions.create', + 'admin.permissions.update', + 'admin.permissions.delete', + 'admin.languages.read', + 'admin.languages.create', + 'admin.languages.update', + 'admin.languages.delete', + 'admin.languages.order', + 'admin.wordings.read', + 'admin.wordings.update', + 'admin.config.read', + 'admin.config.create', + 'admin.config.update', + 'admin.config.delete', + 'admin.config.order', + 'checklist.create', + 'checklist.update', + 'checklist.delete', + 'checklist.order', + 'pokemon.create', + 'pokemon.update', + 'pokemon.delete', + 'pokemon.order', + 'pokemon.fetch', + 'pokemon.upload', + 'habitats.create', + 'habitats.update', + 'habitats.delete', + 'habitats.order', + 'habitats.upload', + 'items.create', + 'items.update', + 'items.delete', + 'items.order', + 'items.upload', + 'recipes.create', + 'recipes.update', + 'recipes.delete', + 'recipes.order', + 'life.posts.create', + 'life.posts.update', + 'life.posts.delete', + 'life.posts.update-any', + 'life.posts.delete-any', + 'life.comments.create', + 'life.comments.delete', + 'life.comments.delete-any', + 'life.reactions.set', + 'discussions.comments.create', + 'discussions.comments.delete', + 'discussions.comments.delete-any' +]) +WHERE r.key = 'admin' + AND NOT EXISTS ( + SELECT 1 + FROM role_permissions existing_role_permission + WHERE existing_role_permission.role_id = r.id + ) +ON CONFLICT DO NOTHING; + +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +JOIN permissions p ON p.key = ANY (ARRAY[ + 'admin.access', + 'admin.config.read', + 'checklist.create', + 'checklist.update', + 'checklist.order', + 'pokemon.create', + 'pokemon.update', + 'pokemon.order', + 'pokemon.fetch', + 'pokemon.upload', + 'habitats.create', + 'habitats.update', + 'habitats.order', + 'habitats.upload', + 'items.create', + 'items.update', + 'items.order', + 'items.upload', + 'recipes.create', + 'recipes.update', + 'recipes.order', + 'life.posts.create', + 'life.posts.update', + 'life.posts.delete', + 'life.comments.create', + 'life.comments.delete', + 'life.reactions.set', + 'discussions.comments.create', + 'discussions.comments.delete' +]) +WHERE r.key = 'editor' + AND NOT EXISTS ( + SELECT 1 + FROM role_permissions existing_role_permission + WHERE existing_role_permission.role_id = r.id + ) +ON CONFLICT DO NOTHING; + +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +JOIN permissions p ON p.key = ANY (ARRAY[ + 'life.posts.create', + 'life.posts.update', + 'life.posts.delete', + 'life.comments.create', + 'life.comments.delete', + 'life.reactions.set', + 'discussions.comments.create', + 'discussions.comments.delete' +]) +WHERE r.key = 'member' + AND NOT EXISTS ( + SELECT 1 + FROM role_permissions existing_role_permission + WHERE existing_role_permission.role_id = r.id + ) +ON CONFLICT DO NOTHING; + +WITH first_owner_user AS ( + SELECT u.id + FROM users u + WHERE u.email_verified_at IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM user_roles ur + JOIN roles existing_role ON existing_role.id = ur.role_id + WHERE existing_role.key = 'owner' + ) + ORDER BY u.email_verified_at ASC, u.id ASC + LIMIT 1 +) +INSERT INTO user_roles (user_id, role_id) +SELECT first_owner_user.id, r.id +FROM first_owner_user +CROSS JOIN roles r +WHERE r.key = 'owner' +ON CONFLICT DO NOTHING; + CREATE TABLE IF NOT EXISTS system_wording_keys ( key text PRIMARY KEY, module text NOT NULL, diff --git a/backend/src/auth.ts b/backend/src/auth.ts index 33c689b..4ee9ec8 100644 --- a/backend/src/auth.ts +++ b/backend/src/auth.ts @@ -1,7 +1,7 @@ import { createHash, randomBytes, scrypt as scryptCallback, timingSafeEqual } from 'node:crypto'; import { promisify } from 'node:util'; import type { PoolClient, QueryResultRow } from 'pg'; -import { pool, queryOne } from './db.ts'; +import { pool, query, queryOne } from './db.ts'; import { systemMessage } from './systemWordingQueries.ts'; const scrypt = promisify(scryptCallback); @@ -24,6 +24,8 @@ type UserRow = QueryResultRow & { email: string; display_name: string; email_verified_at: string | null; + created_at?: string; + updated_at?: string; }; type LoginUserRow = UserRow & { @@ -69,6 +71,8 @@ export type AuthUser = { email: string; displayName: string; emailVerified: boolean; + roles: RoleSummary[]; + permissions: string[]; }; export type ReferralSummary = { @@ -77,6 +81,77 @@ export type ReferralSummary = { verifiedReferralCount: number; }; +export type RoleSummary = { + id: number; + key: string; + name: string; + level: number; +}; + +export type PermissionSummary = { + id: number; + key: string; + name: string; + description: string; + category: string; + enabled: boolean; + systemPermission: boolean; +}; + +export type RoleDetail = RoleSummary & { + description: string; + enabled: boolean; + systemRole: boolean; + permissionIds: number[]; +}; + +export type AdminUser = AuthUser & { + roleIds: number[]; + createdAt: string; + updatedAt: string; +}; + +type RoleRow = QueryResultRow & { + id: number; + key: string; + name: string; + description: string; + level: number; + enabled: boolean; + system_role: boolean; +}; + +type PermissionRow = QueryResultRow & { + id: number; + key: string; + name: string; + description: string; + category: string; + enabled: boolean; + system_permission: boolean; +}; + +type RolePermissionRow = QueryResultRow & { + role_id: number; + permission_id: number; +}; + +const roleKeyPattern = /^[a-z][a-z0-9-]{1,63}$/; +const permissionKeyPattern = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/; +const criticalPermissionKeys = [ + 'admin.access', + 'admin.users.read', + 'admin.users.update', + 'admin.roles.read', + 'admin.roles.create', + 'admin.roles.update', + 'admin.roles.delete', + 'admin.permissions.read', + 'admin.permissions.create', + 'admin.permissions.update', + 'admin.permissions.delete' +]; + function statusError(message: string, statusCode: number): StatusError { const error = new Error(message) as StatusError; error.statusCode = statusCode; @@ -178,15 +253,26 @@ async function cleanReferralCode(value: unknown, locale: string): Promise( + client: DbClient, + sql: string, + params: unknown[] = [] +): Promise { + const result = await client.query(sql, params); + return result.rows; +} + async function clientQueryOne( client: DbClient, sql: string, @@ -196,6 +282,22 @@ async function clientQueryOne( return result.rows[0] ?? null; } +async function runQuery( + client: DbClient | null, + sql: string, + params: unknown[] = [] +): Promise { + return client ? clientQuery(client, sql, params) : query(sql, params); +} + +async function runQueryOne( + client: DbClient | null, + sql: string, + params: unknown[] = [] +): Promise { + return client ? clientQueryOne(client, sql, params) : queryOne(sql, params); +} + async function withTransaction(callback: (client: DbClient) => Promise): Promise { const client = await pool.connect(); @@ -287,6 +389,236 @@ async function ensureReferralCode(client: DbClient, userId: number): Promise { + const existingOwner = await clientQueryOne( + client, + ` + SELECT u.id + FROM user_roles ur + JOIN users u ON u.id = ur.user_id + JOIN roles r ON r.id = ur.role_id + WHERE r.key = 'owner' + AND u.email_verified_at IS NOT NULL + LIMIT 1 + ` + ); + + if (existingOwner) { + return; + } + + const ownerRole = await clientQueryOne(client, "SELECT id FROM roles WHERE key = 'owner'"); + if (!ownerRole) { + return; + } + + await client.query( + ` + INSERT INTO user_roles (user_id, role_id) + VALUES ($1, $2) + ON CONFLICT DO NOTHING + `, + [userId, ownerRole.id] + ); +} + +function toRoleSummary(row: RoleRow): RoleSummary { + return { + id: row.id, + key: row.key, + name: row.name, + level: row.level + }; +} + +function toPermissionSummary(row: PermissionRow): PermissionSummary { + return { + id: row.id, + key: row.key, + name: row.name, + description: row.description, + category: row.category, + enabled: row.enabled, + systemPermission: row.system_permission + }; +} + +function toRoleDetail(row: RoleRow, permissionIds: number[]): RoleDetail { + return { + ...toRoleSummary(row), + description: row.description, + enabled: row.enabled, + systemRole: row.system_role, + permissionIds + }; +} + +async function userRoles(userId: number, client: DbClient | null = null): Promise { + const rows = await runQuery( + client, + ` + SELECT r.id, r.key, r.name, r.description, r.level, r.enabled, r.system_role + FROM user_roles ur + JOIN roles r ON r.id = ur.role_id + WHERE ur.user_id = $1 + AND r.enabled = true + ORDER BY r.level DESC, r.name ASC, r.id ASC + `, + [userId] + ); + + return rows.map(toRoleSummary); +} + +async function userPermissions(userId: number, client: DbClient | null = null): Promise { + const rows = await runQuery( + client, + ` + SELECT DISTINCT p.key + FROM user_roles ur + JOIN roles r ON r.id = ur.role_id + JOIN role_permissions rp ON rp.role_id = r.id + JOIN permissions p ON p.id = rp.permission_id + WHERE ur.user_id = $1 + AND r.enabled = true + AND p.enabled = true + ORDER BY p.key + `, + [userId] + ); + + return rows.map((row) => row.key); +} + +async function publicUserById(userId: number, client: DbClient | null = null): Promise { + const user = await runQueryOne( + client, + 'SELECT id, email, display_name, email_verified_at FROM users WHERE id = $1', + [userId] + ); + + if (!user) { + return null; + } + + return toPublicUser(user, await userRoles(user.id, client), await userPermissions(user.id, client)); +} + +function hasPermission(user: AuthUser, permissionKey: string): boolean { + return user.emailVerified && user.permissions.includes(permissionKey); +} + +export function userHasPermission(user: AuthUser, permissionKey: string): boolean { + return hasPermission(user, permissionKey); +} + +export function userHasAnyPermission(user: AuthUser, permissionKeys: string[]): boolean { + return user.emailVerified && permissionKeys.some((permissionKey) => user.permissions.includes(permissionKey)); +} + +function cleanKey(value: unknown, pattern: RegExp, message: string): string { + const key = typeof value === 'string' ? value.trim() : ''; + if (!pattern.test(key)) { + throw statusError(message, 400); + } + return key; +} + +function cleanText(value: unknown, options: { required?: boolean; max?: number } = {}): string { + const text = typeof value === 'string' ? value.trim() : ''; + if (options.required && !text) { + throw statusError('server.permissions.nameRequired', 400); + } + if (options.max && text.length > options.max) { + throw statusError('server.permissions.valueTooLong', 400); + } + return text; +} + +function cleanInteger(value: unknown, fallback = 0): number { + const numeric = typeof value === 'number' ? value : Number(value); + if (!Number.isInteger(numeric) || numeric < 0) { + return fallback; + } + return numeric; +} + +function cleanBoolean(value: unknown, fallback = true): boolean { + return typeof value === 'boolean' ? value : fallback; +} + +function cleanIdList(value: unknown): number[] { + if (!Array.isArray(value)) { + throw statusError('server.permissions.invalidSelection', 400); + } + + const ids = [...new Set(value.map((item) => Number(item)).filter((item) => Number.isInteger(item) && item > 0))]; + if (ids.length !== value.length) { + throw statusError('server.permissions.invalidSelection', 400); + } + + return ids; +} + +async function assertCriticalPermissionsEnabled(client: DbClient): Promise { + const row = await clientQueryOne( + client, + 'SELECT COUNT(*)::text AS count FROM permissions WHERE key = ANY($1::text[]) AND enabled = true', + [criticalPermissionKeys] + ); + + if (Number(row?.count ?? 0) !== criticalPermissionKeys.length) { + throw statusError('server.permissions.criticalPermissionRequired', 400); + } +} + +async function assertOwnerExists(client: DbClient): Promise { + const row = await clientQueryOne( + client, + ` + SELECT COUNT(DISTINCT u.id)::text AS count + FROM users u + JOIN user_roles ur ON ur.user_id = u.id + JOIN roles r ON r.id = ur.role_id + WHERE r.key = 'owner' + AND r.enabled = true + AND u.email_verified_at IS NOT NULL + ` + ); + + if (Number(row?.count ?? 0) < 1) { + throw statusError('server.permissions.ownerRequired', 400); + } +} + +async function assertPermissionManagerExists(client: DbClient): Promise { + const row = await clientQueryOne( + client, + ` + SELECT COUNT(DISTINCT u.id)::text AS count + FROM users u + JOIN user_roles ur ON ur.user_id = u.id + JOIN roles r ON r.id = ur.role_id + JOIN role_permissions rp ON rp.role_id = r.id + JOIN permissions p ON p.id = rp.permission_id + WHERE u.email_verified_at IS NOT NULL + AND r.enabled = true + AND p.enabled = true + AND p.key = 'admin.permissions.update' + ` + ); + + if (Number(row?.count ?? 0) < 1) { + throw statusError('server.permissions.permissionManagerRequired', 400); + } +} + +async function assertAccessControlSafe(client: DbClient): Promise { + await assertCriticalPermissionsEnabled(client); + await assertOwnerExists(client); + await assertPermissionManagerExists(client); +} + async function referralUserId( client: DbClient, referralCode: string, @@ -499,8 +831,10 @@ export async function verifyEmail(payload: Record, locale = def await client.query('UPDATE email_verification_tokens SET used_at = now() WHERE user_id = $1 AND used_at IS NULL', [ user.id ]); + await ensureOwnerRoleForUser(client, user.id); - return { message: await authMessage(locale, 'emailVerified'), user: toPublicUser(user) }; + const publicUser = await publicUserById(user.id, client); + return { message: await authMessage(locale, 'emailVerified'), user: publicUser ?? toPublicUser(user) }; }); } @@ -610,7 +944,7 @@ export async function loginUser(payload: Record, locale = defau [user.id, hashToken(sessionToken), sessionDays] ); - return { token: sessionToken, user: toPublicUser(user) }; + return { token: sessionToken, user: (await publicUserById(user.id)) ?? toPublicUser(user) }; } export async function getUserBySessionToken(token: string): Promise { @@ -618,18 +952,17 @@ export async function getUserBySessionToken(token: string): Promise( + const session = await queryOne( ` - SELECT u.id, u.email, u.display_name, u.email_verified_at + SELECT s.user_id FROM user_sessions s - JOIN users u ON u.id = s.user_id WHERE s.token_hash = $1 AND s.expires_at > now() `, [hashToken(token)] ); - return user ? toPublicUser(user) : null; + return session ? publicUserById(session.user_id) : null; } export async function updateCurrentUser( @@ -652,7 +985,7 @@ export async function updateCurrentUser( throw statusError(await systemMessage(locale || defaultLocale, 'server.errors.loginRequired'), 401); } - return toPublicUser(user); + return (await publicUserById(user.id)) ?? toPublicUser(user); } export async function getReferralSummary(userId: number): Promise { @@ -672,6 +1005,329 @@ export async function getReferralSummary(userId: number): Promise { + const rows = await query( + ` + SELECT id, email, display_name, email_verified_at, created_at, updated_at + FROM users + ORDER BY created_at DESC, id DESC + ` + ); + + const roleRows = await query( + ` + SELECT ur.user_id, ur.role_id + FROM user_roles ur + JOIN users u ON u.id = ur.user_id + ORDER BY ur.user_id, ur.role_id + ` + ); + const rolesByUserId = new Map(); + for (const roleRow of roleRows) { + rolesByUserId.set(roleRow.user_id, [...(rolesByUserId.get(roleRow.user_id) ?? []), roleRow.role_id]); + } + + return Promise.all( + rows.map(async (row) => { + const publicUser = (await publicUserById(row.id)) ?? toPublicUser(row); + return { + ...publicUser, + roleIds: rolesByUserId.get(row.id) ?? [], + createdAt: row.created_at, + updatedAt: row.updated_at + }; + }) + ); +} + +export async function listPermissions(): Promise { + const rows = await query( + ` + SELECT id, key, name, description, category, enabled, system_permission + FROM permissions + ORDER BY category ASC, key ASC, id ASC + ` + ); + + return rows.map(toPermissionSummary); +} + +export async function createPermission(payload: Record): Promise { + const permissionKey = cleanKey(payload.key, permissionKeyPattern, 'server.permissions.permissionKeyInvalid'); + const name = cleanText(payload.name, { required: true, max: 120 }); + const description = cleanText(payload.description, { max: 500 }); + const category = cleanText(payload.category, { required: true, max: 80 }); + + await withTransaction(async (client) => { + const permission = await clientQueryOne( + client, + ` + INSERT INTO permissions (key, name, description, category, enabled) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, key, name, description, category, enabled, system_permission + `, + [permissionKey, name, description, category, cleanBoolean(payload.enabled)] + ); + + if (!permission) { + throw new Error('Failed to create permission'); + } + + await client.query( + ` + INSERT INTO role_permissions (role_id, permission_id) + SELECT r.id, $1 + FROM roles r + WHERE r.key = 'owner' + ON CONFLICT DO NOTHING + `, + [permission.id] + ); + }); + + return listPermissions(); +} + +export async function updatePermission(id: number, payload: Record): Promise { + const name = cleanText(payload.name, { required: true, max: 120 }); + const description = cleanText(payload.description, { max: 500 }); + const category = cleanText(payload.category, { required: true, max: 80 }); + const enabled = cleanBoolean(payload.enabled); + + await withTransaction(async (client) => { + const permission = await clientQueryOne( + client, + 'SELECT id, key, name, description, category, enabled, system_permission FROM permissions WHERE id = $1 FOR UPDATE', + [id] + ); + if (!permission) { + throw statusError('server.permissions.permissionNotFound', 404); + } + if (!enabled && criticalPermissionKeys.includes(permission.key)) { + throw statusError('server.permissions.criticalPermissionRequired', 400); + } + + await client.query( + ` + UPDATE permissions + SET name = $1, + description = $2, + category = $3, + enabled = $4, + updated_at = now() + WHERE id = $5 + `, + [name, description, category, enabled, id] + ); + await assertAccessControlSafe(client); + }); + + return listPermissions(); +} + +export async function deletePermission(id: number): Promise { + await withTransaction(async (client) => { + const permission = await clientQueryOne( + client, + 'SELECT id, key, name, description, category, enabled, system_permission FROM permissions WHERE id = $1 FOR UPDATE', + [id] + ); + if (!permission) { + throw statusError('server.permissions.permissionNotFound', 404); + } + if (criticalPermissionKeys.includes(permission.key)) { + throw statusError('server.permissions.criticalPermissionRequired', 400); + } + + await client.query('DELETE FROM permissions WHERE id = $1', [id]); + await assertAccessControlSafe(client); + }); +} + +export async function listRoles(): Promise { + const rows = await query( + ` + SELECT id, key, name, description, level, enabled, system_role + FROM roles + ORDER BY level DESC, name ASC, id ASC + ` + ); + const permissionRows = await query( + ` + SELECT role_id, permission_id + FROM role_permissions + ORDER BY role_id, permission_id + ` + ); + const permissionIdsByRoleId = new Map(); + for (const row of permissionRows) { + permissionIdsByRoleId.set(row.role_id, [...(permissionIdsByRoleId.get(row.role_id) ?? []), row.permission_id]); + } + + return rows.map((row) => toRoleDetail(row, permissionIdsByRoleId.get(row.id) ?? [])); +} + +export async function createRole(payload: Record): Promise { + const roleKey = cleanKey(payload.key, roleKeyPattern, 'server.permissions.roleKeyInvalid'); + const name = cleanText(payload.name, { required: true, max: 80 }); + const description = cleanText(payload.description, { max: 500 }); + const level = cleanInteger(payload.level, 0); + const enabled = cleanBoolean(payload.enabled); + + await pool.query( + ` + INSERT INTO roles (key, name, description, level, enabled) + VALUES ($1, $2, $3, $4, $5) + `, + [roleKey, name, description, level, enabled] + ); + + return listRoles(); +} + +export async function updateRole(id: number, payload: Record): Promise { + const name = cleanText(payload.name, { required: true, max: 80 }); + const description = cleanText(payload.description, { max: 500 }); + const level = cleanInteger(payload.level, 0); + const enabled = cleanBoolean(payload.enabled); + + await withTransaction(async (client) => { + const role = await clientQueryOne( + client, + 'SELECT id, key, name, description, level, enabled, system_role FROM roles WHERE id = $1 FOR UPDATE', + [id] + ); + if (!role) { + throw statusError('server.permissions.roleNotFound', 404); + } + if (role.key === 'owner' && !enabled) { + throw statusError('server.permissions.ownerRequired', 400); + } + + await client.query( + ` + UPDATE roles + SET name = $1, + description = $2, + level = $3, + enabled = $4, + updated_at = now() + WHERE id = $5 + `, + [name, description, level, enabled, id] + ); + await assertAccessControlSafe(client); + }); + + return listRoles(); +} + +export async function deleteRole(id: number): Promise { + await withTransaction(async (client) => { + const role = await clientQueryOne( + client, + 'SELECT id, key, name, description, level, enabled, system_role FROM roles WHERE id = $1 FOR UPDATE', + [id] + ); + if (!role) { + throw statusError('server.permissions.roleNotFound', 404); + } + if (role.key === 'owner') { + throw statusError('server.permissions.ownerRequired', 400); + } + + await client.query('DELETE FROM roles WHERE id = $1', [id]); + await assertAccessControlSafe(client); + }); +} + +export async function updateRolePermissions(roleId: number, payload: Record): Promise { + const permissionIds = cleanIdList(payload.permissionIds); + + await withTransaction(async (client) => { + const role = await clientQueryOne( + client, + 'SELECT id, key, name, description, level, enabled, system_role FROM roles WHERE id = $1 FOR UPDATE', + [roleId] + ); + if (!role) { + throw statusError('server.permissions.roleNotFound', 404); + } + if (role.key === 'owner') { + throw statusError('server.permissions.ownerRoleLocked', 400); + } + + if (permissionIds.length) { + const countRow = await clientQueryOne( + client, + 'SELECT COUNT(*)::text AS count FROM permissions WHERE id = ANY($1::int[])', + [permissionIds] + ); + if (Number(countRow?.count ?? 0) !== permissionIds.length) { + throw statusError('server.permissions.permissionNotFound', 404); + } + } + + await client.query('DELETE FROM role_permissions WHERE role_id = $1', [roleId]); + if (permissionIds.length) { + await client.query( + ` + INSERT INTO role_permissions (role_id, permission_id) + SELECT $1, unnest($2::int[]) + ON CONFLICT DO NOTHING + `, + [roleId, permissionIds] + ); + } + await assertAccessControlSafe(client); + }); + + return listRoles(); +} + +export async function updateAdminUserRoles( + targetUserId: number, + payload: Record, + assignedByUserId: number +): Promise { + const roleIds = cleanIdList(payload.roleIds); + + await withTransaction(async (client) => { + const user = await clientQueryOne(client, 'SELECT id, email, display_name, email_verified_at FROM users WHERE id = $1 FOR UPDATE', [ + targetUserId + ]); + if (!user) { + throw statusError('server.permissions.userNotFound', 404); + } + + if (roleIds.length) { + const countRow = await clientQueryOne( + client, + 'SELECT COUNT(*)::text AS count FROM roles WHERE id = ANY($1::int[])', + [roleIds] + ); + if (Number(countRow?.count ?? 0) !== roleIds.length) { + throw statusError('server.permissions.roleNotFound', 404); + } + } + + await client.query('DELETE FROM user_roles WHERE user_id = $1', [targetUserId]); + if (roleIds.length) { + await client.query( + ` + INSERT INTO user_roles (user_id, role_id, assigned_by_user_id) + SELECT $1, unnest($2::int[]), $3 + ON CONFLICT DO NOTHING + `, + [targetUserId, roleIds, assignedByUserId] + ); + } + await assertAccessControlSafe(client); + }); + + return listAdminUsers(); +} + export async function logoutSession(token: string): Promise { if (token.length < 32) { return; diff --git a/backend/src/queries.ts b/backend/src/queries.ts index d720838..aa623c9 100644 --- a/backend/src/queries.ts +++ b/backend/src/queries.ts @@ -2462,7 +2462,13 @@ export async function createLifePost(payload: Record, userId: n return getLifePostById(id, userId, locale); } -export async function updateLifePost(id: number, payload: Record, userId: number, locale = defaultLocale) { +export async function updateLifePost( + id: number, + payload: Record, + userId: number, + locale = defaultLocale, + allowAny = false +) { const cleanPayload = cleanLifePostPayload(payload); const updatedId = await withTransaction(async (client) => { @@ -2471,11 +2477,11 @@ export async function updateLifePost(id: number, payload: Record( ` UPDATE life_posts @@ -2499,11 +2505,11 @@ export async function deleteLifePost(id: number, userId: number) { updated_by_user_id = $2, updated_at = now() WHERE id = $1 - AND created_by_user_id = $2 + AND ($3 = true OR created_by_user_id = $2) AND deleted_at IS NULL RETURNING id `, - [id, userId] + [id, userId, allowAny] ); return Boolean(result); @@ -2605,17 +2611,17 @@ export async function createLifeCommentReply( return result ? getLifeCommentById(result.id) : null; } -export async function deleteLifeComment(id: number, userId: number) { +export async function deleteLifeComment(id: number, userId: number, allowAny = false) { const result = await queryOne<{ id: number }>( ` UPDATE life_post_comments SET deleted_at = now(), deleted_by_user_id = $2, updated_at = now() WHERE id = $1 - AND created_by_user_id = $2 + AND ($3 = true OR created_by_user_id = $2) AND deleted_at IS NULL RETURNING id `, - [id, userId] + [id, userId, allowAny] ); return Boolean(result); @@ -2805,7 +2811,7 @@ export async function createEntityDiscussionReply( return id ? getEntityDiscussionCommentById(id) : null; } -export async function deleteEntityDiscussionComment(id: number, userId: number): Promise { +export async function deleteEntityDiscussionComment(id: number, userId: number, allowAny = false): Promise { const commentId = requirePositiveInteger(id, 'Comment is invalid'); const result = await queryOne<{ id: number }>( ` @@ -2814,11 +2820,11 @@ export async function deleteEntityDiscussionComment(id: number, userId: number): deleted_by_user_id = $2, updated_at = now() WHERE id = $1 - AND created_by_user_id = $2 + AND ($3 = true OR created_by_user_id = $2) AND deleted_at IS NULL RETURNING id `, - [commentId, userId] + [commentId, userId, allowAny] ); return Boolean(result); diff --git a/backend/src/server.ts b/backend/src/server.ts index c4bc17d..909c1de 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -5,14 +5,27 @@ import Fastify from 'fastify'; import type { FastifyReply, FastifyRequest } from 'fastify'; import { mkdir } from 'node:fs/promises'; import { + createPermission, + createRole, + deletePermission, + deleteRole, getReferralSummary, getUserBySessionToken, + listAdminUsers, + listPermissions, + listRoles, loginUser, logoutSession, registerUser, requestPasswordReset, resetPassword, + updateAdminUserRoles, updateCurrentUser, + updatePermission, + updateRole, + updateRolePermissions, + userHasAnyPermission, + userHasPermission, verifyEmail, type AuthUser } from './auth.ts'; @@ -155,7 +168,15 @@ function requestLocale(request: FastifyRequest): string { function serverMessage( locale: string, - key: 'foreignKey' | 'duplicate' | 'invalidField' | 'serverError' | 'loginRequired' | 'verifyEmailFirst' | 'notFound' + key: + | 'foreignKey' + | 'duplicate' + | 'invalidField' + | 'serverError' + | 'loginRequired' + | 'verifyEmailFirst' + | 'permissionDenied' + | 'notFound' ): Promise { return systemMessage(locale, `server.errors.${key}`); } @@ -188,6 +209,42 @@ async function requireVerifiedUser(request: FastifyRequest, reply: FastifyReply) return user; } +async function requirePermission( + request: FastifyRequest, + reply: FastifyReply, + permissionKey: string +): Promise { + const user = await requireVerifiedUser(request, reply); + if (!user) { + return null; + } + + if (!userHasPermission(user, permissionKey)) { + reply.code(403).send({ message: await serverMessage(requestLocale(request), 'permissionDenied') }); + return null; + } + + return user; +} + +async function requireAnyPermission( + request: FastifyRequest, + reply: FastifyReply, + permissionKeys: string[] +): Promise { + const user = await requireVerifiedUser(request, reply); + if (!user) { + return null; + } + + if (!userHasAnyPermission(user, permissionKeys)) { + reply.code(403).send({ message: await serverMessage(requestLocale(request), 'permissionDenied') }); + return null; + } + + return user; +} + async function optionalUser(request: FastifyRequest): Promise { const token = getBearerToken(request.headers.authorization); if (!token) { @@ -260,6 +317,87 @@ app.post('/api/auth/logout', async (request, reply) => { return reply.code(204).send(); }); +app.get('/api/admin/users', async (request, reply) => { + const user = await requirePermission(request, reply, 'admin.users.read'); + return user ? listAdminUsers() : undefined; +}); + +app.put('/api/admin/users/:id/roles', async (request, reply) => { + const user = await requirePermission(request, reply, 'admin.users.update'); + if (!user) { + return; + } + const { id } = request.params as { id: string }; + return updateAdminUserRoles(Number(id), request.body as Record, user.id); +}); + +app.get('/api/admin/roles', async (request, reply) => { + const user = await requirePermission(request, reply, 'admin.roles.read'); + return user ? listRoles() : undefined; +}); + +app.post('/api/admin/roles', async (request, reply) => { + const user = await requirePermission(request, reply, 'admin.roles.create'); + return user ? reply.code(201).send(await createRole(request.body as Record)) : undefined; +}); + +app.put('/api/admin/roles/:id', async (request, reply) => { + const user = await requirePermission(request, reply, 'admin.roles.update'); + if (!user) { + return; + } + const { id } = request.params as { id: string }; + return updateRole(Number(id), request.body as Record); +}); + +app.put('/api/admin/roles/:id/permissions', async (request, reply) => { + const user = await requirePermission(request, reply, 'admin.roles.update'); + if (!user) { + return; + } + const { id } = request.params as { id: string }; + return updateRolePermissions(Number(id), request.body as Record); +}); + +app.delete('/api/admin/roles/:id', async (request, reply) => { + const user = await requirePermission(request, reply, 'admin.roles.delete'); + if (!user) { + return; + } + const { id } = request.params as { id: string }; + await deleteRole(Number(id)); + return reply.code(204).send(); +}); + +app.get('/api/admin/permissions', async (request, reply) => { + const user = await requirePermission(request, reply, 'admin.permissions.read'); + return user ? listPermissions() : undefined; +}); + +app.post('/api/admin/permissions', async (request, reply) => { + const user = await requirePermission(request, reply, 'admin.permissions.create'); + return user ? reply.code(201).send(await createPermission(request.body as Record)) : undefined; +}); + +app.put('/api/admin/permissions/:id', async (request, reply) => { + const user = await requirePermission(request, reply, 'admin.permissions.update'); + if (!user) { + return; + } + const { id } = request.params as { id: string }; + return updatePermission(Number(id), request.body as Record); +}); + +app.delete('/api/admin/permissions/:id', async (request, reply) => { + const user = await requirePermission(request, reply, 'admin.permissions.delete'); + if (!user) { + return; + } + const { id } = request.params as { id: string }; + await deletePermission(Number(id)); + return reply.code(204).send(); +}); + app.get('/api/languages', async () => listLanguages()); app.get('/api/system-wordings', async (request) => getSystemWordings(requestLocale(request))); @@ -274,14 +412,14 @@ app.get('/api/life-posts', async (request) => { }); app.post('/api/life-posts', async (request, reply) => { - const user = await requireVerifiedUser(request, reply); + const user = await requirePermission(request, reply, 'life.posts.create'); return user ? reply.code(201).send(await createLifePost(request.body as Record, user.id, requestLocale(request))) : undefined; }); app.post('/api/life-posts/:postId/comments', async (request, reply) => { - const user = await requireVerifiedUser(request, reply); + const user = await requirePermission(request, reply, 'life.comments.create'); if (!user) { return; } @@ -291,7 +429,7 @@ app.post('/api/life-posts/:postId/comments', async (request, reply) => { }); app.post('/api/life-posts/:postId/comments/:commentId/replies', async (request, reply) => { - const user = await requireVerifiedUser(request, reply); + const user = await requirePermission(request, reply, 'life.comments.create'); if (!user) { return; } @@ -306,17 +444,23 @@ app.post('/api/life-posts/:postId/comments/:commentId/replies', async (request, }); app.put('/api/life-posts/:id', async (request, reply) => { - const user = await requireVerifiedUser(request, reply); + const user = await requireAnyPermission(request, reply, ['life.posts.update', 'life.posts.update-any']); if (!user) { return; } const { id } = request.params as { id: string }; - const post = await updateLifePost(Number(id), request.body as Record, user.id, requestLocale(request)); + const post = await updateLifePost( + Number(id), + request.body as Record, + user.id, + requestLocale(request), + userHasPermission(user, 'life.posts.update-any') + ); return post ? post : notFound(reply, request); }); app.put('/api/life-posts/:id/reaction', async (request, reply) => { - const user = await requireVerifiedUser(request, reply); + const user = await requirePermission(request, reply, 'life.reactions.set'); if (!user) { return; } @@ -326,7 +470,7 @@ app.put('/api/life-posts/:id/reaction', async (request, reply) => { }); app.delete('/api/life-posts/:id/reaction', async (request, reply) => { - const user = await requireVerifiedUser(request, reply); + const user = await requirePermission(request, reply, 'life.reactions.set'); if (!user) { return; } @@ -336,22 +480,22 @@ app.delete('/api/life-posts/:id/reaction', async (request, reply) => { }); app.delete('/api/life-posts/:id', async (request, reply) => { - const user = await requireVerifiedUser(request, reply); + const user = await requireAnyPermission(request, reply, ['life.posts.delete', 'life.posts.delete-any']); if (!user) { return; } const { id } = request.params as { id: string }; - const deleted = await deleteLifePost(Number(id), user.id); + const deleted = await deleteLifePost(Number(id), user.id, userHasPermission(user, 'life.posts.delete-any')); return deleted ? reply.code(204).send() : notFound(reply, request); }); app.delete('/api/life-comments/:id', async (request, reply) => { - const user = await requireVerifiedUser(request, reply); + const user = await requireAnyPermission(request, reply, ['life.comments.delete', 'life.comments.delete-any']); if (!user) { return; } const { id } = request.params as { id: string }; - const deleted = await deleteLifeComment(Number(id), user.id); + const deleted = await deleteLifeComment(Number(id), user.id, userHasPermission(user, 'life.comments.delete-any')); return deleted ? reply.code(204).send() : notFound(reply, request); }); @@ -362,7 +506,7 @@ app.get('/api/discussions/:entityType/:entityId/comments', async (request, reply }); app.post('/api/discussions/:entityType/:entityId/comments', async (request, reply) => { - const user = await requireVerifiedUser(request, reply); + const user = await requirePermission(request, reply, 'discussions.comments.create'); if (!user) { return; } @@ -378,7 +522,7 @@ app.post('/api/discussions/:entityType/:entityId/comments', async (request, repl }); app.post('/api/discussions/:entityType/:entityId/comments/:commentId/replies', async (request, reply) => { - const user = await requireVerifiedUser(request, reply); + const user = await requirePermission(request, reply, 'discussions.comments.create'); if (!user) { return; } @@ -399,13 +543,20 @@ app.post('/api/discussions/:entityType/:entityId/comments/:commentId/replies', a }); app.delete('/api/discussions/comments/:id', async (request, reply) => { - const user = await requireVerifiedUser(request, reply); + const user = await requireAnyPermission(request, reply, [ + 'discussions.comments.delete', + 'discussions.comments.delete-any' + ]); if (!user) { return; } const { id } = request.params as { id: string }; - const deleted = await deleteEntityDiscussionComment(Number(id), user.id); + const deleted = await deleteEntityDiscussionComment( + Number(id), + user.id, + userHasPermission(user, 'discussions.comments.delete-any') + ); return deleted ? reply.code(204).send() : notFound(reply, request); }); @@ -414,7 +565,7 @@ app.get('/api/pokemon', async (request) => ); app.get('/api/pokemon/fetch-options', async (request, reply) => { - const user = await requireVerifiedUser(request, reply); + const user = await requirePermission(request, reply, 'pokemon.fetch'); return user ? listPokemonFetchOptions(request.query as Record, requestLocale(request)) : undefined; @@ -432,33 +583,35 @@ app.get('/api/pokemon/:id', async (request, reply) => { }); app.post('/api/pokemon', async (request, reply) => { - const user = await requireVerifiedUser(request, reply); + const user = await requirePermission(request, reply, 'pokemon.create'); return user ? reply.code(201).send(await createPokemon(request.body as Record, user.id, requestLocale(request))) : undefined; }); app.post('/api/pokemon/fetch', async (request, reply) => { - const user = await requireVerifiedUser(request, reply); + const user = await requirePermission(request, reply, 'pokemon.fetch'); return user ? fetchPokemonData(request.body as Record, user.id) : undefined; }); app.post('/api/pokemon/image-options', async (request, reply) => { - const user = await requireVerifiedUser(request, reply); + const user = await requirePermission(request, reply, 'pokemon.fetch'); return user ? fetchPokemonImageOptions(request.body as Record) : undefined; }); app.post('/api/uploads/:entityType', async (request, reply) => { - const user = await requireVerifiedUser(request, reply); - if (!user) { - return; - } - const { entityType } = request.params as { entityType: string }; if (!isUploadEntityType(entityType)) { return notFound(reply, request); } + const permissionKey = + entityType === 'pokemon' ? 'pokemon.upload' : entityType === 'items' ? 'items.upload' : 'habitats.upload'; + const user = await requirePermission(request, reply, permissionKey); + if (!user) { + return; + } + let file: MultipartFile | undefined; try { file = await request.file(); @@ -474,7 +627,7 @@ app.post('/api/uploads/:entityType', async (request, reply) => { }); app.put('/api/pokemon/:id', async (request, reply) => { - const user = await requireVerifiedUser(request, reply); + const user = await requirePermission(request, reply, 'pokemon.update'); if (!user) { return; } @@ -489,7 +642,7 @@ app.put('/api/pokemon/:id', async (request, reply) => { }); app.delete('/api/pokemon/:id', async (request, reply) => { - const user = await requireVerifiedUser(request, reply); + const user = await requirePermission(request, reply, 'pokemon.delete'); if (!user) { return; } @@ -512,14 +665,14 @@ app.get('/api/habitats/:id', async (request, reply) => { }); app.post('/api/habitats', async (request, reply) => { - const user = await requireVerifiedUser(request, reply); + const user = await requirePermission(request, reply, 'habitats.create'); return user ? reply.code(201).send(await createHabitat(request.body as Record, user.id, requestLocale(request))) : undefined; }); app.put('/api/habitats/:id', async (request, reply) => { - const user = await requireVerifiedUser(request, reply); + const user = await requirePermission(request, reply, 'habitats.update'); if (!user) { return; } @@ -534,7 +687,7 @@ app.put('/api/habitats/:id', async (request, reply) => { }); app.delete('/api/habitats/:id', async (request, reply) => { - const user = await requireVerifiedUser(request, reply); + const user = await requirePermission(request, reply, 'habitats.delete'); if (!user) { return; } @@ -559,14 +712,14 @@ app.get('/api/items/:id', async (request, reply) => { }); app.post('/api/items', async (request, reply) => { - const user = await requireVerifiedUser(request, reply); + const user = await requirePermission(request, reply, 'items.create'); return user ? reply.code(201).send(await createItem(request.body as Record, user.id, requestLocale(request))) : undefined; }); app.put('/api/items/:id', async (request, reply) => { - const user = await requireVerifiedUser(request, reply); + const user = await requirePermission(request, reply, 'items.update'); if (!user) { return; } @@ -581,7 +734,7 @@ app.put('/api/items/:id', async (request, reply) => { }); app.delete('/api/items/:id', async (request, reply) => { - const user = await requireVerifiedUser(request, reply); + const user = await requirePermission(request, reply, 'items.delete'); if (!user) { return; } @@ -606,14 +759,14 @@ app.get('/api/recipes/:id', async (request, reply) => { }); app.post('/api/recipes', async (request, reply) => { - const user = await requireVerifiedUser(request, reply); + const user = await requirePermission(request, reply, 'recipes.create'); return user ? reply.code(201).send(await createRecipe(request.body as Record, user.id, requestLocale(request))) : undefined; }); app.put('/api/recipes/:id', async (request, reply) => { - const user = await requireVerifiedUser(request, reply); + const user = await requirePermission(request, reply, 'recipes.update'); if (!user) { return; } @@ -628,7 +781,7 @@ app.put('/api/recipes/:id', async (request, reply) => { }); app.delete('/api/recipes/:id', async (request, reply) => { - const user = await requireVerifiedUser(request, reply); + const user = await requirePermission(request, reply, 'recipes.delete'); if (!user) { return; } @@ -638,7 +791,7 @@ app.delete('/api/recipes/:id', async (request, reply) => { }); app.post('/api/admin/daily-checklist', async (request, reply) => { - const user = await requireVerifiedUser(request, reply); + const user = await requirePermission(request, reply, 'checklist.create'); return user ? reply .code(201) @@ -647,12 +800,12 @@ app.post('/api/admin/daily-checklist', async (request, reply) => { }); app.put('/api/admin/daily-checklist/order', async (request, reply) => { - const user = await requireVerifiedUser(request, reply); + const user = await requirePermission(request, reply, 'checklist.order'); return user ? reorderDailyChecklistItems(request.body as Record, user.id, requestLocale(request)) : undefined; }); app.put('/api/admin/daily-checklist/:id', async (request, reply) => { - const user = await requireVerifiedUser(request, reply); + const user = await requirePermission(request, reply, 'checklist.update'); if (!user) { return; } @@ -667,7 +820,7 @@ app.put('/api/admin/daily-checklist/:id', async (request, reply) => { }); app.delete('/api/admin/daily-checklist/:id', async (request, reply) => { - const user = await requireVerifiedUser(request, reply); + const user = await requirePermission(request, reply, 'checklist.delete'); if (!user) { return; } @@ -677,42 +830,42 @@ app.delete('/api/admin/daily-checklist/:id', async (request, reply) => { }); app.put('/api/admin/pokemon/order', async (request, reply) => { - const user = await requireVerifiedUser(request, reply); + const user = await requirePermission(request, reply, 'pokemon.order'); return user ? reorderPokemon(request.body as Record, user.id, requestLocale(request)) : undefined; }); app.put('/api/admin/items/order', async (request, reply) => { - const user = await requireVerifiedUser(request, reply); + const user = await requirePermission(request, reply, 'items.order'); return user ? reorderItems(request.body as Record, user.id, requestLocale(request)) : undefined; }); app.put('/api/admin/recipes/order', async (request, reply) => { - const user = await requireVerifiedUser(request, reply); + const user = await requirePermission(request, reply, 'recipes.order'); return user ? reorderRecipes(request.body as Record, user.id, requestLocale(request)) : undefined; }); app.put('/api/admin/habitats/order', async (request, reply) => { - const user = await requireVerifiedUser(request, reply); + const user = await requirePermission(request, reply, 'habitats.order'); return user ? reorderHabitats(request.body as Record, user.id, requestLocale(request)) : undefined; }); app.get('/api/admin/languages', async (request, reply) => { - const user = await requireVerifiedUser(request, reply); + const user = await requirePermission(request, reply, 'admin.languages.read'); return user ? listLanguages(true) : undefined; }); app.post('/api/admin/languages', async (request, reply) => { - const user = await requireVerifiedUser(request, reply); + const user = await requirePermission(request, reply, 'admin.languages.create'); return user ? reply.code(201).send(await createLanguage(request.body as Record)) : undefined; }); app.put('/api/admin/languages/order', async (request, reply) => { - const user = await requireVerifiedUser(request, reply); + const user = await requirePermission(request, reply, 'admin.languages.order'); return user ? reorderLanguages(request.body as Record) : undefined; }); app.put('/api/admin/languages/:code', async (request, reply) => { - const user = await requireVerifiedUser(request, reply); + const user = await requirePermission(request, reply, 'admin.languages.update'); if (!user) { return; } @@ -721,7 +874,7 @@ app.put('/api/admin/languages/:code', async (request, reply) => { }); app.delete('/api/admin/languages/:code', async (request, reply) => { - const user = await requireVerifiedUser(request, reply); + const user = await requirePermission(request, reply, 'admin.languages.delete'); if (!user) { return; } @@ -731,12 +884,12 @@ app.delete('/api/admin/languages/:code', async (request, reply) => { }); app.get('/api/admin/system-wordings', async (request, reply) => { - const user = await requireVerifiedUser(request, reply); + const user = await requirePermission(request, reply, 'admin.wordings.read'); return user ? listSystemWordingRows(request.query as Record) : undefined; }); app.put('/api/admin/system-wordings/:key', async (request, reply) => { - const user = await requireVerifiedUser(request, reply); + const user = await requirePermission(request, reply, 'admin.wordings.update'); if (!user) { return; } @@ -745,7 +898,7 @@ app.put('/api/admin/system-wordings/:key', async (request, reply) => { }); app.get('/api/admin/config/:type', async (request, reply) => { - const user = await requireVerifiedUser(request, reply); + const user = await requirePermission(request, reply, 'admin.config.read'); if (!user) { return; } @@ -757,7 +910,7 @@ app.get('/api/admin/config/:type', async (request, reply) => { }); app.post('/api/admin/config/:type', async (request, reply) => { - const user = await requireVerifiedUser(request, reply); + const user = await requirePermission(request, reply, 'admin.config.create'); if (!user) { return; } @@ -771,7 +924,7 @@ app.post('/api/admin/config/:type', async (request, reply) => { }); app.put('/api/admin/config/:type/order', async (request, reply) => { - const user = await requireVerifiedUser(request, reply); + const user = await requirePermission(request, reply, 'admin.config.order'); if (!user) { return; } @@ -783,7 +936,7 @@ app.put('/api/admin/config/:type/order', async (request, reply) => { }); app.put('/api/admin/config/:type/:id', async (request, reply) => { - const user = await requireVerifiedUser(request, reply); + const user = await requirePermission(request, reply, 'admin.config.update'); if (!user) { return; } @@ -796,7 +949,7 @@ app.put('/api/admin/config/:type/:id', async (request, reply) => { }); app.delete('/api/admin/config/:type/:id', async (request, reply) => { - const user = await requireVerifiedUser(request, reply); + const user = await requirePermission(request, reply, 'admin.config.delete'); if (!user) { return; } diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 313bd4b..f046c69 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -35,20 +35,31 @@ function inDevBadge() { return { label: t('common.inDev'), tone: 'info' as const }; } -const navItems = computed(() => [ - { label: t('nav.pokemon'), to: '/pokemon', icon: iconPokemon }, - { label: t('nav.habitats'), to: '/habitats', icon: iconHabitat }, - { label: t('nav.items'), to: '/items', icon: iconItem }, - { label: t('nav.recipes'), to: '/recipes', icon: iconRecipe }, - { label: t('nav.dish'), to: '/dish', icon: iconDish, badge: inDevBadge() }, - { label: t('nav.events'), to: '/events', icon: iconEvent, badge: inDevBadge() }, - { label: t('nav.actions'), to: '/actions', icon: iconAction, badge: inDevBadge() }, - { label: t('nav.dreamIsland'), to: '/dream-island', icon: iconDreamIsland, badge: inDevBadge() }, - { label: t('nav.clothes'), to: '/clothes', icon: iconClothes, badge: inDevBadge() }, - { label: t('nav.checklist'), to: '/checklist', icon: iconChecklist }, - { label: t('nav.life'), to: '/life', icon: iconLife }, - { label: t('nav.admin'), to: '/admin', icon: iconAdmin } -]); +function can(permissionKey: string) { + return currentUser.value?.permissions.includes(permissionKey) === true; +} + +const navItems = computed(() => { + const items = [ + { label: t('nav.pokemon'), to: '/pokemon', icon: iconPokemon }, + { label: t('nav.habitats'), to: '/habitats', icon: iconHabitat }, + { label: t('nav.items'), to: '/items', icon: iconItem }, + { label: t('nav.recipes'), to: '/recipes', icon: iconRecipe }, + { label: t('nav.dish'), to: '/dish', icon: iconDish, badge: inDevBadge() }, + { label: t('nav.events'), to: '/events', icon: iconEvent, badge: inDevBadge() }, + { label: t('nav.actions'), to: '/actions', icon: iconAction, badge: inDevBadge() }, + { label: t('nav.dreamIsland'), to: '/dream-island', icon: iconDreamIsland, badge: inDevBadge() }, + { label: t('nav.clothes'), to: '/clothes', icon: iconClothes, badge: inDevBadge() }, + { label: t('nav.checklist'), to: '/checklist', icon: iconChecklist }, + { label: t('nav.life'), to: '/life', icon: iconLife } + ]; + + if (can('admin.access')) { + items.push({ label: t('nav.admin'), to: '/admin', icon: iconAdmin }); + } + + return items; +}); async function loadCurrentUser() { if (!getAuthToken()) { diff --git a/frontend/src/components/EntityDiscussionPanel.vue b/frontend/src/components/EntityDiscussionPanel.vue index b715903..7a6b723 100644 --- a/frontend/src/components/EntityDiscussionPanel.vue +++ b/frontend/src/components/EntityDiscussionPanel.vue @@ -36,7 +36,11 @@ const commentMaxLength = 1000; let requestId = 0; let removeAuthListener: (() => void) | null = null; -const canComment = computed(() => currentUser.value?.emailVerified === true); +function can(permissionKey: string) { + return currentUser.value?.permissions.includes(permissionKey) === true; +} + +const canComment = computed(() => can('discussions.comments.create')); const charactersLeft = computed(() => Math.max(0, commentMaxLength - body.value.length)); const commentTotal = computed(() => comments.value.reduce((total, comment) => total + 1 + comment.replies.length, 0)); @@ -108,7 +112,11 @@ function clearCommentError(key: string) { } function canManageComment(comment: EntityDiscussionComment) { - return !comment.deleted && currentUser.value?.id === comment.author?.id; + return ( + !comment.deleted && + ((currentUser.value?.id === comment.author?.id && can('discussions.comments.delete')) || + can('discussions.comments.delete-any')) + ); } function commentAuthorName(comment: EntityDiscussionComment) { diff --git a/frontend/src/components/ImageUploadField.vue b/frontend/src/components/ImageUploadField.vue index 3e4e5d7..6aeecb7 100644 --- a/frontend/src/components/ImageUploadField.vue +++ b/frontend/src/components/ImageUploadField.vue @@ -15,6 +15,7 @@ const props = withDefaults( currentImage?: EntityImage | null; history?: EntityImageUpload[]; disabled?: boolean; + allowUpload?: boolean; showPreview?: boolean; }>(), { @@ -22,6 +23,7 @@ const props = withDefaults( currentImage: null, history: () => [], disabled: false, + allowUpload: true, showPreview: true } ); @@ -39,7 +41,7 @@ const uploadBusy = ref(false); const localUploads = ref([]); const imageLabel = computed(() => props.label || t('media.image')); -const uploadDisabled = computed(() => props.disabled || uploadBusy.value || props.entityName.trim() === ''); +const uploadDisabled = computed(() => !props.allowUpload || props.disabled || uploadBusy.value || props.entityName.trim() === ''); const imageOptions = computed(() => { const images = [ ...localUploads.value, @@ -115,6 +117,7 @@ async function uploadImage(event: Event) { {{ imageLabel }}
- diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 6420280..16ccbcf 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -24,20 +24,20 @@ export const router = createRouter({ routes: [ { path: '/', redirect: '/pokemon' }, { path: '/pokemon', name: 'pokemon-list', component: PokemonList }, - { path: '/pokemon/new', name: 'pokemon-new', component: PokemonList, meta: { requiresVerified: true, editorModal: true } }, - { path: '/pokemon/:id/edit', name: 'pokemon-edit', component: PokemonDetail, meta: { requiresVerified: true, editorModal: true } }, + { path: '/pokemon/new', name: 'pokemon-new', component: PokemonList, meta: { requiredPermission: 'pokemon.create', editorModal: true } }, + { path: '/pokemon/:id/edit', name: 'pokemon-edit', component: PokemonDetail, meta: { requiredPermission: 'pokemon.update', editorModal: true } }, { path: '/pokemon/:id', name: 'pokemon-detail', component: PokemonDetail }, { path: '/habitats', name: 'habitat-list', component: HabitatList }, - { path: '/habitats/new', name: 'habitat-new', component: HabitatList, meta: { requiresVerified: true, editorModal: true } }, - { path: '/habitats/:id/edit', name: 'habitat-edit', component: HabitatDetail, meta: { requiresVerified: true, editorModal: true } }, + { path: '/habitats/new', name: 'habitat-new', component: HabitatList, meta: { requiredPermission: 'habitats.create', editorModal: true } }, + { path: '/habitats/:id/edit', name: 'habitat-edit', component: HabitatDetail, meta: { requiredPermission: 'habitats.update', editorModal: true } }, { path: '/habitats/:id', name: 'habitat-detail', component: HabitatDetail }, { path: '/items', name: 'item-list', component: ItemsList }, - { path: '/items/new', name: 'item-new', component: ItemsList, meta: { requiresVerified: true, editorModal: true } }, - { path: '/items/:id/edit', name: 'item-edit', component: ItemDetail, meta: { requiresVerified: true, editorModal: true } }, + { path: '/items/new', name: 'item-new', component: ItemsList, meta: { requiredPermission: 'items.create', editorModal: true } }, + { path: '/items/:id/edit', name: 'item-edit', component: ItemDetail, meta: { requiredPermission: 'items.update', editorModal: true } }, { path: '/items/:id', name: 'item-detail', component: ItemDetail }, { path: '/recipes', name: 'recipe-list', component: RecipeList }, - { path: '/recipes/new', name: 'recipe-new', component: RecipeList, meta: { requiresVerified: true, editorModal: true } }, - { path: '/recipes/:id/edit', name: 'recipe-edit', component: RecipeDetail, meta: { requiresVerified: true, editorModal: true } }, + { path: '/recipes/new', name: 'recipe-new', component: RecipeList, meta: { requiredPermission: 'recipes.create', editorModal: true } }, + { path: '/recipes/:id/edit', name: 'recipe-edit', component: RecipeDetail, meta: { requiredPermission: 'recipes.update', editorModal: true } }, { path: '/recipes/:id', name: 'recipe-detail', component: RecipeDetail }, { path: '/dish', name: 'dish', component: ComingSoonView, props: { page: 'dish' } }, { path: '/events', name: 'events', component: ComingSoonView, props: { page: 'events' } }, @@ -46,7 +46,7 @@ export const router = createRouter({ { path: '/clothes', name: 'clothes', component: ComingSoonView, props: { page: 'clothes' } }, { path: '/checklist', component: DailyChecklistView }, { path: '/life', component: LifeView }, - { path: '/admin', component: AdminView, meta: { requiresVerified: true } }, + { path: '/admin', component: AdminView, meta: { requiredPermission: 'admin.access' } }, { path: '/profile', component: UserProfileView, meta: { requiresAuth: true } }, { path: '/login', component: LoginView }, { path: '/forgot-password', component: ForgotPasswordView }, @@ -62,7 +62,15 @@ export const router = createRouter({ }); router.beforeEach(async (to) => { - const requiresVerified = to.matched.some((record) => record.meta.requiresVerified === true); + const requiredPermissions = to.matched + .map((record) => record.meta.requiredPermission) + .filter((permission): permission is string => typeof permission === 'string'); + const requiredAnyPermissions = to.matched.flatMap((record) => + Array.isArray(record.meta.requiredAnyPermission) + ? record.meta.requiredAnyPermission.filter((permission): permission is string => typeof permission === 'string') + : [] + ); + const requiresVerified = to.matched.some((record) => record.meta.requiresVerified === true) || requiredPermissions.length > 0 || requiredAnyPermissions.length > 0; const requiresAuth = requiresVerified || to.matched.some((record) => record.meta.requiresAuth === true); if (!requiresAuth) { @@ -75,7 +83,19 @@ router.beforeEach(async (to) => { try { const response = await api.me(); - return !requiresVerified || response.user.emailVerified ? true : { path: '/login', query: { redirect: to.fullPath } }; + if (requiresVerified && !response.user.emailVerified) { + return { path: '/login', query: { redirect: to.fullPath } }; + } + + const permissionSet = new Set(response.user.permissions); + if (requiredPermissions.some((permission) => !permissionSet.has(permission))) { + return { path: '/pokemon' }; + } + if (requiredAnyPermissions.length && !requiredAnyPermissions.some((permission) => permissionSet.has(permission))) { + return { path: '/pokemon' }; + } + + return true; } catch { setAuthToken(null); return { path: '/login', query: { redirect: to.fullPath } }; diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index af747f2..3f1066e 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -314,6 +314,8 @@ export interface AuthUser { email: string; displayName: string; emailVerified: boolean; + roles: RoleSummary[]; + permissions: string[]; } export interface ReferralSummary { @@ -322,6 +324,52 @@ export interface ReferralSummary { verifiedReferralCount: number; } +export interface RoleSummary { + id: number; + key: string; + name: string; + level: number; +} + +export interface RoleDetail extends RoleSummary { + description: string; + enabled: boolean; + systemRole: boolean; + permissionIds: number[]; +} + +export interface Permission { + id: number; + key: string; + name: string; + description: string; + category: string; + enabled: boolean; + systemPermission: boolean; +} + +export interface AdminUser extends AuthUser { + roleIds: number[]; + createdAt: string; + updatedAt: string; +} + +export interface RolePayload { + key?: string; + name: string; + description: string; + level: number; + enabled: boolean; +} + +export interface PermissionPayload { + key?: string; + name: string; + description: string; + category: string; + enabled: boolean; +} + export interface UserProfilePayload { displayName: string; } @@ -646,6 +694,22 @@ export const api = { updateMe: (payload: UserProfilePayload) => sendJson<{ user: AuthUser }>('/api/auth/me', 'PATCH', payload), referral: () => getJson<{ referral: ReferralSummary }>('/api/auth/referral'), logout: () => postEmpty('/api/auth/logout'), + adminUsers: () => getJson('/api/admin/users'), + updateAdminUserRoles: (id: string | number, roleIds: number[]) => + sendJson(`/api/admin/users/${id}/roles`, 'PUT', { roleIds }), + roles: () => getJson('/api/admin/roles'), + createRole: (payload: RolePayload & { key: string }) => sendJson('/api/admin/roles', 'POST', payload), + updateRole: (id: string | number, payload: RolePayload) => + sendJson(`/api/admin/roles/${id}`, 'PUT', payload), + updateRolePermissions: (id: string | number, permissionIds: number[]) => + sendJson(`/api/admin/roles/${id}/permissions`, 'PUT', { permissionIds }), + deleteRole: (id: string | number) => deleteJson(`/api/admin/roles/${id}`), + permissions: () => getJson('/api/admin/permissions'), + createPermission: (payload: PermissionPayload & { key: string }) => + sendJson('/api/admin/permissions', 'POST', payload), + updatePermission: (id: string | number, payload: PermissionPayload) => + sendJson(`/api/admin/permissions/${id}`, 'PUT', payload), + deletePermission: (id: string | number) => deleteJson(`/api/admin/permissions/${id}`), options: () => getJson('/api/options'), dailyChecklist: () => getJson('/api/daily-checklist'), lifePosts: (params: LifePostsParams = {}) => diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index 4844c57..27257e4 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -2828,6 +2828,87 @@ button:disabled, overflow-wrap: anywhere; } +.access-list li { + align-items: flex-start; +} + +.access-row, +.access-modal-heading { + min-width: 0; + display: grid; + gap: 7px; +} + +.access-row strong, +.access-modal-heading strong { + color: var(--ink); + overflow-wrap: anywhere; +} + +.permission-groups { + display: grid; + gap: 14px; +} + +.permission-group { + display: grid; + gap: 10px; + padding: 12px; + border: 1px solid var(--line); + border-radius: var(--radius-card); + background: var(--surface-soft); +} + +.permission-group h3 { + margin: 0; + color: var(--ink); + font-size: 15px; +} + +.permission-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 8px; +} + +.permission-toggle { + min-height: 52px; + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 8px; + align-items: start; + padding: 10px; + border: 1px solid var(--line); + border-radius: var(--radius-control); + background: var(--surface); + color: var(--ink-soft); + cursor: pointer; +} + +.permission-toggle input { + width: 18px; + height: 18px; + margin-top: 2px; + accent-color: var(--pokemon-blue); +} + +.permission-toggle strong, +.permission-toggle small { + display: block; + overflow-wrap: anywhere; +} + +.permission-toggle strong { + color: var(--ink); + font-size: 14px; +} + +.permission-toggle small { + margin-top: 2px; + color: var(--muted); + font-size: 12px; +} + .chips { display: flex; flex-wrap: wrap; diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index 2b4ab0b..c716df0 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -18,7 +18,9 @@ import { iconEdit, iconHabitat, iconItem, + iconKey, iconPokemon, + iconProfile, iconRecipe, iconSave, iconTranslate, @@ -28,24 +30,43 @@ import { defaultLocale, getCurrentLocale, loadSystemWordings, setCurrentLocale } import { api, type AuthUser, + type AdminUser, type ConfigType, type DailyChecklistItem, type Habitat, type Item, type Language, type NamedEntity, + type Permission, + type PermissionPayload, type Pokemon, type Recipe, + type RoleDetail, + type RolePayload, type Skill, type SystemWording, type SystemWordingSurface, type TranslationMap } from '../services/api'; -type AdminTab = 'config' | 'languages' | 'wordings' | 'checklist' | 'pokemon' | 'items' | 'recipes' | 'habitats'; +type AdminTab = + | 'users' + | 'roles' + | 'permissions' + | 'config' + | 'languages' + | 'wordings' + | 'checklist' + | 'pokemon' + | 'items' + | 'recipes' + | 'habitats'; type EditableConfig = (NamedEntity | Skill) & { hasItemDrop?: boolean }; const adminTabIcons: Record = { + users: iconProfile, + roles: iconKey, + permissions: iconKey, config: iconAdmin, languages: iconTranslate, wordings: iconTranslate, @@ -58,16 +79,21 @@ const adminTabIcons: Record = { const { locale, t } = useI18n(); -const tabs = computed>(() => [ - { key: 'config', label: t('pages.admin.config') }, - { key: 'languages', label: t('pages.admin.languages') }, - { key: 'wordings', label: t('pages.admin.wordings') }, - { key: 'checklist', label: t('pages.admin.checklist') }, - { key: 'pokemon', label: 'Pokemon' }, - { key: 'items', label: t('pages.items.title') }, - { key: 'recipes', label: t('pages.recipes.title') }, - { key: 'habitats', label: t('pages.habitats.title') } -]); +const tabs = computed>(() => + [ + { key: 'users' as const, label: t('pages.admin.users'), permission: 'admin.users.read' }, + { key: 'roles' as const, label: t('pages.admin.roles'), permission: 'admin.roles.read' }, + { key: 'permissions' as const, label: t('pages.admin.permissions'), permission: 'admin.permissions.read' }, + { key: 'config' as const, label: t('pages.admin.config'), permission: 'admin.config.read' }, + { key: 'languages' as const, label: t('pages.admin.languages'), permission: 'admin.languages.read' }, + { key: 'wordings' as const, label: t('pages.admin.wordings'), permission: 'admin.wordings.read' }, + { key: 'checklist' as const, label: t('pages.admin.checklist'), permission: ['checklist.create', 'checklist.update', 'checklist.delete', 'checklist.order'] }, + { key: 'pokemon' as const, label: 'Pokemon', permission: ['pokemon.order', 'pokemon.delete'] }, + { key: 'items' as const, label: t('pages.items.title'), permission: ['items.order', 'items.delete'] }, + { key: 'recipes' as const, label: t('pages.recipes.title'), permission: ['recipes.order', 'recipes.delete'] }, + { key: 'habitats' as const, label: t('pages.habitats.title'), permission: ['habitats.order', 'habitats.delete'] } + ].filter((tab) => canAny(tab.permission)) +); const configTypes = computed>(() => [ { key: 'pokemon-types', label: t('config.pokemonTypes') }, @@ -83,6 +109,9 @@ const configTypes = computed('config'); const activeConfigType = ref('skills'); +const userRows = ref([]); +const roleRows = ref([]); +const permissionRows = ref([]); const configRows = ref([]); const languageRows = ref([]); const checklistRows = ref([]); @@ -99,11 +128,19 @@ const configForm = ref({ id: 0, name: '', translations: {} as TranslationMap, ha const checklistForm = ref({ id: 0, title: '', translations: {} as TranslationMap }); const languageForm = ref({ code: '', name: '', enabled: true, isDefault: false, sortOrder: 0 }); const wordingForm = ref({ key: '', locale: defaultLocale, value: '', defaultValue: '', placeholders: [] as string[] }); +const userRoleForm = ref({ userId: 0, roleIds: [] as number[] }); +const roleForm = ref({ id: 0, key: '', name: '', description: '', level: 100, enabled: true }); +const rolePermissionForm = ref({ roleId: 0, permissionIds: [] as number[] }); +const permissionForm = ref({ id: 0, key: '', name: '', description: '', category: 'General', enabled: true }); const editingLanguageCode = ref(''); const configModalOpen = ref(false); const checklistModalOpen = ref(false); const languageModalOpen = ref(false); const wordingModalOpen = ref(false); +const userRoleModalOpen = ref(false); +const roleModalOpen = ref(false); +const rolePermissionsModalOpen = ref(false); +const permissionModalOpen = ref(false); const wordingLocale = ref(getCurrentLocale()); const wordingModule = ref(''); const wordingSurface = ref(''); @@ -143,7 +180,7 @@ const activeConfigTab = computed({ void run(loadConfig); } }); -const canEdit = computed(() => currentUser.value?.emailVerified === true); +const canEdit = computed(() => can('admin.access')); const showAdminSkeleton = computed(() => busy.value && !message.value && (!currentUser.value || contentLoading.value)); const canSetLanguageDefault = computed(() => languageForm.value.code === 'en'); const configModalTitle = computed(() => @@ -152,6 +189,21 @@ const configModalTitle = computed(() => const checklistModalTitle = computed(() => (checklistForm.value.id ? t('pages.checklist.editTask') : t('pages.checklist.newTask'))); const languageModalTitle = computed(() => (editingLanguageCode.value ? t('pages.admin.editLanguage') : t('pages.admin.newLanguage'))); const wordingModalTitle = computed(() => t('pages.admin.editWording')); +const roleModalTitle = computed(() => (roleForm.value.id ? t('pages.admin.editRole') : t('pages.admin.newRole'))); +const permissionModalTitle = computed(() => + permissionForm.value.id ? t('pages.admin.editPermission') : t('pages.admin.newPermission') +); +const rolePermissionsModalTitle = computed(() => t('pages.admin.rolePermissions')); +const userRoleModalTitle = computed(() => t('pages.admin.userRoles')); +const editingUser = computed(() => userRows.value.find((user) => user.id === userRoleForm.value.userId) ?? null); +const editingRole = computed(() => roleRows.value.find((role) => role.id === rolePermissionForm.value.roleId) ?? null); +const permissionGroups = computed(() => { + const groups = new Map(); + for (const permission of permissionRows.value) { + groups.set(permission.category, [...(groups.get(permission.category) ?? []), permission]); + } + return [...groups.entries()].map(([category, permissions]) => ({ category, permissions })); +}); const wordingLocaleOptions = computed(() => languageRows.value.length ? languageRows.value @@ -196,10 +248,51 @@ const recipeLabel = (item: Recipe) => item.name; const habitatKey = (item: Habitat) => item.id; const habitatLabel = (item: Habitat) => item.name; +function can(permissionKey: string) { + return currentUser.value?.permissions.includes(permissionKey) === true; +} + +function canAny(permissionKey: string | string[]) { + return Array.isArray(permissionKey) ? permissionKey.some((key) => can(key)) : can(permissionKey); +} + function dragSortLabel(name: string) { return t('pages.admin.dragSort', { name }); } +function roleNames(roleIds: number[], fallbackRoles: AuthUser['roles'] = []) { + const names = roleIds + .map((roleId) => roleRows.value.find((role) => role.id === roleId)?.name) + .filter((name): name is string => Boolean(name)); + const fallbackNames = fallbackRoles.map((role) => role.name); + const visibleNames = names.length ? names : fallbackNames; + return visibleNames.length ? visibleNames.join(', ') : t('pages.admin.noRoles'); +} + +function rolePermissionCount(role: RoleDetail) { + return t('pages.admin.permissionCount', { count: role.permissionIds.length }); +} + +function toggleUserRole(roleId: number) { + const roleIds = new Set(userRoleForm.value.roleIds); + if (roleIds.has(roleId)) { + roleIds.delete(roleId); + } else { + roleIds.add(roleId); + } + userRoleForm.value.roleIds = [...roleIds].sort((a, b) => a - b); +} + +function toggleRolePermission(permissionId: number) { + const permissionIds = new Set(rolePermissionForm.value.permissionIds); + if (permissionIds.has(permissionId)) { + permissionIds.delete(permissionId); + } else { + permissionIds.add(permissionId); + } + rolePermissionForm.value.permissionIds = [...permissionIds].sort((a, b) => a - b); +} + function errorText(error: unknown, fallback: string) { return error instanceof Error && error.message ? error.message : fallback; } @@ -242,6 +335,22 @@ function resetWordingForm() { wordingForm.value = { key: '', locale: wordingLocale.value || defaultLocale, value: '', defaultValue: '', placeholders: [] }; } +function resetUserRoleForm() { + userRoleForm.value = { userId: 0, roleIds: [] }; +} + +function resetRoleForm() { + roleForm.value = { id: 0, key: '', name: '', description: '', level: 100, enabled: true }; +} + +function resetRolePermissionForm() { + rolePermissionForm.value = { roleId: 0, permissionIds: [] }; +} + +function resetPermissionForm() { + permissionForm.value = { id: 0, key: '', name: '', description: '', category: 'General', enabled: true }; +} + function selectWordingModule(module: string) { wordingModule.value = module; } @@ -291,6 +400,70 @@ function closeWordingModal() { resetWordingForm(); } +function openUserRoles(user: AdminUser) { + userRoleForm.value = { userId: user.id, roleIds: [...user.roleIds] }; + userRoleModalOpen.value = true; +} + +function closeUserRoleModal() { + userRoleModalOpen.value = false; + resetUserRoleForm(); +} + +function openNewRole() { + resetRoleForm(); + roleModalOpen.value = true; +} + +function editRole(role: RoleDetail) { + roleForm.value = { + id: role.id, + key: role.key, + name: role.name, + description: role.description, + level: role.level, + enabled: role.enabled + }; + roleModalOpen.value = true; +} + +function closeRoleModal() { + roleModalOpen.value = false; + resetRoleForm(); +} + +function editRolePermissions(role: RoleDetail) { + rolePermissionForm.value = { roleId: role.id, permissionIds: [...role.permissionIds] }; + rolePermissionsModalOpen.value = true; +} + +function closeRolePermissionsModal() { + rolePermissionsModalOpen.value = false; + resetRolePermissionForm(); +} + +function openNewPermission() { + resetPermissionForm(); + permissionModalOpen.value = true; +} + +function editPermission(permission: Permission) { + permissionForm.value = { + id: permission.id, + key: permission.key, + name: permission.name, + description: permission.description, + category: permission.category, + enabled: permission.enabled + }; + permissionModalOpen.value = true; +} + +function closePermissionModal() { + permissionModalOpen.value = false; + resetPermissionForm(); +} + function editLanguage(item: Language) { editingLanguageCode.value = item.code; languageForm.value = { @@ -550,6 +723,26 @@ async function loadHabitats() { habitatRows.value = await api.habitats(); } +async function loadUsers() { + if (can('admin.roles.read')) { + roleRows.value = await api.roles(); + } + userRows.value = await api.adminUsers(); +} + +async function loadRoles() { + const [roles, permissions] = await Promise.all([ + api.roles(), + can('admin.permissions.read') ? api.permissions() : Promise.resolve(permissionRows.value) + ]); + roleRows.value = roles; + permissionRows.value = permissions; +} + +async function loadPermissions() { + permissionRows.value = await api.permissions(); +} + async function loadWordings() { await loadLanguages(); if (!wordingLocaleOptions.value.some((language) => language.code === wordingLocale.value)) { @@ -576,6 +769,58 @@ async function saveWording() { }); } +async function saveUserRoles() { + await run(async () => { + userRows.value = await api.updateAdminUserRoles(userRoleForm.value.userId, userRoleForm.value.roleIds); + closeUserRoleModal(); + if (can('admin.roles.read')) { + roleRows.value = await api.roles(); + } + }); +} + +async function saveRole() { + await run(async () => { + const payload: RolePayload = { + name: roleForm.value.name, + description: roleForm.value.description, + level: roleForm.value.level, + enabled: roleForm.value.enabled + }; + + roleRows.value = roleForm.value.id + ? await api.updateRole(roleForm.value.id, payload) + : await api.createRole({ ...payload, key: roleForm.value.key }); + closeRoleModal(); + }); +} + +async function saveRolePermissions() { + await run(async () => { + roleRows.value = await api.updateRolePermissions(rolePermissionForm.value.roleId, rolePermissionForm.value.permissionIds); + closeRolePermissionsModal(); + }); +} + +async function savePermission() { + await run(async () => { + const payload: PermissionPayload = { + name: permissionForm.value.name, + description: permissionForm.value.description, + category: permissionForm.value.category, + enabled: permissionForm.value.enabled + }; + + permissionRows.value = permissionForm.value.id + ? await api.updatePermission(permissionForm.value.id, payload) + : await api.createPermission({ ...payload, key: permissionForm.value.key }); + closePermissionModal(); + if (can('admin.roles.read')) { + roleRows.value = await api.roles(); + } + }); +} + async function loadCurrentTab(showSkeleton = false) { if (showSkeleton) { contentLoading.value = true; @@ -583,6 +828,9 @@ async function loadCurrentTab(showSkeleton = false) { try { if (activeTab.value === 'config') await loadConfig(); + if (activeTab.value === 'users') await loadUsers(); + if (activeTab.value === 'roles') await loadRoles(); + if (activeTab.value === 'permissions') await loadPermissions(); if (activeTab.value === 'languages') await loadLanguages(); if (activeTab.value === 'wordings') await loadWordings(); if (activeTab.value === 'checklist') await loadChecklist(); @@ -599,7 +847,7 @@ async function loadCurrentTab(showSkeleton = false) { function setTab(tab: AdminTab) { if (!canEdit.value) { - message.value = t('errors.completeEmailVerification'); + message.value = t('errors.permissionDenied'); return; } @@ -607,6 +855,12 @@ function setTab(tab: AdminTab) { void run(() => loadCurrentTab(true)); } +function ensureActiveTabAllowed() { + if (!tabs.value.some((tab) => tab.key === activeTab.value)) { + activeTab.value = tabs.value[0]?.key ?? 'config'; + } +} + async function loadAdmin() { const response = await api.me(); currentUser.value = response.user; @@ -615,7 +869,12 @@ async function loadAdmin() { message.value = t('errors.completeEmailVerification'); return; } + if (!canEdit.value || !tabs.value.length) { + message.value = t('errors.permissionDenied'); + return; + } + ensureActiveTabAllowed(); await loadCurrentTab(true); } @@ -678,6 +937,32 @@ async function removeHabitat(id: number) { }); } +async function removeRole(id: number) { + await run(async () => { + await api.deleteRole(id); + if (roleForm.value.id === id) { + closeRoleModal(); + } + if (rolePermissionForm.value.roleId === id) { + closeRolePermissionsModal(); + } + await loadRoles(); + }); +} + +async function removePermission(id: number) { + await run(async () => { + await api.deletePermission(id); + if (permissionForm.value.id === id) { + closePermissionModal(); + } + await loadPermissions(); + if (can('admin.roles.read')) { + roleRows.value = await api.roles(); + } + }); +} + onMounted(() => { void run(loadAdmin); }); @@ -710,10 +995,110 @@ onMounted(() => { +
+
+

{{ t('pages.admin.users') }}

+
+
    +
  • + + {{ user.displayName }} + {{ user.email }} + + {{ user.emailVerified ? t('pages.profile.emailVerified') : t('pages.profile.emailUnverified') }} + {{ roleNames(user.roleIds, user.roles) }} + + + + + +
  • +
+

{{ t('common.noRecords') }}

+
+ +
+
+

{{ t('pages.admin.roles') }}

+ +
+
    +
  • + + {{ role.name }} + {{ role.description }} + + {{ role.key }} + {{ t('pages.admin.roleLevel', { level: role.level }) }} + {{ role.enabled ? t('pages.admin.enabled') : t('pages.admin.disabled') }} + {{ t('pages.admin.systemRole') }} + {{ rolePermissionCount(role) }} + + + + + + + +
  • +
+

{{ t('common.noRecords') }}

+
+ +
+
+

{{ t('pages.admin.permissions') }}

+ +
+
    +
  • + + {{ permission.name }} + {{ permission.description }} + + {{ permission.key }} + {{ permission.category }} + {{ permission.enabled ? t('pages.admin.enabled') : t('pages.admin.disabled') }} + {{ t('pages.admin.systemPermission') }} + + + + + + +
  • +
+

{{ t('common.noRecords') }}

+
+

{{ t('pages.admin.checklist') }}

- @@ -725,7 +1110,7 @@ onMounted(() => { :item-key="checklistKey" :item-label="checklistLabel" list-key-prefix="checklist" - :disabled="busy" + :disabled="busy || !can('checklist.order')" :handle-label="dragSortLabel" :handle-title="t('pages.admin.dragSortTitle')" @preview="previewChecklistOrder" @@ -735,11 +1120,11 @@ onMounted(() => {