Merge pull request #695 from rajnandan1/rbac-1-2

Implement role and permission management system with seeding scripts …
This commit is contained in:
Raj Nandan Sharma
2026-03-31 22:08:34 +05:30
committed by GitHub
23 changed files with 2366 additions and 669 deletions
@@ -0,0 +1,58 @@
import type { Knex } from "knex";
export async function up(knex: Knex): Promise<void> {
// 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<void> {
await knex.schema.dropTableIfExists("users_roles");
await knex.schema.dropTableIfExists("roles_permissions");
await knex.schema.dropTableIfExists("permissions");
await knex.schema.dropTableIfExists("roles");
}
@@ -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<string, string> = {
admin: "admin",
editor: "editor",
member: "member",
user: "member",
};
export async function up(knex: Knex): Promise<void> {
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<void> {
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<number, string[]>();
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 });
}
}
+28
View File
@@ -0,0 +1,28 @@
import type { Knex } from "knex";
import { permissions } from "../src/lib/allPerms.ts";
export async function seed(knex: Knex): Promise<void> {
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();
}
}
+106
View File
@@ -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<string, string[]> = {
admin: allPermissionIds,
editor: allPermissionIds.filter((id) => id !== "api_keys.delete"),
member: readPermissionIds,
};
export async function seed(knex: Knex): Promise<void> {
// 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(),
});
}
}
}
}
+274
View File
@@ -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<string, string | null> = {
// 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<string, string | null> = {
// 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",
};
+282 -61
View File
@@ -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<UserRecordPublic[]> => {
return await db.getUsersPaginated(data.page, data.limit);
export const GetAllUsersPaginated = async (
data: PaginationInput,
filter?: { is_active?: number },
): Promise<UserRecordPublic[]> => {
return await db.getUsersPaginated(data.page, data.limit, filter);
};
export const GetAllUsersPaginatedDashboard = async (data: PaginationInput): Promise<UserRecordDashboard[]> => {
const users = await db.getUsersPaginated(data.page, data.limit);
export const GetAllUsersPaginatedDashboard = async (
data: PaginationInput,
filter?: { is_active?: number },
): Promise<UserRecordDashboard[]> => {
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<number> =>
}
};
export const CreateNewUser = async (currentUser: { role: string }, data: NewUserInput): Promise<number[]> => {
let acceptedRoles = ["member", "editor"];
if (!acceptedRoles.includes(data.role)) {
throw new Error("Invalid role");
export const CreateNewUser = async (data: NewUserInput): Promise<number[]> => {
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<number>
});
};
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<number | undefined> => {
export const ManualUpdateUserData = async (forUserId: number, data: ManualUserUpdateInput): Promise<number | void> => {
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<number> => {
};
//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<RoleRecord> => {
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<RoleRecord> => {
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<RoleRecord[]> => {
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<Set<string>> => {
const permissionIds = await db.getUserPermissionIds(userId);
return new Set(permissionIds);
};
export const RequirePermission = (userPermissions: Set<string>, permissionId: string): void => {
if (!userPermissions.has(permissionId)) {
throw new Error("You do not have permission to perform this action");
}
};
+38 -2
View File
@@ -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 {
+238 -20
View File
@@ -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<CountResult>();
}
private readonly userColumns = [
"id",
"email",
"name",
"is_active",
"is_verified",
"is_owner",
"created_at",
"updated_at",
] as const;
private async enrichWithRoleIds(user: Record<string, unknown>): Promise<UserRecordPublic> {
const roleIds = await this.getUserRoleIds(user.id as number);
return { ...user, role_ids: roleIds } as UserRecordPublic;
}
private async enrichManyWithRoleIds(users: Record<string, unknown>[]): Promise<UserRecordPublic[]> {
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<number, string[]>();
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<UserRecordPublic | undefined> {
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<UserRecordPublic | undefined> {
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<number[]> {
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<number> {
@@ -54,21 +119,31 @@ export class UsersRepository extends BaseRepository {
}
async getAllUsers(): Promise<UserRecordPublic[]> {
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<UserRecordPublic[]> {
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<UserRecordPublic[]> {
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<CountResult | undefined> {
return await this.knex("users").count("* as count").first<CountResult>();
async getTotalUsers(filter?: { is_active?: number }): Promise<CountResult | undefined> {
const query = this.knex("users").count("* as count");
if (filter?.is_active !== undefined) {
query.where("is_active", filter.is_active);
}
return await query.first<CountResult>();
}
async updateUserName(id: number, name: string): Promise<number> {
@@ -78,11 +153,18 @@ export class UsersRepository extends BaseRepository {
});
}
async updateUserRole(id: number, role: string): Promise<number> {
return await this.knex("users").where({ id }).update({
role,
updated_at: this.knex.fn.now(),
});
async updateUserRoles(id: number, roleIds: string[]): Promise<void> {
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<number> {
@@ -138,4 +220,140 @@ export class UsersRepository extends BaseRepository {
}
// ============ Invitations ============
// ============ Roles ============
async getRoleById(id: string): Promise<RoleRecord | undefined> {
return await this.knex("roles").where("id", id).first();
}
async getAllRoles(): Promise<RoleRecord[]> {
return await this.knex("roles").orderBy("created_at", "asc");
}
async insertRole(data: { id: string; role_name: string; readonly?: number }): Promise<void> {
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<number> {
const updateData: Record<string, unknown> = { 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<number> {
return await this.knex("roles").where("id", id).delete();
}
async getUsersCountByRoleId(roleId: string): Promise<number> {
const result = await this.knex("users_roles").where("roles_id", roleId).count("* as count").first<CountResult>();
return result ? Number(result.count) : 0;
}
async migrateUsersRole(fromRoleId: string, toRoleId: string): Promise<void> {
// 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<RolePermissionRecord[]> {
return await this.knex("roles_permissions").where("roles_id", roleId);
}
async getAllPermissions(): Promise<Array<{ id: string; permission_name: string }>> {
return await this.knex("permissions").select("id", "permission_name").orderBy("id", "asc");
}
async addRolePermission(roleId: string, permissionId: string): Promise<void> {
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<number> {
return await this.knex("roles_permissions").where({ roles_id: roleId, permissions_id: permissionId }).delete();
}
// ============ Role Users ============
async getUsersByRoleId(roleId: string): Promise<Array<UserRecordPublic & { roles_id: string }>> {
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<void> {
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<number> {
return await this.knex("users_roles").where({ roles_id: roleId, users_id: userId }).delete();
}
async getUserRoleIds(userId: number): Promise<string[]> {
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<string[]> {
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);
}
}
+28 -3
View File
@@ -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;
+27 -8
View File
@@ -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";
</script>
@@ -21,7 +15,32 @@
<svelte:head>
<meta name="robots" content="noindex, nofollow" />
{@html `<style>:root{--up:${colorUp};--degraded:${colorDegraded};--down:${colorDown};--maintenance:${colorMaintenance};}</style>`}
<link rel="icon" href={data.favicon} />
{#if data.font?.cssSrc}
<link rel="stylesheet" href={data.font.cssSrc} />
{/if}
{@html `
<style id="dynamic-styles">
body {
--up: ${data.siteStatusColors.UP};
--degraded: ${data.siteStatusColors.DEGRADED};
--down: ${data.siteStatusColors.DOWN};
--maintenance: ${data.siteStatusColors.MAINTENANCE};
--accent: ${data.siteStatusColors.ACCENT || "#f4f4f5"};
--accent-foreground: ${data.siteStatusColors.ACCENT_FOREGROUND || data.siteStatusColors.ACCENT || "#e96e2d"};
${data.font?.family ? `--font-family:'${data.font.family}', sans-serif;` : ""}
}
:is(.dark) body {
--up: ${data.siteStatusColorsDark.UP};
--degraded: ${data.siteStatusColorsDark.DEGRADED};
--down: ${data.siteStatusColorsDark.DOWN};
--maintenance: ${data.siteStatusColorsDark.MAINTENANCE};
--accent: ${data.siteStatusColorsDark.ACCENT || "#27272a"};
--accent-foreground: ${data.siteStatusColorsDark.ACCENT_FOREGROUND || data.siteStatusColorsDark.ACCENT || "#e96e2d"};
}
${data.customCSS || ""}
</style>`}
<script src={clientResolver(resolve, "/capture.js")}></script>
</svelte:head>
<main>
<!-- Nav -->
@@ -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, {
+4
View File
@@ -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"
@@ -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.
@@ -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}
+20 -2
View File
@@ -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,
+17 -4
View File
@@ -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();
</script>
<ModeWatcher />
+68 -83
View File
@@ -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);
@@ -273,7 +273,7 @@
<Table.Cell class="pr-4 text-right">
<Button
variant="destructive"
disabled={page.data.userDb.role !== "admin"}
disabled={!page.data.userPermissions?.includes("api_keys.delete")}
size="sm"
onclick={() => openDeleteDialog(apiKey)}
>
@@ -0,0 +1,821 @@
<script lang="ts">
import * as Accordion from "$lib/components/ui/accordion/index.js";
import * as Card from "$lib/components/ui/card/index.js";
import * as Table from "$lib/components/ui/table/index.js";
import * as Dialog from "$lib/components/ui/dialog/index.js";
import * as Sheet from "$lib/components/ui/sheet/index.js";
import * as Checkbox from "$lib/components/ui/checkbox/index.js";
import { Button } from "$lib/components/ui/button/index.js";
import { Input } from "$lib/components/ui/input/index.js";
import { Label } from "$lib/components/ui/label/index.js";
import { Badge } from "$lib/components/ui/badge/index.js";
import { Spinner } from "$lib/components/ui/spinner/index.js";
import { Separator } from "$lib/components/ui/separator/index.js";
import ShieldIcon from "@lucide/svelte/icons/shield";
import PlusIcon from "@lucide/svelte/icons/plus";
import LockIcon from "@lucide/svelte/icons/lock";
import KeyIcon from "@lucide/svelte/icons/key";
import UsersIcon from "@lucide/svelte/icons/users";
import PencilIcon from "@lucide/svelte/icons/pencil";
import CopyIcon from "@lucide/svelte/icons/copy";
import TrashIcon from "@lucide/svelte/icons/trash-2";
import UserMinusIcon from "@lucide/svelte/icons/user-minus";
import UserPlusIcon from "@lucide/svelte/icons/user-plus";
import { toast } from "svelte-sonner";
import { onMount } from "svelte";
import { resolve } from "$app/paths";
import clientResolver from "$lib/client/resolver.js";
import type { UserRecordPublic, RoleRecord } from "$lib/server/types/db.js";
interface Permission {
id: string;
permission_name: string;
}
interface RoleUser extends UserRecordPublic {
roles_id: string;
}
interface PageData {
userDb: UserRecordPublic;
userPermissions: string[];
}
let { data }: { data: PageData } = $props();
let currentUser = $derived(data.userDb);
let userPermissions = $derived(data.userPermissions);
function hasPermission(perm: string): boolean {
return userPermissions.includes(perm);
}
// State
let loading = $state(true);
let roles = $state<RoleRecord[]>([]);
let allPermissions = $state<Permission[]>([]);
let allUsers = $state<UserRecordPublic[]>([]);
// Create role dialog
let showCreateDialog = $state(false);
let creatingRole = $state(false);
let createError = $state("");
let newRole = $state({ role_id: "", name: "" });
let createPermissionMode = $state<"pick" | "clone">("pick");
let cloneFromRoleId = $state("");
// Delete role dialog
let showDeleteDialog = $state(false);
let deletingRole = $state(false);
let roleToDelete = $state<RoleRecord | null>(null);
let deleteAction = $state<"migrate" | "remove">("remove");
let deleteTargetRoleId = $state("");
// Edit role dialog
let showEditDialog = $state(false);
let editingRole = $state(false);
let editError = $state("");
let roleToEdit = $state<RoleRecord | null>(null);
let editRole = $state({ name: "", status: "ACTIVE" });
// Permissions sheet
let showPermissionsSheet = $state(false);
let permissionsRole = $state<RoleRecord | null>(null);
let rolePermissionIds = $state<Set<string>>(new Set());
let savingPermissions = $state(false);
let loadingPermissions = $state(false);
// Users sheet
let showUsersSheet = $state(false);
let usersRole = $state<RoleRecord | null>(null);
let roleUsers = $state<RoleUser[]>([]);
let loadingUsers = $state(false);
let addingUserId = $state<number | null>(null);
let removingUserId = $state<number | null>(null);
const apiUrl = clientResolver(resolve, "/manage/api");
async function apiCall(action: string, data: Record<string, unknown> = {}) {
const res = await fetch(apiUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action, data })
});
const result = await res.json();
if (result.error) throw new Error(result.error);
return result;
}
async function fetchRoles() {
loading = true;
try {
const result = await apiCall("getRoles");
roles = result;
} catch {
toast.error("Failed to load roles");
} finally {
loading = false;
}
}
async function fetchAllPermissions() {
try {
allPermissions = await apiCall("getAllPermissions");
} catch {
toast.error("Failed to load permissions");
}
}
async function fetchAllUsers() {
try {
const result = await apiCall("getUsers", { page: 1, limit: 1000 });
allUsers = result.users || [];
} catch {
toast.error("Failed to load users");
}
}
function openCreateDialog(prefill?: { role_id: string; name: string; cloneFromRoleId: string }) {
if (prefill) {
newRole = { role_id: prefill.role_id, name: prefill.name };
createPermissionMode = "clone";
cloneFromRoleId = prefill.cloneFromRoleId;
} else {
newRole = { role_id: "", name: "" };
createPermissionMode = "pick";
cloneFromRoleId = "";
}
createError = "";
showCreateDialog = true;
}
// Create role
async function handleCreateRole() {
createError = "";
if (!newRole.role_id.trim()) {
createError = "Role ID is required";
return;
}
if (!newRole.name.trim()) {
createError = "Role name is required";
return;
}
if (createPermissionMode === "clone" && !cloneFromRoleId) {
createError = "Please select a role to clone permissions from";
return;
}
creatingRole = true;
try {
const created = await apiCall("createRole", { role_id: newRole.role_id, name: newRole.name });
// Clone permissions if selected
if (createPermissionMode === "clone" && cloneFromRoleId) {
const sourcePerms = await apiCall("getRolePermissions", { roleId: cloneFromRoleId });
const permIds = sourcePerms.map((p: { permissions_id: string }) => p.permissions_id);
if (permIds.length > 0) {
await apiCall("updateRolePermissions", {
roleId: newRole.role_id.trim().toLowerCase().replace(/\s+/g, "_"),
permissionIds: permIds
});
}
}
toast.success("Role created");
showCreateDialog = false;
const createdRoleId = newRole.role_id.trim().toLowerCase().replace(/\s+/g, "_");
newRole = { role_id: "", name: "" };
cloneFromRoleId = "";
createPermissionMode = "pick";
await fetchRoles();
// Open permissions sheet for the newly created role
const createdRole = roles.find((r) => r.id === createdRoleId);
if (createdRole) {
openPermissions(createdRole);
}
} catch (e: unknown) {
createError = e instanceof Error ? e.message : "Failed to create role";
} finally {
creatingRole = false;
}
}
// Delete role
async function handleDeleteRole() {
if (!roleToDelete) return;
deletingRole = true;
try {
const options =
deleteAction === "migrate"
? { action: "migrate" as const, targetRoleId: deleteTargetRoleId }
: { action: "remove" as const };
await apiCall("deleteRole", { roleId: roleToDelete.id, options });
toast.success("Role deleted");
showDeleteDialog = false;
roleToDelete = null;
await fetchRoles();
} catch (e: unknown) {
toast.error(e instanceof Error ? e.message : "Failed to delete role");
} finally {
deletingRole = false;
}
}
// Edit role
function openEditDialog(role: RoleRecord) {
roleToEdit = role;
editRole = { name: role.role_name, status: role.status };
editError = "";
showEditDialog = true;
}
async function handleEditRole() {
if (!roleToEdit) return;
editError = "";
if (!editRole.name.trim()) {
editError = "Role name is required";
return;
}
editingRole = true;
try {
await apiCall("updateRole", {
roleId: roleToEdit.id,
name: editRole.name,
status: editRole.status
});
toast.success("Role updated");
showEditDialog = false;
roleToEdit = null;
await fetchRoles();
} catch (e: unknown) {
editError = e instanceof Error ? e.message : "Failed to update role";
} finally {
editingRole = false;
}
}
// Open permissions sheet
async function openPermissions(role: RoleRecord) {
permissionsRole = role;
rolePermissionIds = new Set();
showPermissionsSheet = true;
loadingPermissions = true;
try {
const perms = await apiCall("getRolePermissions", { roleId: role.id });
rolePermissionIds = new Set(perms.map((p: { permissions_id: string }) => p.permissions_id));
} catch {
toast.error("Failed to load permissions");
} finally {
loadingPermissions = false;
}
}
// Save permissions
async function savePermissions() {
if (!permissionsRole) return;
savingPermissions = true;
try {
await apiCall("updateRolePermissions", {
roleId: permissionsRole.id,
permissionIds: Array.from(rolePermissionIds)
});
toast.success("Permissions updated");
showPermissionsSheet = false;
} catch (e: unknown) {
toast.error(e instanceof Error ? e.message : "Failed to update permissions");
} finally {
savingPermissions = false;
}
}
function togglePermission(permId: string) {
const next = new Set(rolePermissionIds);
if (next.has(permId)) {
next.delete(permId);
} else {
next.add(permId);
}
rolePermissionIds = next;
}
// Open users sheet
async function openUsers(role: RoleRecord) {
usersRole = role;
roleUsers = [];
showUsersSheet = true;
loadingUsers = true;
try {
const canAssign = hasPermission("roles.assign_users");
const [users] = await Promise.all([
apiCall("getRoleUsers", { roleId: role.id }),
canAssign ? fetchAllUsers() : Promise.resolve()
]);
roleUsers = users;
} catch {
toast.error("Failed to load role users");
} finally {
loadingUsers = false;
}
}
// Add user to role
async function addUser(userId: number) {
if (!usersRole) return;
addingUserId = userId;
try {
await apiCall("addUserToRole", { roleId: usersRole.id, userId });
toast.success("User added to role");
roleUsers = await apiCall("getRoleUsers", { roleId: usersRole.id });
} catch (e: unknown) {
toast.error(e instanceof Error ? e.message : "Failed to add user");
} finally {
addingUserId = null;
}
}
// Remove user from role
async function removeUser(userId: number) {
if (!usersRole) return;
removingUserId = userId;
try {
await apiCall("removeUserFromRole", { roleId: usersRole.id, userId });
toast.success("User removed from role");
roleUsers = await apiCall("getRoleUsers", { roleId: usersRole.id });
} catch (e: unknown) {
toast.error(e instanceof Error ? e.message : "Failed to remove user");
} finally {
removingUserId = null;
}
}
let groupedPermissions = $derived.by(() => {
const groups: Array<{ group: string; label: string; permissions: Permission[] }> = [];
const groupMap = new Map<string, Permission[]>();
for (const perm of allPermissions) {
const dotIndex = perm.id.indexOf(".");
const group = dotIndex > -1 ? perm.id.substring(0, dotIndex) : perm.id;
if (!groupMap.has(group)) groupMap.set(group, []);
groupMap.get(group)!.push(perm);
}
for (const [group, perms] of groupMap) {
const label = group.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
groups.push({ group, label, permissions: perms });
}
return groups;
});
function groupGrantedCount(perms: Permission[]): number {
return perms.filter((p) => rolePermissionIds.has(p.id)).length;
}
let availableUsersToAdd = $derived(allUsers.filter((u) => !roleUsers.some((ru) => ru.id === u.id)));
onMount(async () => {
await Promise.all([fetchRoles(), fetchAllPermissions()]);
});
</script>
<div class="kener-manage flex flex-1 flex-col gap-4 p-4">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<ShieldIcon class="h-5 w-5" />
<h2 class="text-xl font-semibold">Roles</h2>
</div>
{#if hasPermission("roles.write")}
<Button size="sm" onclick={() => openCreateDialog()}>
<PlusIcon class="mr-1 h-4 w-4" />
Create Role
</Button>
{/if}
</div>
<!-- Roles Table -->
<div class="">
{#if loading}
<div class="flex items-center justify-center p-8">
<Spinner class="h-6 w-6" />
</div>
{:else if roles.length === 0}
<div class="text-muted-foreground p-8 text-center text-sm">No roles found</div>
{:else}
<div class="ktable overflow-hidden rounded-xl border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Role ID</Table.Head>
<Table.Head>Name</Table.Head>
<Table.Head>Status</Table.Head>
<Table.Head>Type</Table.Head>
<Table.Head class="text-right"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each roles as role (role.id)}
<Table.Row>
<Table.Cell class="font-mono text-sm">{role.id}</Table.Cell>
<Table.Cell>{role.role_name}</Table.Cell>
<Table.Cell>
<Badge variant={role.status === "ACTIVE" ? "default" : "secondary"}>
{role.status}
</Badge>
</Table.Cell>
<Table.Cell>
{#if role.readonly === 1}
<Badge variant="outline">
<LockIcon class="mr-1 h-3 w-3" />
Readonly
</Badge>
{:else}
<Badge variant="outline">Custom</Badge>
{/if}
</Table.Cell>
<Table.Cell class="text-right">
<div class="flex items-center justify-end gap-1">
<Button variant="ghost" size="sm" onclick={() => openPermissions(role)}>
<KeyIcon class="mr-1 h-4 w-4" />
Permissions
</Button>
<Button variant="ghost" size="sm" onclick={() => openUsers(role)}>
<UsersIcon class="mr-1 h-4 w-4" />
Users
</Button>
{#if hasPermission("roles.write")}
<Button
variant="ghost"
size="sm"
title="Duplicate"
onclick={() =>
openCreateDialog({
role_id: role.id + "-copy",
name: role.role_name + " Copy",
cloneFromRoleId: role.id
})}
>
<CopyIcon class="h-4 w-4" />
</Button>
{/if}
{#if role.readonly !== 1 && hasPermission("roles.write")}
<Button variant="ghost" size="sm" onclick={() => openEditDialog(role)}>
<PencilIcon class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onclick={() => {
roleToDelete = role;
deleteAction = "remove";
deleteTargetRoleId = "";
showDeleteDialog = true;
}}
>
<TrashIcon class="text-destructive h-4 w-4" />
</Button>
{/if}
</div>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{/if}
</div>
</div>
<!-- Edit Role Dialog -->
<Dialog.Root bind:open={showEditDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Edit Role</Dialog.Title>
<Dialog.Description>
Update <span class="font-semibold">{roleToEdit?.role_name}</span> role.
</Dialog.Description>
</Dialog.Header>
<div class="grid gap-4 py-4">
<div class="grid gap-2">
<Label for="edit-role-name">Role Name</Label>
<Input id="edit-role-name" bind:value={editRole.name} />
</div>
<div class="grid gap-2">
<Label for="edit-role-status">Status</Label>
<select
id="edit-role-status"
class="border-input flex h-9 w-full rounded-md border bg-transparent px-3 py-1 text-sm shadow-sm"
bind:value={editRole.status}
>
<option value="ACTIVE">Active</option>
<option value="INACTIVE">Inactive</option>
</select>
</div>
{#if editError}
<p class="text-destructive text-sm">{editError}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="outline" onclick={() => (showEditDialog = false)}>Cancel</Button>
<Button onclick={handleEditRole} disabled={editingRole}>
{#if editingRole}
<Spinner class="mr-2 h-4 w-4" />
{/if}
Save
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<!-- Create Role Dialog -->
<Dialog.Root bind:open={showCreateDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Create Role</Dialog.Title>
<Dialog.Description>Create a new custom role with its own permissions.</Dialog.Description>
</Dialog.Header>
<div class="grid gap-4 py-4">
<div class="grid gap-2">
<Label for="role-id">Role ID</Label>
<Input id="role-id" placeholder="e.g. viewer" bind:value={newRole.role_id} />
<p class="text-muted-foreground text-xs">Lowercase letters, numbers, underscores, hyphens only.</p>
</div>
<div class="grid gap-2">
<Label for="role-name">Role Name</Label>
<Input id="role-name" placeholder="e.g. Viewer" bind:value={newRole.name} />
</div>
<div class="grid gap-2">
<Label>Permissions</Label>
<div class="flex gap-2">
<Button
variant={createPermissionMode === "pick" ? "default" : "outline"}
size="sm"
onclick={() => {
createPermissionMode = "pick";
cloneFromRoleId = "";
}}
>
Pick after creation
</Button>
<Button
variant={createPermissionMode === "clone" ? "default" : "outline"}
size="sm"
onclick={() => (createPermissionMode = "clone")}
>
Clone from existing role
</Button>
</div>
</div>
{#if createPermissionMode === "clone"}
<div class="grid gap-2">
<Label for="clone-role">Clone permissions from</Label>
<select
id="clone-role"
class="border-input flex h-9 w-full rounded-md border bg-transparent px-3 py-1 text-sm shadow-sm"
bind:value={cloneFromRoleId}
>
<option value="">Select a role...</option>
{#each roles.filter((r) => r.status === "ACTIVE") as r (r.id)}
<option value={r.id}>{r.role_name}</option>
{/each}
</select>
</div>
{/if}
{#if createError}
<p class="text-destructive text-sm">{createError}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="outline" onclick={() => (showCreateDialog = false)}>Cancel</Button>
<Button onclick={handleCreateRole} disabled={creatingRole}>
{#if creatingRole}
<Spinner class="mr-2 h-4 w-4" />
{/if}
Create
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<!-- Delete Role Dialog -->
<Dialog.Root bind:open={showDeleteDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Delete Role</Dialog.Title>
<Dialog.Description>
Are you sure you want to delete <span class="font-semibold">{roleToDelete?.role_name}</span>?
</Dialog.Description>
</Dialog.Header>
<div class="grid gap-4 py-4">
<div class="grid gap-2">
<Label>What should happen to users in this role?</Label>
<div class="flex gap-2">
<Button
variant={deleteAction === "remove" ? "default" : "outline"}
size="sm"
onclick={() => (deleteAction = "remove")}
>
Remove assignments
</Button>
<Button
variant={deleteAction === "migrate" ? "default" : "outline"}
size="sm"
onclick={() => (deleteAction = "migrate")}
>
Migrate to another role
</Button>
</div>
</div>
{#if deleteAction === "migrate"}
<div class="grid gap-2">
<Label for="target-role">Target Role</Label>
<select
id="target-role"
class="border-input flex h-9 w-full rounded-md border bg-transparent px-3 py-1 text-sm shadow-sm"
bind:value={deleteTargetRoleId}
>
<option value="">Select a role...</option>
{#each roles.filter((r) => r.id !== roleToDelete?.id && r.status === "ACTIVE") as r (r.id)}
<option value={r.id}>{r.role_name}</option>
{/each}
</select>
</div>
{/if}
</div>
<Dialog.Footer>
<Button variant="outline" onclick={() => (showDeleteDialog = false)}>Cancel</Button>
<Button
variant="destructive"
onclick={handleDeleteRole}
disabled={deletingRole || (deleteAction === "migrate" && !deleteTargetRoleId)}
>
{#if deletingRole}
<Spinner class="mr-2 h-4 w-4" />
{/if}
Delete
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<!-- Permissions Sheet -->
<Sheet.Root bind:open={showPermissionsSheet}>
<Sheet.Content side="right" class="w-full overflow-y-auto sm:max-w-lg">
<Sheet.Header>
<Sheet.Title>
Permissions — {permissionsRole?.role_name}
</Sheet.Title>
<Sheet.Description>
{#if permissionsRole?.readonly === 1}
This is a readonly role. Permissions cannot be modified.
{:else}
Toggle permissions for this role.
{/if}
</Sheet.Description>
</Sheet.Header>
<div class=" p-4">
{#if loadingPermissions}
<div class="flex items-center justify-center p-8">
<Spinner class="h-6 w-6" />
</div>
{:else}
<div class="rounded-xl border">
<Accordion.Root type="multiple">
{#each groupedPermissions as group (group.group)}
{@const granted = groupGrantedCount(group.permissions)}
<Accordion.Item value={group.group}>
<Accordion.Trigger class="px-4">
<div>
<span class="capitalize">{group.label}</span>
<Badge
variant={granted === group.permissions.length ? "default" : granted > 0 ? "secondary" : "outline"}
class="ml-2"
>
{granted}/{group.permissions.length}
</Badge>
</div>
</Accordion.Trigger>
<Accordion.Content class="px-4">
<div class="flex flex-col gap-2 pt-0">
{#each group.permissions as perm (perm.id)}
<Button
variant={rolePermissionIds.has(perm.id) ? "outline" : "ghost"}
class="h-auto justify-start gap-3 p-3 text-left {rolePermissionIds.has(perm.id)
? 'border-primary bg-primary/5'
: ''}"
disabled={permissionsRole?.readonly === 1 || !hasPermission("roles.assign_permissions")}
onclick={() => togglePermission(perm.id)}
>
<Checkbox.Root
checked={rolePermissionIds.has(perm.id)}
disabled={permissionsRole?.readonly === 1 || !hasPermission("roles.assign_permissions")}
/>
<div class="flex flex-col">
<span class="text-sm font-medium">{perm.permission_name}</span>
</div>
</Button>
{/each}
</div>
</Accordion.Content>
</Accordion.Item>
{/each}
</Accordion.Root>
</div>
{#if permissionsRole?.readonly !== 1 && hasPermission("roles.assign_permissions")}
<div class="flex justify-end gap-2 p-4">
<Button variant="outline" onclick={() => (showPermissionsSheet = false)}>Cancel</Button>
<Button onclick={savePermissions} disabled={savingPermissions}>
{#if savingPermissions}
<Spinner class="mr-2 h-4 w-4" />
{/if}
Save Permissions
</Button>
</div>
{/if}
{/if}
</div>
</Sheet.Content>
</Sheet.Root>
<!-- Users Sheet -->
<Sheet.Root bind:open={showUsersSheet}>
<Sheet.Content side="right" class="w-full overflow-y-auto sm:max-w-lg">
<Sheet.Header>
<Sheet.Title>
Users — {usersRole?.role_name}
</Sheet.Title>
<Sheet.Description>Manage users assigned to this role.</Sheet.Description>
</Sheet.Header>
{#if loadingUsers}
<div class="flex items-center justify-center p-8">
<Spinner class="h-6 w-6" />
</div>
{:else}
<!-- Current users in role -->
<div class="p-4">
<h4 class="mb-2 text-sm font-medium">Current Users ({roleUsers.length})</h4>
{#if roleUsers.length === 0}
<p class="text-muted-foreground text-sm">No users assigned to this role.</p>
{:else}
<div class="flex flex-col gap-2">
{#each roleUsers as user (user.id)}
<div class="flex items-center justify-between rounded-md border p-3">
<div class="flex flex-col">
<span class="text-sm font-medium">{user.name}</span>
<span class="text-muted-foreground text-xs">{user.email}</span>
</div>
{#if hasPermission("roles.assign_users")}
<Button
variant="ghost"
size="sm"
disabled={removingUserId === user.id}
onclick={() => removeUser(user.id)}
>
{#if removingUserId === user.id}
<Spinner class="h-4 w-4" />
{:else}
<UserMinusIcon class="text-destructive h-4 w-4" />
{/if}
</Button>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{#if hasPermission("roles.assign_users")}
<Separator />
<!-- Add users -->
<div class="p-4">
<h4 class="mb-2 text-sm font-medium">Add Users</h4>
{#if availableUsersToAdd.length === 0}
<p class="text-muted-foreground text-sm">All users are already in this role.</p>
{:else}
<div class="flex max-h-64 flex-col gap-2 overflow-y-auto">
{#each availableUsersToAdd as user (user.id)}
<div class="flex items-center justify-between rounded-md border p-3">
<div class="flex flex-col">
<span class="text-sm font-medium">{user.name}</span>
<span class="text-muted-foreground text-xs">{user.email}</span>
</div>
<Button
variant="ghost"
size="sm"
disabled={addingUserId === user.id}
onclick={() => addUser(user.id)}
>
{#if addingUserId === user.id}
<Spinner class="h-4 w-4" />
{:else}
<UserPlusIcon class="h-4 w-4" />
{/if}
</Button>
</div>
{/each}
</div>
{/if}
</div>
{/if}
{/if}
</Sheet.Content>
</Sheet.Root>
@@ -29,7 +29,6 @@
SitemapXMLConfig,
GlobalMaintenanceNotificationSettings
} from "$lib/types/site.js";
interface NavItem {
name: string;
url: string;
+127 -72
View File
@@ -1,10 +1,10 @@
<script lang="ts">
import * as Card from "$lib/components/ui/card/index.js";
import * as Table from "$lib/components/ui/table/index.js";
import * as Select from "$lib/components/ui/select/index.js";
import * as Dialog from "$lib/components/ui/dialog/index.js";
import * as Sheet from "$lib/components/ui/sheet/index.js";
import * as Alert from "$lib/components/ui/alert/index.js";
import { Checkbox } from "$lib/components/ui/checkbox/index.js";
import { Button } from "$lib/components/ui/button/index.js";
import { Input } from "$lib/components/ui/input/index.js";
import { Label } from "$lib/components/ui/label/index.js";
@@ -27,7 +27,7 @@
import { toast } from "svelte-sonner";
import { format } from "date-fns";
import { onMount } from "svelte";
import type { UserRecordDashboard, UserRecordPublic } from "$lib/server/types/db.js";
import type { UserRecordDashboard, UserRecordPublic, RoleRecord } from "$lib/server/types/db.js";
import { resolve } from "$app/paths";
import clientResolver from "$lib/client/resolver.js";
@@ -35,7 +35,7 @@
interface NewUser {
name: string;
email: string;
role: string;
role_ids: string[];
}
interface EditUser extends UserRecordDashboard {
@@ -50,6 +50,7 @@
interface PageData {
userDb: UserRecordPublic;
userPermissions: string[];
canSendEmail: boolean;
}
@@ -57,15 +58,22 @@
// Derived from data
let currentUser = $derived(data.userDb);
let userPermissions = $derived(data.userPermissions);
let canSendEmail = $derived(data.canSendEmail);
function hasPermission(perm: string): boolean {
return userPermissions.includes(perm);
}
// State
let loading = $state(true);
let users = $state<UserRecordDashboard[]>([]);
let roles = $state<RoleRecord[]>([]);
let page = $state(1);
let limit = $state(10);
let total = $state(0);
let totalPages = $state(0);
let statusFilter = $state<"ACTIVE" | "INACTIVE">("ACTIVE");
// Add user modal state
let showAddUserDialog = $state(false);
@@ -74,8 +82,7 @@
let newUser = $state<NewUser>({
name: "",
email: "",
role: "member"
role_ids: []
});
// Edit user sheet state
@@ -104,7 +111,7 @@
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "getUsers",
data: { page, limit }
data: { page, limit, is_active: statusFilter === "ACTIVE" ? 1 : 0 }
})
});
const result = await res.json();
@@ -148,8 +155,8 @@
creatingUserError = "Please enter a valid email address";
return;
}
if (!["admin", "editor", "member"].includes(newUser.role)) {
creatingUserError = "Invalid role selected";
if (newUser.role_ids.length === 0) {
creatingUserError = "At least one role must be selected";
return;
}
@@ -189,8 +196,7 @@
newUser = {
name: "",
email: "",
role: "member"
role_ids: []
};
}
@@ -198,7 +204,6 @@
function openSettingsSheet(user: UserRecordDashboard) {
toEditUser = {
...JSON.parse(JSON.stringify(user)),
actions: {
sendingVerificationEmail: false,
resendingInvitation: false,
@@ -308,32 +313,79 @@
}
}
// Role badge variant
function getRoleBadgeVariant(role: string): "default" | "secondary" | "outline" {
switch (role) {
case "admin":
return "default";
case "editor":
return "secondary";
default:
return "outline";
// Role badge variant by precedence: admin > editor > others
function getRoleBadgeVariant(roleIds: string[]): "default" | "secondary" | "outline" {
if (roleIds.includes("admin")) return "default";
if (roleIds.includes("editor")) return "secondary";
return "outline";
}
let activeRoles = $derived(roles.filter((r) => r.status === "ACTIVE"));
// Fetch roles
async function fetchRoles() {
try {
const res = await fetch(clientResolver(resolve, "/manage/api"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "getRoles", data: {} })
});
const result = await res.json();
if (!result.error) {
roles = result;
}
} catch {
toast.error("Failed to load roles");
}
}
function toggleRole(roleId: string, currentList: string[]): string[] {
if (currentList.includes(roleId)) {
return currentList.filter((r) => r !== roleId);
} else {
return [...currentList, roleId];
}
}
// Initial load
onMount(() => {
fetchUsers();
fetchRoles();
});
</script>
<div class="container mx-auto space-y-6 py-6">
<!-- Header -->
<div class="flex items-center justify-end">
<div class="flex items-center justify-between">
<div class="flex items-center gap-1">
<Button
variant={statusFilter === "ACTIVE" ? "default" : "outline"}
size="sm"
onclick={() => {
statusFilter = "ACTIVE";
page = 1;
fetchUsers();
}}
>
Active
</Button>
<Button
variant={statusFilter === "INACTIVE" ? "default" : "outline"}
size="sm"
onclick={() => {
statusFilter = "INACTIVE";
page = 1;
fetchUsers();
}}
>
Inactive
</Button>
</div>
<div class="flex items-center gap-2">
{#if loading}
<Spinner class="size-5" />
{/if}
{#if currentUser.role === "admin" || currentUser.role === "editor"}
{#if hasPermission("users.write")}
{#if !canSendEmail}
<p class="text-muted-foreground max-w-xs text-xs">
Email service not configured. Cannot invite new users. Please go to
@@ -394,8 +446,8 @@
{/if}
</Table.Cell>
<Table.Cell>
<Badge variant={getRoleBadgeVariant(user.role)} class="uppercase">
{user.role}
<Badge variant={getRoleBadgeVariant(user.role_ids)} class="uppercase">
{user.role_ids.join(", ")}
</Badge>
</Table.Cell>
<Table.Cell>
@@ -406,7 +458,7 @@
{/if}
</Table.Cell>
<Table.Cell class="text-center">
{#if currentUser.role === "admin" && currentUser.id !== user.id && (user.role !== "admin" || currentUser.is_owner === "YES")}
{#if hasPermission("users.write") && currentUser.id !== user.id}
<Button variant="ghost" size="icon" class="h-8 w-8" onclick={() => openSettingsSheet(user)}>
<SettingsIcon class="h-4 w-4" />
</Button>
@@ -486,19 +538,23 @@
</div>
<div class="space-y-2">
<Label for="role">Role</Label>
<Select.Root type="single" value={newUser.role} onValueChange={(v) => v && (newUser.role = v)}>
<Select.Trigger class="w-full">
{newUser.role.toUpperCase()}
</Select.Trigger>
<Select.Content>
{#if currentUser.role === "admin"}
<Select.Item value="admin">ADMIN</Select.Item>
{/if}
<Select.Item value="editor">EDITOR</Select.Item>
<Select.Item value="member">MEMBER</Select.Item>
</Select.Content>
</Select.Root>
<Label>Roles</Label>
<div class="space-y-2">
{#each activeRoles as role (role.id)}
<label class="flex items-center gap-2">
<Checkbox
checked={newUser.role_ids.includes(role.id)}
onCheckedChange={() => {
newUser.role_ids = toggleRole(role.id, newUser.role_ids);
}}
/>
<span class="text-sm uppercase">{role.role_name}</span>
</label>
{/each}
{#if activeRoles.length === 0}
<p class="text-muted-foreground text-sm">No roles available</p>
{/if}
</div>
</div>
{#if creatingUserError}
<p class="text-destructive text-sm font-medium">{creatingUserError}</p>
@@ -578,42 +634,41 @@
<Card.Root>
<Card.Content class="p-4">
<p class="mb-3 text-sm">
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.
</p>
<div class="flex items-center gap-3">
<Select.Root
type="single"
value={toEditUser.role}
onValueChange={(v) => v && (toEditUser!.role = v)}
disabled={toEditUser.actions.updatingRole}
>
<Select.Trigger class="w-48">
{toEditUser.role.toUpperCase()}
</Select.Trigger>
<Select.Content>
{#if currentUser.role === "admin"}
<Select.Item value="admin">ADMIN</Select.Item>
{/if}
<Select.Item value="editor">EDITOR</Select.Item>
<Select.Item value="member">MEMBER</Select.Item>
</Select.Content>
</Select.Root>
<Button
variant="secondary"
disabled={toEditUser.actions.updatingRole}
onclick={() => {
toEditUser!.actions.updatingRole = true;
manualUpdateData("role").then(() => {
toEditUser!.actions.updatingRole = false;
});
}}
>
{#if toEditUser.actions.updatingRole}
<Spinner class="size-4" />
{/if}
Update Role
</Button>
<div class="space-y-2">
{#each activeRoles as role (role.id)}
<label class="flex items-center gap-2">
<Checkbox
checked={toEditUser.role_ids.includes(role.id)}
disabled={toEditUser.actions.updatingRole}
onCheckedChange={() => {
toEditUser!.role_ids = toggleRole(role.id, toEditUser!.role_ids);
}}
/>
<span class="text-sm uppercase">{role.role_name}</span>
</label>
{/each}
{#if activeRoles.length === 0}
<p class="text-muted-foreground text-sm">No roles available</p>
{/if}
</div>
<Button
variant="secondary"
class="mt-3"
disabled={toEditUser.actions.updatingRole || toEditUser.role_ids.length === 0}
onclick={() => {
toEditUser!.actions.updatingRole = true;
manualUpdateData("role").then(() => {
toEditUser!.actions.updatingRole = false;
});
}}
>
{#if toEditUser.actions.updatingRole}
<Spinner class="size-4" />
{/if}
Update Roles
</Button>
</Card.Content>
</Card.Root>
@@ -1,339 +0,0 @@
<script lang="ts">
import { SvelteSet } from "svelte/reactivity";
import * as Card from "$lib/components/ui/card/index.js";
import * as AlertDialog from "$lib/components/ui/alert-dialog/index.js";
import { Button } from "$lib/components/ui/button/index.js";
import { Input } from "$lib/components/ui/input/index.js";
import { Label } from "$lib/components/ui/label/index.js";
import { Spinner } from "$lib/components/ui/spinner/index.js";
import { Textarea } from "$lib/components/ui/textarea/index.js";
import KeyIcon from "@lucide/svelte/icons/key";
import PlusIcon from "@lucide/svelte/icons/plus";
import PencilIcon from "@lucide/svelte/icons/pencil";
import TrashIcon from "@lucide/svelte/icons/trash-2";
import EyeIcon from "@lucide/svelte/icons/eye";
import EyeOffIcon from "@lucide/svelte/icons/eye-off";
import SaveIcon from "@lucide/svelte/icons/save";
import XIcon from "@lucide/svelte/icons/x";
import { toast } from "svelte-sonner";
import { onMount } from "svelte";
import { resolve } from "$app/paths";
import clientResolver from "$lib/client/resolver.js";
// Types
interface VaultSecret {
id: number;
secret_name: string;
secret_value: string;
created_at: string;
updated_at: string;
}
// State
let loading = $state(true);
let saving = $state(false);
let secrets = $state<VaultSecret[]>([]);
let visibleSecrets = new SvelteSet<number>();
// Form state
let isEditing = $state(false);
let editingId = $state<number | null>(null);
let formName = $state("");
let formValue = $state("");
// Delete confirmation state
let deleteDialogOpen = $state(false);
let secretToDelete = $state<VaultSecret | null>(null);
let isDeleting = $state(false);
// API functions
async function loadSecrets() {
try {
const res = await fetch(clientResolver(resolve, "/manage/api"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "getVaultSecrets" })
});
const result = await res.json();
if (result.error) {
toast.error(result.error);
} else if (Array.isArray(result)) {
secrets = result;
}
} catch (error) {
console.error("Error loading secrets:", error);
toast.error("Failed to load secrets");
}
}
async function saveSecret() {
if (!formName.trim()) {
toast.error("Secret name is required");
return;
}
if (!formValue.trim()) {
toast.error("Secret value is required");
return;
}
saving = true;
try {
const action = editingId ? "updateVaultSecret" : "createVaultSecret";
const payload: Record<string, unknown> = {
action,
data: {
secret_name: formName.trim(),
secret_value: formValue
}
};
if (editingId) {
payload.data = { ...(payload.data as object), id: editingId };
}
const res = await fetch(clientResolver(resolve, "/manage/api"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
const result = await res.json();
if (result.error) {
toast.error(result.error);
} else {
toast.success(editingId ? "Secret updated successfully" : "Secret created successfully");
resetForm();
await loadSecrets();
}
} catch (error) {
console.error("Error saving secret:", error);
toast.error("Failed to save secret");
} finally {
saving = false;
}
}
async function confirmDelete() {
if (!secretToDelete) return;
isDeleting = true;
try {
const res = await fetch(clientResolver(resolve, "/manage/api"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "deleteVaultSecret",
data: { id: secretToDelete.id }
})
});
const result = await res.json();
if (result.error) {
toast.error(result.error);
} else {
toast.success("Secret deleted successfully");
await loadSecrets();
}
} catch (error) {
console.error("Error deleting secret:", error);
toast.error("Failed to delete secret");
} finally {
isDeleting = false;
deleteDialogOpen = false;
secretToDelete = null;
}
}
function startEdit(secret: VaultSecret) {
editingId = secret.id;
formName = secret.secret_name;
formValue = secret.secret_value;
isEditing = true;
}
function startCreate() {
editingId = null;
formName = "";
formValue = "";
isEditing = true;
}
function resetForm() {
editingId = null;
formName = "";
formValue = "";
isEditing = false;
}
function openDeleteDialog(secret: VaultSecret) {
secretToDelete = secret;
deleteDialogOpen = true;
}
function toggleSecretVisibility(id: number) {
if (visibleSecrets.has(id)) {
visibleSecrets.delete(id);
} else {
visibleSecrets.add(id);
}
}
function maskValue(value: string): string {
return "*".repeat(Math.min(value.length, 20));
}
// Initial load
onMount(async () => {
await loadSecrets();
loading = false;
});
</script>
<div class="container mx-auto space-y-6 py-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<KeyIcon class="text-muted-foreground size-6" />
<div>
<h1 class="text-2xl font-bold">Vault</h1>
<p class="text-muted-foreground text-sm">Securely store and manage your secrets</p>
</div>
</div>
{#if !isEditing}
<Button onclick={startCreate}>
<PlusIcon class="size-4" />
Add Secret
</Button>
{/if}
</div>
{#if loading}
<div class="flex h-96 items-center justify-center">
<Spinner class="size-8" />
</div>
{:else}
<!-- Form Card -->
{#if isEditing}
<Card.Root>
<Card.Header>
<Card.Title>{editingId ? "Edit Secret" : "Add New Secret"}</Card.Title>
<Card.Description>
{editingId ? "Update the secret details below" : "Enter the secret name and value below"}
</Card.Description>
</Card.Header>
<Card.Content class="space-y-4">
<div class="space-y-2">
<Label for="secret-name">Secret Name</Label>
<Input id="secret-name" placeholder="e.g., API_KEY" bind:value={formName} disabled={saving} />
</div>
<div class="space-y-2">
<Label for="secret-value">Secret Value</Label>
<Textarea
id="secret-value"
placeholder="Enter secret value..."
bind:value={formValue}
disabled={saving}
rows={4}
/>
</div>
</Card.Content>
<Card.Footer class="flex justify-end gap-2">
<Button variant="outline" onclick={resetForm} disabled={saving}>
<XIcon class="size-4" />
Cancel
</Button>
<Button onclick={saveSecret} disabled={saving}>
{#if saving}
<Spinner class="size-4" />
{:else}
<SaveIcon class="size-4" />
{/if}
{editingId ? "Update Secret" : "Save Secret"}
</Button>
</Card.Footer>
</Card.Root>
{/if}
<!-- Secrets List -->
<Card.Root>
<Card.Header>
<div class="flex items-center gap-2">
<KeyIcon class="text-muted-foreground size-5" />
<div>
<Card.Title>Stored Secrets</Card.Title>
<Card.Description>All secrets are encrypted using KENER_SECRET_KEY</Card.Description>
</div>
</div>
</Card.Header>
<Card.Content>
{#if secrets.length === 0}
<div class="text-muted-foreground py-8 text-center">
No secrets stored yet. Click "Add Secret" to create your first secret.
</div>
{:else}
<div class="space-y-3">
{#each secrets as secret (secret.id)}
<div class="flex items-center justify-between rounded-lg border p-4">
<div class="flex-1 space-y-1">
<div class="flex items-center gap-2">
<KeyIcon class="text-muted-foreground size-4" />
<span class="font-medium">{secret.secret_name}</span>
</div>
<div class="flex items-center gap-2">
<code class="bg-muted rounded px-2 py-1 font-mono text-sm">
{visibleSecrets.has(secret.id) ? secret.secret_value : maskValue(secret.secret_value)}
</code>
<Button
variant="ghost"
size="sm"
class="size-8 p-0"
onclick={() => toggleSecretVisibility(secret.id)}
>
{#if visibleSecrets.has(secret.id)}
<EyeOffIcon class="size-4" />
{:else}
<EyeIcon class="size-4" />
{/if}
</Button>
</div>
</div>
<div class="flex items-center gap-2">
<Button variant="outline" size="sm" onclick={() => startEdit(secret)} disabled={isEditing}>
<PencilIcon class="size-4" />
Edit
</Button>
<Button variant="destructive" size="sm" onclick={() => openDeleteDialog(secret)} disabled={isEditing}>
<TrashIcon class="size-4" />
Delete
</Button>
</div>
</div>
{/each}
</div>
{/if}
</Card.Content>
</Card.Root>
{/if}
</div>
<!-- Delete Confirmation Dialog -->
<AlertDialog.Root bind:open={deleteDialogOpen}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Delete Secret</AlertDialog.Title>
<AlertDialog.Description>
Are you sure you want to delete the secret "{secretToDelete?.secret_name}"? This action cannot be undone.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel disabled={isDeleting}>Cancel</AlertDialog.Cancel>
<AlertDialog.Action
onclick={confirmDelete}
disabled={isDeleting}
class="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{#if isDeleting}
<Spinner class="size-4" />
{/if}
Delete
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
+9 -10
View File
@@ -59,15 +59,14 @@
// Role badge styling
let roleBadgeClass = $derived.by(() => {
switch (user.role) {
case "admin":
return "bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-300";
case "editor":
return "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300";
case "member":
return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300";
default:
return "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300";
if (user.role_ids.includes("admin")) {
return "bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-300";
} else if (user.role_ids.includes("editor")) {
return "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300";
} else if (user.role_ids.includes("member")) {
return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300";
} else {
return "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300";
}
});
@@ -227,7 +226,7 @@
{user.email}
</span>
<span class="text-foreground rounded-sm font-medium uppercase">
{user.role}
{user.role_ids.join(", ")}
</span>
</div>
</Dialog.Description>