From 364d46254b04fcac4b6fc55c7b2d5b5f683b9b43 Mon Sep 17 00:00:00 2001 From: Plattenspatz Date: Fri, 8 May 2026 09:48:19 +0200 Subject: [PATCH] Feature: page access groups with RBAC-based visibility control --- .../20260508120000_add_access_groups.ts | 105 ++++++ src/lib/allPerms.ts | 9 + src/lib/server/api-server/pages/get.ts | 21 +- src/lib/server/controllers/pagesController.ts | 198 +++++++++++- src/lib/server/controllers/siteDataKeys.ts | 5 + src/lib/server/db/dbimpl.ts | 21 ++ src/lib/server/db/repositories/pages.ts | 86 +++++ src/lib/server/db/seedSiteData.ts | 1 + src/lib/types/api.ts | 3 + .../(api)/api/v4/access-groups/+server.ts | 77 +++++ src/routes/(api)/api/v4/pages/+server.ts | 32 ++ .../(api)/api/v4/pages/[page_path]/+server.ts | 25 ++ src/routes/(kener)/+page.server.ts | 18 +- .../(kener)/[page_path]/+page.server.ts | 20 +- src/routes/(manage)/manage/api/+server.ts | 23 ++ .../manage/app/pages/[page_id]/+page.svelte | 136 +++++++- .../(manage)/manage/app/roles/+page.svelte | 301 +++++++++++++++++- .../app/site-configurations/+page.svelte | 66 ++++ 18 files changed, 1130 insertions(+), 17 deletions(-) create mode 100644 migrations/20260508120000_add_access_groups.ts create mode 100644 src/routes/(api)/api/v4/access-groups/+server.ts diff --git a/migrations/20260508120000_add_access_groups.ts b/migrations/20260508120000_add_access_groups.ts new file mode 100644 index 000000000..b7e709436 --- /dev/null +++ b/migrations/20260508120000_add_access_groups.ts @@ -0,0 +1,105 @@ +import type { Knex } from "knex"; + +export async function up(knex: Knex): Promise { + // Access groups define visibility scopes for pages. + // "public" = visible to everyone without login. + // "admin" = grants access to all pages (assigned to admin role). + if (!(await knex.schema.hasTable("access_groups"))) { + await knex.schema.createTable("access_groups", (table) => { + table.string("id", 100).primary(); + table.text("group_name").notNullable(); + table.text("description"); + table.integer("is_system").notNullable().defaultTo(0); + table.timestamp("created_at").defaultTo(knex.fn.now()); + table.timestamp("updated_at").defaultTo(knex.fn.now()); + }); + } + + // Junction: which access groups are assigned to which page + if (!(await knex.schema.hasTable("page_access_groups"))) { + await knex.schema.createTable("page_access_groups", (table) => { + table.integer("page_id").unsigned().notNullable() + .references("id").inTable("pages").onDelete("CASCADE"); + table.string("access_group_id", 100).notNullable() + .references("id").inTable("access_groups").onDelete("CASCADE"); + table.timestamp("created_at").defaultTo(knex.fn.now()); + + table.primary(["page_id", "access_group_id"]); + }); + } + + // Junction: which access groups a role can see + if (!(await knex.schema.hasTable("role_access_groups"))) { + await knex.schema.createTable("role_access_groups", (table) => { + table.string("role_id", 100).notNullable() + .references("id").inTable("roles").onDelete("CASCADE"); + table.string("access_group_id", 100).notNullable() + .references("id").inTable("access_groups").onDelete("CASCADE"); + table.timestamp("created_at").defaultTo(knex.fn.now()); + + table.primary(["role_id", "access_group_id"]); + }); + } + + // Seed the "public" system group + const publicExists = await knex("access_groups").where("id", "public").first(); + if (!publicExists) { + await knex("access_groups").insert({ + id: "public", + group_name: "Public", + description: "Pages with this group are visible to everyone without login", + is_system: 1, + created_at: knex.fn.now(), + updated_at: knex.fn.now(), + }); + } + + // Seed the "admin" system group + const adminExists = await knex("access_groups").where("id", "admin").first(); + if (!adminExists) { + await knex("access_groups").insert({ + id: "admin", + group_name: "Admin", + description: "Grants access to all pages regardless of their groups", + is_system: 1, + created_at: knex.fn.now(), + updated_at: knex.fn.now(), + }); + } + + // Assign "public" to all existing pages (backward compatibility) + const allPages = await knex("pages").select("id"); + for (const page of allPages) { + const exists = await knex("page_access_groups") + .where({ page_id: page.id, access_group_id: "public" }) + .first(); + if (!exists) { + await knex("page_access_groups").insert({ + page_id: page.id, + access_group_id: "public", + created_at: knex.fn.now(), + }); + } + } + + // Assign "admin" group to the admin role + const adminRoleExists = await knex("roles").where("id", "admin").first(); + if (adminRoleExists) { + const alreadyAssigned = await knex("role_access_groups") + .where({ role_id: "admin", access_group_id: "admin" }) + .first(); + if (!alreadyAssigned) { + await knex("role_access_groups").insert({ + role_id: "admin", + access_group_id: "admin", + created_at: knex.fn.now(), + }); + } + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists("role_access_groups"); + await knex.schema.dropTableIfExists("page_access_groups"); + await knex.schema.dropTableIfExists("access_groups"); +} diff --git a/src/lib/allPerms.ts b/src/lib/allPerms.ts index a95186644..7936b5a36 100644 --- a/src/lib/allPerms.ts +++ b/src/lib/allPerms.ts @@ -220,6 +220,15 @@ export const ACTION_PERMISSION_MAP: Record = { updateRolePermissions: "roles.assign_permissions", addUserToRole: "roles.assign_users", removeUserFromRole: "roles.assign_users", + + // Access Groups + getAccessGroups: "pages.read", + createAccessGroup: "roles.write", + deleteAccessGroup: "roles.write", + getPageAccessGroups: "pages.read", + setPageAccessGroups: "pages.write", + getRoleAccessGroups: "roles.read", + setRoleAccessGroups: "roles.write", }; export const ROUTE_PERMISSION_MAP: Record = { diff --git a/src/lib/server/api-server/pages/get.ts b/src/lib/server/api-server/pages/get.ts index 60b5ecaf8..d98849085 100644 --- a/src/lib/server/api-server/pages/get.ts +++ b/src/lib/server/api-server/pages/get.ts @@ -1,31 +1,34 @@ import { json } from "@sveltejs/kit"; import type { APIServerRequest } from "$lib/server/types/api-server"; -import { GetAllPages } from "$lib/server/controllers/pagesController"; +import { GetAccessiblePages } from "$lib/server/controllers/pagesController"; import { GetSiteDataByKey } from "$lib/server/controllers/siteDataController"; +import { GetLoggedInSession } from "$lib/server/controllers/controller"; import type { PageNavItem } from "$lib/server/controllers/dashboardController"; import type { PageOrderingSettings } from "$lib/types/site"; /** * GET /dashboard-apis/pages - * Returns all pages as PageNavItem[] (page_title, page_path) - * Respects pageOrderingSettings if enabled + * Returns pages the current user is allowed to see, respecting + * access groups and page ordering settings. */ -export default async function get(_req: APIServerRequest): Promise { - const allPagesData = await GetAllPages(); +export default async function get(req: APIServerRequest): Promise { + // Get the logged-in user from the session cookie (null if anonymous) + const user = req.cookies ? await GetLoggedInSession(req.cookies) : null; + + // Get only pages the user can access + const accessiblePages = await GetAccessiblePages(user); const pageOrderingSettings = (await GetSiteDataByKey("pageOrderingSettings")) as PageOrderingSettings | null; - let orderedPages = allPagesData; + let orderedPages = accessiblePages; if (pageOrderingSettings?.enabled && pageOrderingSettings.order?.length > 0) { const orderMap = new Map(pageOrderingSettings.order.map((id, idx) => [id, idx])); - orderedPages = [...allPagesData].sort((a, b) => { + orderedPages = [...accessiblePages].sort((a, b) => { const aIdx = orderMap.get(a.id); const bIdx = orderMap.get(b.id); - // Pages in the order list come first, sorted by their position if (aIdx !== undefined && bIdx !== undefined) return aIdx - bIdx; if (aIdx !== undefined) return -1; if (bIdx !== undefined) return 1; - // Pages not in the order list keep their default order (by id) return a.id - b.id; }); } diff --git a/src/lib/server/controllers/pagesController.ts b/src/lib/server/controllers/pagesController.ts index 9a004cb4e..b3a67b383 100644 --- a/src/lib/server/controllers/pagesController.ts +++ b/src/lib/server/controllers/pagesController.ts @@ -1,10 +1,19 @@ import db from "../db/db.js"; -import type { PageRecord, PageRecordInsert, PageMonitorRecord, PageMonitorRecordInsert } from "../types/db.js"; +import type { + PageRecord, + PageRecordInsert, + PageMonitorRecord, + PageMonitorRecordInsert, + UserRecordPublic, +} from "../types/db.js"; +import { GetSiteDataByKey } from "./siteDataController.js"; // ============ Page CRUD Operations ============ /** - * Create a new page + * Create a new page. + * When the site setting "autoPublicPages" is true (default), + * newly created pages automatically get the "public" access group. */ export async function CreatePage(data: PageRecordInsert): Promise { // Validate required fields @@ -12,7 +21,7 @@ export async function CreatePage(data: PageRecordInsert): Promise { throw new Error("page_path, page_title, and page_header are required"); } - // Make page_path URL-friendly: lowercase, replace spaces with hyphens, remove special chars (including leading slashes) + // Make page_path URL-friendly data.page_path = data.page_path .toLowerCase() .trim() @@ -25,7 +34,15 @@ export async function CreatePage(data: PageRecordInsert): Promise { throw new Error(`Page with path "${data.page_path}" already exists`); } - return await db.createPage(data); + const newPage = await db.createPage(data); + + // Auto-assign "public" group to new pages (unless disabled in settings) + const autoPublic = await GetSiteDataByKey("autoPublicPages"); + if (autoPublic !== false && autoPublic !== "false") { + await db.setPageAccessGroups(newPage.id, ["public"]); + } + + return newPage; } /** @@ -228,3 +245,176 @@ export async function GetPageByPathWithMonitors( const monitors = await db.getPageMonitorsExcludeHidden(page.id); return { page, monitors }; } + +// ============ Access Group Operations ============ + +// Reserved group ID: role with "admin" can see all pages +const ADMIN_GROUP = "admin"; +// Reserved group ID: pages with "public" are visible without login +const PUBLIC_GROUP = "public"; + +/** + * Check if a user can access a specific page. + * + * Rules (evaluated top to bottom, first match wins): + * 1. Page has "public" group → allow (no login needed) + * 2. No login → redirect to sign-in + * 3. User's roles include the "admin" group → allow (admin sees everything) + * 4. Page has no access groups → denied (unconfigured pages are hidden) + * 5. Any overlap between page groups and role groups → allow + * 6. No overlap → denied + * + * Returns: "allow" | "login_required" | "denied" + */ +export async function CheckPageAccess( + page_id: number, + user: UserRecordPublic | null, +): Promise<"allow" | "login_required" | "denied"> { + const pageGroups = await db.getAccessGroupsForPage(page_id); + + // Rule 1: public pages are visible to everyone + if (pageGroups.includes(PUBLIC_GROUP)) { + return "allow"; + } + + // Rule 2: non-public pages require login + if (!user) { + return "login_required"; + } + + // Rule 3: admin group grants access to all pages + const roleGroups = await db.getAccessGroupsForRoles(user.role_ids); + if (roleGroups.includes(ADMIN_GROUP)) { + return "allow"; + } + + // Rule 4: pages with no groups are hidden (except for admin) + if (pageGroups.length === 0) { + return "denied"; + } + + // Rule 5+6: check for overlap between page and role groups + const hasAccess = pageGroups.some((pg) => roleGroups.includes(pg)); + return hasAccess ? "allow" : "denied"; +} + +/** + * Get all pages that a user is allowed to see. + * Used by the page switcher to filter the dropdown. + */ +export async function GetAccessiblePages( + user: UserRecordPublic | null, +): Promise { + const allPages = await db.getAllPages(); + if (allPages.length === 0) return []; + + // Load access groups for all pages in one query + const pageIds = allPages.map((p) => p.id); + const pageGroupsMap = await db.getAccessGroupsForPages(pageIds); + + // Load user's role groups (if logged in) + let roleGroups: string[] = []; + let isAdmin = false; + if (user) { + roleGroups = await db.getAccessGroupsForRoles(user.role_ids); + isAdmin = roleGroups.includes(ADMIN_GROUP); + } + + return allPages.filter((page) => { + const groups = pageGroupsMap.get(page.id) || []; + + // Public pages are visible to everyone + if (groups.includes(PUBLIC_GROUP)) return true; + + // Non-public pages require login + if (!user) return false; + + // Admin sees everything + if (isAdmin) return true; + + // Pages with no groups are hidden + if (groups.length === 0) return false; + + // Check if role groups overlap with page groups + return groups.some((g) => roleGroups.includes(g)); + }); +} + +// ============ Access Group Admin Operations ============ + +/** + * Get all access groups (for admin UI dropdowns) + */ +export async function GetAllAccessGroups() { + return await db.getAllAccessGroups(); +} + +/** + * Create a new access group + */ +export async function CreateAccessGroup(data: { id: string; group_name: string; description?: string }) { + if (!data.id || !data.group_name) { + throw new Error("id and group_name are required"); + } + + // Normalize ID: lowercase, hyphens, no special chars + data.id = data.id.toLowerCase().trim().replace(/\s+/g, "-").replace(/[^a-z0-9_-]/g, ""); + + if (data.id === "public") { + throw new Error("Cannot create a group with the reserved ID 'public'"); + } + + const existing = (await db.getAllAccessGroups()).find((g) => g.id === data.id); + if (existing) { + throw new Error(`Access group '${data.id}' already exists`); + } + + await db.createAccessGroup(data); + return { success: true, id: data.id }; +} + +/** + * Delete an access group. System groups (public, admin) cannot be deleted. + */ +export async function DeleteAccessGroup(id: string) { + const allGroups = await db.getAllAccessGroups(); + const group = allGroups.find((g) => g.id === id); + if (!group) { + throw new Error(`Access group '${id}' not found`); + } + if ((group as any).is_system === 1) { + throw new Error(`Cannot delete system group '${id}'`); + } + await db.deleteAccessGroup(id); + return { success: true }; +} + +/** + * Get access groups assigned to a page + */ +export async function GetPageAccessGroups(page_id: number): Promise { + return await db.getAccessGroupsForPage(page_id); +} + +/** + * Set access groups for a page (replaces all existing assignments) + */ +export async function SetPageAccessGroups(page_id: number, group_ids: string[]) { + await db.setPageAccessGroups(page_id, group_ids); + return { success: true }; +} + +/** + * Get access groups assigned to a role + */ +export async function GetRoleAccessGroups(role_id: string): Promise { + return await db.getAccessGroupsForRole(role_id); +} + +/** + * Set access groups for a role (replaces all existing assignments) + */ +export async function SetRoleAccessGroups(role_id: string, group_ids: string[]) { + await db.setRoleAccessGroups(role_id, group_ids); + return { success: true }; +} diff --git a/src/lib/server/controllers/siteDataKeys.ts b/src/lib/server/controllers/siteDataKeys.ts index 1d62b6aa4..5b8259505 100644 --- a/src/lib/server/controllers/siteDataKeys.ts +++ b/src/lib/server/controllers/siteDataKeys.ts @@ -296,4 +296,9 @@ export const siteDataKeys: SiteDataKey[] = [ isValid: IsValidJSONString, data_type: "object", }, + { + key: "autoPublicPages", + isValid: (value) => value === true || value === false || value === "true" || value === "false", + data_type: "string", + }, ]; diff --git a/src/lib/server/db/dbimpl.ts b/src/lib/server/db/dbimpl.ts index b7621a196..0dd5a445b 100644 --- a/src/lib/server/db/dbimpl.ts +++ b/src/lib/server/db/dbimpl.ts @@ -233,6 +233,17 @@ class DbImpl { deletePageMonitorsByPageId!: PagesRepository["deletePageMonitorsByPageId"]; updatePageMonitorPositions!: PagesRepository["updatePageMonitorPositions"]; + // ============ Access Groups ============ + getAccessGroupsForPage!: PagesRepository["getAccessGroupsForPage"]; + getAccessGroupsForPages!: PagesRepository["getAccessGroupsForPages"]; + getAccessGroupsForRole!: PagesRepository["getAccessGroupsForRole"]; + getAccessGroupsForRoles!: PagesRepository["getAccessGroupsForRoles"]; + setPageAccessGroups!: PagesRepository["setPageAccessGroups"]; + setRoleAccessGroups!: PagesRepository["setRoleAccessGroups"]; + getAllAccessGroups!: PagesRepository["getAllAccessGroups"]; + createAccessGroup!: PagesRepository["createAccessGroup"]; + deleteAccessGroup!: PagesRepository["deleteAccessGroup"]; + // ============ Maintenances ============ createMaintenance!: MaintenancesRepository["createMaintenance"]; getMaintenanceById!: MaintenancesRepository["getMaintenanceById"]; @@ -600,6 +611,16 @@ class DbImpl { this.deletePageMonitorsByTag = this.pages.deletePageMonitorsByTag.bind(this.pages); this.deletePageMonitorsByPageId = this.pages.deletePageMonitorsByPageId.bind(this.pages); this.updatePageMonitorPositions = this.pages.updatePageMonitorPositions.bind(this.pages); + // Access Groups + this.getAccessGroupsForPage = this.pages.getAccessGroupsForPage.bind(this.pages); + this.getAccessGroupsForPages = this.pages.getAccessGroupsForPages.bind(this.pages); + this.getAccessGroupsForRole = this.pages.getAccessGroupsForRole.bind(this.pages); + this.getAccessGroupsForRoles = this.pages.getAccessGroupsForRoles.bind(this.pages); + this.setPageAccessGroups = this.pages.setPageAccessGroups.bind(this.pages); + this.setRoleAccessGroups = this.pages.setRoleAccessGroups.bind(this.pages); + this.getAllAccessGroups = this.pages.getAllAccessGroups.bind(this.pages); + this.createAccessGroup = this.pages.createAccessGroup.bind(this.pages); + this.deleteAccessGroup = this.pages.deleteAccessGroup.bind(this.pages); } private bindMaintenancesMethods(): void { diff --git a/src/lib/server/db/repositories/pages.ts b/src/lib/server/db/repositories/pages.ts index c4267fb16..e17044a5f 100644 --- a/src/lib/server/db/repositories/pages.ts +++ b/src/lib/server/db/repositories/pages.ts @@ -129,4 +129,90 @@ export class PagesRepository extends BaseRepository { } }); } + + // ============ Access Groups ============ + + async getAccessGroupsForPage(page_id: number): Promise { + const rows = await this.knex("page_access_groups") + .where("page_id", page_id) + .select("access_group_id"); + return rows.map((r: { access_group_id: string }) => r.access_group_id); + } + + async getAccessGroupsForPages(page_ids: number[]): Promise> { + if (page_ids.length === 0) return new Map(); + const rows = await this.knex("page_access_groups") + .whereIn("page_id", page_ids) + .select("page_id", "access_group_id"); + + const map = new Map(); + for (const row of rows) { + const list = map.get(row.page_id) || []; + list.push(row.access_group_id); + map.set(row.page_id, list); + } + return map; + } + + async getAccessGroupsForRole(role_id: string): Promise { + const rows = await this.knex("role_access_groups") + .where("role_id", role_id) + .select("access_group_id"); + return rows.map((r: { access_group_id: string }) => r.access_group_id); + } + + async getAccessGroupsForRoles(role_ids: string[]): Promise { + if (role_ids.length === 0) return []; + const rows = await this.knex("role_access_groups") + .whereIn("role_id", role_ids) + .distinct("access_group_id") + .select("access_group_id"); + return rows.map((r: { access_group_id: string }) => r.access_group_id); + } + + async setPageAccessGroups(page_id: number, group_ids: string[]): Promise { + await this.knex.transaction(async (trx) => { + await trx("page_access_groups").where("page_id", page_id).del(); + if (group_ids.length > 0) { + const inserts = group_ids.map((gid) => ({ + page_id, + access_group_id: gid, + created_at: trx.fn.now(), + })); + await trx("page_access_groups").insert(inserts); + } + }); + } + + async setRoleAccessGroups(role_id: string, group_ids: string[]): Promise { + await this.knex.transaction(async (trx) => { + await trx("role_access_groups").where("role_id", role_id).del(); + if (group_ids.length > 0) { + const inserts = group_ids.map((gid) => ({ + role_id, + access_group_id: gid, + created_at: trx.fn.now(), + })); + await trx("role_access_groups").insert(inserts); + } + }); + } + + async getAllAccessGroups(): Promise> { + return await this.knex("access_groups").orderBy("id", "asc"); + } + + async createAccessGroup(data: { id: string; group_name: string; description?: string }): Promise { + await this.knex("access_groups").insert({ + id: data.id, + group_name: data.group_name, + description: data.description || null, + created_at: this.knex.fn.now(), + updated_at: this.knex.fn.now(), + }); + } + + async deleteAccessGroup(id: string): Promise { + return await this.knex("access_groups").where("id", id).del(); + } } diff --git a/src/lib/server/db/seedSiteData.ts b/src/lib/server/db/seedSiteData.ts index 30e245025..fe8f17332 100644 --- a/src/lib/server/db/seedSiteData.ts +++ b/src/lib/server/db/seedSiteData.ts @@ -168,6 +168,7 @@ const seedSiteData = { mode: "off", urls: [], }, + autoPublicPages: true, globalMaintenanceNotificationSettings: { event_types: { created: false, diff --git a/src/lib/types/api.ts b/src/lib/types/api.ts index 49c5325cc..7bf46c782 100644 --- a/src/lib/types/api.ts +++ b/src/lib/types/api.ts @@ -454,6 +454,7 @@ export interface PageResponse { page_logo: string | null; page_settings: PageSettings; monitors: PageMonitorResponse[]; + access_groups: string[]; created_at: string; updated_at: string; } @@ -474,6 +475,7 @@ export interface CreatePageRequest { page_logo?: string | null; page_settings?: Partial; monitors?: string[]; + access_groups?: string[]; } export interface CreatePageResponse { @@ -488,6 +490,7 @@ export interface UpdatePageRequest { page_logo?: string | null; page_settings?: Partial; monitors?: string[]; + access_groups?: string[]; } export interface UpdatePageResponse { diff --git a/src/routes/(api)/api/v4/access-groups/+server.ts b/src/routes/(api)/api/v4/access-groups/+server.ts new file mode 100644 index 000000000..20d64d35f --- /dev/null +++ b/src/routes/(api)/api/v4/access-groups/+server.ts @@ -0,0 +1,77 @@ +import { json, type RequestHandler } from "@sveltejs/kit"; +import db from "$lib/server/db/db"; +import type { BadRequestResponse } from "$lib/types/api"; +import { CreateAccessGroup } from "$lib/server/controllers/pagesController"; + +/** + * POST /api/v4/access-groups + * Create a new access group. + * + * Request body: + * { "id": "customer-a", "group_name": "Customer A", "description": "..." } + * + * The "id" is normalized: lowercase, spaces to hyphens, special chars removed. + * System groups (public, admin) cannot be created via API. + */ +export const POST: RequestHandler = async ({ request }) => { + let body: { id: string; group_name: string; description?: string }; + + try { + body = await request.json(); + } catch { + const errorResponse: BadRequestResponse = { + error: { + code: "BAD_REQUEST", + message: "Invalid JSON body", + }, + }; + return json(errorResponse, { status: 400 }); + } + + if (!body.id || typeof body.id !== "string") { + const errorResponse: BadRequestResponse = { + error: { + code: "BAD_REQUEST", + message: "id is required and must be a string", + }, + }; + return json(errorResponse, { status: 400 }); + } + + if (!body.group_name || typeof body.group_name !== "string") { + const errorResponse: BadRequestResponse = { + error: { + code: "BAD_REQUEST", + message: "group_name is required and must be a string", + }, + }; + return json(errorResponse, { status: 400 }); + } + + try { + const result = await CreateAccessGroup({ + id: body.id, + group_name: body.group_name, + description: body.description, + }); + + return json(result, { status: 201 }); + } catch (e: unknown) { + const errorResponse: BadRequestResponse = { + error: { + code: "BAD_REQUEST", + message: e instanceof Error ? e.message : "Failed to create access group", + }, + }; + return json(errorResponse, { status: 400 }); + } +}; + +/** + * GET /api/v4/access-groups + * List all access groups. + */ +export const GET: RequestHandler = async () => { + const groups = await db.getAllAccessGroups(); + return json({ access_groups: groups }); +}; diff --git a/src/routes/(api)/api/v4/pages/+server.ts b/src/routes/(api)/api/v4/pages/+server.ts index a2f56b8c4..113e61ac3 100644 --- a/src/routes/(api)/api/v4/pages/+server.ts +++ b/src/routes/(api)/api/v4/pages/+server.ts @@ -9,6 +9,9 @@ import type { BadRequestResponse, } from "$lib/types/api"; import type { PageRecord } from "$lib/server/types/db"; +import { GetPageAccessGroups } from "$lib/server/controllers/pagesController"; +import { SetPageAccessGroups } from "$lib/server/controllers/pagesController"; +import { GetSiteDataByKey } from "$lib/server/controllers/siteDataController"; function formatDateToISO(date: Date | string): string { if (date instanceof Date) { @@ -97,6 +100,9 @@ async function formatPageResponse(page: PageRecord): Promise { const pageMonitors = await db.getPageMonitors(page.id); + // Fetch access groups for this page + const accessGroups = await GetPageAccessGroups(page.id); + return { id: page.id, page_path: page.page_path, @@ -106,6 +112,7 @@ async function formatPageResponse(page: PageRecord): Promise { page_logo: page.page_logo, page_settings: pageSettings, monitors: pageMonitors.map((pm) => ({ monitor_tag: pm.monitor_tag, position: pm.position })), + access_groups: accessGroups, created_at: formatDateToISO(page.created_at), updated_at: formatDateToISO(page.updated_at), }; @@ -231,6 +238,31 @@ export const POST: RequestHandler = async ({ request }) => { } } + // Handle access groups + if (body.access_groups && Array.isArray(body.access_groups)) { + // Validate that the groups exist + const allGroups = await db.getAllAccessGroups(); + const validGroupIds = allGroups.map((g: { id: string }) => g.id); + for (const groupId of body.access_groups) { + if (!validGroupIds.includes(groupId)) { + const errorResponse: BadRequestResponse = { + error: { + code: "BAD_REQUEST", + message: `Access group '${groupId}' does not exist`, + }, + }; + return json(errorResponse, { status: 400 }); + } + } + await SetPageAccessGroups(createdPage.id, body.access_groups); + } else { + // No access_groups specified — apply auto-public setting + const autoPublic = await GetSiteDataByKey("autoPublicPages"); + if (autoPublic !== false && autoPublic !== "false") { + await SetPageAccessGroups(createdPage.id, ["public"]); + } + } + const response: CreatePageResponse = { page: await formatPageResponse(createdPage), }; diff --git a/src/routes/(api)/api/v4/pages/[page_path]/+server.ts b/src/routes/(api)/api/v4/pages/[page_path]/+server.ts index 7bac48d9b..dd5d5984a 100644 --- a/src/routes/(api)/api/v4/pages/[page_path]/+server.ts +++ b/src/routes/(api)/api/v4/pages/[page_path]/+server.ts @@ -11,6 +11,8 @@ import type { NotFoundResponse, } from "$lib/types/api"; import type { PageRecord } from "$lib/server/types/db"; +import { GetPageAccessGroups } from "$lib/server/controllers/pagesController"; +import { SetPageAccessGroups } from "$lib/server/controllers/pagesController"; function formatDateToISO(date: Date | string): string { if (date instanceof Date) { @@ -99,6 +101,9 @@ async function formatPageResponse(page: PageRecord): Promise { const pageMonitors = await db.getPageMonitors(page.id); + // Fetch access groups for this page + const accessGroups = await GetPageAccessGroups(page.id); + return { id: page.id, page_path: page.page_path, @@ -108,6 +113,7 @@ async function formatPageResponse(page: PageRecord): Promise { page_logo: page.page_logo, page_settings: pageSettings, monitors: pageMonitors.map((pm) => ({ monitor_tag: pm.monitor_tag, position: pm.position })), + access_groups: accessGroups, created_at: formatDateToISO(page.created_at), updated_at: formatDateToISO(page.updated_at), }; @@ -301,6 +307,25 @@ export const PATCH: RequestHandler = async ({ locals, request }) => { } } + // Handle access groups update + if (body.access_groups !== undefined && Array.isArray(body.access_groups)) { + // Validate that the groups exist + const allGroups = await db.getAllAccessGroups(); + const validGroupIds = allGroups.map((g: { id: string }) => g.id); + for (const groupId of body.access_groups) { + if (!validGroupIds.includes(groupId)) { + const errorResponse: BadRequestResponse = { + error: { + code: "BAD_REQUEST", + message: `Access group '${groupId}' does not exist`, + }, + }; + return json(errorResponse, { status: 400 }); + } + } + await SetPageAccessGroups(page.id, body.access_groups); + } + // Fetch updated page const updatedPage = await db.getPageById(page.id); diff --git a/src/routes/(kener)/+page.server.ts b/src/routes/(kener)/+page.server.ts index 31c2c7da0..a972e04f1 100644 --- a/src/routes/(kener)/+page.server.ts +++ b/src/routes/(kener)/+page.server.ts @@ -1,9 +1,25 @@ -import { error } from "@sveltejs/kit"; +import { error, redirect } from "@sveltejs/kit"; import type { PageServerLoad } from "./$types"; import { GetPageDashboardData } from "$lib/server/controllers/dashboardController.js"; +import { CheckPageAccess } from "$lib/server/controllers/pagesController.js"; +import { GetPageByPath } from "$lib/server/controllers/pagesController.js"; +import serverResolve from "$lib/server/resolver.js"; export const load: PageServerLoad = async ({ parent }) => { const layoutData = await parent(); + + // Check access before loading dashboard data + const page = await GetPageByPath(""); + if (page) { + const access = await CheckPageAccess(page.id, layoutData.loggedInUser); + if (access === "login_required") { + throw redirect(302, serverResolve("/account/signin")); + } + if (access === "denied") { + throw error(404, "Page Not Found"); + } + } + const dashboardData = await GetPageDashboardData("", layoutData); if (!dashboardData) { throw error(404, "Page Not Found"); diff --git a/src/routes/(kener)/[page_path]/+page.server.ts b/src/routes/(kener)/[page_path]/+page.server.ts index 51fb73676..872715a02 100644 --- a/src/routes/(kener)/[page_path]/+page.server.ts +++ b/src/routes/(kener)/[page_path]/+page.server.ts @@ -1,9 +1,27 @@ -import { error } from "@sveltejs/kit"; +import { error, redirect } from "@sveltejs/kit"; import type { PageServerLoad } from "./$types"; import { GetPageDashboardData } from "$lib/server/controllers/dashboardController.js"; +import { CheckPageAccess, GetPageByPath } from "$lib/server/controllers/pagesController.js"; +import serverResolve from "$lib/server/resolver.js"; export const load: PageServerLoad = async ({ params, parent }) => { const layoutData = await parent(); + + // Check access before loading dashboard data + const page = await GetPageByPath(params.page_path); + if (!page) { + throw error(404, "Page Not Found"); + } + + const access = await CheckPageAccess(page.id, layoutData.loggedInUser); + if (access === "login_required") { + throw redirect(302, serverResolve("/account/signin")); + } + if (access === "denied") { + // Return 404 instead of 403 to not reveal page existence + throw error(404, "Page Not Found"); + } + const dashboardData = await GetPageDashboardData(params.page_path, layoutData); if (!dashboardData) { throw error(404, "Page Not Found"); diff --git a/src/routes/(manage)/manage/api/+server.ts b/src/routes/(manage)/manage/api/+server.ts index 410e2d51e..e2094db47 100644 --- a/src/routes/(manage)/manage/api/+server.ts +++ b/src/routes/(manage)/manage/api/+server.ts @@ -63,6 +63,13 @@ import { RemoveMonitorFromPage, GetPageMonitors, ReorderPageMonitors, + GetAllAccessGroups, + CreateAccessGroup, + DeleteAccessGroup, + GetPageAccessGroups, + SetPageAccessGroups, + GetRoleAccessGroups, + SetRoleAccessGroups, } from "$lib/server/controllers/pagesController.js"; import { CreateMaintenance, @@ -413,6 +420,22 @@ export async function POST({ request, cookies }) { await ReorderPageMonitors(data.page_id, data.monitor_tags); resp = { success: true }; } + // ============ Access Group Actions ============ + else if (action == "getAccessGroups") { + resp = await GetAllAccessGroups(); + } else if (action == "createAccessGroup") { + resp = await CreateAccessGroup(data); + } else if (action == "deleteAccessGroup") { + resp = await DeleteAccessGroup(data.id); + } else if (action == "getPageAccessGroups") { + resp = await GetPageAccessGroups(data.page_id); + } else if (action == "setPageAccessGroups") { + resp = await SetPageAccessGroups(data.page_id, data.group_ids); + } else if (action == "getRoleAccessGroups") { + resp = await GetRoleAccessGroups(data.roleId); + } else if (action == "setRoleAccessGroups") { + resp = await SetRoleAccessGroups(data.roleId, data.group_ids); + } // ============ Maintenance Actions ============ else if (action == "getMaintenances") { resp = await GetMaintenancesDashboard(data); diff --git a/src/routes/(manage)/manage/app/pages/[page_id]/+page.svelte b/src/routes/(manage)/manage/app/pages/[page_id]/+page.svelte index 4a249a891..3af59d0c3 100644 --- a/src/routes/(manage)/manage/app/pages/[page_id]/+page.svelte +++ b/src/routes/(manage)/manage/app/pages/[page_id]/+page.svelte @@ -29,6 +29,7 @@ import { resolve } from "$app/paths"; import clientResolver from "$lib/client/resolver.js"; import GC from "$lib/global-constants.js"; + import * as Checkbox from "$lib/components/ui/checkbox/index.js"; // Default page settings const defaultPageSettings: PageSettingsType = { @@ -76,6 +77,10 @@ // Delete state let deleteConfirmText = $state(""); + // Access Groups state + let allAccessGroups = $state>([]); + let pageAccessGroupIds = $state>(new Set()); + let savingAccessGroups = $state(false); let deleting = $state(false); const canDelete = $derived( !isNew && @@ -165,6 +170,77 @@ } } + async function fetchAccessGroups() { + try { + const response = await fetch(clientResolver(resolve, "/manage/api"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "getAccessGroups" }) + }); + const result = await response.json(); + if (!result.error) { + allAccessGroups = result; + } + } catch (e) { + console.error("Failed to fetch access groups", e); + } + } + + async function fetchPageAccessGroups() { + if (isNew || !currentPage) return; + try { + const response = await fetch(clientResolver(resolve, "/manage/api"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "getPageAccessGroups", data: { page_id: currentPage.id } }) + }); + const result = await response.json(); + if (!result.error) { + pageAccessGroupIds = new Set(result); + } + } catch (e) { + console.error("Failed to fetch page access groups", e); + } + } + + async function saveAccessGroups() { + if (!currentPage) return; + savingAccessGroups = true; + try { + const response = await fetch(clientResolver(resolve, "/manage/api"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action: "setPageAccessGroups", + data: { + page_id: currentPage.id, + group_ids: Array.from(pageAccessGroupIds) + } + }) + }); + const result = await response.json(); + if (result.error) { + toast.error(result.error); + } else { + toast.success("Access groups updated"); + } + } catch (e) { + toast.error("Failed to update access groups"); + } finally { + savingAccessGroups = false; + } + } + + function toggleAccessGroup(groupId: string) { + const next = new Set(pageAccessGroupIds); + if (next.has(groupId)) { + next.delete(groupId); + } else { + next.add(groupId); + } + pageAccessGroupIds = next; + } + async function savePage() { if (!isFormValid) return; @@ -509,8 +585,9 @@ } onMount(() => { - void fetchPage(); + void fetchPage().then(() => fetchPageAccessGroups()); void fetchMonitors(); + void fetchAccessGroups(); }); @@ -949,6 +1026,63 @@ + + + + Access Groups + + Control who can see this page. Pages with the "public" group are visible to everyone. + Pages without "public" require login and are restricted to users whose role includes + a matching access group. + + + + {#if allAccessGroups.length === 0} +

No access groups configured yet.

+ {:else} + {#each allAccessGroups.filter((g) => g.id !== "admin") as group (group.id)} + + {/each} + {/if} + {#if !pageAccessGroupIds.has("public") && pageAccessGroupIds.size > 0} +

+ This page requires login. Only users with a matching role can access it. +

+ {:else if pageAccessGroupIds.size === 0} +

+ Warning: No access groups selected. This page is only visible to users with the admin group. +

+ {/if} +
+ + + +
+ {#if currentPage.page_path !== ""} diff --git a/src/routes/(manage)/manage/app/roles/+page.svelte b/src/routes/(manage)/manage/app/roles/+page.svelte index 8348eee80..112f1aea3 100644 --- a/src/routes/(manage)/manage/app/roles/+page.svelte +++ b/src/routes/(manage)/manage/app/roles/+page.svelte @@ -16,6 +16,8 @@ import LockIcon from "@lucide/svelte/icons/lock"; import KeyIcon from "@lucide/svelte/icons/key"; import UsersIcon from "@lucide/svelte/icons/users"; + import GlobeIcon from "@lucide/svelte/icons/globe"; + import EyeIcon from "@lucide/svelte/icons/eye"; import PencilIcon from "@lucide/svelte/icons/pencil"; import CopyIcon from "@lucide/svelte/icons/copy"; import TrashIcon from "@lucide/svelte/icons/trash-2"; @@ -92,6 +94,23 @@ let addingUserId = $state(null); let removingUserId = $state(null); + // Access Groups sheet + let showAccessGroupsSheet = $state(false); + let accessGroupsRole = $state(null); + let allAccessGroups = $state>([]); + let roleAccessGroupIds = $state>(new Set()); + let savingAccessGroups = $state(false); + let loadingAccessGroups = $state(false); + + // Access Group management + let showCreateGroupDialog = $state(false); + let creatingGroup = $state(false); + let createGroupError = $state(""); + let newGroup = $state({ id: "", name: "", description: "" }); + let showDeleteGroupDialog = $state(false); + let deletingGroup = $state(false); + let groupToDelete = $state<{ id: string; group_name: string } | null>(null); + const apiUrl = clientResolver(resolve, "/manage/api"); async function apiCall(action: string, data: Record = {}) { @@ -347,6 +366,112 @@ } } + // ============ Access Groups ============ + + async function fetchAccessGroups() { + try { + allAccessGroups = await apiCall("getAccessGroups"); + } catch { + toast.error("Failed to load access groups"); + } + } + + async function openAccessGroups(role: RoleRecord) { + accessGroupsRole = role; + roleAccessGroupIds = new Set(); + showAccessGroupsSheet = true; + loadingAccessGroups = true; + try { + const [groups, roleGroups] = await Promise.all([ + apiCall("getAccessGroups"), + apiCall("getRoleAccessGroups", { roleId: role.id }) + ]); + allAccessGroups = groups; + roleAccessGroupIds = new Set(roleGroups); + } catch { + toast.error("Failed to load access groups"); + } finally { + loadingAccessGroups = false; + } + } + + async function saveAccessGroups() { + if (!accessGroupsRole) return; + savingAccessGroups = true; + try { + await apiCall("setRoleAccessGroups", { + roleId: accessGroupsRole.id, + group_ids: Array.from(roleAccessGroupIds) + }); + toast.success("Access groups updated"); + showAccessGroupsSheet = false; + } catch (e: unknown) { + toast.error(e instanceof Error ? e.message : "Failed to update access groups"); + } finally { + savingAccessGroups = false; + } + } + + function toggleAccessGroup(groupId: string) { + const next = new Set(roleAccessGroupIds); + if (next.has(groupId)) { + next.delete(groupId); + } else { + next.add(groupId); + } + roleAccessGroupIds = next; + } + + async function handleCreateGroup() { + createGroupError = ""; + if (!newGroup.id.trim()) { + createGroupError = "Group ID is required"; + return; + } + if (!newGroup.name.trim()) { + createGroupError = "Group name is required"; + return; + } + creatingGroup = true; + try { + await apiCall("createAccessGroup", { + id: newGroup.id, + group_name: newGroup.name, + description: newGroup.description || null + }); + toast.success("Access group created"); + showCreateGroupDialog = false; + newGroup = { id: "", name: "", description: "" }; + allAccessGroups = await apiCall("getAccessGroups"); + } catch (e: unknown) { + createGroupError = e instanceof Error ? e.message : "Failed to create group"; + } finally { + creatingGroup = false; + } + } + + async function handleDeleteGroup() { + if (!groupToDelete) return; + deletingGroup = true; + try { + await apiCall("deleteAccessGroup", { id: groupToDelete.id }); + toast.success("Access group deleted"); + showDeleteGroupDialog = false; + groupToDelete = null; + allAccessGroups = await apiCall("getAccessGroups"); + // Remove from current selection if it was selected + if (roleAccessGroupIds.has(groupToDelete?.id || "")) { + const next = new Set(roleAccessGroupIds); + next.delete(groupToDelete?.id || ""); + roleAccessGroupIds = next; + } + } catch (e: unknown) { + toast.error(e instanceof Error ? e.message : "Failed to delete group"); + } finally { + deletingGroup = false; + } + } + let groupedPermissions = $derived.by(() => { const groups: Array<{ group: string; label: string; permissions: Permission[] }> = []; const groupMap = new Map(); @@ -370,7 +495,7 @@ let availableUsersToAdd = $derived(allUsers.filter((u) => !roleUsers.some((ru) => ru.id === u.id))); onMount(async () => { - await Promise.all([fetchRoles(), fetchAllPermissions()]); + await Promise.all([fetchRoles(), fetchAllPermissions(), fetchAccessGroups()]); }); @@ -439,6 +564,10 @@ Users + {#if hasPermission("roles.write")} + {/each} + {/if} + + {#if roleAccessGroupIds.size === 0} +

+ No groups selected — this role can see all pages (including non-public ones). +

+ {/if} + + + + + +
+
+

Manage Groups

+ {#if hasPermission("roles.write")} + + {/if} +
+
+ {#each allAccessGroups as group (group.id)} +
+
+ {#if group.id === "public"} + + {:else} + + {/if} + {group.group_name} + {group.id} +
+ {#if !group.is_system && hasPermission("roles.write")} + + {/if} +
+ {/each} +
+
+ +
+ + +
+ {/if} + + + + + + + + + Create Access Group + + Create a new access group to control page visibility. + + +
+ {#if createGroupError} +

{createGroupError}

+ {/if} +
+ + +

Lowercase, hyphens allowed. Cannot be changed later.

+
+
+ + +
+
+ + +
+
+ + + + +
+
+ + + + + + Delete Access Group + + Are you sure you want to delete {groupToDelete?.group_name}? + This will remove it from all pages and roles. + + + + + + + + diff --git a/src/routes/(manage)/manage/app/site-configurations/+page.svelte b/src/routes/(manage)/manage/app/site-configurations/+page.svelte index c2ec2d383..f2d82a68c 100644 --- a/src/routes/(manage)/manage/app/site-configurations/+page.svelte +++ b/src/routes/(manage)/manage/app/site-configurations/+page.svelte @@ -49,6 +49,8 @@ let savingEventDisplaySettings = $state(false); let savingSitemap = $state(false); let savingMaintenanceNotificationSettings = $state(false); + let savingAutoPublicPages = $state(false); + let autoPublicPages = $state(true); let uploadingLogo = $state(false); let uploadingFavicon = $state(false); let uploadingSocialPreviewImage = $state(false); @@ -196,6 +198,12 @@ globalPageVisibilitySettings = structuredClone(defaultGlobalPageVisibilitySettings); } + if (data.autoPublicPages !== undefined && data.autoPublicPages !== null) { + autoPublicPages = data.autoPublicPages === true || data.autoPublicPages === "true"; + } else { + autoPublicPages = true; + } + dataRetentionPolicy = { enabled: data.dataRetentionPolicy?.enabled ?? true, retentionDays: data.dataRetentionPolicy?.retentionDays ?? 90 @@ -451,6 +459,30 @@ } } + async function saveAutoPublicPages() { + savingAutoPublicPages = true; + try { + const response = await fetch(clientResolver(resolve, "/manage/api"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action: "storeSiteData", + data: { autoPublicPages: String(autoPublicPages) } + }) + }); + const result = await response.json(); + if (result.error) { + toast.error(result.error); + } else { + toast.success("Setting saved successfully"); + } + } catch (e) { + toast.error("Failed to save setting"); + } finally { + savingAutoPublicPages = false; + } + } + async function saveDataRetentionPolicy() { savingDataRetentionPolicy = true; try { @@ -1173,6 +1205,7 @@ + + +
+