feat(auth): implement role-based access control (RBAC)
Add roles, permissions, and user_roles tables with default seed data Protect backend API endpoints with granular permission checks Add admin UI for managing users, roles, and permissions Update frontend views to conditionally render actions based on permissions
This commit is contained in:
121
DESIGN.md
121
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` 权限。
|
||||
|
||||
## 开发与验证
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string
|
||||
return referralCode;
|
||||
}
|
||||
|
||||
function toPublicUser(user: UserRow): AuthUser {
|
||||
function toPublicUser(user: UserRow, roles: RoleSummary[] = [], permissions: string[] = []): AuthUser {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
displayName: user.display_name,
|
||||
emailVerified: user.email_verified_at !== null
|
||||
emailVerified: user.email_verified_at !== null,
|
||||
roles,
|
||||
permissions
|
||||
};
|
||||
}
|
||||
|
||||
async function clientQuery<T extends QueryResultRow>(
|
||||
client: DbClient,
|
||||
sql: string,
|
||||
params: unknown[] = []
|
||||
): Promise<T[]> {
|
||||
const result = await client.query<T>(sql, params);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
async function clientQueryOne<T extends QueryResultRow>(
|
||||
client: DbClient,
|
||||
sql: string,
|
||||
@@ -196,6 +282,22 @@ async function clientQueryOne<T extends QueryResultRow>(
|
||||
return result.rows[0] ?? null;
|
||||
}
|
||||
|
||||
async function runQuery<T extends QueryResultRow>(
|
||||
client: DbClient | null,
|
||||
sql: string,
|
||||
params: unknown[] = []
|
||||
): Promise<T[]> {
|
||||
return client ? clientQuery<T>(client, sql, params) : query<T>(sql, params);
|
||||
}
|
||||
|
||||
async function runQueryOne<T extends QueryResultRow>(
|
||||
client: DbClient | null,
|
||||
sql: string,
|
||||
params: unknown[] = []
|
||||
): Promise<T | null> {
|
||||
return client ? clientQueryOne<T>(client, sql, params) : queryOne<T>(sql, params);
|
||||
}
|
||||
|
||||
async function withTransaction<T>(callback: (client: DbClient) => Promise<T>): Promise<T> {
|
||||
const client = await pool.connect();
|
||||
|
||||
@@ -287,6 +389,236 @@ async function ensureReferralCode(client: DbClient, userId: number): Promise<str
|
||||
throw new Error('Failed to assign referral code');
|
||||
}
|
||||
|
||||
async function ensureOwnerRoleForUser(client: DbClient, userId: number): Promise<void> {
|
||||
const existingOwner = await clientQueryOne<QueryResultRow & { id: number }>(
|
||||
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<QueryResultRow & { id: number }>(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<RoleSummary[]> {
|
||||
const rows = await runQuery<RoleRow>(
|
||||
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<string[]> {
|
||||
const rows = await runQuery<QueryResultRow & { key: string }>(
|
||||
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<AuthUser | null> {
|
||||
const user = await runQueryOne<UserRow>(
|
||||
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<void> {
|
||||
const row = await clientQueryOne<QueryResultRow & { count: string }>(
|
||||
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<void> {
|
||||
const row = await clientQueryOne<QueryResultRow & { count: string }>(
|
||||
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<void> {
|
||||
const row = await clientQueryOne<QueryResultRow & { count: string }>(
|
||||
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<void> {
|
||||
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<string, unknown>, 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<string, unknown>, 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<AuthUser | null> {
|
||||
@@ -618,18 +952,17 @@ export async function getUserBySessionToken(token: string): Promise<AuthUser | n
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = await queryOne<UserRow>(
|
||||
const session = await queryOne<QueryResultRow & { user_id: number }>(
|
||||
`
|
||||
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<ReferralSummary> {
|
||||
@@ -672,6 +1005,329 @@ export async function getReferralSummary(userId: number): Promise<ReferralSummar
|
||||
});
|
||||
}
|
||||
|
||||
export async function listAdminUsers(): Promise<AdminUser[]> {
|
||||
const rows = await query<UserRow & { created_at: string; updated_at: string }>(
|
||||
`
|
||||
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<QueryResultRow & { user_id: number; role_id: number }>(
|
||||
`
|
||||
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<number, number[]>();
|
||||
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<PermissionSummary[]> {
|
||||
const rows = await query<PermissionRow>(
|
||||
`
|
||||
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<string, unknown>): Promise<PermissionSummary[]> {
|
||||
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<PermissionRow>(
|
||||
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<string, unknown>): Promise<PermissionSummary[]> {
|
||||
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<PermissionRow>(
|
||||
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<void> {
|
||||
await withTransaction(async (client) => {
|
||||
const permission = await clientQueryOne<PermissionRow>(
|
||||
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<RoleDetail[]> {
|
||||
const rows = await query<RoleRow>(
|
||||
`
|
||||
SELECT id, key, name, description, level, enabled, system_role
|
||||
FROM roles
|
||||
ORDER BY level DESC, name ASC, id ASC
|
||||
`
|
||||
);
|
||||
const permissionRows = await query<RolePermissionRow>(
|
||||
`
|
||||
SELECT role_id, permission_id
|
||||
FROM role_permissions
|
||||
ORDER BY role_id, permission_id
|
||||
`
|
||||
);
|
||||
const permissionIdsByRoleId = new Map<number, number[]>();
|
||||
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<string, unknown>): Promise<RoleDetail[]> {
|
||||
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<string, unknown>): Promise<RoleDetail[]> {
|
||||
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<RoleRow>(
|
||||
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<void> {
|
||||
await withTransaction(async (client) => {
|
||||
const role = await clientQueryOne<RoleRow>(
|
||||
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<string, unknown>): Promise<RoleDetail[]> {
|
||||
const permissionIds = cleanIdList(payload.permissionIds);
|
||||
|
||||
await withTransaction(async (client) => {
|
||||
const role = await clientQueryOne<RoleRow>(
|
||||
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<QueryResultRow & { count: string }>(
|
||||
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<string, unknown>,
|
||||
assignedByUserId: number
|
||||
): Promise<AdminUser[]> {
|
||||
const roleIds = cleanIdList(payload.roleIds);
|
||||
|
||||
await withTransaction(async (client) => {
|
||||
const user = await clientQueryOne<UserRow>(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<QueryResultRow & { count: string }>(
|
||||
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<void> {
|
||||
if (token.length < 32) {
|
||||
return;
|
||||
|
||||
@@ -2462,7 +2462,13 @@ export async function createLifePost(payload: Record<string, unknown>, userId: n
|
||||
return getLifePostById(id, userId, locale);
|
||||
}
|
||||
|
||||
export async function updateLifePost(id: number, payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
||||
export async function updateLifePost(
|
||||
id: number,
|
||||
payload: Record<string, unknown>,
|
||||
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<string, unknown
|
||||
UPDATE life_posts
|
||||
SET body = $1, updated_by_user_id = $2, updated_at = now()
|
||||
WHERE id = $3
|
||||
AND created_by_user_id = $2
|
||||
AND ($4 = true OR created_by_user_id = $2)
|
||||
AND deleted_at IS NULL
|
||||
RETURNING id
|
||||
`,
|
||||
[cleanPayload.body, userId, id]
|
||||
[cleanPayload.body, userId, id, allowAny]
|
||||
);
|
||||
|
||||
const resultId = result.rows[0]?.id ?? null;
|
||||
@@ -2490,7 +2496,7 @@ export async function updateLifePost(id: number, payload: Record<string, unknown
|
||||
return updatedId ? getLifePostById(updatedId, userId, locale) : null;
|
||||
}
|
||||
|
||||
export async function deleteLifePost(id: number, userId: number) {
|
||||
export async function deleteLifePost(id: number, userId: number, allowAny = false) {
|
||||
const result = await queryOne<{ id: number }>(
|
||||
`
|
||||
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<boolean> {
|
||||
export async function deleteEntityDiscussionComment(id: number, userId: number, allowAny = false): Promise<boolean> {
|
||||
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);
|
||||
|
||||
@@ -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<string> {
|
||||
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<AuthUser | null> {
|
||||
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<AuthUser | null> {
|
||||
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<AuthUser | null> {
|
||||
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<string, unknown>, 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<string, unknown>)) : 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<string, unknown>);
|
||||
});
|
||||
|
||||
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<string, unknown>);
|
||||
});
|
||||
|
||||
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<string, unknown>)) : 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<string, unknown>);
|
||||
});
|
||||
|
||||
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<string, unknown>, 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<string, unknown>, user.id, requestLocale(request));
|
||||
const post = await updateLifePost(
|
||||
Number(id),
|
||||
request.body as Record<string, unknown>,
|
||||
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<string, string | string[] | undefined>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>) : 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>)) : 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<string, unknown>) : 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<string, unknown>) : 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;
|
||||
}
|
||||
|
||||
@@ -35,7 +35,12 @@ function inDevBadge() {
|
||||
return { label: t('common.inDev'), tone: 'info' as const };
|
||||
}
|
||||
|
||||
const navItems = computed(() => [
|
||||
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 },
|
||||
@@ -46,9 +51,15 @@ const navItems = computed(() => [
|
||||
{ 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 }
|
||||
]);
|
||||
{ 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()) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<EntityImageUpload[]>([]);
|
||||
|
||||
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<EntityImage[]>(() => {
|
||||
const images = [
|
||||
...localUploads.value,
|
||||
@@ -115,6 +117,7 @@ async function uploadImage(event: Event) {
|
||||
<span class="field-label">{{ imageLabel }}</span>
|
||||
<div class="image-upload-field__actions">
|
||||
<input
|
||||
v-if="allowUpload"
|
||||
ref="fileInput"
|
||||
class="image-upload-field__input"
|
||||
type="file"
|
||||
@@ -122,7 +125,7 @@ async function uploadImage(event: Event) {
|
||||
:disabled="uploadDisabled"
|
||||
@change="uploadImage"
|
||||
/>
|
||||
<button type="button" class="ui-button ui-button--blue ui-button--small" :disabled="uploadDisabled" @click="openFilePicker">
|
||||
<button v-if="allowUpload" type="button" class="ui-button ui-button--blue ui-button--small" :disabled="uploadDisabled" @click="openFilePicker">
|
||||
<Icon :icon="iconUpload" class="ui-icon" aria-hidden="true" />
|
||||
{{ uploadBusy ? t('media.uploading') : t('media.uploadImage') }}
|
||||
</button>
|
||||
|
||||
@@ -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 } };
|
||||
|
||||
@@ -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<AdminUser[]>('/api/admin/users'),
|
||||
updateAdminUserRoles: (id: string | number, roleIds: number[]) =>
|
||||
sendJson<AdminUser[]>(`/api/admin/users/${id}/roles`, 'PUT', { roleIds }),
|
||||
roles: () => getJson<RoleDetail[]>('/api/admin/roles'),
|
||||
createRole: (payload: RolePayload & { key: string }) => sendJson<RoleDetail[]>('/api/admin/roles', 'POST', payload),
|
||||
updateRole: (id: string | number, payload: RolePayload) =>
|
||||
sendJson<RoleDetail[]>(`/api/admin/roles/${id}`, 'PUT', payload),
|
||||
updateRolePermissions: (id: string | number, permissionIds: number[]) =>
|
||||
sendJson<RoleDetail[]>(`/api/admin/roles/${id}/permissions`, 'PUT', { permissionIds }),
|
||||
deleteRole: (id: string | number) => deleteJson(`/api/admin/roles/${id}`),
|
||||
permissions: () => getJson<Permission[]>('/api/admin/permissions'),
|
||||
createPermission: (payload: PermissionPayload & { key: string }) =>
|
||||
sendJson<Permission[]>('/api/admin/permissions', 'POST', payload),
|
||||
updatePermission: (id: string | number, payload: PermissionPayload) =>
|
||||
sendJson<Permission[]>(`/api/admin/permissions/${id}`, 'PUT', payload),
|
||||
deletePermission: (id: string | number) => deleteJson(`/api/admin/permissions/${id}`),
|
||||
options: () => getJson<Options>('/api/options'),
|
||||
dailyChecklist: () => getJson<DailyChecklistItem[]>('/api/daily-checklist'),
|
||||
lifePosts: (params: LifePostsParams = {}) =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<AdminTab, AppIcon> = {
|
||||
users: iconProfile,
|
||||
roles: iconKey,
|
||||
permissions: iconKey,
|
||||
config: iconAdmin,
|
||||
languages: iconTranslate,
|
||||
wordings: iconTranslate,
|
||||
@@ -58,16 +79,21 @@ const adminTabIcons: Record<AdminTab, AppIcon> = {
|
||||
|
||||
const { locale, t } = useI18n();
|
||||
|
||||
const tabs = computed<Array<{ key: AdminTab; label: string }>>(() => [
|
||||
{ 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<Array<{ key: AdminTab; label: string; permission: string | string[] }>>(() =>
|
||||
[
|
||||
{ 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<Array<{ key: ConfigType; label: string; supportsItemDrop?: boolean }>>(() => [
|
||||
{ key: 'pokemon-types', label: t('config.pokemonTypes') },
|
||||
@@ -83,6 +109,9 @@ const configTypes = computed<Array<{ key: ConfigType; label: string; supportsIte
|
||||
|
||||
const activeTab = ref<AdminTab>('config');
|
||||
const activeConfigType = ref<ConfigType>('skills');
|
||||
const userRows = ref<AdminUser[]>([]);
|
||||
const roleRows = ref<RoleDetail[]>([]);
|
||||
const permissionRows = ref<Permission[]>([]);
|
||||
const configRows = ref<EditableConfig[]>([]);
|
||||
const languageRows = ref<Language[]>([]);
|
||||
const checklistRows = ref<DailyChecklistItem[]>([]);
|
||||
@@ -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<SystemWordingSurface | ''>('');
|
||||
@@ -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<string, Permission[]>();
|
||||
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(() => {
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section v-else-if="canEdit && activeTab === 'users'" class="detail-section">
|
||||
<div class="detail-section__header">
|
||||
<h2>{{ t('pages.admin.users') }}</h2>
|
||||
</div>
|
||||
<ul v-if="userRows.length" class="row-list access-list">
|
||||
<li v-for="user in userRows" :key="user.id">
|
||||
<span class="access-row">
|
||||
<strong>{{ user.displayName }}</strong>
|
||||
<span class="meta-line">{{ user.email }}</span>
|
||||
<span class="system-wording-row__meta">
|
||||
<span class="config-flag">{{ user.emailVerified ? t('pages.profile.emailVerified') : t('pages.profile.emailUnverified') }}</span>
|
||||
<span class="config-flag">{{ roleNames(user.roleIds, user.roles) }}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="row-actions">
|
||||
<button v-if="can('admin.users.update') && can('admin.roles.read')" type="button" :disabled="busy" @click="openUserRoles(user)">
|
||||
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('pages.admin.userRoles') }}
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
|
||||
</section>
|
||||
|
||||
<section v-else-if="canEdit && activeTab === 'roles'" class="detail-section">
|
||||
<div class="detail-section__header">
|
||||
<h2>{{ t('pages.admin.roles') }}</h2>
|
||||
<button v-if="can('admin.roles.create')" type="button" class="ui-button ui-button--primary ui-button--small" :disabled="busy" @click="openNewRole">
|
||||
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('common.new') }}
|
||||
</button>
|
||||
</div>
|
||||
<ul v-if="roleRows.length" class="row-list access-list">
|
||||
<li v-for="role in roleRows" :key="role.id">
|
||||
<span class="access-row">
|
||||
<strong>{{ role.name }}</strong>
|
||||
<span class="meta-line">{{ role.description }}</span>
|
||||
<span class="system-wording-row__meta">
|
||||
<span class="config-flag">{{ role.key }}</span>
|
||||
<span class="config-flag">{{ t('pages.admin.roleLevel', { level: role.level }) }}</span>
|
||||
<span class="config-flag">{{ role.enabled ? t('pages.admin.enabled') : t('pages.admin.disabled') }}</span>
|
||||
<span v-if="role.systemRole" class="config-flag">{{ t('pages.admin.systemRole') }}</span>
|
||||
<span class="config-flag">{{ rolePermissionCount(role) }}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="row-actions">
|
||||
<button v-if="can('admin.roles.update')" type="button" :disabled="busy" @click="editRole(role)">
|
||||
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('common.edit') }}
|
||||
</button>
|
||||
<button v-if="can('admin.roles.update') && can('admin.permissions.read')" type="button" :disabled="busy || role.key === 'owner'" @click="editRolePermissions(role)">
|
||||
<Icon :icon="iconKey" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('pages.admin.rolePermissions') }}
|
||||
</button>
|
||||
<button v-if="can('admin.roles.delete')" type="button" :disabled="busy || role.key === 'owner'" @click="removeRole(role.id)">
|
||||
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('common.delete') }}
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
|
||||
</section>
|
||||
|
||||
<section v-else-if="canEdit && activeTab === 'permissions'" class="detail-section">
|
||||
<div class="detail-section__header">
|
||||
<h2>{{ t('pages.admin.permissions') }}</h2>
|
||||
<button v-if="can('admin.permissions.create')" type="button" class="ui-button ui-button--primary ui-button--small" :disabled="busy" @click="openNewPermission">
|
||||
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('common.new') }}
|
||||
</button>
|
||||
</div>
|
||||
<ul v-if="permissionRows.length" class="row-list access-list">
|
||||
<li v-for="permission in permissionRows" :key="permission.id">
|
||||
<span class="access-row">
|
||||
<strong>{{ permission.name }}</strong>
|
||||
<span class="meta-line">{{ permission.description }}</span>
|
||||
<span class="system-wording-row__meta">
|
||||
<span class="config-flag">{{ permission.key }}</span>
|
||||
<span class="config-flag">{{ permission.category }}</span>
|
||||
<span class="config-flag">{{ permission.enabled ? t('pages.admin.enabled') : t('pages.admin.disabled') }}</span>
|
||||
<span v-if="permission.systemPermission" class="config-flag">{{ t('pages.admin.systemPermission') }}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="row-actions">
|
||||
<button v-if="can('admin.permissions.update')" type="button" :disabled="busy" @click="editPermission(permission)">
|
||||
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('common.edit') }}
|
||||
</button>
|
||||
<button v-if="can('admin.permissions.delete')" type="button" :disabled="busy" @click="removePermission(permission.id)">
|
||||
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('common.delete') }}
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
|
||||
</section>
|
||||
|
||||
<section v-else-if="canEdit && activeTab === 'checklist'" class="detail-section">
|
||||
<div class="detail-section__header">
|
||||
<h2>{{ t('pages.admin.checklist') }}</h2>
|
||||
<button type="button" class="ui-button ui-button--primary ui-button--small" :disabled="busy" @click="openNewChecklistItem">
|
||||
<button v-if="can('checklist.create')" type="button" class="ui-button ui-button--primary ui-button--small" :disabled="busy" @click="openNewChecklistItem">
|
||||
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('common.new') }}
|
||||
</button>
|
||||
@@ -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(() => {
|
||||
<template #default="{ item }">
|
||||
<span class="reorderable-row-title">{{ item.title }}</span>
|
||||
<span class="row-actions">
|
||||
<button type="button" :disabled="busy" @click="editChecklistItem(item)">
|
||||
<button v-if="can('checklist.update')" type="button" :disabled="busy" @click="editChecklistItem(item)">
|
||||
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('common.edit') }}
|
||||
</button>
|
||||
<button type="button" :disabled="busy" @click="removeChecklistItem(item.id)">
|
||||
<button v-if="can('checklist.delete')" type="button" :disabled="busy" @click="removeChecklistItem(item.id)">
|
||||
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('common.delete') }}
|
||||
</button>
|
||||
@@ -752,7 +1137,7 @@ onMounted(() => {
|
||||
<section v-else-if="canEdit && activeTab === 'config'" class="detail-section">
|
||||
<div class="detail-section__header">
|
||||
<h2>{{ t('pages.admin.config') }}</h2>
|
||||
<button type="button" class="ui-button ui-button--primary ui-button--small" :disabled="busy" @click="openNewConfig">
|
||||
<button v-if="can('admin.config.create')" type="button" class="ui-button ui-button--primary ui-button--small" :disabled="busy" @click="openNewConfig">
|
||||
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('common.new') }}
|
||||
</button>
|
||||
@@ -765,7 +1150,7 @@ onMounted(() => {
|
||||
:item-key="configKey"
|
||||
:item-label="configLabel"
|
||||
:list-key-prefix="`config-${activeConfigType}`"
|
||||
:disabled="busy"
|
||||
:disabled="busy || !can('admin.config.order')"
|
||||
:handle-label="dragSortLabel"
|
||||
:handle-title="t('pages.admin.dragSortTitle')"
|
||||
@preview="previewConfigOrder"
|
||||
@@ -777,11 +1162,11 @@ onMounted(() => {
|
||||
{{ item.name }}<span v-if="item.hasItemDrop" class="config-flag">{{ t('pages.admin.hasItemDrop') }}</span>
|
||||
</span>
|
||||
<span class="row-actions">
|
||||
<button type="button" :disabled="busy" @click="editConfig(item)">
|
||||
<button v-if="can('admin.config.update')" type="button" :disabled="busy" @click="editConfig(item)">
|
||||
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('common.edit') }}
|
||||
</button>
|
||||
<button type="button" :disabled="busy" @click="removeConfig(item.id)">
|
||||
<button v-if="can('admin.config.delete')" type="button" :disabled="busy" @click="removeConfig(item.id)">
|
||||
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('common.delete') }}
|
||||
</button>
|
||||
@@ -794,7 +1179,7 @@ onMounted(() => {
|
||||
<section v-else-if="canEdit && activeTab === 'languages'" class="detail-section">
|
||||
<div class="detail-section__header">
|
||||
<h2>{{ t('pages.admin.languages') }}</h2>
|
||||
<button type="button" class="ui-button ui-button--primary ui-button--small" :disabled="busy" @click="openNewLanguage">
|
||||
<button v-if="can('admin.languages.create')" type="button" class="ui-button ui-button--primary ui-button--small" :disabled="busy" @click="openNewLanguage">
|
||||
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('common.new') }}
|
||||
</button>
|
||||
@@ -805,7 +1190,7 @@ onMounted(() => {
|
||||
:item-key="languageKey"
|
||||
:item-label="languageLabel"
|
||||
list-key-prefix="languages"
|
||||
:disabled="busy"
|
||||
:disabled="busy || !can('admin.languages.order')"
|
||||
:handle-label="dragSortLabel"
|
||||
:handle-title="t('pages.admin.dragSortTitle')"
|
||||
@preview="previewLanguageOrder"
|
||||
@@ -818,11 +1203,11 @@ onMounted(() => {
|
||||
<span v-if="item.isDefault" class="config-flag">{{ t('pages.admin.defaultLanguage') }}</span>
|
||||
</span>
|
||||
<span class="row-actions">
|
||||
<button type="button" @click="editLanguage(item)">
|
||||
<button v-if="can('admin.languages.update')" type="button" @click="editLanguage(item)">
|
||||
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('common.edit') }}
|
||||
</button>
|
||||
<button type="button" :disabled="item.isDefault" @click="removeLanguage(item.code)">
|
||||
<button v-if="can('admin.languages.delete')" type="button" :disabled="item.isDefault" @click="removeLanguage(item.code)">
|
||||
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('common.delete') }}
|
||||
</button>
|
||||
@@ -893,7 +1278,7 @@ onMounted(() => {
|
||||
<span class="system-wording-row__value">{{ item.value || item.defaultValue }}</span>
|
||||
</span>
|
||||
<span class="row-actions">
|
||||
<button type="button" :disabled="busy" @click="editWording(item)">
|
||||
<button v-if="can('admin.wordings.update')" type="button" :disabled="busy" @click="editWording(item)">
|
||||
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('common.edit') }}
|
||||
</button>
|
||||
@@ -913,7 +1298,7 @@ onMounted(() => {
|
||||
:item-key="pokemonKey"
|
||||
:item-label="pokemonLabel"
|
||||
list-key-prefix="pokemon"
|
||||
:disabled="busy"
|
||||
:disabled="busy || !can('pokemon.order')"
|
||||
:handle-label="dragSortLabel"
|
||||
:handle-title="t('pages.admin.dragSortTitle')"
|
||||
@preview="previewPokemonOrder"
|
||||
@@ -923,7 +1308,7 @@ onMounted(() => {
|
||||
<template #default="{ item }">
|
||||
<RouterLink :to="`/pokemon/${item.id}`">#{{ item.displayId }} {{ item.name }}</RouterLink>
|
||||
<span class="row-actions">
|
||||
<button type="button" :disabled="busy" @click="removePokemon(item.id)">
|
||||
<button v-if="can('pokemon.delete')" type="button" :disabled="busy" @click="removePokemon(item.id)">
|
||||
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('common.delete') }}
|
||||
</button>
|
||||
@@ -941,7 +1326,7 @@ onMounted(() => {
|
||||
:item-key="itemKey"
|
||||
:item-label="itemLabel"
|
||||
list-key-prefix="items"
|
||||
:disabled="busy"
|
||||
:disabled="busy || !can('items.order')"
|
||||
:handle-label="dragSortLabel"
|
||||
:handle-title="t('pages.admin.dragSortTitle')"
|
||||
@preview="previewItemOrder"
|
||||
@@ -951,7 +1336,7 @@ onMounted(() => {
|
||||
<template #default="{ item }">
|
||||
<RouterLink :to="`/items/${item.id}`">{{ item.name }}</RouterLink>
|
||||
<span class="row-actions">
|
||||
<button type="button" :disabled="busy" @click="removeItem(item.id)">
|
||||
<button v-if="can('items.delete')" type="button" :disabled="busy" @click="removeItem(item.id)">
|
||||
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('common.delete') }}
|
||||
</button>
|
||||
@@ -969,7 +1354,7 @@ onMounted(() => {
|
||||
:item-key="recipeKey"
|
||||
:item-label="recipeLabel"
|
||||
list-key-prefix="recipes"
|
||||
:disabled="busy"
|
||||
:disabled="busy || !can('recipes.order')"
|
||||
:handle-label="dragSortLabel"
|
||||
:handle-title="t('pages.admin.dragSortTitle')"
|
||||
@preview="previewRecipeOrder"
|
||||
@@ -979,7 +1364,7 @@ onMounted(() => {
|
||||
<template #default="{ item }">
|
||||
<RouterLink :to="`/recipes/${item.id}`">{{ item.name }}</RouterLink>
|
||||
<span class="row-actions">
|
||||
<button type="button" :disabled="busy" @click="removeRecipe(item.id)">
|
||||
<button v-if="can('recipes.delete')" type="button" :disabled="busy" @click="removeRecipe(item.id)">
|
||||
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('common.delete') }}
|
||||
</button>
|
||||
@@ -997,7 +1382,7 @@ onMounted(() => {
|
||||
:item-key="habitatKey"
|
||||
:item-label="habitatLabel"
|
||||
list-key-prefix="habitats"
|
||||
:disabled="busy"
|
||||
:disabled="busy || !can('habitats.order')"
|
||||
:handle-label="dragSortLabel"
|
||||
:handle-title="t('pages.admin.dragSortTitle')"
|
||||
@preview="previewHabitatOrder"
|
||||
@@ -1007,7 +1392,7 @@ onMounted(() => {
|
||||
<template #default="{ item }">
|
||||
<RouterLink :to="`/habitats/${item.id}`">{{ item.name }}</RouterLink>
|
||||
<span class="row-actions">
|
||||
<button type="button" :disabled="busy" @click="removeHabitat(item.id)">
|
||||
<button v-if="can('habitats.delete')" type="button" :disabled="busy" @click="removeHabitat(item.id)">
|
||||
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('common.delete') }}
|
||||
</button>
|
||||
@@ -1017,6 +1402,149 @@ onMounted(() => {
|
||||
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
|
||||
</section>
|
||||
|
||||
<Modal v-if="userRoleModalOpen" :title="userRoleModalTitle" :close-label="t('common.close')" size="wide" @close="closeUserRoleModal">
|
||||
<form id="admin-user-roles-form" class="modal-edit-form" @submit.prevent="saveUserRoles">
|
||||
<div v-if="editingUser" class="access-modal-heading">
|
||||
<strong>{{ editingUser.displayName }}</strong>
|
||||
<span class="meta-line">{{ editingUser.email }}</span>
|
||||
</div>
|
||||
<div class="permission-grid" role="group" :aria-label="t('pages.admin.roles')">
|
||||
<label v-for="role in roleRows" :key="role.id" class="permission-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="userRoleForm.roleIds.includes(role.id)"
|
||||
:disabled="busy || !role.enabled"
|
||||
@change="toggleUserRole(role.id)"
|
||||
/>
|
||||
<span>
|
||||
<strong>{{ role.name }}</strong>
|
||||
<small>{{ role.description }}</small>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<button type="submit" form="admin-user-roles-form" class="link-button" :disabled="busy">
|
||||
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
|
||||
{{ busy ? t('common.saving') : t('common.save') }}
|
||||
</button>
|
||||
<button type="button" class="plain-button" :disabled="busy" @click="closeUserRoleModal">
|
||||
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<Modal v-if="roleModalOpen" :title="roleModalTitle" :close-label="t('common.close')" @close="closeRoleModal">
|
||||
<form id="admin-role-form" class="modal-edit-form" @submit.prevent="saveRole">
|
||||
<div class="field">
|
||||
<label for="role-key">{{ t('pages.admin.roleKey') }}</label>
|
||||
<input id="role-key" v-model="roleForm.key" :disabled="Boolean(roleForm.id)" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="role-name">{{ t('pages.admin.roleName') }}</label>
|
||||
<input id="role-name" v-model="roleForm.name" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="role-description">{{ t('pages.admin.description') }}</label>
|
||||
<textarea id="role-description" v-model="roleForm.description"></textarea>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="role-level">{{ t('pages.admin.level') }}</label>
|
||||
<input id="role-level" v-model.number="roleForm.level" type="number" min="0" step="1" required />
|
||||
</div>
|
||||
<div class="check-row">
|
||||
<label><input v-model="roleForm.enabled" type="checkbox" /> {{ t('pages.admin.enabled') }}</label>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<button type="submit" form="admin-role-form" class="link-button" :disabled="busy">
|
||||
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
|
||||
{{ busy ? t('common.saving') : t('common.save') }}
|
||||
</button>
|
||||
<button type="button" class="plain-button" :disabled="busy" @click="closeRoleModal">
|
||||
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<Modal v-if="rolePermissionsModalOpen" :title="rolePermissionsModalTitle" :close-label="t('common.close')" size="wide" @close="closeRolePermissionsModal">
|
||||
<form id="admin-role-permissions-form" class="modal-edit-form" @submit.prevent="saveRolePermissions">
|
||||
<div v-if="editingRole" class="access-modal-heading">
|
||||
<strong>{{ editingRole.name }}</strong>
|
||||
<span class="meta-line">{{ editingRole.description }}</span>
|
||||
</div>
|
||||
<div class="permission-groups">
|
||||
<section v-for="group in permissionGroups" :key="group.category" class="permission-group">
|
||||
<h3>{{ group.category }}</h3>
|
||||
<div class="permission-grid" role="group" :aria-label="group.category">
|
||||
<label v-for="permission in group.permissions" :key="permission.id" class="permission-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="rolePermissionForm.permissionIds.includes(permission.id)"
|
||||
:disabled="busy || !permission.enabled"
|
||||
@change="toggleRolePermission(permission.id)"
|
||||
/>
|
||||
<span>
|
||||
<strong>{{ permission.name }}</strong>
|
||||
<small>{{ permission.key }}</small>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<button type="submit" form="admin-role-permissions-form" class="link-button" :disabled="busy">
|
||||
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
|
||||
{{ busy ? t('common.saving') : t('common.save') }}
|
||||
</button>
|
||||
<button type="button" class="plain-button" :disabled="busy" @click="closeRolePermissionsModal">
|
||||
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<Modal v-if="permissionModalOpen" :title="permissionModalTitle" :close-label="t('common.close')" @close="closePermissionModal">
|
||||
<form id="admin-permission-form" class="modal-edit-form" @submit.prevent="savePermission">
|
||||
<div class="field">
|
||||
<label for="permission-key">{{ t('pages.admin.permissionKey') }}</label>
|
||||
<input id="permission-key" v-model="permissionForm.key" :disabled="Boolean(permissionForm.id)" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="permission-name">{{ t('pages.admin.permissionName') }}</label>
|
||||
<input id="permission-name" v-model="permissionForm.name" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="permission-category">{{ t('pages.admin.category') }}</label>
|
||||
<input id="permission-category" v-model="permissionForm.category" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="permission-description">{{ t('pages.admin.description') }}</label>
|
||||
<textarea id="permission-description" v-model="permissionForm.description"></textarea>
|
||||
</div>
|
||||
<div class="check-row">
|
||||
<label><input v-model="permissionForm.enabled" type="checkbox" /> {{ t('pages.admin.enabled') }}</label>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<button type="submit" form="admin-permission-form" class="link-button" :disabled="busy">
|
||||
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
|
||||
{{ busy ? t('common.saving') : t('common.save') }}
|
||||
</button>
|
||||
<button type="button" class="plain-button" :disabled="busy" @click="closePermissionModal">
|
||||
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<Modal v-if="checklistModalOpen" :title="checklistModalTitle" :close-label="t('common.close')" size="wide" @close="closeChecklistModal">
|
||||
<form id="admin-checklist-form" class="modal-edit-form" @submit.prevent="saveChecklistItem">
|
||||
<TranslationFields
|
||||
|
||||
@@ -12,16 +12,18 @@ import PokeBallMark from '../components/PokeBallMark.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||
import { iconBack, iconEdit, iconHabitat } from '../icons';
|
||||
import { api, type HabitatDetail } from '../services/api';
|
||||
import { api, getAuthToken, type AuthUser, type HabitatDetail } from '../services/api';
|
||||
import HabitatEdit from './HabitatEdit.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
const habitat = ref<HabitatDetail | null>(null);
|
||||
const currentUser = ref<AuthUser | null>(null);
|
||||
const detailTab = ref('details');
|
||||
const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
|
||||
const weathers = ['晴天', '阴天', '雨天'];
|
||||
const showEditor = computed(() => route.name === 'habitat-edit');
|
||||
const canUpdateHabitat = computed(() => currentUser.value?.permissions.includes('habitats.update') === true);
|
||||
const detailTabs = computed<TabOption[]>(() => [
|
||||
{ value: 'details', label: t('common.details') },
|
||||
{ value: 'discussion', label: t('discussion.title') },
|
||||
@@ -118,6 +120,13 @@ async function loadHabitatDetail() {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (getAuthToken()) {
|
||||
try {
|
||||
currentUser.value = (await api.me()).user;
|
||||
} catch {
|
||||
currentUser.value = null;
|
||||
}
|
||||
}
|
||||
await loadHabitatDetail();
|
||||
});
|
||||
|
||||
@@ -190,7 +199,7 @@ watch(
|
||||
<PageHeader :title="habitat.name" :subtitle="t('pages.habitats.detailSubtitle')">
|
||||
<template #kicker>{{ t('pages.habitats.detailKicker') }}</template>
|
||||
<template #actions>
|
||||
<RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/habitats/${habitat.id}/edit`">
|
||||
<RouterLink v-if="canUpdateHabitat" class="ui-button ui-button--primary ui-button--small" :to="`/habitats/${habitat.id}/edit`">
|
||||
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('common.edit') }}
|
||||
</RouterLink>
|
||||
|
||||
@@ -13,6 +13,8 @@ import TranslationFields from '../components/TranslationFields.vue';
|
||||
import { iconAdd, iconCancel, iconDelete, iconPokemon, iconSave } from '../icons';
|
||||
import {
|
||||
api,
|
||||
getAuthToken,
|
||||
type AuthUser,
|
||||
type ConfigType,
|
||||
type EntityImage,
|
||||
type EntityImageUpload,
|
||||
@@ -40,6 +42,7 @@ const options = ref<Options | null>(null);
|
||||
const itemRows = ref<Item[]>([]);
|
||||
const pokemonRows = ref<Pokemon[]>([]);
|
||||
const languages = ref<Language[]>([]);
|
||||
const currentUser = ref<AuthUser | null>(null);
|
||||
const currentImage = ref<EntityImage | null>(null);
|
||||
const imageHistory = ref<EntityImageUpload[]>([]);
|
||||
const loading = ref(true);
|
||||
@@ -81,6 +84,8 @@ const pageTitle = computed(() =>
|
||||
);
|
||||
const cancelTo = computed(() => (isEditing.value ? `/habitats/${routeId.value}` : '/habitats'));
|
||||
const imageEntityName = computed(() => habitatNameForSave().trim());
|
||||
const canCreateConfig = computed(() => currentUser.value?.permissions.includes('admin.config.create') === true);
|
||||
const canUploadImage = computed(() => currentUser.value?.permissions.includes('habitats.upload') === true);
|
||||
|
||||
function toIds(values: string[]): number[] {
|
||||
return values.map(Number).filter((item) => Number.isInteger(item) && item > 0);
|
||||
@@ -146,12 +151,26 @@ function habitatNameForSave() {
|
||||
return habitatForm.value.translations[String(locale.value || '')]?.name ?? '';
|
||||
}
|
||||
|
||||
async function loadCurrentUser() {
|
||||
if (!getAuthToken()) {
|
||||
currentUser.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
currentUser.value = (await api.me()).user;
|
||||
} catch {
|
||||
currentUser.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEditor() {
|
||||
loading.value = true;
|
||||
message.value = '';
|
||||
|
||||
try {
|
||||
const [loadedOptions, loadedItems, loadedPokemon, loadedLanguages] = await Promise.all([
|
||||
const [, loadedOptions, loadedItems, loadedPokemon, loadedLanguages] = await Promise.all([
|
||||
loadCurrentUser(),
|
||||
api.options(),
|
||||
api.items({}),
|
||||
api.pokemon({}),
|
||||
@@ -188,7 +207,7 @@ async function loadOptions() {
|
||||
|
||||
async function createMultiOption(selectKey: string, type: ConfigType, name: string, values: string[]) {
|
||||
const cleanName = name.trim();
|
||||
if (!cleanName) return;
|
||||
if (!cleanName || !canCreateConfig.value) return;
|
||||
|
||||
creatingSelect.value = selectKey;
|
||||
message.value = '';
|
||||
@@ -274,6 +293,7 @@ onMounted(() => {
|
||||
:current-image="currentImage"
|
||||
:history="imageHistory"
|
||||
:disabled="busy"
|
||||
:allow-upload="canUploadImage"
|
||||
@selected="handleImageSelected"
|
||||
@uploaded="handleImageUploaded"
|
||||
@error="message = $event"
|
||||
@@ -341,7 +361,7 @@ onMounted(() => {
|
||||
:id="`appearance-maps-${index}`"
|
||||
v-model="row.mapIds"
|
||||
:options="options.maps"
|
||||
allow-create
|
||||
:allow-create="canCreateConfig"
|
||||
:creating="creatingSelect === `appearance-maps-${index}`"
|
||||
:placeholder="t('pages.habitats.searchMaps')"
|
||||
@create="createMultiOption(`appearance-maps-${index}`, 'maps', $event, row.mapIds)"
|
||||
|
||||
@@ -7,21 +7,30 @@ import EntityCard from '../components/EntityCard.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
import { iconAdd, iconHabitat } from '../icons';
|
||||
import { api, type Habitat } from '../services/api';
|
||||
import { api, getAuthToken, type AuthUser, type Habitat } from '../services/api';
|
||||
import HabitatEdit from './HabitatEdit.vue';
|
||||
|
||||
const habitats = ref<Habitat[]>([]);
|
||||
const currentUser = ref<AuthUser | null>(null);
|
||||
const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
const loading = ref(true);
|
||||
const skeletonCardCount = 6;
|
||||
const showEditor = computed(() => route.name === 'habitat-new');
|
||||
const canCreateHabitat = computed(() => currentUser.value?.permissions.includes('habitats.create') === true);
|
||||
|
||||
function habitatCardImage(item: Habitat) {
|
||||
return item.image ? { src: item.image.url, alt: t('media.imageAlt', { name: item.name }) } : undefined;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (getAuthToken()) {
|
||||
try {
|
||||
currentUser.value = (await api.me()).user;
|
||||
} catch {
|
||||
currentUser.value = null;
|
||||
}
|
||||
}
|
||||
habitats.value = await api.habitats();
|
||||
loading.value = false;
|
||||
});
|
||||
@@ -32,7 +41,7 @@ onMounted(async () => {
|
||||
<PageHeader :title="t('pages.habitats.title')" :subtitle="t('pages.habitats.subtitle')">
|
||||
<template #kicker>Habitats</template>
|
||||
<template #actions>
|
||||
<RouterLink class="ui-button ui-button--primary ui-button--small" to="/habitats/new">
|
||||
<RouterLink v-if="canCreateHabitat" class="ui-button ui-button--primary ui-button--small" to="/habitats/new">
|
||||
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('common.add') }}
|
||||
</RouterLink>
|
||||
|
||||
@@ -12,14 +12,17 @@ import PokeBallMark from '../components/PokeBallMark.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||
import { iconAdd, iconBack, iconEdit, iconHabitat, iconItem } from '../icons';
|
||||
import { api, type ItemDetail } from '../services/api';
|
||||
import { api, getAuthToken, type AuthUser, type ItemDetail } from '../services/api';
|
||||
import ItemEdit from './ItemEdit.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
const item = ref<ItemDetail | null>(null);
|
||||
const currentUser = ref<AuthUser | null>(null);
|
||||
const detailTab = ref('details');
|
||||
const showEditor = computed(() => route.name === 'item-edit');
|
||||
const canUpdateItem = computed(() => currentUser.value?.permissions.includes('items.update') === true);
|
||||
const canCreateRecipe = computed(() => currentUser.value?.permissions.includes('recipes.create') === true);
|
||||
const detailTabs = computed<TabOption[]>(() => [
|
||||
{ value: 'details', label: t('common.details') },
|
||||
{ value: 'discussion', label: t('discussion.title') },
|
||||
@@ -50,6 +53,13 @@ async function loadItemDetail() {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (getAuthToken()) {
|
||||
try {
|
||||
currentUser.value = (await api.me()).user;
|
||||
} catch {
|
||||
currentUser.value = null;
|
||||
}
|
||||
}
|
||||
await loadItemDetail();
|
||||
});
|
||||
|
||||
@@ -129,7 +139,7 @@ watch(
|
||||
<PageHeader :title="item.name" :subtitle="itemSubtitle">
|
||||
<template #kicker>{{ t('pages.items.detailKicker') }}</template>
|
||||
<template #actions>
|
||||
<RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/items/${item.id}/edit`">
|
||||
<RouterLink v-if="canUpdateItem" class="ui-button ui-button--primary ui-button--small" :to="`/items/${item.id}/edit`">
|
||||
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('common.edit') }}
|
||||
</RouterLink>
|
||||
@@ -211,7 +221,7 @@ watch(
|
||||
<p v-else-if="item.noRecipe" class="meta-line">{{ t('pages.items.noRecipe') }}</p>
|
||||
<template v-else>
|
||||
<p class="meta-line">{{ t('common.none') }}</p>
|
||||
<RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/recipes/new?itemId=${item.id}`">
|
||||
<RouterLink v-if="canCreateRecipe" class="ui-button ui-button--primary ui-button--small" :to="`/recipes/new?itemId=${item.id}`">
|
||||
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('pages.items.createRecipe') }}
|
||||
</RouterLink>
|
||||
|
||||
@@ -10,13 +10,25 @@ import StatusMessage from '../components/StatusMessage.vue';
|
||||
import TagsSelect from '../components/TagsSelect.vue';
|
||||
import TranslationFields from '../components/TranslationFields.vue';
|
||||
import { iconCancel, iconSave } from '../icons';
|
||||
import { api, type ConfigType, type EntityImage, type EntityImageUpload, type ItemPayload, type Language, type Options, type TranslationMap } from '../services/api';
|
||||
import {
|
||||
api,
|
||||
getAuthToken,
|
||||
type AuthUser,
|
||||
type ConfigType,
|
||||
type EntityImage,
|
||||
type EntityImageUpload,
|
||||
type ItemPayload,
|
||||
type Language,
|
||||
type Options,
|
||||
type TranslationMap
|
||||
} from '../services/api';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { locale, t } = useI18n();
|
||||
const options = ref<Options | null>(null);
|
||||
const languages = ref<Language[]>([]);
|
||||
const currentUser = ref<AuthUser | null>(null);
|
||||
const currentImage = ref<EntityImage | null>(null);
|
||||
const imageHistory = ref<EntityImageUpload[]>([]);
|
||||
const loading = ref(true);
|
||||
@@ -48,6 +60,8 @@ const pageTitle = computed(() =>
|
||||
const cancelTo = computed(() => (isEditing.value ? `/items/${routeId.value}` : '/items'));
|
||||
const hasRecipe = ref(false);
|
||||
const imageEntityName = computed(() => itemNameForSave().trim());
|
||||
const canCreateConfig = computed(() => currentUser.value?.permissions.includes('admin.config.create') === true);
|
||||
const canUploadImage = computed(() => currentUser.value?.permissions.includes('items.upload') === true);
|
||||
|
||||
function toIds(values: string[]): number[] {
|
||||
return values.map(Number).filter((item) => Number.isInteger(item) && item > 0);
|
||||
@@ -76,12 +90,25 @@ async function loadOptions() {
|
||||
languages.value = loadedLanguages;
|
||||
}
|
||||
|
||||
async function loadCurrentUser() {
|
||||
if (!getAuthToken()) {
|
||||
currentUser.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
currentUser.value = (await api.me()).user;
|
||||
} catch {
|
||||
currentUser.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEditor() {
|
||||
loading.value = true;
|
||||
message.value = '';
|
||||
|
||||
try {
|
||||
await loadOptions();
|
||||
await Promise.all([loadCurrentUser(), loadOptions()]);
|
||||
if (isEditing.value) {
|
||||
const item = await api.itemDetail(routeId.value);
|
||||
itemForm.value = {
|
||||
@@ -111,7 +138,7 @@ async function loadEditor() {
|
||||
|
||||
async function createSingleOption(selectKey: string, type: ConfigType, name: string, assign: (value: string) => void) {
|
||||
const cleanName = name.trim();
|
||||
if (!cleanName) return;
|
||||
if (!cleanName || !canCreateConfig.value) return;
|
||||
|
||||
creatingSelect.value = selectKey;
|
||||
message.value = '';
|
||||
@@ -128,7 +155,7 @@ async function createSingleOption(selectKey: string, type: ConfigType, name: str
|
||||
|
||||
async function createMultiOption(selectKey: string, type: ConfigType, name: string, values: string[]) {
|
||||
const cleanName = name.trim();
|
||||
if (!cleanName) return;
|
||||
if (!cleanName || !canCreateConfig.value) return;
|
||||
|
||||
creatingSelect.value = selectKey;
|
||||
message.value = '';
|
||||
@@ -212,6 +239,7 @@ onMounted(() => {
|
||||
:current-image="currentImage"
|
||||
:history="imageHistory"
|
||||
:disabled="busy"
|
||||
:allow-upload="canUploadImage"
|
||||
@selected="handleImageSelected"
|
||||
@uploaded="handleImageUploaded"
|
||||
@error="message = $event"
|
||||
@@ -224,7 +252,7 @@ onMounted(() => {
|
||||
v-model="itemForm.categoryId"
|
||||
:options="options.itemCategories"
|
||||
:multiple="false"
|
||||
allow-create
|
||||
:allow-create="canCreateConfig"
|
||||
:creating="creatingSelect === 'item-category'"
|
||||
:placeholder="t('common.select')"
|
||||
:search-placeholder="t('pages.items.searchCategory')"
|
||||
@@ -239,7 +267,7 @@ onMounted(() => {
|
||||
v-model="itemForm.usageId"
|
||||
:options="options.itemUsages"
|
||||
:multiple="false"
|
||||
allow-create
|
||||
:allow-create="canCreateConfig"
|
||||
:creating="creatingSelect === 'item-usage'"
|
||||
:placeholder="t('common.none')"
|
||||
:search-placeholder="t('pages.items.searchUsage')"
|
||||
@@ -261,7 +289,7 @@ onMounted(() => {
|
||||
id="item-methods"
|
||||
v-model="itemForm.acquisitionMethodIds"
|
||||
:options="options.acquisitionMethods"
|
||||
allow-create
|
||||
:allow-create="canCreateConfig"
|
||||
:creating="creatingSelect === 'item-methods'"
|
||||
:placeholder="t('pages.items.searchMethods')"
|
||||
@create="createMultiOption('item-methods', 'acquisition-methods', $event, itemForm.acquisitionMethodIds)"
|
||||
@@ -274,7 +302,7 @@ onMounted(() => {
|
||||
id="item-tags"
|
||||
v-model="itemForm.tagIds"
|
||||
:options="options.itemTags"
|
||||
allow-create
|
||||
:allow-create="canCreateConfig"
|
||||
:creating="creatingSelect === 'item-tags'"
|
||||
:placeholder="t('pages.items.searchTags')"
|
||||
@create="createMultiOption('item-tags', 'favorite-things', $event, itemForm.tagIds)"
|
||||
|
||||
@@ -10,13 +10,14 @@ import Skeleton from '../components/Skeleton.vue';
|
||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||
import TagsSelect from '../components/TagsSelect.vue';
|
||||
import { iconAdd, iconItem } from '../icons';
|
||||
import { api, type Item, type Options } from '../services/api';
|
||||
import { api, getAuthToken, type AuthUser, type Item, type Options } from '../services/api';
|
||||
import ItemEdit from './ItemEdit.vue';
|
||||
|
||||
const options = ref<Options | null>(null);
|
||||
const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
const items = ref<Item[]>([]);
|
||||
const currentUser = ref<AuthUser | null>(null);
|
||||
const loading = ref(true);
|
||||
const search = ref('');
|
||||
const categoryId = ref('');
|
||||
@@ -39,6 +40,7 @@ const itemQuery = computed(() => ({
|
||||
tagIds: tagIds.value.join(',')
|
||||
}));
|
||||
const showEditor = computed(() => route.name === 'item-new');
|
||||
const canCreateItem = computed(() => currentUser.value?.permissions.includes('items.create') === true);
|
||||
|
||||
function itemCardImage(item: Item) {
|
||||
return item.image ? { src: item.image.url, alt: t('media.imageAlt', { name: item.name }) } : undefined;
|
||||
@@ -51,6 +53,13 @@ async function loadItems() {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (getAuthToken()) {
|
||||
try {
|
||||
currentUser.value = (await api.me()).user;
|
||||
} catch {
|
||||
currentUser.value = null;
|
||||
}
|
||||
}
|
||||
options.value = await api.options();
|
||||
await loadItems();
|
||||
});
|
||||
@@ -63,7 +72,7 @@ watch(itemQuery, loadItems);
|
||||
<PageHeader :title="t('pages.items.title')" :subtitle="t('pages.items.subtitle')">
|
||||
<template #kicker>Bag</template>
|
||||
<template #actions>
|
||||
<RouterLink class="ui-button ui-button--primary ui-button--small" to="/items/new">
|
||||
<RouterLink v-if="canCreateItem" class="ui-button ui-button--primary ui-button--small" to="/items/new">
|
||||
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('common.add') }}
|
||||
</RouterLink>
|
||||
|
||||
@@ -86,7 +86,13 @@ const reactionOptions = [
|
||||
{ type: 'thanks', icon: iconReactionThanks, labelKey: 'pages.life.reactionThanks' }
|
||||
] as const satisfies ReadonlyArray<{ type: LifeReactionType; icon: string; labelKey: string }>;
|
||||
|
||||
const canPost = computed(() => currentUser.value?.emailVerified === true);
|
||||
function can(permissionKey: string) {
|
||||
return currentUser.value?.permissions.includes(permissionKey) === true;
|
||||
}
|
||||
|
||||
const canPost = computed(() => can('life.posts.create'));
|
||||
const canComment = computed(() => can('life.comments.create'));
|
||||
const canReact = computed(() => can('life.reactions.set'));
|
||||
const charactersLeft = computed(() => Math.max(0, bodyMaxLength - body.value.length));
|
||||
const isEditing = computed(() => editingPostId.value !== null);
|
||||
const searchQuery = computed(() => submittedSearch.value.trim());
|
||||
@@ -303,11 +309,15 @@ async function submitPost() {
|
||||
}
|
||||
|
||||
function canManage(post: LifePost) {
|
||||
return currentUser.value?.id === post.author?.id;
|
||||
return (currentUser.value?.id === post.author?.id && can('life.posts.update')) || can('life.posts.update-any');
|
||||
}
|
||||
|
||||
function canDeletePost(post: LifePost) {
|
||||
return (currentUser.value?.id === post.author?.id && can('life.posts.delete')) || can('life.posts.delete-any');
|
||||
}
|
||||
|
||||
function canManageComment(comment: LifeComment) {
|
||||
return !comment.deleted && currentUser.value?.id === comment.author?.id;
|
||||
return !comment.deleted && ((currentUser.value?.id === comment.author?.id && can('life.comments.delete')) || can('life.comments.delete-any'));
|
||||
}
|
||||
|
||||
function commentKey(postId: number) {
|
||||
@@ -411,7 +421,7 @@ function clearReactionError(postId: number) {
|
||||
}
|
||||
|
||||
function canUseReactions() {
|
||||
return canPost.value && reactionBusyPostId.value === null;
|
||||
return canReact.value && reactionBusyPostId.value === null;
|
||||
}
|
||||
|
||||
function closeReactionPicker() {
|
||||
@@ -808,12 +818,13 @@ onUnmounted(() => {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="canManage(post)" class="life-post__actions">
|
||||
<button class="life-icon-button" type="button" :aria-label="t('pages.life.editPost')" @click="startEdit(post)">
|
||||
<div v-if="canManage(post) || canDeletePost(post)" class="life-post__actions">
|
||||
<button v-if="canManage(post)" class="life-icon-button" type="button" :aria-label="t('pages.life.editPost')" @click="startEdit(post)">
|
||||
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
|
||||
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.editPost') }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="canDeletePost(post)"
|
||||
class="life-icon-button life-icon-button--danger"
|
||||
type="button"
|
||||
:aria-label="t('pages.life.deletePost')"
|
||||
@@ -842,7 +853,7 @@ onUnmounted(() => {
|
||||
:aria-controls="`life-reactions-${post.id}`"
|
||||
:aria-expanded="reactionPickerPostId === post.id"
|
||||
:aria-label="reactionButtonLabel(post)"
|
||||
:disabled="!canPost || reactionBusyPostId !== null"
|
||||
:disabled="!canReact || reactionBusyPostId !== null"
|
||||
@click="toggleDefaultReaction(post)"
|
||||
@contextmenu="handleReactionContextMenu($event, post.id)"
|
||||
@keydown="handleReactionKeydown($event, post.id)"
|
||||
@@ -857,7 +868,7 @@ onUnmounted(() => {
|
||||
:aria-controls="`life-reactions-${post.id}`"
|
||||
:aria-expanded="reactionPickerPostId === post.id"
|
||||
:aria-label="t('pages.life.chooseReaction')"
|
||||
:disabled="!canPost || reactionBusyPostId !== null"
|
||||
:disabled="!canReact || reactionBusyPostId !== null"
|
||||
@click="toggleReactionPicker(post.id)"
|
||||
@contextmenu="handleReactionContextMenu($event, post.id)"
|
||||
@keydown="handleReactionKeydown($event, post.id)"
|
||||
@@ -868,7 +879,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="reactionPickerPostId === post.id && canPost"
|
||||
v-if="reactionPickerPostId === post.id && canReact"
|
||||
:id="`life-reactions-${post.id}`"
|
||||
class="life-reaction-picker"
|
||||
role="group"
|
||||
@@ -953,7 +964,7 @@ onUnmounted(() => {
|
||||
<span>{{ commentCount(post) }}</span>
|
||||
</div>
|
||||
|
||||
<form v-if="canPost" class="life-comment-form" @submit.prevent="submitComment(post)">
|
||||
<form v-if="canComment" class="life-comment-form" @submit.prevent="submitComment(post)">
|
||||
<div class="field">
|
||||
<label :for="`life-comment-${post.id}`">{{ t('pages.life.comment') }}</label>
|
||||
<textarea
|
||||
@@ -994,7 +1005,7 @@ onUnmounted(() => {
|
||||
|
||||
<div v-if="!comment.deleted" class="life-comment__actions">
|
||||
<button
|
||||
v-if="canPost"
|
||||
v-if="canComment"
|
||||
class="life-icon-button life-icon-button--flat"
|
||||
type="button"
|
||||
:aria-label="t('pages.life.reply')"
|
||||
@@ -1020,7 +1031,7 @@ onUnmounted(() => {
|
||||
</p>
|
||||
|
||||
<form
|
||||
v-if="canPost && replyTargetId === comment.id"
|
||||
v-if="canComment && replyTargetId === comment.id"
|
||||
class="life-comment-form life-comment-form--reply"
|
||||
@submit.prevent="submitReply(post, comment)"
|
||||
>
|
||||
|
||||
@@ -14,12 +14,13 @@ import PokemonStatsPanel from '../components/PokemonStatsPanel.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||
import { iconBack, iconEdit, iconHabitat, iconItem } from '../icons';
|
||||
import { api, type PokemonDetail } from '../services/api';
|
||||
import { api, getAuthToken, type AuthUser, type PokemonDetail } from '../services/api';
|
||||
import PokemonEdit from './PokemonEdit.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
const pokemon = ref<PokemonDetail | null>(null);
|
||||
const currentUser = ref<AuthUser | null>(null);
|
||||
const itemCategoryTab = ref('');
|
||||
const relatedHabitatTab = ref('');
|
||||
const detailTab = ref('details');
|
||||
@@ -118,6 +119,7 @@ const habitatRows = computed<HabitatRow[]>(() => {
|
||||
});
|
||||
const skillDropRows = computed(() => pokemon.value?.skills.filter((skill) => skill.itemDrop) ?? []);
|
||||
const showEditor = computed(() => route.name === 'pokemon-edit');
|
||||
const canUpdatePokemon = computed(() => currentUser.value?.permissions.includes('pokemon.update') === true);
|
||||
const detailTabs = computed<TabOption[]>(() => [
|
||||
{ value: 'details', label: t('common.details') },
|
||||
{ value: 'discussion', label: t('discussion.title') },
|
||||
@@ -222,6 +224,13 @@ async function loadPokemonDetail() {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (getAuthToken()) {
|
||||
try {
|
||||
currentUser.value = (await api.me()).user;
|
||||
} catch {
|
||||
currentUser.value = null;
|
||||
}
|
||||
}
|
||||
await loadPokemonDetail();
|
||||
});
|
||||
|
||||
@@ -307,7 +316,7 @@ watch(
|
||||
<PageHeader :title="`#${pokemon.displayId} ${pokemon.name}`" :subtitle="t('pages.pokemon.environmentPrefix', { name: pokemon.environment.name })">
|
||||
<template #kicker>Pokédex Detail</template>
|
||||
<template #actions>
|
||||
<RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/pokemon/${pokemon.id}/edit`">
|
||||
<RouterLink v-if="canUpdatePokemon" class="ui-button ui-button--primary ui-button--small" :to="`/pokemon/${pokemon.id}/edit`">
|
||||
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('common.edit') }}
|
||||
</RouterLink>
|
||||
|
||||
@@ -14,6 +14,8 @@ import TranslationFields from '../components/TranslationFields.vue';
|
||||
import { iconCancel, iconSave, iconSearch } from '../icons';
|
||||
import {
|
||||
api,
|
||||
getAuthToken,
|
||||
type AuthUser,
|
||||
type ConfigType,
|
||||
type EntityImage,
|
||||
type EntityImageUpload,
|
||||
@@ -39,6 +41,7 @@ const { locale, t } = useI18n();
|
||||
const options = ref<Options | null>(null);
|
||||
const itemOptions = ref<NamedEntity[]>([]);
|
||||
const languages = ref<Language[]>([]);
|
||||
const currentUser = ref<AuthUser | null>(null);
|
||||
const loading = ref(true);
|
||||
const busy = ref(false);
|
||||
const fetchBusy = ref(false);
|
||||
@@ -125,6 +128,9 @@ const displayedImageOptions = computed(() => {
|
||||
return [selectedImage, ...imageOptions.value];
|
||||
});
|
||||
const selectedUploadImage = computed(() => (selectedPokemonImage.value?.source === 'upload' ? selectedPokemonImage.value : null));
|
||||
const canCreateConfig = computed(() => currentUser.value?.permissions.includes('admin.config.create') === true);
|
||||
const canFetchPokemon = computed(() => currentUser.value?.permissions.includes('pokemon.fetch') === true);
|
||||
const canUploadImage = computed(() => currentUser.value?.permissions.includes('pokemon.upload') === true);
|
||||
|
||||
function toIds(values: string[]): number[] {
|
||||
return values.map(Number).filter((item) => Number.isInteger(item) && item > 0);
|
||||
@@ -171,6 +177,19 @@ async function loadOptions() {
|
||||
languages.value = loadedLanguages;
|
||||
}
|
||||
|
||||
async function loadCurrentUser() {
|
||||
if (!getAuthToken()) {
|
||||
currentUser.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
currentUser.value = (await api.me()).user;
|
||||
} catch {
|
||||
currentUser.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function syncSkillItemDrops() {
|
||||
const selectedSkillIds = new Set(pokemonForm.value.skillIds);
|
||||
const rows = pokemonForm.value.skillItemDrops.filter((row) => selectedSkillIds.has(row.skillId) && skillSupportsItemDrop(row.skillId));
|
||||
@@ -270,7 +289,7 @@ async function loadEditor() {
|
||||
message.value = '';
|
||||
|
||||
try {
|
||||
await loadOptions();
|
||||
await Promise.all([loadCurrentUser(), loadOptions()]);
|
||||
if (isEditing.value) {
|
||||
const pokemon = await api.pokemonDetail(routeId.value);
|
||||
pokemonForm.value = {
|
||||
@@ -316,6 +335,10 @@ function fetchOptionLabel(option: PokemonFetchOption) {
|
||||
}
|
||||
|
||||
async function loadFetchOptions() {
|
||||
if (!canFetchPokemon.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
cancelFetchOptionsRequest();
|
||||
const controller = new AbortController();
|
||||
fetchOptionsController = controller;
|
||||
@@ -351,6 +374,10 @@ function refreshFetchOptions() {
|
||||
}
|
||||
|
||||
function openFetchOptions() {
|
||||
if (!canFetchPokemon.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetchOptionsOpen.value = true;
|
||||
refreshFetchOptions();
|
||||
}
|
||||
@@ -361,6 +388,10 @@ function closeFetchOptions() {
|
||||
}
|
||||
|
||||
function handleFetchIdentifierInput() {
|
||||
if (!canFetchPokemon.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetchOptionsOpen.value = true;
|
||||
}
|
||||
|
||||
@@ -375,6 +406,10 @@ async function selectFetchOption(option: PokemonFetchOption) {
|
||||
}
|
||||
|
||||
async function fetchPokemonByIdentifier(identifierValue?: string) {
|
||||
if (!canFetchPokemon.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const identifier = (identifierValue ?? fetchIdentifier.value).trim() || pokemonForm.value.id.trim();
|
||||
if (!identifier) {
|
||||
message.value = t('pages.pokemon.fetchIdentifierRequired');
|
||||
@@ -446,6 +481,10 @@ function handleUploadImageUploaded(image: EntityImageUpload) {
|
||||
}
|
||||
|
||||
async function fetchPokemonImages() {
|
||||
if (!canFetchPokemon.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const identifier = fetchIdentifier.value.trim() || pokemonForm.value.id.trim();
|
||||
if (!identifier) {
|
||||
message.value = t('pages.pokemon.fetchIdentifierRequired');
|
||||
@@ -487,7 +526,7 @@ function fetchPokemonImagesFromInput() {
|
||||
|
||||
async function createSingleOption(selectKey: string, type: ConfigType, name: string, assign: (value: string) => void) {
|
||||
const cleanName = name.trim();
|
||||
if (!cleanName) return;
|
||||
if (!cleanName || !canCreateConfig.value) return;
|
||||
|
||||
creatingSelect.value = selectKey;
|
||||
message.value = '';
|
||||
@@ -504,7 +543,7 @@ async function createSingleOption(selectKey: string, type: ConfigType, name: str
|
||||
|
||||
async function createMultiOption(selectKey: string, type: ConfigType, name: string, values: string[], max = 0) {
|
||||
const cleanName = name.trim();
|
||||
if (!cleanName || (max > 0 && values.length >= max)) return;
|
||||
if (!cleanName || !canCreateConfig.value || (max > 0 && values.length >= max)) return;
|
||||
|
||||
creatingSelect.value = selectKey;
|
||||
message.value = '';
|
||||
@@ -581,7 +620,7 @@ watch(fetchIdentifier, refreshFetchOptions);
|
||||
<form v-if="!loading && options" id="pokemon-edit-form" class="modal-edit-form modal-edit-form--tabbed pokemon-edit-form" @submit.prevent="savePokemon">
|
||||
<Tabs id="pokemon-edit-tabs" v-model="activeEditTab" :tabs="editTabs" :label="t('pages.pokemon.editSections')" />
|
||||
|
||||
<div class="pokemon-fetch-panel" :aria-label="t('pages.pokemon.fetchData')">
|
||||
<div v-if="canFetchPokemon" class="pokemon-fetch-panel" :aria-label="t('pages.pokemon.fetchData')">
|
||||
<div class="field pokemon-fetch-panel__input">
|
||||
<label for="pokemon-fetch-identifier">{{ t('pages.pokemon.fetchIdentifier') }}</label>
|
||||
<input
|
||||
@@ -660,7 +699,7 @@ watch(fetchIdentifier, refreshFetchOptions);
|
||||
v-model="pokemonForm.environmentId"
|
||||
:options="options.environments"
|
||||
:multiple="false"
|
||||
allow-create
|
||||
:allow-create="canCreateConfig"
|
||||
:creating="creatingSelect === 'pokemon-environment'"
|
||||
:placeholder="t('common.select')"
|
||||
:search-placeholder="t('pages.pokemon.searchEnvironment')"
|
||||
@@ -675,7 +714,7 @@ watch(fetchIdentifier, refreshFetchOptions);
|
||||
v-model="pokemonForm.skillIds"
|
||||
:options="options.skills"
|
||||
:max="2"
|
||||
allow-create
|
||||
:allow-create="canCreateConfig"
|
||||
:creating="creatingSelect === 'pokemon-skills'"
|
||||
:placeholder="t('pages.pokemon.searchSkills')"
|
||||
@create="createMultiOption('pokemon-skills', 'skills', $event, pokemonForm.skillIds, 2)"
|
||||
@@ -690,7 +729,7 @@ watch(fetchIdentifier, refreshFetchOptions);
|
||||
v-model="pokemonForm.favoriteThingIds"
|
||||
:options="options.favoriteThings"
|
||||
:max="6"
|
||||
allow-create
|
||||
:allow-create="canCreateConfig"
|
||||
:creating="creatingSelect === 'pokemon-things'"
|
||||
:placeholder="t('pages.pokemon.searchFavoriteThings')"
|
||||
@create="createMultiOption('pokemon-things', 'favorite-things', $event, pokemonForm.favoriteThingIds, 6)"
|
||||
@@ -764,6 +803,7 @@ watch(fetchIdentifier, refreshFetchOptions);
|
||||
:current-image="selectedUploadImage"
|
||||
:history="imageHistory"
|
||||
:disabled="busy || imageBusy"
|
||||
:allow-upload="canUploadImage"
|
||||
:show-preview="false"
|
||||
@selected="handleUploadImageSelected"
|
||||
@uploaded="handleUploadImageUploaded"
|
||||
|
||||
@@ -9,13 +9,14 @@ import PageHeader from '../components/PageHeader.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
import TagsSelect from '../components/TagsSelect.vue';
|
||||
import { iconAdd } from '../icons';
|
||||
import { api, type Options, type Pokemon } from '../services/api';
|
||||
import { api, getAuthToken, type AuthUser, type Options, type Pokemon } from '../services/api';
|
||||
import PokemonEdit from './PokemonEdit.vue';
|
||||
|
||||
const options = ref<Options | null>(null);
|
||||
const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
const pokemon = ref<Pokemon[]>([]);
|
||||
const currentUser = ref<AuthUser | null>(null);
|
||||
const loading = ref(true);
|
||||
const search = ref('');
|
||||
const environmentId = ref('');
|
||||
@@ -35,6 +36,7 @@ const query = computed(() => ({
|
||||
favoriteThingMode: favoriteThingMode.value
|
||||
}));
|
||||
const showEditor = computed(() => route.name === 'pokemon-new');
|
||||
const canCreatePokemon = computed(() => currentUser.value?.permissions.includes('pokemon.create') === true);
|
||||
|
||||
async function loadPokemon() {
|
||||
loading.value = true;
|
||||
@@ -47,6 +49,13 @@ function pokemonCardImage(item: Pokemon) {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (getAuthToken()) {
|
||||
try {
|
||||
currentUser.value = (await api.me()).user;
|
||||
} catch {
|
||||
currentUser.value = null;
|
||||
}
|
||||
}
|
||||
options.value = await api.options();
|
||||
await loadPokemon();
|
||||
});
|
||||
@@ -59,7 +68,7 @@ watch(query, loadPokemon);
|
||||
<PageHeader :title="t('pages.pokemon.title')" :subtitle="t('pages.pokemon.subtitle')">
|
||||
<template #kicker>Pokédex</template>
|
||||
<template #actions>
|
||||
<RouterLink class="ui-button ui-button--primary ui-button--small" to="/pokemon/new">
|
||||
<RouterLink v-if="canCreatePokemon" class="ui-button ui-button--primary ui-button--small" to="/pokemon/new">
|
||||
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('common.add') }}
|
||||
</RouterLink>
|
||||
|
||||
@@ -11,14 +11,16 @@ import PageHeader from '../components/PageHeader.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||
import { iconBack, iconEdit, iconRecipe } from '../icons';
|
||||
import { api, type RecipeDetail } from '../services/api';
|
||||
import { api, getAuthToken, type AuthUser, type RecipeDetail } from '../services/api';
|
||||
import RecipeEdit from './RecipeEdit.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
const recipe = ref<RecipeDetail | null>(null);
|
||||
const currentUser = ref<AuthUser | null>(null);
|
||||
const detailTab = ref('details');
|
||||
const showEditor = computed(() => route.name === 'recipe-edit');
|
||||
const canUpdateRecipe = computed(() => currentUser.value?.permissions.includes('recipes.update') === true);
|
||||
const detailTabs = computed<TabOption[]>(() => [
|
||||
{ value: 'details', label: t('common.details') },
|
||||
{ value: 'discussion', label: t('discussion.title') },
|
||||
@@ -44,6 +46,13 @@ async function loadRecipeDetail() {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (getAuthToken()) {
|
||||
try {
|
||||
currentUser.value = (await api.me()).user;
|
||||
} catch {
|
||||
currentUser.value = null;
|
||||
}
|
||||
}
|
||||
await loadRecipeDetail();
|
||||
});
|
||||
|
||||
@@ -97,7 +106,7 @@ watch(
|
||||
<PageHeader :title="recipe.name" :subtitle="recipeSubtitle">
|
||||
<template #kicker>{{ t('pages.recipes.detailKicker') }}</template>
|
||||
<template #actions>
|
||||
<RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/recipes/${recipe.id}/edit`">
|
||||
<RouterLink v-if="canUpdateRecipe" class="ui-button ui-button--primary ui-button--small" :to="`/recipes/${recipe.id}/edit`">
|
||||
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('common.edit') }}
|
||||
</RouterLink>
|
||||
|
||||
@@ -8,13 +8,14 @@ import Skeleton from '../components/Skeleton.vue';
|
||||
import StatusMessage from '../components/StatusMessage.vue';
|
||||
import TagsSelect from '../components/TagsSelect.vue';
|
||||
import { iconAdd, iconCancel, iconDelete, iconSave } from '../icons';
|
||||
import { api, type ConfigType, type Item, type Options, type RecipePayload } from '../services/api';
|
||||
import { api, getAuthToken, type AuthUser, type ConfigType, type Item, type Options, type RecipePayload } from '../services/api';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const options = ref<Options | null>(null);
|
||||
const itemRows = ref<Item[]>([]);
|
||||
const currentUser = ref<AuthUser | null>(null);
|
||||
const loading = ref(true);
|
||||
const busy = ref(false);
|
||||
const message = ref('');
|
||||
@@ -40,6 +41,7 @@ const pageTitle = computed(() =>
|
||||
: t('pages.recipes.newTitle')
|
||||
);
|
||||
const cancelTo = computed(() => (isEditing.value ? `/recipes/${routeId.value}` : '/recipes'));
|
||||
const canCreateConfig = computed(() => currentUser.value?.permissions.includes('admin.config.create') === true);
|
||||
|
||||
function toIds(values: string[]): number[] {
|
||||
return values.map(Number).filter((item) => Number.isInteger(item) && item > 0);
|
||||
@@ -73,7 +75,7 @@ async function loadEditor() {
|
||||
message.value = '';
|
||||
|
||||
try {
|
||||
const [loadedOptions, loadedItems] = await Promise.all([api.options(), api.items({})]);
|
||||
const [, loadedOptions, loadedItems] = await Promise.all([loadCurrentUser(), api.options(), api.items({})]);
|
||||
options.value = loadedOptions;
|
||||
itemRows.value = loadedItems;
|
||||
|
||||
@@ -102,9 +104,22 @@ async function loadOptions() {
|
||||
options.value = await api.options();
|
||||
}
|
||||
|
||||
async function loadCurrentUser() {
|
||||
if (!getAuthToken()) {
|
||||
currentUser.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
currentUser.value = (await api.me()).user;
|
||||
} catch {
|
||||
currentUser.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function createMultiOption(selectKey: string, type: ConfigType, name: string, values: string[]) {
|
||||
const cleanName = name.trim();
|
||||
if (!cleanName) return;
|
||||
if (!cleanName || !canCreateConfig.value) return;
|
||||
|
||||
creatingSelect.value = selectKey;
|
||||
message.value = '';
|
||||
@@ -169,7 +184,7 @@ onMounted(() => {
|
||||
id="recipe-methods"
|
||||
v-model="recipeForm.acquisitionMethodIds"
|
||||
:options="options.acquisitionMethods"
|
||||
allow-create
|
||||
:allow-create="canCreateConfig"
|
||||
:creating="creatingSelect === 'recipe-methods'"
|
||||
:placeholder="t('pages.items.searchMethods')"
|
||||
@create="createMultiOption('recipe-methods', 'acquisition-methods', $event, recipeForm.acquisitionMethodIds)"
|
||||
|
||||
@@ -10,13 +10,14 @@ import Skeleton from '../components/Skeleton.vue';
|
||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||
import TagsSelect from '../components/TagsSelect.vue';
|
||||
import { iconAdd, iconNoRecipe, iconRecipe } from '../icons';
|
||||
import { api, type Item, type Options } from '../services/api';
|
||||
import { api, getAuthToken, type AuthUser, type Item, type Options } from '../services/api';
|
||||
import RecipeEdit from './RecipeEdit.vue';
|
||||
|
||||
const options = ref<Options | null>(null);
|
||||
const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
const items = ref<Item[]>([]);
|
||||
const currentUser = ref<AuthUser | null>(null);
|
||||
const loading = ref(true);
|
||||
const search = ref('');
|
||||
const categoryId = ref('');
|
||||
@@ -40,6 +41,7 @@ const itemQuery = computed(() => ({
|
||||
recipeOrder: 1
|
||||
}));
|
||||
const showEditor = computed(() => route.name === 'recipe-new');
|
||||
const canCreateRecipe = computed(() => currentUser.value?.permissions.includes('recipes.create') === true);
|
||||
|
||||
function recipeTarget(item: Item) {
|
||||
return item.recipe ? `/recipes/${item.recipe.id}` : undefined;
|
||||
@@ -68,6 +70,13 @@ async function loadItems() {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (getAuthToken()) {
|
||||
try {
|
||||
currentUser.value = (await api.me()).user;
|
||||
} catch {
|
||||
currentUser.value = null;
|
||||
}
|
||||
}
|
||||
options.value = await api.options();
|
||||
await loadItems();
|
||||
});
|
||||
@@ -80,7 +89,7 @@ watch(itemQuery, loadItems);
|
||||
<PageHeader :title="t('pages.recipes.title')" :subtitle="t('pages.recipes.subtitle')">
|
||||
<template #kicker>Recipes</template>
|
||||
<template #actions>
|
||||
<RouterLink class="ui-button ui-button--primary ui-button--small" to="/recipes/new">
|
||||
<RouterLink v-if="canCreateRecipe" class="ui-button ui-button--primary ui-button--small" to="/recipes/new">
|
||||
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('common.add') }}
|
||||
</RouterLink>
|
||||
@@ -172,7 +181,7 @@ watch(itemQuery, loadItems);
|
||||
{{ t('pages.items.createRecipe') }}
|
||||
</button>
|
||||
<RouterLink
|
||||
v-else
|
||||
v-else-if="canCreateRecipe"
|
||||
class="ui-button ui-button--primary ui-button--small catalog-card-action"
|
||||
:to="createRecipeTarget(item)"
|
||||
>
|
||||
|
||||
@@ -110,7 +110,8 @@ export const systemWordingMessages = {
|
||||
loadFailed: 'Load failed',
|
||||
addFailed: 'Add failed',
|
||||
saveFailed: 'Save failed',
|
||||
completeEmailVerification: 'Please complete email verification first.'
|
||||
completeEmailVerification: 'Please complete email verification first.',
|
||||
permissionDenied: 'You do not have permission to use this action.'
|
||||
},
|
||||
pages: {
|
||||
profile: {
|
||||
@@ -445,6 +446,9 @@ export const systemWordingMessages = {
|
||||
subtitle: 'Maintain system configuration and manage Wiki records.',
|
||||
modules: 'Admin modules',
|
||||
loading: 'Loading admin list',
|
||||
users: 'Users',
|
||||
roles: 'Roles',
|
||||
permissions: 'Permissions',
|
||||
config: 'System config',
|
||||
configType: 'System config type',
|
||||
checklist: 'CheckList',
|
||||
@@ -480,7 +484,26 @@ export const systemWordingMessages = {
|
||||
surfaceFrontend: 'Frontend',
|
||||
surfaceBackend: 'Backend',
|
||||
surfaceEmail: 'Email',
|
||||
editWording: 'Edit wording'
|
||||
editWording: 'Edit wording',
|
||||
userRoles: 'User roles',
|
||||
noRoles: 'No roles',
|
||||
newRole: 'New role',
|
||||
editRole: 'Edit role',
|
||||
roleKey: 'Role key',
|
||||
roleName: 'Role name',
|
||||
description: 'Description',
|
||||
level: 'Level',
|
||||
disabled: 'Disabled',
|
||||
systemRole: 'System role',
|
||||
roleLevel: 'Level {level}',
|
||||
permissionCount: '{count} permissions',
|
||||
rolePermissions: 'Role permissions',
|
||||
newPermission: 'New permission',
|
||||
editPermission: 'Edit permission',
|
||||
permissionKey: 'Permission key',
|
||||
permissionName: 'Permission name',
|
||||
category: 'Category',
|
||||
systemPermission: 'System permission'
|
||||
}
|
||||
},
|
||||
config: {
|
||||
@@ -574,6 +597,7 @@ export const systemWordingMessages = {
|
||||
serverError: 'Server error',
|
||||
loginRequired: 'Please log in first',
|
||||
verifyEmailFirst: 'Please complete email verification first',
|
||||
permissionDenied: 'Permission denied',
|
||||
notFound: 'Not found'
|
||||
},
|
||||
auth: {
|
||||
@@ -656,6 +680,20 @@ export const systemWordingMessages = {
|
||||
localeRequired: 'Locale is required',
|
||||
valueRequired: 'Wording is required',
|
||||
placeholderMismatch: 'Placeholders must match the default wording'
|
||||
},
|
||||
permissions: {
|
||||
nameRequired: 'Name is required',
|
||||
valueTooLong: 'Value is too long',
|
||||
invalidSelection: 'Selection is invalid',
|
||||
roleKeyInvalid: 'Role key is invalid',
|
||||
roleNotFound: 'Role not found',
|
||||
ownerRequired: 'At least one Owner is required',
|
||||
ownerRoleLocked: 'Owner role permissions cannot be edited',
|
||||
permissionKeyInvalid: 'Permission key is invalid',
|
||||
permissionNotFound: 'Permission not found',
|
||||
criticalPermissionRequired: 'Critical administration permissions must remain enabled',
|
||||
permissionManagerRequired: 'At least one verified user must be able to manage permissions',
|
||||
userNotFound: 'User not found'
|
||||
}
|
||||
},
|
||||
email: {
|
||||
@@ -776,7 +814,8 @@ export const systemWordingMessages = {
|
||||
loadFailed: '加载失败',
|
||||
addFailed: '添加失败',
|
||||
saveFailed: '保存失败',
|
||||
completeEmailVerification: '请先完成邮箱验证'
|
||||
completeEmailVerification: '请先完成邮箱验证',
|
||||
permissionDenied: '你没有权限执行这个操作'
|
||||
},
|
||||
pages: {
|
||||
profile: {
|
||||
@@ -1111,6 +1150,9 @@ export const systemWordingMessages = {
|
||||
subtitle: '维护系统配置,查看并删除 Wiki 数据记录。',
|
||||
modules: '管理模块',
|
||||
loading: '正在加载管理列表',
|
||||
users: '用户',
|
||||
roles: '角色',
|
||||
permissions: '权限',
|
||||
config: '系统配置',
|
||||
configType: '系统配置类型',
|
||||
checklist: 'CheckList',
|
||||
@@ -1146,7 +1188,26 @@ export const systemWordingMessages = {
|
||||
surfaceFrontend: '前端',
|
||||
surfaceBackend: '后端',
|
||||
surfaceEmail: '邮件',
|
||||
editWording: '编辑文案'
|
||||
editWording: '编辑文案',
|
||||
userRoles: '用户角色',
|
||||
noRoles: '无角色',
|
||||
newRole: '新增角色',
|
||||
editRole: '编辑角色',
|
||||
roleKey: '角色 Key',
|
||||
roleName: '角色名称',
|
||||
description: '说明',
|
||||
level: '层级',
|
||||
disabled: '停用',
|
||||
systemRole: '系统角色',
|
||||
roleLevel: '层级 {level}',
|
||||
permissionCount: '{count} 个权限',
|
||||
rolePermissions: '角色权限',
|
||||
newPermission: '新增权限',
|
||||
editPermission: '编辑权限',
|
||||
permissionKey: '权限 Key',
|
||||
permissionName: '权限名称',
|
||||
category: '分类',
|
||||
systemPermission: '系统权限'
|
||||
}
|
||||
},
|
||||
config: {
|
||||
@@ -1240,6 +1301,7 @@ export const systemWordingMessages = {
|
||||
serverError: '服务器错误',
|
||||
loginRequired: '请先登录',
|
||||
verifyEmailFirst: '请先完成邮箱验证',
|
||||
permissionDenied: '权限不足',
|
||||
notFound: '未找到记录'
|
||||
},
|
||||
auth: {
|
||||
@@ -1322,6 +1384,20 @@ export const systemWordingMessages = {
|
||||
localeRequired: '请选择语言',
|
||||
valueRequired: '请输入文案',
|
||||
placeholderMismatch: '占位符必须与默认文案一致'
|
||||
},
|
||||
permissions: {
|
||||
nameRequired: '请输入名称',
|
||||
valueTooLong: '内容过长',
|
||||
invalidSelection: '选择项不合法',
|
||||
roleKeyInvalid: '角色 Key 不合法',
|
||||
roleNotFound: '角色不存在',
|
||||
ownerRequired: '必须至少保留一个 Owner',
|
||||
ownerRoleLocked: 'Owner 角色权限不能编辑',
|
||||
permissionKeyInvalid: '权限 Key 不合法',
|
||||
permissionNotFound: '权限不存在',
|
||||
criticalPermissionRequired: '关键管理权限必须保持启用',
|
||||
permissionManagerRequired: '必须至少保留一个可管理权限的已验证用户',
|
||||
userNotFound: '用户不存在'
|
||||
}
|
||||
},
|
||||
email: {
|
||||
|
||||
Reference in New Issue
Block a user