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:
2026-05-03 11:16:58 +08:00
parent 05898f9441
commit 05f531ddf2
26 changed files with 2384 additions and 228 deletions

View File

@@ -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,