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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user