diff --git a/migrations/20260330120000_add_rbac_tables.ts b/migrations/20260330120000_add_rbac_tables.ts new file mode 100644 index 00000000..6f6ff935 --- /dev/null +++ b/migrations/20260330120000_add_rbac_tables.ts @@ -0,0 +1,58 @@ +import type { Knex } from "knex"; + +export async function up(knex: Knex): Promise { + // 1. Roles table + if (!(await knex.schema.hasTable("roles"))) { + await knex.schema.createTable("roles", (table) => { + table.string("id", 100).primary(); + table.text("role_name").notNullable(); + table.integer("readonly").notNullable().defaultTo(0); + table.string("status", 20).notNullable().defaultTo("ACTIVE"); + table.timestamp("created_at").defaultTo(knex.fn.now()); + table.timestamp("updated_at").defaultTo(knex.fn.now()); + }); + } + + // 2. Permissions table + if (!(await knex.schema.hasTable("permissions"))) { + await knex.schema.createTable("permissions", (table) => { + table.string("id", 100).primary(); + table.text("permission_name").notNullable(); + table.timestamp("created_at").defaultTo(knex.fn.now()); + table.timestamp("updated_at").defaultTo(knex.fn.now()); + }); + } + + // 3. Roles ↔ Permissions junction table + if (!(await knex.schema.hasTable("roles_permissions"))) { + await knex.schema.createTable("roles_permissions", (table) => { + table.string("roles_id", 100).notNullable().references("id").inTable("roles").onDelete("CASCADE"); + table.string("permissions_id", 100).notNullable().references("id").inTable("permissions").onDelete("CASCADE"); + table.string("status", 20).notNullable().defaultTo("ACTIVE"); + table.timestamp("created_at").defaultTo(knex.fn.now()); + table.timestamp("updated_at").defaultTo(knex.fn.now()); + + table.primary(["roles_id", "permissions_id"]); + }); + } + + // 4. Users ↔ Roles junction table + if (!(await knex.schema.hasTable("users_roles"))) { + await knex.schema.createTable("users_roles", (table) => { + table.string("roles_id", 100).notNullable().references("id").inTable("roles").onDelete("CASCADE"); + table.integer("users_id").unsigned().notNullable().references("id").inTable("users").onDelete("CASCADE"); + table.timestamp("created_at").defaultTo(knex.fn.now()); + table.timestamp("updated_at").defaultTo(knex.fn.now()); + + table.primary(["roles_id", "users_id"]); + table.index("users_id", "idx_users_roles_users_id"); + }); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists("users_roles"); + await knex.schema.dropTableIfExists("roles_permissions"); + await knex.schema.dropTableIfExists("permissions"); + await knex.schema.dropTableIfExists("roles"); +} diff --git a/migrations/20260331120000_remove_role_from_users.ts b/migrations/20260331120000_remove_role_from_users.ts new file mode 100644 index 00000000..0f174f13 --- /dev/null +++ b/migrations/20260331120000_remove_role_from_users.ts @@ -0,0 +1,94 @@ +import type { Knex } from "knex"; + +// Maps the legacy users.role string to the new roles.id value. +// The old default was "user"; everything unmapped falls back to "member". +const ROLE_MAP: Record = { + admin: "admin", + editor: "editor", + member: "member", + user: "member", +}; + +export async function up(knex: Knex): Promise { + const hasColumn = await knex.schema.hasColumn("users", "role"); + if (!hasColumn) return; + + // 1. Ensure the three target roles exist so FK inserts succeed. + // Seeds will reconcile permissions later; we only need the rows. + const rolesToEnsure = [ + { id: "admin", role_name: "Administrator" }, + { id: "editor", role_name: "Editor" }, + { id: "member", role_name: "Member" }, + ]; + for (const role of rolesToEnsure) { + const exists = await knex("roles").where("id", role.id).first(); + if (!exists) { + await knex("roles").insert({ + id: role.id, + role_name: role.role_name, + readonly: 1, + status: "ACTIVE", + created_at: knex.fn.now(), + updated_at: knex.fn.now(), + }); + } + } + + // 2. Read users.role into memory BEFORE dropping the column. + // On SQLite, dropColumn recreates the table (create → copy → drop → rename), + // which can discard DML inserts to tables with FKs pointing at users. + const users: Array<{ id: number; role: string }> = await knex("users").select("id", "role"); + + // 3. Drop the column first. + await knex.schema.alterTable("users", (table) => { + table.dropColumn("role"); + }); + + // 4. Now populate users_roles from the in-memory snapshot. + for (const user of users) { + const newRoleId = ROLE_MAP[user.role] ?? "member"; + + const alreadyAssigned = await knex("users_roles").where({ roles_id: newRoleId, users_id: user.id }).first(); + + if (!alreadyAssigned) { + await knex("users_roles").insert({ + roles_id: newRoleId, + users_id: user.id, + created_at: knex.fn.now(), + updated_at: knex.fn.now(), + }); + } + } +} + +// Reverse map: pick the highest-precedence role when backfilling. +const REVERSE_ROLE_PRECEDENCE: string[] = ["admin", "editor", "member"]; + +export async function down(knex: Knex): Promise { + const hasColumn = await knex.schema.hasColumn("users", "role"); + if (!hasColumn) { + await knex.schema.alterTable("users", (table) => { + table.string("role").defaultTo("member"); + }); + } + + // Backfill users.role from users_roles using deterministic precedence + const assignments: Array<{ users_id: number; roles_id: string }> = await knex("users_roles").select( + "users_id", + "roles_id", + ); + + // Group roles by user + const userRolesMap = new Map(); + for (const row of assignments) { + const list = userRolesMap.get(row.users_id) || []; + list.push(row.roles_id); + userRolesMap.set(row.users_id, list); + } + + // Pick highest-precedence role for each user + for (const [userId, roleIds] of userRolesMap) { + const bestRole = REVERSE_ROLE_PRECEDENCE.find((r) => roleIds.includes(r)) || roleIds[0] || "member"; + await knex("users").where("id", userId).update({ role: bestRole }); + } +} diff --git a/seeds/permissions.ts b/seeds/permissions.ts new file mode 100644 index 00000000..efbc4b3d --- /dev/null +++ b/seeds/permissions.ts @@ -0,0 +1,28 @@ +import type { Knex } from "knex"; +import { permissions } from "../src/lib/allPerms.ts"; + +export async function seed(knex: Knex): Promise { + const permissionIds = new Set(permissions.map((p) => p.id)); + + // Get all existing permissions + const existing: Array<{ id: string }> = await knex("permissions").select("id"); + const existingIds = new Set(existing.map((e) => e.id)); + + // Insert missing permissions + for (const perm of permissions) { + if (!existingIds.has(perm.id)) { + await knex("permissions").insert({ + id: perm.id, + permission_name: perm.permission_name, + created_at: knex.fn.now(), + updated_at: knex.fn.now(), + }); + } + } + + // Delete permissions that are no longer in the seed list + const toDelete = existing.filter((e) => !permissionIds.has(e.id)).map((e) => e.id); + if (toDelete.length > 0) { + await knex("permissions").whereIn("id", toDelete).del(); + } +} diff --git a/seeds/roles.ts b/seeds/roles.ts new file mode 100644 index 00000000..21ccf193 --- /dev/null +++ b/seeds/roles.ts @@ -0,0 +1,106 @@ +import type { Knex } from "knex"; +import { permissions } from "../src/lib/allPerms.ts"; + +/** + * Seeds the three readonly roles (admin, editor, member), + * assigns permissions to each role in roles_permissions, + * and migrates existing users.role → users_roles. + * + * Permission mapping derived from src/routes/(manage)/manage/api/+server.ts: + * + * admin → all permissions + * editor → all except api_keys.delete (AdminCan-only) + * member → all .read permissions only + */ + +const readonlyRoles = [ + { id: "admin", role_name: "Administrator" }, + { id: "editor", role_name: "Editor" }, + { id: "member", role_name: "Member" }, +]; + +const allPermissionIds = permissions.map((p) => p.id); +const readPermissionIds = allPermissionIds.filter((id) => id.endsWith(".read")); + +const rolePermissions: Record = { + admin: allPermissionIds, + editor: allPermissionIds.filter((id) => id !== "api_keys.delete"), + member: readPermissionIds, +}; + +export async function seed(knex: Knex): Promise { + // 1. Ensure readonly roles exist + for (const role of readonlyRoles) { + const existing = await knex("roles").where("id", role.id).first(); + if (!existing) { + await knex("roles").insert({ + id: role.id, + role_name: role.role_name, + readonly: 1, + status: "ACTIVE", + created_at: knex.fn.now(), + updated_at: knex.fn.now(), + }); + } + } + + // 2. Seed roles_permissions for readonly roles + // Only insert permissions that actually exist in the permissions table + // to avoid FK constraint errors if permissions seed hasn't run yet. + const existingPermRows: Array<{ id: string }> = await knex("permissions").select("id"); + const existingPermIds = new Set(existingPermRows.map((p) => p.id)); + + for (const [roleId, permissionIds] of Object.entries(rolePermissions)) { + const validPermissionIds = permissionIds.filter((id) => existingPermIds.has(id)); + + const existingPerms: Array<{ permissions_id: string }> = await knex("roles_permissions") + .where("roles_id", roleId) + .select("permissions_id"); + const existingSet = new Set(existingPerms.map((e) => e.permissions_id)); + + // Insert missing permissions + for (const permId of validPermissionIds) { + if (!existingSet.has(permId)) { + await knex("roles_permissions").insert({ + roles_id: roleId, + permissions_id: permId, + status: "ACTIVE", + created_at: knex.fn.now(), + updated_at: knex.fn.now(), + }); + } + } + + // Remove permissions no longer assigned to this role + const desiredSet = new Set(validPermissionIds); + const toRemove = existingPerms.filter((e) => !desiredSet.has(e.permissions_id)).map((e) => e.permissions_id); + if (toRemove.length > 0) { + await knex("roles_permissions").where("roles_id", roleId).whereIn("permissions_id", toRemove).del(); + } + } + + // 3. Migrate existing users: read users.role → insert into users_roles + const hasRoleColumn = await knex.schema.hasColumn("users", "role"); + if (hasRoleColumn) { + const users: Array<{ id: number; role: string }> = await knex("users").select("id", "role"); + + for (const user of users) { + if (!user.role) continue; + + // Only migrate if a matching role exists + const roleExists = await knex("roles").where("id", user.role).first(); + if (!roleExists) continue; + + // Skip if already assigned + const existing = await knex("users_roles").where({ roles_id: user.role, users_id: user.id }).first(); + if (!existing) { + await knex("users_roles").insert({ + roles_id: user.role, + users_id: user.id, + created_at: knex.fn.now(), + updated_at: knex.fn.now(), + }); + } + } + } +} diff --git a/src/lib/allPerms.ts b/src/lib/allPerms.ts new file mode 100644 index 00000000..a9518664 --- /dev/null +++ b/src/lib/allPerms.ts @@ -0,0 +1,274 @@ +/** + * Permissions derived from src/routes/(manage)/manage/api/+server.ts actions. + * Grouped by domain with read/write granularity. + * + * Mapping from actions → permissions: + * + * monitors.read → getMonitors, getMonitoringDataPaginated + * monitors.write → storeMonitorData, updateMonitoringData, deleteMonitor, deleteMonitorData, cloneMonitor, testMonitor + * + * incidents.read → getIncidents, getIncident, getComments + * incidents.write → createIncident, updateIncident, deleteIncident, addMonitor, removeMonitor, addComment, deleteComment, updateComment + * + * maintenances.read → getMaintenances, getMaintenance, getMaintenanceEvents, getMaintenanceEvent, getMaintenanceMonitors + * maintenances.write → createMaintenance, updateMaintenance, deleteMaintenance, createMaintenanceEvent, updateMaintenanceEvent, deleteMaintenanceEvent, addMonitorToMaintenance, removeMonitorFromMaintenance, updateMaintenanceMonitorImpact + * + * pages.read → getPages + * pages.write → createPage, updatePage, deletePage, addMonitorToPage, removeMonitorFromPage, reorderPageMonitors + * + * triggers.read → getTriggers + * triggers.write → createUpdateTrigger, updateMonitorTriggers, deleteTrigger, testTrigger + * + * alerts.read → getMonitorAlertConfig, getMonitorAlertConfigById, getMonitorAlertConfigsByMonitorTag, getAlertConfigsPaginated, getAllAlertsPaginated + * alerts.write → createMonitorAlertConfig, updateMonitorAlertConfig, deleteMonitorAlertConfig, toggleMonitorAlertConfigStatus, deleteMonitorAlertV2, updateMonitorAlertV2Status + * + * api_keys.read → getAPIKeys + * api_keys.write → createNewApiKey, updateApiKeyStatus + * api_keys.delete → deleteApiKey (admin-only today) + * + * users.read → getUsers + * users.write → manualUpdate, createNewUser, resendInvitation, sendVerificationEmail + * + * settings.read → getAllSiteData, getSiteDataByKey, getSubscriptionsConfig + * settings.write → storeSiteData, updateSubscriptionsConfig + * + * subscribers.read → getSubscribersByMethod, getSubscriberWithSubscriptions, getSubscriberCountsByMethod, getAdminSubscribers + * subscribers.write → deleteUserSubscription, updateUserSubscriptionStatus, adminUpdateSubscriptionStatus, adminDeleteSubscriber, adminAddSubscriber + * + * email_templates.read → getGeneralEmailTemplates, getGeneralEmailTemplateById + * email_templates.write → updateGeneralEmailTemplate + * + * images.write → uploadImage, deleteImage + */ +export const permissions: Array<{ id: string; permission_name: string }> = [ + // Monitors + { id: "monitors.read", permission_name: "View monitors and monitoring data" }, + { id: "monitors.write", permission_name: "Create, update, delete, and clone monitors" }, + + // Incidents + { id: "incidents.read", permission_name: "View incidents and comments" }, + { id: "incidents.write", permission_name: "Create, update, and delete incidents and comments" }, + + // Maintenances + { id: "maintenances.read", permission_name: "View maintenances and events" }, + { id: "maintenances.write", permission_name: "Create, update, and delete maintenances and events" }, + + // Pages + { id: "pages.read", permission_name: "View pages" }, + { id: "pages.write", permission_name: "Create, update, and delete pages" }, + + // Triggers + { id: "triggers.read", permission_name: "View triggers" }, + { id: "triggers.write", permission_name: "Create, update, delete, and test triggers" }, + + // Alerts + { id: "alerts.read", permission_name: "View alert configurations and alert history" }, + { id: "alerts.write", permission_name: "Create, update, and delete alert configurations" }, + + // API Keys + { id: "api_keys.read", permission_name: "View API keys" }, + { id: "api_keys.write", permission_name: "Create and update API keys" }, + { id: "api_keys.delete", permission_name: "Delete API keys" }, + + // Users + { id: "users.read", permission_name: "View users" }, + { id: "users.write", permission_name: "Manage users, invitations, and verification" }, + + // Settings (site data + subscriptions config) + { id: "settings.read", permission_name: "View site settings and subscriptions config" }, + { id: "settings.write", permission_name: "Update site settings and subscriptions config" }, + + // Subscribers + { id: "subscribers.read", permission_name: "View subscribers" }, + { id: "subscribers.write", permission_name: "Manage subscribers and subscriptions" }, + + // Email Templates + { id: "email_templates.read", permission_name: "View email templates" }, + { id: "email_templates.write", permission_name: "Update email templates" }, + + // Images + { id: "images.write", permission_name: "Upload and delete images" }, + + // Roles + { id: "roles.read", permission_name: "View roles, permissions, and user assignments" }, + { id: "roles.write", permission_name: "Create, update, and delete roles" }, + { id: "roles.assign_permissions", permission_name: "Add and remove permissions from roles" }, + { id: "roles.assign_users", permission_name: "Add and remove users to and from roles" }, +]; + +export const ACTION_PERMISSION_MAP: Record = { + // Self-actions — no permission needed beyond being logged in + updateUser: null, + updatePassword: null, + sendVerificationEmail: null, // controller has its own self-vs-other check + + // Settings + getAllSiteData: "settings.read", + getSiteDataByKey: "settings.read", + getSubscriptionsConfig: "settings.read", + storeSiteData: "settings.write", + updateSubscriptionsConfig: "settings.write", + + // Users + getUsers: "users.read", + manualUpdate: "users.write", + createNewUser: "users.write", + resendInvitation: "users.write", + + // Monitors + getMonitors: "monitors.read", + getMonitoringDataPaginated: "monitors.read", + storeMonitorData: "monitors.write", + updateMonitoringData: "monitors.write", + deleteMonitor: "monitors.write", + deleteMonitorData: "monitors.write", + cloneMonitor: "monitors.write", + testMonitor: "monitors.write", + + // Incidents + getIncidents: "incidents.read", + getIncident: "incidents.read", + getComments: "incidents.read", + createIncident: "incidents.write", + updateIncident: "incidents.write", + deleteIncident: "incidents.write", + addMonitor: "incidents.write", + removeMonitor: "incidents.write", + addComment: "incidents.write", + deleteComment: "incidents.write", + updateComment: "incidents.write", + + // Maintenances + getMaintenances: "maintenances.read", + getMaintenance: "maintenances.read", + getMaintenanceEvents: "maintenances.read", + getMaintenanceEvent: "maintenances.read", + getMaintenanceMonitors: "maintenances.read", + createMaintenance: "maintenances.write", + updateMaintenance: "maintenances.write", + deleteMaintenance: "maintenances.write", + createMaintenanceEvent: "maintenances.write", + updateMaintenanceEvent: "maintenances.write", + deleteMaintenanceEvent: "maintenances.write", + addMonitorToMaintenance: "maintenances.write", + removeMonitorFromMaintenance: "maintenances.write", + updateMaintenanceMonitorImpact: "maintenances.write", + + // Pages + getPages: "pages.read", + createPage: "pages.write", + updatePage: "pages.write", + deletePage: "pages.write", + addMonitorToPage: "pages.write", + removeMonitorFromPage: "pages.write", + reorderPageMonitors: "pages.write", + + // Triggers + getTriggers: "triggers.read", + createUpdateTrigger: "triggers.write", + updateMonitorTriggers: "triggers.write", + deleteTrigger: "triggers.write", + testTrigger: "triggers.write", + + // Alerts + getAllAlertsPaginated: "alerts.read", + getMonitorAlertConfig: "alerts.read", + getMonitorAlertConfigById: "alerts.read", + getMonitorAlertConfigsByMonitorTag: "alerts.read", + getAlertConfigsPaginated: "alerts.read", + createMonitorAlertConfig: "alerts.write", + updateMonitorAlertConfig: "alerts.write", + deleteMonitorAlertConfig: "alerts.write", + toggleMonitorAlertConfigStatus: "alerts.write", + deleteMonitorAlertV2: "alerts.write", + updateMonitorAlertV2Status: "alerts.write", + + // API Keys + getAPIKeys: "api_keys.read", + createNewApiKey: "api_keys.write", + updateApiKeyStatus: "api_keys.write", + deleteApiKey: "api_keys.delete", + + // Subscribers + getSubscribersByMethod: "subscribers.read", + getSubscriberWithSubscriptions: "subscribers.read", + getSubscriberCountsByMethod: "subscribers.read", + getAdminSubscribers: "subscribers.read", + deleteUserSubscription: "subscribers.write", + updateUserSubscriptionStatus: "subscribers.write", + adminUpdateSubscriptionStatus: "subscribers.write", + adminDeleteSubscriber: "subscribers.write", + adminAddSubscriber: "subscribers.write", + + // Email Templates + getGeneralEmailTemplates: "email_templates.read", + getGeneralEmailTemplateById: "email_templates.read", + updateGeneralEmailTemplate: "email_templates.write", + + // Images + uploadImage: "images.write", + deleteImage: "images.write", + + // Roles + getRoles: "roles.read", + getAllPermissions: "roles.read", + getRolePermissions: "roles.read", + getRoleUsers: "roles.read", + createRole: "roles.write", + updateRole: "roles.write", + deleteRole: "roles.write", + updateRolePermissions: "roles.assign_permissions", + addUserToRole: "roles.assign_users", + removeUserFromRole: "roles.assign_users", +}; + +export const ROUTE_PERMISSION_MAP: Record = { + // Monitors + "/(manage)/manage/app/monitors": "monitors.read", + "/(manage)/manage/app/monitors/[tag]": "monitors.read", + "/(manage)/manage/app/monitoring-data": "monitors.read", + + // Incidents + "/(manage)/manage/app/incidents": "incidents.read", + "/(manage)/manage/app/incidents/[incident_id]": "incidents.read", + + // Maintenances + "/(manage)/manage/app/maintenances": "maintenances.read", + "/(manage)/manage/app/maintenances/[id]": "maintenances.read", + + // Pages + "/(manage)/manage/app/pages": "pages.read", + "/(manage)/manage/app/pages/[page_id]": "pages.read", + + // Triggers + "/(manage)/manage/app/triggers": "triggers.read", + "/(manage)/manage/app/triggers/[trigger_id]": "triggers.read", + + // Alerts + "/(manage)/manage/app/alerts": "alerts.read", + "/(manage)/manage/app/alerts/[alert_config_id]": "alerts.read", + "/(manage)/manage/app/alerts/logs/[alert_config_id]": "alerts.read", + + // API Keys + "/(manage)/manage/app/api-keys": "api_keys.read", + + // Users + "/(manage)/manage/app/users": "users.read", + + // Settings + "/(manage)/manage/app/site-configurations": "settings.read", + "/(manage)/manage/app/customizations": "settings.read", + "/(manage)/manage/app/internationalization": "settings.read", + "/(manage)/manage/app/analytics-providers": "settings.read", + "/(manage)/manage/app/badges": "settings.read", + "/(manage)/manage/app/embed": "settings.read", + + // Subscribers + "/(manage)/manage/app/subscriptions": "subscribers.read", + + // Email Templates + "/(manage)/manage/app/templates": "email_templates.read", + + // Roles + "/(manage)/manage/app/roles": "roles.read", +}; diff --git a/src/lib/server/controllers/userController.ts b/src/lib/server/controllers/userController.ts index 5077eea0..13c9990b 100644 --- a/src/lib/server/controllers/userController.ts +++ b/src/lib/server/controllers/userController.ts @@ -2,7 +2,7 @@ import db from "../db/db.js"; import type { PaginationInput } from "$lib/types/common"; import { GenerateToken, HashPassword, ValidatePassword, VerifyToken } from "./commonController.js"; import type { Cookies } from "@sveltejs/kit"; -import type { UserRecordPublic, UserRecordDashboard } from "../types/db.js"; +import type { UserRecordPublic, UserRecordDashboard, RoleRecord } from "../types/db.js"; import { GetAllSiteData } from "./controller.js"; import { siteDataToVariables } from "../notification/notification_utils.js"; import sendEmail from "../notification/email_notification.js"; @@ -16,7 +16,7 @@ export interface UserUpdateInput { interface ManualUserUpdateInput { updateType: string; - role?: string; + role_ids?: string[]; is_active?: number; password?: string; passwordPlain?: string; @@ -32,7 +32,7 @@ interface NewUserInput { name: string; password: string; plainPassword: string; - role: string; + role_ids: string[]; } const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; @@ -65,12 +65,18 @@ const validateNameOrThrow = (name: string): string => { return normalizedName; }; -export const GetAllUsersPaginated = async (data: PaginationInput): Promise => { - return await db.getUsersPaginated(data.page, data.limit); +export const GetAllUsersPaginated = async ( + data: PaginationInput, + filter?: { is_active?: number }, +): Promise => { + return await db.getUsersPaginated(data.page, data.limit, filter); }; -export const GetAllUsersPaginatedDashboard = async (data: PaginationInput): Promise => { - const users = await db.getUsersPaginated(data.page, data.limit); +export const GetAllUsersPaginatedDashboard = async ( + data: PaginationInput, + filter?: { is_active?: number }, +): Promise => { + const users = await db.getUsersPaginated(data.page, data.limit, filter); if (users.length === 0) return []; // Batch fetch password statuses for all users @@ -88,8 +94,8 @@ export const GetAllUsers = async () => { return await db.getAllUsers(); }; -export const GetUsersCount = async () => { - return await db.getUsersCount(); +export const GetUsersCount = async (filter?: { is_active?: number }) => { + return await db.getTotalUsers(filter); }; export const GetUserPasswordHashById = async (id: number) => { @@ -145,14 +151,20 @@ export const UpdateUserData = async (data: UserUpdateInput): Promise => } }; -export const CreateNewUser = async (currentUser: { role: string }, data: NewUserInput): Promise => { - let acceptedRoles = ["member", "editor"]; - if (!acceptedRoles.includes(data.role)) { - throw new Error("Invalid role"); +export const CreateNewUser = async (data: NewUserInput): Promise => { + if (!data.role_ids || data.role_ids.length === 0) { + throw new Error("At least one role is required"); } - if (currentUser.role === "member") { - throw new Error("Only admins and editors can create new users"); + // Validate all role_ids exist and are active + for (const roleId of data.role_ids) { + const role = await db.getRoleById(roleId); + if (!role) { + throw new Error(`Role "${roleId}" does not exist`); + } + if (role.status !== "ACTIVE") { + throw new Error(`Role "${roleId}" is not active`); + } } const normalizedEmail = validateEmailOrThrow(data.email); @@ -163,11 +175,6 @@ export const CreateNewUser = async (currentUser: { role: string }, data: NewUser throw new Error("Password cannot be empty"); } - //if data.role empty, throw error - if (!!!data.role) { - throw new Error("Role cannot be empty"); - } - //if data.password not equal to data.plainPassword, throw error if (data.password !== data.plainPassword) { throw new Error("Passwords do not match"); @@ -182,7 +189,7 @@ export const CreateNewUser = async (currentUser: { role: string }, data: NewUser email: normalizedEmail, password_hash: await HashPassword(data.password), name: normalizedName, - role: data.role, + role_ids: data.role_ids, }; return await db.insertUser(user); }; @@ -202,7 +209,7 @@ export const CreateFirstUser = async (data: { email: string; name: string; passw email: normalizedEmail, password_hash: await HashPassword(data.password), name: normalizedName, - role: "admin", + role_ids: ["admin"], is_owner: "YES", }; return await db.insertUser(user); @@ -229,33 +236,34 @@ export const UpdatePassword = async (data: PasswordUpdateInput): Promise }); }; -const VALID_ROLES = ["admin", "editor", "member"] as const; - -export const ManualUpdateUserData = async ( - byUser: { id: number; role: string; is_owner: string }, - forUserId: number, - data: ManualUserUpdateInput, -): Promise => { +export const ManualUpdateUserData = async (forUserId: number, data: ManualUserUpdateInput): Promise => { let forUser = await db.getUserById(forUserId); if (!forUser) { throw new Error("User not found"); } - //only admins can update - if (byUser.role !== "admin") { - throw new Error("You do not have permission to update user"); - } - // non-owner admins cannot modify other admins (self-updates are allowed) - if (forUser.role === "admin" && byUser.is_owner !== "YES" && forUser.id !== byUser.id) { - throw new Error("Only the owner can modify other admins"); - } if (data.updateType == "role") { - if (!data.role) throw new Error("Role is required"); - if (!VALID_ROLES.includes(data.role as (typeof VALID_ROLES)[number])) { - throw new Error(`Invalid role. Must be one of: ${VALID_ROLES.join(", ")}`); + if (!data.role_ids || data.role_ids.length === 0) throw new Error("At least one role is required"); + // Owner must always retain the admin role + if (forUser.is_owner === "YES" && !data.role_ids.includes("admin")) { + throw new Error("Owner must retain the admin role"); } - return await db.updateUserRole(forUser.id, data.role); + // Validate all role_ids exist and are active + for (const roleId of data.role_ids) { + const role = await db.getRoleById(roleId); + if (!role) { + throw new Error(`Role "${roleId}" does not exist`); + } + if (role.status !== "ACTIVE") { + throw new Error(`Role "${roleId}" is not active`); + } + } + return await db.updateUserRoles(forUser.id, data.role_ids); } else if (data.updateType == "is_active") { if (data.is_active === undefined) throw new Error("is_active is required"); + // Owner cannot be deactivated + if (forUser.is_owner === "YES" && data.is_active === 0) { + throw new Error("Owner account cannot be deactivated"); + } return await db.updateUserIsActive(forUser.id, data.is_active); } else if (data.updateType == "password") { if (!data.password || !data.passwordPlain) throw new Error("Password is required"); @@ -297,15 +305,20 @@ export const GetTotalUserPages = async (limit: number): Promise => { }; //send invitation email to user for account creation -export const SendInvitationEmail = async (email: string, role: string, name: string, currentUserRole: string) => { - if (currentUserRole === "member") { - throw new Error("Only admins and editors can create new users"); +export const SendInvitationEmail = async (email: string, role_ids: string[], name: string) => { + if (!role_ids || role_ids.length === 0) { + throw new Error("At least one role is required"); } - // Admins can add admin, editor, member; Editors can only add editor, member - const acceptedRoles = currentUserRole === "admin" ? ["admin", "editor", "member"] : ["editor", "member"]; - if (!acceptedRoles.includes(role)) { - throw new Error("Invalid role"); + // Validate all role_ids exist and are active + for (const roleId of role_ids) { + const role = await db.getRoleById(roleId); + if (!role) { + throw new Error(`Role "${roleId}" does not exist`); + } + if (role.status !== "ACTIVE") { + throw new Error(`Role "${roleId}" is not active`); + } } const normalizedEmail = validateEmailOrThrow(email); @@ -323,7 +336,7 @@ export const SendInvitationEmail = async (email: string, role: string, name: str email: normalizedEmail, password_hash: "", name: normalizedName, - role, + role_ids: role_ids, is_active: 0, }); } catch (error: unknown) { @@ -364,11 +377,7 @@ export const SendInvitationEmail = async (email: string, role: string, name: str }; //resend invitation email to existing user with blank password -export const ResendInvitationEmail = async (email: string, currentUserRole: string) => { - if (currentUserRole === "member") { - throw new Error("Only admins and editors can resend invitations"); - } - +export const ResendInvitationEmail = async (email: string) => { const normalizedEmail = validateEmailOrThrow(email); const user = await db.getUserByEmail(normalizedEmail); @@ -410,17 +419,11 @@ export const ResendInvitationEmail = async (email: string, currentUserRole: stri }; // send verification email with verification link -export const SendVerificationEmail = async (toUserId: number, currentUser: { id: number; role: string }) => { +export const SendVerificationEmail = async (toUserId: number, currentUserId: number) => { if (!toUserId) { throw new Error("User ID is required"); } - // Only admins/editors can send verification to other users. - // Members can only send verification email to themselves. - if (currentUser.role === "member" && currentUser.id !== toUserId) { - throw new Error("You do not have permission to send verification email for this user"); - } - const user = await db.getUserById(toUserId); if (!user) { throw new Error("User not found"); @@ -458,3 +461,221 @@ export const SendVerificationEmail = async (toUserId: number, currentUser: { id: template.template_text_body || "", ); }; + +const RESTRICTED_ROLE_IDS = ["admin", "editor", "member"]; +const ROLE_ID_REGEX = /^[a-z0-9_-]+$/; + +const normalizeRoleId = (id: string): string => { + return id.trim().toLowerCase().replace(/\s+/g, "_"); +}; + +export const CreateRole = async (data: { role_id: string; name: string }): Promise => { + const roleId = normalizeRoleId(data.role_id || ""); + const roleName = data.name?.trim(); + + if (!roleId) { + throw new Error("Role ID is required"); + } + if (!ROLE_ID_REGEX.test(roleId)) { + throw new Error("Role ID can only contain lowercase letters, numbers, underscores, and hyphens"); + } + if (!roleName) { + throw new Error("Role name is required"); + } + + if (RESTRICTED_ROLE_IDS.includes(roleId)) { + throw new Error(`Role ID "${roleId}" is restricted and cannot be used`); + } + + const existing = await db.getRoleById(roleId); + if (existing) { + throw new Error(`Role with ID "${roleId}" already exists`); + } + + await db.insertRole({ id: roleId, role_name: roleName }); + + const created = await db.getRoleById(roleId); + if (!created) { + throw new Error("Failed to create role"); + } + return created; +}; + +export const UpdateRole = async (roleId: string, data: { name?: string; status?: string }): Promise => { + if (!roleId) { + throw new Error("Role ID is required"); + } + + const existing = await db.getRoleById(roleId); + if (!existing) { + throw new Error(`Role "${roleId}" not found`); + } + + if (existing.readonly === 1) { + throw new Error("Readonly roles cannot be updated"); + } + + const updates: { role_name?: string; status?: string } = {}; + + if (data.name !== undefined) { + const trimmed = data.name.trim(); + if (!trimmed) { + throw new Error("Role name cannot be empty"); + } + updates.role_name = trimmed; + } + + if (data.status !== undefined) { + if (data.status !== "ACTIVE" && data.status !== "INACTIVE") { + throw new Error("Status must be ACTIVE or INACTIVE"); + } + updates.status = data.status; + } + + if (Object.keys(updates).length === 0) { + throw new Error("No valid fields to update"); + } + + await db.updateRole(roleId, updates); + + const updated = await db.getRoleById(roleId); + if (!updated) { + throw new Error("Failed to retrieve updated role"); + } + return updated; +}; + +export const DeleteRole = async ( + roleId: string, + options: { action: "migrate"; targetRoleId: string } | { action: "remove" }, +): Promise<{ success: true }> => { + if (!roleId) { + throw new Error("Role ID is required"); + } + + const existing = await db.getRoleById(roleId); + if (!existing) { + throw new Error(`Role "${roleId}" not found`); + } + + if (existing.readonly === 1) { + throw new Error("Readonly roles cannot be deleted"); + } + + if (options.action === "migrate") { + const targetRoleId = options.targetRoleId?.trim(); + if (!targetRoleId) { + throw new Error("Target role ID is required for migration"); + } + if (targetRoleId === roleId) { + throw new Error("Target role cannot be the same as the role being deleted"); + } + const targetRole = await db.getRoleById(targetRoleId); + if (!targetRole) { + throw new Error(`Target role "${targetRoleId}" not found`); + } + if (targetRole.status !== "ACTIVE") { + throw new Error("Cannot migrate users to an inactive role"); + } + await db.migrateUsersRole(roleId, targetRoleId); + } + + // CASCADE on FK will clean up users_roles and roles_permissions + await db.deleteRole(roleId); + + return { success: true }; +}; + +export const GetAllRoles = async (): Promise => { + return await db.getAllRoles(); +}; + +export const GetAllPermissions = async () => { + return await db.getAllPermissions(); +}; + +export const GetRolePermissions = async (roleId: string) => { + const role = await db.getRoleById(roleId); + if (!role) { + throw new Error(`Role "${roleId}" not found`); + } + return await db.getRolePermissions(roleId); +}; + +export const UpdateRolePermissions = async (roleId: string, permissionIds: string[]) => { + const role = await db.getRoleById(roleId); + if (!role) { + throw new Error(`Role "${roleId}" not found`); + } + if (role.readonly === 1) { + throw new Error("Readonly roles cannot have their permissions modified"); + } + + // Get current permissions + const current = await db.getRolePermissions(roleId); + const currentIds = new Set(current.map((p) => p.permissions_id)); + const desiredIds = new Set(permissionIds); + + // Add new permissions + for (const pid of permissionIds) { + if (!currentIds.has(pid)) { + await db.addRolePermission(roleId, pid); + } + } + + // Remove old permissions + for (const pid of currentIds) { + if (!desiredIds.has(pid)) { + await db.removeRolePermission(roleId, pid); + } + } + + return await db.getRolePermissions(roleId); +}; + +export const GetRoleUsers = async (roleId: string) => { + const role = await db.getRoleById(roleId); + if (!role) { + throw new Error(`Role "${roleId}" not found`); + } + return await db.getUsersByRoleId(roleId); +}; + +export const AddUserToRole = async (roleId: string, userId: number) => { + const role = await db.getRoleById(roleId); + if (!role) { + throw new Error(`Role "${roleId}" not found`); + } + if (role.status !== "ACTIVE") { + throw new Error(`Role "${roleId}" is not active`); + } + // Check if user already in role + const users = await db.getUsersByRoleId(roleId); + if (users.some((u) => u.id === userId)) { + throw new Error("User is already assigned to this role"); + } + await db.addUserToRole(roleId, userId); + return { success: true }; +}; + +export const RemoveUserFromRole = async (roleId: string, userId: number) => { + if (roleId === "admin") { + const user = await db.getUserById(userId); + if (user && user.is_owner === "YES") { + throw new Error("The owner cannot be removed from the admin role"); + } + } + await db.removeUserFromRole(roleId, userId); + return { success: true }; +}; + +export const GetUserPermissions = async (userId: number): Promise> => { + const permissionIds = await db.getUserPermissionIds(userId); + return new Set(permissionIds); +}; + +export const RequirePermission = (userPermissions: Set, permissionId: string): void => { + if (!userPermissions.has(permissionId)) { + throw new Error("You do not have permission to perform this action"); + } +}; diff --git a/src/lib/server/db/dbimpl.ts b/src/lib/server/db/dbimpl.ts index 5940076e..b7621a19 100644 --- a/src/lib/server/db/dbimpl.ts +++ b/src/lib/server/db/dbimpl.ts @@ -115,11 +115,29 @@ class DbImpl { getUsersPaginated!: UsersRepository["getUsersPaginated"]; getTotalUsers!: UsersRepository["getTotalUsers"]; updateUserName!: UsersRepository["updateUserName"]; - updateUserRole!: UsersRepository["updateUserRole"]; + updateUserRoles!: UsersRepository["updateUserRoles"]; updateUserIsActive!: UsersRepository["updateUserIsActive"]; updateUserPasswordById!: UsersRepository["updateUserPasswordById"]; updateIsVerified!: UsersRepository["updateIsVerified"]; + // ============ Roles ============ + getRoleById!: UsersRepository["getRoleById"]; + getAllRoles!: UsersRepository["getAllRoles"]; + insertRole!: UsersRepository["insertRole"]; + updateRole!: UsersRepository["updateRole"]; + deleteRole!: UsersRepository["deleteRole"]; + getUsersCountByRoleId!: UsersRepository["getUsersCountByRoleId"]; + migrateUsersRole!: UsersRepository["migrateUsersRole"]; + getRolePermissions!: UsersRepository["getRolePermissions"]; + getAllPermissions!: UsersRepository["getAllPermissions"]; + addRolePermission!: UsersRepository["addRolePermission"]; + removeRolePermission!: UsersRepository["removeRolePermission"]; + getUsersByRoleId!: UsersRepository["getUsersByRoleId"]; + addUserToRole!: UsersRepository["addUserToRole"]; + removeUserFromRole!: UsersRepository["removeUserFromRole"]; + getUserPermissionIds!: UsersRepository["getUserPermissionIds"]; + getUserRoleIds!: UsersRepository["getUserRoleIds"]; + // ============ API Keys ============ createNewApiKey!: UsersRepository["createNewApiKey"]; updateApiKeyStatus!: UsersRepository["updateApiKeyStatus"]; @@ -460,7 +478,7 @@ class DbImpl { this.getUsersPaginated = this.users.getUsersPaginated.bind(this.users); this.getTotalUsers = this.users.getTotalUsers.bind(this.users); this.updateUserName = this.users.updateUserName.bind(this.users); - this.updateUserRole = this.users.updateUserRole.bind(this.users); + this.updateUserRoles = this.users.updateUserRoles.bind(this.users); this.updateUserIsActive = this.users.updateUserIsActive.bind(this.users); this.updateUserPasswordById = this.users.updateUserPasswordById.bind(this.users); this.updateIsVerified = this.users.updateIsVerified.bind(this.users); @@ -469,6 +487,24 @@ class DbImpl { this.deleteApiKey = this.users.deleteApiKey.bind(this.users); this.getApiKeyByHashedKey = this.users.getApiKeyByHashedKey.bind(this.users); this.getAllApiKeys = this.users.getAllApiKeys.bind(this.users); + + // Roles + this.getRoleById = this.users.getRoleById.bind(this.users); + this.getAllRoles = this.users.getAllRoles.bind(this.users); + this.insertRole = this.users.insertRole.bind(this.users); + this.updateRole = this.users.updateRole.bind(this.users); + this.deleteRole = this.users.deleteRole.bind(this.users); + this.getUsersCountByRoleId = this.users.getUsersCountByRoleId.bind(this.users); + this.migrateUsersRole = this.users.migrateUsersRole.bind(this.users); + this.getRolePermissions = this.users.getRolePermissions.bind(this.users); + this.getAllPermissions = this.users.getAllPermissions.bind(this.users); + this.addRolePermission = this.users.addRolePermission.bind(this.users); + this.removeRolePermission = this.users.removeRolePermission.bind(this.users); + this.getUsersByRoleId = this.users.getUsersByRoleId.bind(this.users); + this.addUserToRole = this.users.addUserToRole.bind(this.users); + this.removeUserFromRole = this.users.removeUserFromRole.bind(this.users); + this.getUserPermissionIds = this.users.getUserPermissionIds.bind(this.users); + this.getUserRoleIds = this.users.getUserRoleIds.bind(this.users); } private bindSiteDataMethods(): void { diff --git a/src/lib/server/db/repositories/users.ts b/src/lib/server/db/repositories/users.ts index a8a312fd..fa6861fa 100644 --- a/src/lib/server/db/repositories/users.ts +++ b/src/lib/server/db/repositories/users.ts @@ -1,5 +1,14 @@ import { BaseRepository, type CountResult } from "./base.js"; -import type { UserRecordInsert, UserRecordPublic, ApiKeyRecord, ApiKeyRecordInsert } from "../../types/db.js"; +import type { + UserRecordInsert, + UserRecordPublic, + ApiKeyRecord, + ApiKeyRecordInsert, + RoleRecord, + RolePermissionRecord, + UserRoleRecord, +} from "../../types/db.js"; +import { GetDbType } from "../../tool.js"; /** * Repository for users, API keys operations @@ -11,11 +20,46 @@ export class UsersRepository extends BaseRepository { return await this.knex("users").count("* as count").first(); } + private readonly userColumns = [ + "id", + "email", + "name", + "is_active", + "is_verified", + "is_owner", + "created_at", + "updated_at", + ] as const; + + private async enrichWithRoleIds(user: Record): Promise { + const roleIds = await this.getUserRoleIds(user.id as number); + return { ...user, role_ids: roleIds } as UserRecordPublic; + } + + private async enrichManyWithRoleIds(users: Record[]): Promise { + if (users.length === 0) return []; + const userIds = users.map((u) => u.id as number); + const roleRows = await this.knex("users_roles") + .join("roles", "users_roles.roles_id", "roles.id") + .whereIn("users_roles.users_id", userIds) + .where("roles.status", "ACTIVE") + .select("users_roles.users_id as users_id", "roles.id as role_id"); + const roleMap = new Map(); + for (const row of roleRows) { + const list = roleMap.get(row.users_id) || []; + list.push(row.role_id); + roleMap.set(row.users_id, list); + } + return users.map((u) => ({ ...u, role_ids: roleMap.get(u.id as number) || [] }) as UserRecordPublic); + } + async getUserByEmail(email: string): Promise { - return await this.knex("users") - .select("id", "email", "name", "is_active", "is_verified", "is_owner", "role", "created_at", "updated_at") + const row = await this.knex("users") + .select(...this.userColumns) .where("email", email) .first(); + if (!row) return undefined; + return await this.enrichWithRoleIds(row); } async getUserPasswordHashById(id: number): Promise<{ password_hash: string } | undefined> { @@ -28,22 +72,43 @@ export class UsersRepository extends BaseRepository { } async getUserById(id: number): Promise { - return await this.knex("users") - .select("id", "email", "name", "is_active", "is_verified", "is_owner", "role", "created_at", "updated_at") + const row = await this.knex("users") + .select(...this.userColumns) .where("id", id) .first(); + if (!row) return undefined; + return await this.enrichWithRoleIds(row); } async insertUser(data: UserRecordInsert): Promise { - return await this.knex("users").insert({ + const dbType = GetDbType(); + + const insertData = { email: data.email, name: data.name, password_hash: data.password_hash, - role: data.role, is_owner: data.is_owner || "NO", created_at: this.knex.fn.now(), updated_at: this.knex.fn.now(), - }); + }; + + let userId: number; + if (dbType === "postgresql") { + const [row] = await this.knex("users").insert(insertData).returning("id"); + userId = typeof row === "object" ? (row as { id: number }).id : (row as number); + } else { + const result = await this.knex("users").insert(insertData); + userId = result[0]; + } + + if (data.role_ids && data.role_ids.length > 0) { + const roleInserts = data.role_ids.map((roleId) => ({ + users_id: userId, + roles_id: roleId, + })); + await this.knex("users_roles").insert(roleInserts); + } + return [userId]; } async updateUserPassword(data: { id: number; password_hash: string }): Promise { @@ -54,21 +119,31 @@ export class UsersRepository extends BaseRepository { } async getAllUsers(): Promise { - return await this.knex("users") - .select("id", "email", "name", "role", "is_active", "is_verified", "is_owner", "created_at", "updated_at") + const rows = await this.knex("users") + .select(...this.userColumns) .orderBy("created_at", "desc"); + return await this.enrichManyWithRoleIds(rows); } - async getUsersPaginated(page: number, limit: number): Promise { - return await this.knex("users") - .select("id", "email", "name", "role", "is_active", "is_verified", "is_owner", "created_at", "updated_at") + async getUsersPaginated(page: number, limit: number, filter?: { is_active?: number }): Promise { + const query = this.knex("users") + .select(...this.userColumns) .orderBy("created_at", "desc") .limit(limit) .offset((page - 1) * limit); + if (filter?.is_active !== undefined) { + query.where("is_active", filter.is_active); + } + const rows = await query; + return await this.enrichManyWithRoleIds(rows); } - async getTotalUsers(): Promise { - return await this.knex("users").count("* as count").first(); + async getTotalUsers(filter?: { is_active?: number }): Promise { + const query = this.knex("users").count("* as count"); + if (filter?.is_active !== undefined) { + query.where("is_active", filter.is_active); + } + return await query.first(); } async updateUserName(id: number, name: string): Promise { @@ -78,11 +153,18 @@ export class UsersRepository extends BaseRepository { }); } - async updateUserRole(id: number, role: string): Promise { - return await this.knex("users").where({ id }).update({ - role, - updated_at: this.knex.fn.now(), - }); + async updateUserRoles(id: number, roleIds: string[]): Promise { + await this.knex("users_roles").where("users_id", id).delete(); + if (roleIds.length > 0) { + const inserts = roleIds.map((roleId) => ({ + users_id: id, + roles_id: roleId, + created_at: this.knex.fn.now(), + updated_at: this.knex.fn.now(), + })); + await this.knex("users_roles").insert(inserts); + } + await this.knex("users").where({ id }).update({ updated_at: this.knex.fn.now() }); } async updateUserIsActive(id: number, is_active: number): Promise { @@ -138,4 +220,140 @@ export class UsersRepository extends BaseRepository { } // ============ Invitations ============ + + // ============ Roles ============ + + async getRoleById(id: string): Promise { + return await this.knex("roles").where("id", id).first(); + } + + async getAllRoles(): Promise { + return await this.knex("roles").orderBy("created_at", "asc"); + } + + async insertRole(data: { id: string; role_name: string; readonly?: number }): Promise { + await this.knex("roles").insert({ + id: data.id, + role_name: data.role_name, + readonly: data.readonly ?? 0, + status: "ACTIVE", + created_at: this.knex.fn.now(), + updated_at: this.knex.fn.now(), + }); + } + + async updateRole(id: string, data: { role_name?: string; status?: string }): Promise { + const updateData: Record = { updated_at: this.knex.fn.now() }; + if (data.role_name !== undefined) updateData.role_name = data.role_name; + if (data.status !== undefined) updateData.status = data.status; + return await this.knex("roles").where("id", id).update(updateData); + } + + async deleteRole(id: string): Promise { + return await this.knex("roles").where("id", id).delete(); + } + + async getUsersCountByRoleId(roleId: string): Promise { + const result = await this.knex("users_roles").where("roles_id", roleId).count("* as count").first(); + return result ? Number(result.count) : 0; + } + + async migrateUsersRole(fromRoleId: string, toRoleId: string): Promise { + // Find users who already have the target role to avoid duplicate PK + const usersWithTarget = this.knex("users_roles").where("roles_id", toRoleId).select("users_id"); + + // Update users who don't already have the target role + await this.knex("users_roles").where("roles_id", fromRoleId).whereNotIn("users_id", usersWithTarget).update({ + roles_id: toRoleId, + updated_at: this.knex.fn.now(), + }); + + // Delete remaining assignments (users who already had the target role) + await this.knex("users_roles").where("roles_id", fromRoleId).delete(); + } + + // ============ Role Permissions ============ + + async getRolePermissions(roleId: string): Promise { + return await this.knex("roles_permissions").where("roles_id", roleId); + } + + async getAllPermissions(): Promise> { + return await this.knex("permissions").select("id", "permission_name").orderBy("id", "asc"); + } + + async addRolePermission(roleId: string, permissionId: string): Promise { + await this.knex("roles_permissions").insert({ + roles_id: roleId, + permissions_id: permissionId, + status: "ACTIVE", + created_at: this.knex.fn.now(), + updated_at: this.knex.fn.now(), + }); + } + + async removeRolePermission(roleId: string, permissionId: string): Promise { + return await this.knex("roles_permissions").where({ roles_id: roleId, permissions_id: permissionId }).delete(); + } + + // ============ Role Users ============ + + async getUsersByRoleId(roleId: string): Promise> { + const rows = await this.knex("users_roles") + .join("users", "users_roles.users_id", "users.id") + .where("users_roles.roles_id", roleId) + .select( + "users.id", + "users.email", + "users.name", + "users.is_active", + "users.is_verified", + "users.is_owner", + "users.created_at", + "users.updated_at", + "users_roles.roles_id", + ); + const enriched = await this.enrichManyWithRoleIds(rows); + return enriched.map((u, i) => ({ ...u, roles_id: rows[i].roles_id })); + } + + async addUserToRole(roleId: string, userId: number): Promise { + await this.knex("users_roles").insert({ + roles_id: roleId, + users_id: userId, + created_at: this.knex.fn.now(), + updated_at: this.knex.fn.now(), + }); + } + + async removeUserFromRole(roleId: string, userId: number): Promise { + return await this.knex("users_roles").where({ roles_id: roleId, users_id: userId }).delete(); + } + + async getUserRoleIds(userId: number): Promise { + const rows = await this.knex("users_roles") + .join("roles", function () { + this.on("users_roles.roles_id", "roles.id"); + }) + .where("users_roles.users_id", userId) + .where("roles.status", "ACTIVE") + .distinct("roles.id as id") + .select(); + return rows.map((r: { id: string }) => r.id); + } + + async getUserPermissionIds(userId: number): Promise { + const knex = this.knex; + const rows = await knex("users_roles") + .join("roles", function () { + this.on("users_roles.roles_id", "roles.id").andOn("roles.status", knex.raw("?", ["ACTIVE"])); + }) + .join("roles_permissions", function () { + this.on("roles_permissions.roles_id", "roles.id").andOn("roles_permissions.status", knex.raw("?", ["ACTIVE"])); + }) + .where("users_roles.users_id", userId) + .distinct("roles_permissions.permissions_id as id") + .select(); + return rows.map((r: { id: string }) => r.id); + } } diff --git a/src/lib/server/types/db.ts b/src/lib/server/types/db.ts index d32e97c8..f13ea9ae 100644 --- a/src/lib/server/types/db.ts +++ b/src/lib/server/types/db.ts @@ -236,7 +236,7 @@ export interface UserRecord { password_hash: string; is_active: number; is_verified: number; - role: string; + role_ids: string[]; // Array of role IDs created_at: Date; updated_at: Date; } @@ -245,9 +245,9 @@ export interface UserRecordInsert { email: string; name: string; password_hash: string; + role_ids: string[]; // Array of role IDs is_active?: number; is_verified?: number; - role?: string; is_owner?: string; } @@ -258,7 +258,7 @@ export interface UserRecordPublic { is_active: number; is_verified: number; is_owner: string; - role: string; + role_ids: string[]; created_at: Date; updated_at: Date; } @@ -266,6 +266,31 @@ export interface UserRecordDashboard extends UserRecordPublic { has_password: boolean; } +// ============ roles table ============ +export interface RoleRecord { + id: string; + role_name: string; + readonly: number; + status: string; + created_at: Date; + updated_at: Date; +} + +export interface RolePermissionRecord { + roles_id: string; + permissions_id: string; + status: string; + created_at: Date; + updated_at: Date; +} + +export interface UserRoleRecord { + roles_id: string; + users_id: number; + created_at: Date; + updated_at: Date; +} + // ============ api_keys table ============ export interface ApiKeyRecord { id: number; diff --git a/src/routes/(account)/+layout.svelte b/src/routes/(account)/+layout.svelte index 297d2b32..17b62efa 100644 --- a/src/routes/(account)/+layout.svelte +++ b/src/routes/(account)/+layout.svelte @@ -4,15 +4,9 @@ import { ModeWatcher } from "mode-watcher"; import { resolve } from "$app/paths"; import { Toaster } from "$lib/components/ui/sonner/index.js"; - - let base = resolve("/"); + import clientResolver from "$lib/client/resolver.js"; let { children, data } = $props(); - - const colorUp = $derived(data.siteStatusColors.UP); - const colorDegraded = $derived(data.siteStatusColors.DEGRADED); - const colorDown = $derived(data.siteStatusColors.DOWN); - const colorMaintenance = $derived(data.siteStatusColors.MAINTENANCE); import KenerNav from "$lib/components/KenerNav.svelte"; @@ -21,7 +15,32 @@ - {@html ``} + + {#if data.font?.cssSrc} + + {/if} + {@html ` + `} +
diff --git a/src/routes/(account)/account/signin/+page.server.ts b/src/routes/(account)/account/signin/+page.server.ts index f232228f..3d70bc7f 100644 --- a/src/routes/(account)/account/signin/+page.server.ts +++ b/src/routes/(account)/account/signin/+page.server.ts @@ -59,6 +59,13 @@ export const actions: Actions = { }); } + if (!userDB.role_ids || userDB.role_ids.length === 0) { + return fail(403, { + error: "Your account has no active roles assigned. Please contact an administrator.", + values: { email }, + }); + } + const token = await GenerateToken(userDB); const cookieConfig = CookieConfig(); cookies.set(cookieConfig.name, token, { diff --git a/src/routes/(docs)/docs.json b/src/routes/(docs)/docs.json index e3c5c669..97524b87 100644 --- a/src/routes/(docs)/docs.json +++ b/src/routes/(docs)/docs.json @@ -303,6 +303,10 @@ "group": "v4.x", "collapsible": false, "pages": [ + { + "title": "v4.0.23", + "content": "v4/changelogs/v4.0.23" + }, { "title": "v4.0.22", "content": "v4/changelogs/v4.0.22" diff --git a/src/routes/(docs)/docs/content/v4/changelogs/v4.0.23.md b/src/routes/(docs)/docs/content/v4/changelogs/v4.0.23.md new file mode 100644 index 00000000..0351222c --- /dev/null +++ b/src/routes/(docs)/docs/content/v4/changelogs/v4.0.23.md @@ -0,0 +1,54 @@ +--- +title: v4.0.23 Changelog +description: See what's new in Kener v4.0.23, including new features, improvements, and bug fixes +--- + +## New features {#new-features} + +### Role-based access control (RBAC) {#rbac} + +Kener now uses a full RBAC system with roles, permissions, and user-role assignments. This replaces the previous single-role-per-user model with a flexible, permission-driven approach. + +- **Permissions** follow a `domain.action` format (e.g. `monitors.read`, `incidents.write`). There are 30+ permissions covering all domains: monitors, incidents, maintenances, pages, triggers, alerts, API keys, users, settings, subscribers, email templates, images, and roles. +- **Built-in roles** — `admin`, `editor`, and `member` — are seeded automatically and cannot be edited or deleted. Admin gets all permissions, editor gets all except `api_keys.delete`, and member gets read-only access. +- **Custom roles** can be created, edited, deactivated, and deleted from the new **Manage → Roles** page. Permissions can be cloned from an existing role during creation. +- **Multi-role assignment** — users can now be assigned multiple roles simultaneously. A user's effective permissions are the union of all their roles' permissions. +- Permissions are enforced at both the **route level** (page access) and the **action level** (API operations). + +New database tables: `roles`, `permissions`, `roles_permissions`, `users_roles`. Existing users are automatically migrated from the old `users.role` column to the new `users_roles` table. + +See [User Management](/docs/v4/user-management) for full details. + +### Roles management UI {#roles-management-ui} + +A new **Manage → Roles** page provides full role administration: + +- View all roles with their status and type (readonly or custom). +- **Permissions panel** — toggle individual permissions grouped by domain. Readonly roles show permissions in read-only mode. +- **Users panel** — view, add, and remove users assigned to each role. +- **Duplicate role** — create a new role by cloning permissions from an existing one. +- **Delete role** — choose to remove user assignments or migrate users to another role before deletion. + +### Login role validation {#login-role-validation} + +Users must have at least one active role to sign in. If a user's account exists but has no active roles assigned, login is blocked with a descriptive error message directing them to contact an administrator. + +## Improvements {#improvements} + +### Multi-role user invitations {#multi-role-invitations} + +The **Add User** dialog now shows checkboxes for all active roles instead of a single role dropdown. At least one role must be selected when inviting a new user. All selected roles are validated to be active before the invitation is sent. + +### Permission-based UI visibility {#permission-based-ui} + +Sidebar navigation and action buttons throughout the manage dashboard are now driven by the current user's permissions. Pages and actions that the user lacks permission for are hidden rather than showing access-denied errors. + +## Breaking changes {#breaking-changes} + +### Vault page removed {#vault-removed} + +The **Manage → Vault** page has been removed from the admin dashboard. The vault route and its associated permission (`vault`) have been dropped from the route permission map. + +### User role column migration {#role-column-migration} + +The `role` column on the `users` table is migrated to the `users_roles` junction table. A down migration re-creates the `role` column by backfilling from `users_roles` if you need to roll back. Existing user roles are preserved during the migration. \ No newline at end of file diff --git a/src/routes/(docs)/docs/content/v4/user-management.md b/src/routes/(docs)/docs/content/v4/user-management.md index 5b8f7964..8d6f80c8 100644 --- a/src/routes/(docs)/docs/content/v4/user-management.md +++ b/src/routes/(docs)/docs/content/v4/user-management.md @@ -1,75 +1,81 @@ --- title: User Management -description: Manage users, roles, invitations, and role permissions in Kener +description: Manage users, roles, permissions, and invitations in Kener --- -Use **Manage → Users** to invite teammates, control access, and manage account status. +Use **Manage → Users** to invite teammates and manage account status. Use **Manage → Roles** to control access with fine-grained permissions. -## Roles overview {#roles-overview} +## Roles and permissions {#roles-and-permissions} -Kener uses three roles: +Kener uses a role-based access control (RBAC) system. Each user can be assigned one or more **roles**, and each role has a set of **permissions** that determine what actions the user can perform. -| Role | What it means | -| -------- | ------------------------------------------------------------------------------------------------------------ | -| `admin` | Full access, including user administration and vault/API-key level operations | -| `editor` | Can run day-to-day operations (monitors, incidents, maintenances, site settings) but cannot administer users | -| `member` | Limited access; cannot administer users or change system settings | +### Built-in roles {#built-in-roles} -## What each role can do {#what-each-role-can-do} +Three readonly roles are seeded automatically: -### Admin {#admin} +| Role | Permissions | Notes | +| -------- | ----------- | ----- | +| `admin` | All permissions | Full access including `api_keys.delete` | +| `editor` | All except `api_keys.delete` | Day-to-day operations | +| `member` | All `.read` permissions only | View-only access | -Admin can: +Built-in roles cannot be edited or deleted. -- invite users -- resend invitations -- change user role -- activate/deactivate users -- send verification email to any user -- perform all editor-level operational actions -- manage admin-only areas like vault and certain privileged API actions +### Custom roles {#custom-roles} -Admin invite permissions: +From **Manage → Roles**, users with the `roles.write` permission can create custom roles: -- admin can invite `admin`, `editor`, and `member` +1. Click **Create Role**. +2. Enter a role ID (lowercase, numbers, underscores, hyphens) and display name. +3. Optionally clone permissions from an existing role. +4. After creation, assign permissions in the **Permissions** panel. -Admin user-management restrictions: +Custom roles can be edited, deactivated, or deleted. When deleting a custom role, you can either remove user assignments or migrate them to another role. -- non-owner admin cannot modify other admins -- owner admin can modify other admins (role update and activate/deactivate) +### Permission domains {#permission-domains} -### Editor {#editor} +Permissions follow a `domain.action` format: -Editor can: +| Domain | Actions | +| ------ | ------- | +| `monitors` | `read`, `write` | +| `incidents` | `read`, `write` | +| `maintenances` | `read`, `write` | +| `pages` | `read`, `write` | +| `triggers` | `read`, `write` | +| `alerts` | `read`, `write` | +| `api_keys` | `read`, `write`, `delete` | +| `users` | `read`, `write` | +| `settings` | `read`, `write` | +| `subscribers` | `read`, `write` | +| `email_templates` | `read`, `write` | +| `images` | `write` | +| `roles` | `read`, `write`, `assign_permissions`, `assign_users` | -- invite users +Permissions are enforced at both the **route level** (page access) and the **action level** (API operations). + +### Managing role permissions {#managing-role-permissions} + +From the roles table, click **Permissions** on any role to view or edit its permissions. Permissions are grouped by domain and can be toggled individually. Readonly (built-in) roles show permissions in read-only mode. + +### Managing role users {#managing-role-users} + +Click **Users** on any role to see assigned users. Users with `roles.assign_users` permission can add or remove users from roles. + +## User management {#user-management} + +Users with the `users.write` permission can: + +- invite new users - resend invitation emails -- manage monitors, incidents, maintenances, alerts, triggers, pages, subscriptions, and site data - -Editor invite permissions: - -- editor can invite `editor` and `member` - -Editor cannot: - -- change user roles +- update user roles - activate/deactivate users -- perform admin-only user administration actions +- send verification emails -### Member {#member} +Owner-specific restrictions: -Member can: - -- sign in and use allowed views -- send verification email for their own account (if unverified) - -Member cannot: - -- invite users -- resend invitations -- change roles -- activate/deactivate other users -- perform admin/editor configuration actions +- the owner must always retain the `admin` role +- the owner account cannot be deactivated ## Invite flow {#invite-flow} @@ -79,19 +85,14 @@ Member cannot: From **Manage → Users**: 1. Click **Add User**. -2. Enter name, email, and role. +2. Enter name, email, and select one or more roles. 3. Invitation email is sent with a secure token link. -Role options in **Add User** are filtered by your role: - -- admin: `admin`, `editor`, `member` -- editor: `editor`, `member` -- member: no access to Add User - Current behavior: - invited user is created with inactive account and empty password - invitation token expires after 7 days +- all selected roles must be active ## How users accept invitation {#how-users-accept-invitation} @@ -106,19 +107,20 @@ If link is invalid, expired, or already used, invitation page shows an error and ## Verification emails {#verification-emails} -- Admin/editor can send verification email to users. -- Member can only trigger verification for their own account. +- Users with `users.write` permission can send verification emails to other users. +- Any user can trigger verification for their own account (if unverified). -## Common user management tasks {#common-user-management-tasks} +## Common tasks {#common-tasks} -- **Promote/demote user**: admin updates role in user settings sheet. Non-owner admins cannot change other admins. -- **Deactivate user**: admin toggles account inactive (session access removed). Non-owner admins cannot deactivate other admins. +- **Change user roles**: open user settings sheet, toggle roles, and click **Update Roles**. Users can be assigned multiple roles simultaneously. +- **Deactivate user**: toggle account inactive in user settings sheet. Existing sessions are invalidated. - **Re-invite user**: resend invitation if user has not set password yet. ## UI behavior notes {#ui-behavior-notes} - The current signed-in user is highlighted in the users table. -- For non-owner admins, admin targets do not show admin-management actions. +- Users table can be filtered by active/inactive status. +- Role badges show the user's assigned role IDs. ## Requirements and dependencies {#requirements-and-dependencies} diff --git a/src/routes/(manage)/+layout.server.ts b/src/routes/(manage)/+layout.server.ts index eb3fa8ea..1112a161 100644 --- a/src/routes/(manage)/+layout.server.ts +++ b/src/routes/(manage)/+layout.server.ts @@ -2,8 +2,11 @@ import { redirect } from "@sveltejs/kit"; import MobileDetect from "mobile-detect"; import type { LayoutServerLoad } from "./$types"; import { IsEmailSetup } from "$lib/server/controllers/controller.js"; +import { RequirePermission } from "$lib/server/controllers/userController.js"; import seedSiteData from "$lib/server/db/seedSiteData.js"; import serverResolve from "$lib/server/resolver.js"; +import { ROUTE_PERMISSION_MAP } from "$lib/allPerms.js"; +import { error } from "@sveltejs/kit"; import { resolve } from "$app/paths"; import { @@ -12,8 +15,9 @@ import { GetLoggedInSession, GetLocaleFromCookie, } from "$lib/server/controllers/controller.js"; +import { GetUserPermissions } from "$lib/server/controllers/userController.js"; -export const load: LayoutServerLoad = async ({ cookies }) => { +export const load: LayoutServerLoad = async ({ cookies, route }) => { let isSetupComplete = await IsSetupComplete(); if (!isSetupComplete) { throw redirect(302, serverResolve(`/account/signin`)); @@ -27,13 +31,27 @@ export const load: LayoutServerLoad = async ({ cookies }) => { } const siteData = await GetAllSiteData(); + const userPermissions = await GetUserPermissions(loggedInUser.id); + const routeId = route.id || ""; + + const requiredPermission = ROUTE_PERMISSION_MAP[routeId]; + if (requiredPermission === undefined) { + throw error(403, "Forbidden"); + } + if (requiredPermission !== null) { + try { + RequirePermission(userPermissions, requiredPermission); + } catch { + throw error(403, "Forbidden"); + } + } const siteStatusColors = siteData.colors; const siteStatusColorsDark = siteData.colorsDark || siteStatusColors; const font = siteData.font || { cssSrc: "", family: "" }; - // const emailSubscriptionTrigger = await GetSubscriptionTriggerByEmail(); return { userDb: loggedInUser, + userPermissions: [...userPermissions], siteStatusColors, siteStatusColorsDark, font, diff --git a/src/routes/(manage)/+layout.svelte b/src/routes/(manage)/+layout.svelte index d931e73f..be78f2cc 100644 --- a/src/routes/(manage)/+layout.svelte +++ b/src/routes/(manage)/+layout.svelte @@ -22,6 +22,7 @@ import BookOpenIcon from "@lucide/svelte/icons/book-open"; import KeyIcon from "@lucide/svelte/icons/key"; import UsersIcon from "@lucide/svelte/icons/users"; + import ShieldIcon from "@lucide/svelte/icons/shield"; import Columns3CogIcon from "@lucide/svelte/icons/columns-3-cog"; import SiteHeader from "./manage/site-header.svelte"; import TemplateIcon from "@lucide/svelte/icons/layout-template"; @@ -30,9 +31,12 @@ import { Toaster } from "$lib/components/ui/sonner/index.js"; import * as Tooltip from "$lib/components/ui/tooltip/index.js"; + import { ROUTE_PERMISSION_MAP } from "$lib/allPerms.js"; + + let { children, data } = $props(); // Navigation items - single source of truth - const navItems = [ + const allNavItems = [ { title: "Site Configurations", url: "/manage/app/site-configurations", icon: Settings2Icon }, { title: "Internationalization", url: "/manage/app/internationalization", icon: GlobeIcon }, { title: "Customizations", url: "/manage/app/customizations", icon: Columns3CogIcon }, @@ -45,17 +49,26 @@ { title: "Alerts", url: "/manage/app/alerts", icon: SirenIcon }, { title: "Subscriptions", url: "/manage/app/subscriptions", icon: BellIcon }, { title: "Users", url: "/manage/app/users", icon: UsersIcon }, + { title: "Roles", url: "/manage/app/roles", icon: ShieldIcon }, { title: "Triggers", url: "/manage/app/triggers", icon: MailboxIcon }, { title: "Templates", url: "/manage/app/templates", icon: TemplateIcon }, { title: "Badges", url: "/manage/app/badges", icon: BadgeIcon }, { title: "Embed", url: "/manage/app/embed", icon: CodeIcon }, { title: "API Keys", url: "/manage/app/api-keys", icon: KeyIcon } - ].map((item) => ({ ...item, url: clientResolver(resolve, item.url) })); + ]; + + const navItems = allNavItems + .filter((item) => { + const routeId = `/(manage)${item.url}`; + const requiredPermission = ROUTE_PERMISSION_MAP[routeId]; + if (requiredPermission === undefined) return false; + if (requiredPermission === null) return true; + return (data.userPermissions ?? []).includes(requiredPermission); + }) + .map((item) => ({ ...item, url: clientResolver(resolve, item.url) })); // Derive page title from current URL let pageTitle = $derived(navItems.find((item) => page.url.pathname.startsWith(item.url))?.title || "Dashboard"); - - let { children, data } = $props(); diff --git a/src/routes/(manage)/manage/api/+server.ts b/src/routes/(manage)/manage/api/+server.ts index de6e2472..410e2d51 100644 --- a/src/routes/(manage)/manage/api/+server.ts +++ b/src/routes/(manage)/manage/api/+server.ts @@ -111,6 +111,20 @@ import { GetGeneralEmailTemplateById, UpdateGeneralEmailTemplate, } from "$lib/server/controllers/generalTemplateController.js"; +import { + GetAllRoles, + GetAllPermissions, + GetRolePermissions, + UpdateRolePermissions, + GetRoleUsers, + AddUserToRole, + RemoveUserFromRole, + CreateRole, + UpdateRole, + DeleteRole, + GetUserPermissions, + RequirePermission, +} from "$lib/server/controllers/userController.js"; import type { SiteDataForNotification } from "$lib/server/notification/types"; import { alertToVariables, siteDataToVariables } from "$lib/server/notification/notification_utils"; import type { TriggerMeta } from "$lib/server/types/db.js"; @@ -120,29 +134,7 @@ import sendDiscord from "$lib/server/notification/discord_notification.js"; import sendSlack from "$lib/server/notification/slack_notification.js"; import heicConvert from "heic-convert"; import serverResolver from "$lib/server/resolver.js"; - -function AdminCan(role: string) { - if (role !== "admin") { - throw new Error("Only Admins can perform this action"); - } -} - -function EditorCan(role: string) { - if (role !== "editor") { - throw new Error("Only Editors can perform this action"); - } -} -function MemberCan(role: string) { - if (role !== "member") { - throw new Error("Only Member can perform this action"); - } -} - -function AdminEditorCan(role: string) { - if (role !== "admin" && role !== "editor") { - throw new Error("Only Admins and Editors can perform this action"); - } -} +import { ACTION_PERMISSION_MAP } from "$lib/allPerms.js"; export async function POST({ request, cookies }) { const payload = await request.json(); @@ -155,6 +147,22 @@ export async function POST({ request, cookies }) { return json({ error: "User not logged in" }, { status: 401 }); } + // Fetch user permissions once for the entire request + const userPermissions = await GetUserPermissions(userDB.id); + + // Check permission for the action + const requiredPermission = ACTION_PERMISSION_MAP[action]; + if (requiredPermission === undefined) { + return json({ error: "Unknown action" }, { status: 400 }); + } + if (requiredPermission !== null) { + try { + RequirePermission(userPermissions, requiredPermission); + } catch { + return json({ error: "You do not have permission to perform this action" }, { status: 403 }); + } + } + try { if (action == "updateUser") { data.userID = userDB.id; @@ -162,68 +170,69 @@ export async function POST({ request, cookies }) { } else if (action == "getAllSiteData") { resp = await GetAllSiteData(); } else if (action == "manualUpdate") { - await ManualUpdateUserData(userDB, data.id, data); + await ManualUpdateUserData(data.id, data); resp = await GetUserByIDDashboard(data.id); } else if (action == "updatePassword") { data.userID = userDB.id; resp = await UpdatePassword(data); } else if (action == "createNewUser") { - await SendInvitationEmail(data.email, data.role, data.name, userDB.role); + await SendInvitationEmail(data.email, data.role_ids, data.name); resp = await GetUserByEmail(data.email); } else if (action == "resendInvitation") { - AdminEditorCan(userDB.role); - await ResendInvitationEmail(data.email, userDB.role); + await ResendInvitationEmail(data.email); resp = { success: true }; } else if (action == "sendVerificationEmail") { const toId = parseInt(String(data.toId)); if (!toId) { throw new Error("User ID is required"); } - await SendVerificationEmail(toId, { id: userDB.id, role: userDB.role }); + // Non-self verification requires users.write permission + if (toId !== userDB.id) { + if (!userPermissions.has("users.write")) { + return json({ error: "You do not have permission to perform this action" }, { status: 403 }); + } + } + await SendVerificationEmail(toId, userDB.id); resp = { success: true }; } else if (action == "getUsers") { const page = parseInt(String(data.page)) || 1; const limit = parseInt(String(data.limit)) || 10; - const users = await GetAllUsersPaginatedDashboard({ page, limit }); - const totalResult = await GetUsersCount(); + const filter: { is_active?: number } = {}; + if (data.is_active !== undefined && data.is_active !== null) { + filter.is_active = parseInt(String(data.is_active)); + } + const hasFilter = Object.keys(filter).length > 0 ? filter : undefined; + const users = await GetAllUsersPaginatedDashboard({ page, limit }, hasFilter); + const totalResult = await GetUsersCount(hasFilter); const total = totalResult ? Number(totalResult.count) : 0; resp = { users, total }; } else if (action === "storeSiteData") { - AdminEditorCan(userDB.role); resp = await storeSiteData(data); } else if (action == "storeMonitorData") { - AdminEditorCan(userDB.role); resp = await CreateUpdateMonitor(data); } else if (action == "updateMonitoringData") { - AdminEditorCan(userDB.role); data.type = GC.MANUAL; resp = await UpdateMonitoringData(data); } else if (action == "getMonitors") { resp = await GetMonitors(data); } else if (action == "deleteMonitor") { - AdminEditorCan(userDB.role); resp = await DeleteMonitorCompletelyUsingTag(data.tag); } else if (action == "deleteMonitorData") { - AdminEditorCan(userDB.role); await db.deleteMonitorDataByTag(data.tag || undefined, data.start, data.end); resp = { success: true }; } else if (action == "cloneMonitor") { - AdminEditorCan(userDB.role); resp = await CloneMonitor({ sourceTag: String(data.sourceTag || ""), newTag: String(data.newTag || ""), newName: String(data.newName || ""), }); } else if (action == "createUpdateTrigger") { - AdminEditorCan(userDB.role); resp = await CreateUpdateTrigger(data); } else if (action == "getTriggers") { resp = await GetAllTriggers(data); } else if (action == "updateMonitorTriggers") { - AdminEditorCan(userDB.role); resp = await UpdateTriggerData(data); } else if (action == "deleteTrigger") { - AdminEditorCan(userDB.role); resp = await DeleteTrigger(data.trigger_id); } else if (action == "getAllAlertsPaginated") { const page = parseInt(String(data.page)) || 1; @@ -249,13 +258,10 @@ export async function POST({ request, cookies }) { } else if (action == "getAPIKeys") { resp = await GetAllAPIKeys(); } else if (action == "createNewApiKey") { - AdminEditorCan(userDB.role); resp = await CreateNewAPIKey(data); } else if (action == "updateApiKeyStatus") { - AdminEditorCan(userDB.role); resp = await UpdateApiKeyStatus(data); } else if (action == "deleteApiKey") { - AdminCan(userDB.role); const deleted = await DeleteApiKey(data); if (!deleted) { throw new Error("API key not found"); @@ -269,33 +275,24 @@ export async function POST({ request, cookies }) { throw new Error("Incident not found"); } } else if (action == "createIncident") { - AdminEditorCan(userDB.role); resp = await CreateIncident(data); } else if (action == "updateIncident") { - AdminEditorCan(userDB.role); resp = await UpdateIncident(data.id, data); } else if (action == "deleteIncident") { - AdminEditorCan(userDB.role); resp = await DeleteIncident(data.incident_id); } else if (action == "addMonitor") { - AdminEditorCan(userDB.role); resp = await AddIncidentMonitor(data.incident_id, data.monitor_tag, data.monitor_impact); } else if (action == "removeMonitor") { - AdminEditorCan(userDB.role); resp = await RemoveIncidentMonitor(data.incident_id, data.monitor_tag); } else if (action == "getComments") { resp = await GetIncidentActiveComments(data.incident_id); } else if (action == "addComment") { - AdminEditorCan(userDB.role); resp = await AddIncidentComment(data.incident_id, data.comment, data.state, data.commented_at); } else if (action == "deleteComment") { - AdminEditorCan(userDB.role); resp = await UpdateCommentStatusByID(data.incident_id, data.comment_id, "INACTIVE"); } else if (action == "updateComment") { - AdminEditorCan(userDB.role); resp = await UpdateCommentByID(data.incident_id, data.comment_id, data.comment, data.state, data.commented_at); } else if (action == "testTrigger") { - AdminEditorCan(userDB.role); const trigger = await GetTriggerByID(data.trigger_id); const siteData = await GetAllSiteData(); if (!trigger || !siteData) { @@ -370,7 +367,6 @@ export async function POST({ request, cookies }) { throw new Error("Unsupported trigger type for testing"); } } else if (action == "testMonitor") { - AdminEditorCan(userDB.role); let monitorID = data.monitor_id; let monitors = await GetMonitorsParsed({ id: monitorID }); let monitor = monitors[0]; @@ -386,10 +382,8 @@ export async function POST({ request, cookies }) { const serviceClient = new Service(monitorReducedType); resp = await serviceClient.execute(); } else if (action == "uploadImage") { - AdminEditorCan(userDB.role); resp = await uploadImage(data); } else if (action == "deleteImage") { - AdminEditorCan(userDB.role); resp = await db.deleteImage(data.id); } else if (action == "getPages") { const pages = await GetAllPages(); @@ -402,26 +396,20 @@ export async function POST({ request, cookies }) { ); resp = pagesWithMonitors; } else if (action == "createPage") { - AdminEditorCan(userDB.role); resp = await CreatePage(data); } else if (action == "updatePage") { - AdminEditorCan(userDB.role); const { id, ...updateData } = data; resp = await UpdatePage(id, updateData); } else if (action == "deletePage") { - AdminEditorCan(userDB.role); await DeletePage(data.id); resp = { success: true }; } else if (action == "addMonitorToPage") { - AdminEditorCan(userDB.role); await AddMonitorToPage(data.page_id, data.monitor_tag); resp = { success: true }; } else if (action == "removeMonitorFromPage") { - AdminEditorCan(userDB.role); await RemoveMonitorFromPage(data.page_id, data.monitor_tag); resp = { success: true }; } else if (action == "reorderPageMonitors") { - AdminEditorCan(userDB.role); await ReorderPageMonitors(data.page_id, data.monitor_tags); resp = { success: true }; } @@ -434,15 +422,12 @@ export async function POST({ request, cookies }) { throw new Error("Maintenance not found"); } } else if (action == "createMaintenance") { - AdminEditorCan(userDB.role); resp = await CreateMaintenance(data); } else if (action == "updateMaintenance") { - AdminEditorCan(userDB.role); const { id, ...updateData } = data; await UpdateMaintenance(id, updateData); resp = { success: true }; } else if (action == "deleteMaintenance") { - AdminEditorCan(userDB.role); await DeleteMaintenance(data.id); resp = { success: true }; } else if (action == "getMaintenanceEvents") { @@ -453,38 +438,30 @@ export async function POST({ request, cookies }) { throw new Error("Maintenance event not found"); } } else if (action == "createMaintenanceEvent") { - AdminEditorCan(userDB.role); resp = await CreateMaintenanceEvent(data); } else if (action == "updateMaintenanceEvent") { - AdminEditorCan(userDB.role); const { id, ...updateData } = data; await UpdateMaintenanceEvent(id, updateData); resp = { success: true }; } else if (action == "deleteMaintenanceEvent") { - AdminEditorCan(userDB.role); await DeleteMaintenanceEvent(data.id); resp = { success: true }; } else if (action == "addMonitorToMaintenance") { - AdminEditorCan(userDB.role); await AddMonitorToMaintenance(data.maintenance_id, data.monitor_tag); resp = { success: true }; } else if (action == "removeMonitorFromMaintenance") { - AdminEditorCan(userDB.role); await RemoveMonitorFromMaintenance(data.maintenance_id, data.monitor_tag); resp = { success: true }; } else if (action == "getMaintenanceMonitors") { resp = await GetMaintenanceMonitors(data.maintenance_id); } else if (action == "updateMaintenanceMonitorImpact") { - AdminEditorCan(userDB.role); await UpdateMaintenanceMonitorImpact(data.maintenance_id, data.monitor_tag, data.monitor_impact); resp = { success: true }; } // ============ Monitor Alert Config Actions ============ else if (action == "createMonitorAlertConfig") { - AdminEditorCan(userDB.role); resp = await CreateMonitorAlertConfig(data); } else if (action == "updateMonitorAlertConfig") { - AdminEditorCan(userDB.role); resp = await UpdateMonitorAlertConfig(data); } else if (action == "getMonitorAlertConfig" || action == "getMonitorAlertConfigById") { resp = await GetMonitorAlertConfigById(data.id); @@ -494,11 +471,9 @@ export async function POST({ request, cookies }) { } else if (action == "getMonitorAlertConfigsByMonitorTag") { resp = await GetMonitorAlertConfigsByMonitorTag(data.monitor_tag); } else if (action == "deleteMonitorAlertConfig") { - AdminEditorCan(userDB.role); await DeleteMonitorAlertConfig(data.id); resp = { success: true }; } else if (action == "toggleMonitorAlertConfigStatus") { - AdminEditorCan(userDB.role); resp = await ToggleMonitorAlertConfigStatus(data.id); } else if (action == "getAlertConfigsPaginated") { const page = parseInt(String(data.page)) || 1; @@ -510,7 +485,6 @@ export async function POST({ request, cookies }) { if (data.alert_for) filter.alert_for = data.alert_for as "STATUS" | "LATENCY" | "UPTIME"; resp = await GetMonitorAlertConfigsPaginated(page, limit, Object.keys(filter).length > 0 ? filter : undefined); } else if (action == "deleteMonitorAlertV2") { - AdminEditorCan(userDB.role); const deleteIncident = data.deleteIncident === true; // If deleteIncident is true, delete the incident first if (deleteIncident && data.incident_id) { @@ -518,7 +492,6 @@ export async function POST({ request, cookies }) { } resp = await DeleteMonitorAlertV2(data.id); } else if (action == "updateMonitorAlertV2Status") { - AdminEditorCan(userDB.role); resp = await UpdateMonitorAlertV2Status(data.id, data.status); } @@ -565,14 +538,12 @@ export async function POST({ request, cookies }) { } else if (action == "getSubscriberCountsByMethod") { resp = await GetSubscriberCountsByMethod(); } else if (action == "deleteUserSubscription") { - AdminCan(userDB.role); const { subscriptionId } = data; if (!subscriptionId) { throw new Error("subscriptionId is required"); } resp = await DeleteUserSubscription(subscriptionId); } else if (action == "updateUserSubscriptionStatus") { - AdminCan(userDB.role); const { subscriptionId, status } = data; if (!subscriptionId || !status) { throw new Error("subscriptionId and status are required"); @@ -594,7 +565,6 @@ export async function POST({ request, cookies }) { throw new Error("Template not found"); } } else if (action == "updateGeneralEmailTemplate") { - AdminEditorCan(userDB.role); const { templateId, template_subject, template_html_body, template_text_body } = data; if (!templateId) { throw new Error("Template ID is required"); @@ -614,7 +584,6 @@ export async function POST({ request, cookies }) { const limit = parseInt(String(data.limit)) || 10; resp = await GetAdminSubscribersPaginated(page, limit); } else if (action == "adminUpdateSubscriptionStatus") { - AdminEditorCan(userDB.role); const { methodId, eventType, enabled } = data; if (!methodId || !eventType) { throw new Error("Method ID and event type are required"); @@ -624,7 +593,6 @@ export async function POST({ request, cookies }) { throw new Error(resp.error); } } else if (action == "adminDeleteSubscriber") { - AdminEditorCan(userDB.role); const { methodId } = data; if (!methodId) { throw new Error("Method ID is required"); @@ -634,7 +602,6 @@ export async function POST({ request, cookies }) { throw new Error(resp.error); } } else if (action == "adminAddSubscriber") { - AdminEditorCan(userDB.role); const { email, incidents, maintenances } = data; if (!email) { throw new Error("Email is required"); @@ -644,7 +611,6 @@ export async function POST({ request, cookies }) { throw new Error(resp.error); } } else if (action == "getSubscriptionsConfig") { - AdminCan(userDB.role); let subscriptionsSettings = await GetSiteDataByKey("subscriptionsSettings"); if (!!!subscriptionsSettings) { subscriptionsSettings = { @@ -669,8 +635,27 @@ export async function POST({ request, cookies }) { } resp = siteData; } else if (action == "updateSubscriptionsConfig") { - AdminCan(userDB.role); resp = await InsertKeyValue("subscriptionsSettings", JSON.stringify(data)); + } else if (action == "getRoles") { + resp = await GetAllRoles(); + } else if (action == "getAllPermissions") { + resp = await GetAllPermissions(); + } else if (action == "getRolePermissions") { + resp = await GetRolePermissions(data.roleId); + } else if (action == "updateRolePermissions") { + resp = await UpdateRolePermissions(data.roleId, data.permissionIds); + } else if (action == "getRoleUsers") { + resp = await GetRoleUsers(data.roleId); + } else if (action == "addUserToRole") { + resp = await AddUserToRole(data.roleId, data.userId); + } else if (action == "removeUserFromRole") { + resp = await RemoveUserFromRole(data.roleId, data.userId); + } else if (action == "createRole") { + resp = await CreateRole({ role_id: data.role_id, name: data.name }); + } else if (action == "updateRole") { + resp = await UpdateRole(data.roleId, { name: data.name, status: data.status }); + } else if (action == "deleteRole") { + resp = await DeleteRole(data.roleId, data.options); } } catch (error: unknown) { console.log(error); diff --git a/src/routes/(manage)/manage/app/api-keys/+page.svelte b/src/routes/(manage)/manage/app/api-keys/+page.svelte index 971371af..c9bfb5c1 100644 --- a/src/routes/(manage)/manage/app/api-keys/+page.svelte +++ b/src/routes/(manage)/manage/app/api-keys/+page.svelte @@ -273,7 +273,7 @@ + {/if} + + + +
+ {#if loading} +
+ +
+ {:else if roles.length === 0} +
No roles found
+ {:else} +
+ + + + Role ID + Name + Status + Type + + + + + {#each roles as role (role.id)} + + {role.id} + {role.role_name} + + + {role.status} + + + + {#if role.readonly === 1} + + + Readonly + + {:else} + Custom + {/if} + + +
+ + + {#if hasPermission("roles.write")} + + {/if} + {#if role.readonly !== 1 && hasPermission("roles.write")} + + + {/if} +
+
+
+ {/each} +
+
+
+ {/if} +
+ + + + + + + Edit Role + + Update {roleToEdit?.role_name} role. + + +
+
+ + +
+
+ + +
+ {#if editError} +

{editError}

+ {/if} +
+ + + + +
+
+ + + + + + Create Role + Create a new custom role with its own permissions. + +
+
+ + +

Lowercase letters, numbers, underscores, hyphens only.

+
+
+ + +
+
+ +
+ + +
+
+ {#if createPermissionMode === "clone"} +
+ + +
+ {/if} + {#if createError} +

{createError}

+ {/if} +
+ + + + +
+
+ + + + + + Delete Role + + Are you sure you want to delete {roleToDelete?.role_name}? + + +
+
+ +
+ + +
+
+ {#if deleteAction === "migrate"} +
+ + +
+ {/if} +
+ + + + +
+
+ + + + + + + Permissions — {permissionsRole?.role_name} + + + {#if permissionsRole?.readonly === 1} + This is a readonly role. Permissions cannot be modified. + {:else} + Toggle permissions for this role. + {/if} + + +
+ {#if loadingPermissions} +
+ +
+ {:else} +
+ + {#each groupedPermissions as group (group.group)} + {@const granted = groupGrantedCount(group.permissions)} + + +
+ {group.label} + 0 ? "secondary" : "outline"} + class="ml-2" + > + {granted}/{group.permissions.length} + +
+
+ +
+ {#each group.permissions as perm (perm.id)} + + {/each} +
+
+
+ {/each} +
+
+ + {#if permissionsRole?.readonly !== 1 && hasPermission("roles.assign_permissions")} +
+ + +
+ {/if} + {/if} +
+
+
+ + + + + + + Users — {usersRole?.role_name} + + Manage users assigned to this role. + + + {#if loadingUsers} +
+ +
+ {:else} + +
+

Current Users ({roleUsers.length})

+ {#if roleUsers.length === 0} +

No users assigned to this role.

+ {:else} +
+ {#each roleUsers as user (user.id)} +
+
+ {user.name} + {user.email} +
+ {#if hasPermission("roles.assign_users")} + + {/if} +
+ {/each} +
+ {/if} +
+ + {#if hasPermission("roles.assign_users")} + + + +
+

Add Users

+ {#if availableUsersToAdd.length === 0} +

All users are already in this role.

+ {:else} +
+ {#each availableUsersToAdd as user (user.id)} +
+
+ {user.name} + {user.email} +
+ +
+ {/each} +
+ {/if} +
+ {/if} + {/if} +
+
diff --git a/src/routes/(manage)/manage/app/site-configurations/+page.svelte b/src/routes/(manage)/manage/app/site-configurations/+page.svelte index fc01aed9..c2ec2d38 100644 --- a/src/routes/(manage)/manage/app/site-configurations/+page.svelte +++ b/src/routes/(manage)/manage/app/site-configurations/+page.svelte @@ -29,7 +29,6 @@ SitemapXMLConfig, GlobalMaintenanceNotificationSettings } from "$lib/types/site.js"; - interface NavItem { name: string; url: string; diff --git a/src/routes/(manage)/manage/app/users/+page.svelte b/src/routes/(manage)/manage/app/users/+page.svelte index f9096120..92c47a67 100644 --- a/src/routes/(manage)/manage/app/users/+page.svelte +++ b/src/routes/(manage)/manage/app/users/+page.svelte @@ -1,10 +1,10 @@
-
+
+
+ + +
{#if loading} {/if} - {#if currentUser.role === "admin" || currentUser.role === "editor"} + {#if hasPermission("users.write")} {#if !canSendEmail}

Email service not configured. Cannot invite new users. Please go to @@ -394,8 +446,8 @@ {/if} - - {user.role} + + {user.role_ids.join(", ")} @@ -406,7 +458,7 @@ {/if} - {#if currentUser.role === "admin" && currentUser.id !== user.id && (user.role !== "admin" || currentUser.is_owner === "YES")} + {#if hasPermission("users.write") && currentUser.id !== user.id} @@ -486,19 +538,23 @@

- - v && (newUser.role = v)}> - - {newUser.role.toUpperCase()} - - - {#if currentUser.role === "admin"} - ADMIN - {/if} - EDITOR - MEMBER - - + +
+ {#each activeRoles as role (role.id)} + + {/each} + {#if activeRoles.length === 0} +

No roles available

+ {/if} +
{#if creatingUserError}

{creatingUserError}

@@ -578,42 +634,41 @@

- Change the role of the user. The user will have different permissions based on the role. + Change the roles of the user. The user will have different permissions based on assigned roles.

-
- v && (toEditUser!.role = v)} - disabled={toEditUser.actions.updatingRole} - > - - {toEditUser.role.toUpperCase()} - - - {#if currentUser.role === "admin"} - ADMIN - {/if} - EDITOR - MEMBER - - - +
+ {#each activeRoles as role (role.id)} + + {/each} + {#if activeRoles.length === 0} +

No roles available

+ {/if}
+ diff --git a/src/routes/(manage)/manage/app/vault/+page.svelte b/src/routes/(manage)/manage/app/vault/+page.svelte deleted file mode 100644 index a5b83a68..00000000 --- a/src/routes/(manage)/manage/app/vault/+page.svelte +++ /dev/null @@ -1,339 +0,0 @@ - - -
- -
-
- -
-

Vault

-

Securely store and manage your secrets

-
-
- {#if !isEditing} - - {/if} -
- - {#if loading} -
- -
- {:else} - - {#if isEditing} - - - {editingId ? "Edit Secret" : "Add New Secret"} - - {editingId ? "Update the secret details below" : "Enter the secret name and value below"} - - - -
- - -
-
- -