Skip to content

Feature: page access groups with RBAC-based visibility control - #727

Open
Plattenspatz wants to merge 2 commits into
rajnandan1:mainfrom
Plattenspatz:feature/page-access-groups
Open

Feature: page access groups with RBAC-based visibility control#727
Plattenspatz wants to merge 2 commits into
rajnandan1:mainfrom
Plattenspatz:feature/page-access-groups

Conversation

@Plattenspatz

@Plattenspatz Plattenspatz commented May 8, 2026

Copy link
Copy Markdown

Summary

This PR adds access groups to Kener — a flexible, RBAC-based mechanism to control who can see which status pages. It enables multi-tenant setups where different customers or teams only see their own pages.

Instead of a simple public/internal toggle, access groups provide a unified model: a built-in public group controls whether a page is publicly visible, while custom groups (e.g. customer-a, customer-b) restrict access to users whose role includes that group. This keeps things simple for basic setups (everything is public by default) while supporting complex scenarios when needed.

Motivation

This was built for a multi-tenant use case: a single Kener instance serving status pages for multiple customers, where each customer should only see their own pages. Currently, all pages are visible to everyone who knows the URL.

Resolves #725.
Related to #714 and #697 — this PR takes a different approach by building on the RBAC system (v4.0.23) rather than adding a binary internal/public flag. The public access group replaces the need for a separate page_is_internal field, and the same mechanism handles both public visibility and per-user filtering.

How It Works

Access groups on pages

Each page can have one or more access groups assigned. Two system groups exist by default:

  • public — pages with this group are visible to everyone without login (current default behavior)
  • admin — roles with this group can see all pages regardless of their groups
    Custom groups (e.g. customer-a) are created by admins and assigned to pages and roles as needed.

Access check logic

Page has "public" group?
  → Yes: visible to everyone
  → No: user logged in?
    → No: redirect to sign-in
    → Yes: user's role has "admin" group?
      → Yes: visible
      → No: any overlap between page groups and role groups?
        → Yes: visible
        → No: 404 (not 403, to avoid revealing the page exists)

Example

Page "customer-a-status"     → groups: [customer-a]
Page "customer-b-status"     → groups: [customer-b]
Page "shared-infra"          → groups: [customer-a, customer-b]
Page "public-status"         → groups: [public]
 
Role "Customer A Viewer"     → page scope: [customer-a]
Role "Admin"                 → page scope: [admin]  (built-in)
  • Customer A users see customer-a-status, shared-infra, and public-status
  • Customer B users see customer-b-status, shared-infra, and public-status
  • Admins see everything
  • Anonymous visitors see only public-status

What's Included

Database

  • New migration adding three tables: access_groups, page_access_groups (page ↔ group), role_access_groups (role ↔ group)
  • System groups public and admin are seeded automatically and cannot be deleted
  • All existing pages get the public group on migration (backward compatible — nothing changes for existing installations)
  • The admin role gets the admin group on migration

Backend

  • Access check in page routes — (kener)/+page.server.ts and (kener)/[page_path]/+page.server.ts
  • Page switcher API filters pages by user's access (api-server/pages/get.ts)
  • Site setting autoPublicPages — when enabled (default), newly created pages automatically get the public group
  • 9 new repository methods for access group CRUD and assignment

Admin UI

  • Page edit: Access Groups card with checkboxes to assign groups to a page
  • Roles page: "Page Access" button per role, opening a sheet to select which groups the role can view
  • Roles page: Group management (create/delete custom groups) within the same sheet
  • Site Configuration: "Auto-public new pages" toggle

REST API (/api/v4/)

  • GET /api/v4/pages and GET /api/v4/pages/{path} — response includes access_groups array
  • POST /api/v4/pages — accepts optional access_groups array (falls back to auto-public setting when omitted)
  • PATCH /api/v4/pages/{path} — accepts access_groups array to replace current assignments (omitting it leaves groups unchanged)
  • GET /api/v4/access-groups — list all access groups
  • POST /api/v4/access-groups — create a new access group

Backward Compatibility

This PR is fully backward compatible:

  • All existing pages receive the public group during migration, so they remain visible to everyone
  • All existing roles work unchanged — the built-in admin/editor/member roles continue to function as before
  • The admin role automatically gets the admin group, giving it access to all pages
  • No existing API contracts are broken — access_groups is an additive field in API responses

Files Changed

Area Files Changes
DB 1 new migration 3 tables, system group seeds, backward-compat migration
Backend 5 modified, 1 new Access check, page switcher filter, repository methods, permission mapping
Admin UI 3 modified Page edit card, role sheet, site config toggle
API 3 modified, 1 new GET/POST/PATCH with access_groups, new access-groups endpoint
Types 2 modified API interfaces, site data keys

Total: ~1,130 lines added across 18 files.

Testing

Tested on two independent instances (fresh install + existing installation with data) covering:

  • Public/private page visibility for anonymous and authenticated users
  • Role-based access filtering with custom groups
  • Shared pages across multiple groups
  • Page switcher filtering
  • Admin override via admin group
  • Auto-public setting (on/off)
  • API operations (create, update, list with access groups)
  • System group protection (cannot delete public/admin)
  • Backward compatibility (existing pages remain public after migration)

Summary by CodeRabbit

  • New Features

    • Added access groups for pages and roles, including management screens and API support.
    • Page responses now include assigned access groups, and editors can update them.
    • Added a site setting to automatically make new pages public.
  • Bug Fixes

    • Page listings now respect access rules and only show content the current user can access.
    • New pages can be assigned to the public group automatically for backward compatibility.

@coderabbitai

coderabbitai Bot commented Jun 26, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds access groups, access-group assignments for pages and roles, access checks for page loads, and UI/API updates to view and edit those assignments. It also adds an autoPublicPages site setting and uses it when creating pages.

Changes

Access Groups and Page Visibility

Layer / File(s) Summary
Schema and contracts
migrations/20260508120000_add_access_groups.ts, src/lib/allPerms.ts, src/lib/types/api.ts, src/lib/server/controllers/siteDataKeys.ts, src/lib/server/db/seedSiteData.ts
Adds access-group tables, seeds public and admin, extends permission mappings and page request/response shapes with access_groups, and adds the autoPublicPages site-data key and seed value.
Repository methods
src/lib/server/db/repositories/pages.ts, src/lib/server/db/dbimpl.ts
Adds page/role access-group lookup, replace, and CRUD methods in the repository layer and binds them onto DbImpl.
Controller access logic
src/lib/server/controllers/pagesController.ts
Updates page creation, access checks, accessible-page filtering, and access-group helper methods in the controller.
Page visibility routes
src/lib/server/api-server/pages/get.ts, src/routes/(kener)/+page.server.ts, src/routes/(kener)/[page_path]/+page.server.ts
Filters the page switcher response to accessible pages and gates dashboard page loads with access checks.
API routes
src/routes/(api)/api/v4/access-groups/+server.ts, src/routes/(api)/api/v4/pages/+server.ts, src/routes/(api)/api/v4/pages/[page_path]/+server.ts, src/routes/(manage)/manage/api/+server.ts
Returns access_groups in page APIs, accepts access-group assignments on create and update, adds the access-group CRUD endpoint, and dispatches the new manage actions.
Manage UI
src/routes/(manage)/manage/app/pages/[page_id]/+page.svelte, src/routes/(manage)/manage/app/roles/+page.svelte, src/routes/(manage)/manage/app/site-configurations/+page.svelte
Adds page and role access-group editors and an autoPublicPages site-setting toggle.

Sequence Diagram(s)

sequenceDiagram
  participant PageLoad as "src/routes/(kener)/[page_path]/+page.server.ts"
  participant CheckPageAccess
  participant DbImpl

  PageLoad->>DbImpl: GetPageByPath(params.page_path)
  DbImpl-->>PageLoad: page record
  PageLoad->>CheckPageAccess: CheckPageAccess(page.id, loggedInUser)
  CheckPageAccess->>DbImpl: load page and role access groups
  DbImpl-->>CheckPageAccess: group IDs
  CheckPageAccess-->>PageLoad: allow / login_required / denied
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~90+ minutes

Suggested reviewers

  • rajnandan1

Poem

🐰 I hopped through pages, public and shy,
and tucked new access keys beneath my eye.
public and admin, side by side,
now every page knows who may ride.
Hoppity hooray, the burrow sings!

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR implements access groups and RBAC visibility, but the linked issue requires per-user pattern-based access via page_access_pattern. Add user-level page_access_pattern support, apply pattern matching in access checks, and keep the page switcher filtered by that rule.
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: page access groups and RBAC-based visibility control.
Out of Scope Changes check ✅ Passed All changes support the access-group visibility feature and related admin/API plumbing; no unrelated additions stand out.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@greptile-apps

greptile-apps Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds RBAC-based page visibility control to Kener via "access groups" — a public group for unauthenticated access, an admin group for unrestricted access, and arbitrary custom groups for multi-tenant scoping. It includes a DB migration, repository methods, access-check logic in the public page routes, REST API extensions, and admin UI panels for managing groups and role assignments.

  • Three new junction tables (access_groups, page_access_groups, role_access_groups) with a backward-compatible migration that assigns public to all existing pages.
  • CheckPageAccess and GetAccessiblePages in pagesController.ts gate every public page load and the page-switcher dropdown; the hook-level auth (hooks.server.ts) already covers the new /api/v4/access-groups endpoint.
  • Two bugs in the role management UI: groupToDelete is set to null before the cleanup code that should remove the deleted group from the in-memory selection Set, and the "no groups selected" helper text tells admins the role can "see all pages" when it actually can only see public pages — both can lead to misconfigured role access.

Confidence Score: 3/5

The access-check logic is sound and the migration is backward compatible, but two bugs in the role management UI can cause admins to silently misconfigure role visibility before merging.

The core page-gating logic in CheckPageAccess and GetAccessiblePages is correct and the migration safely back-fills existing pages. However, the roles UI has a null-reference defect that prevents the deleted-group cleanup from ever running (a group removed from the list stays silently selected), and a helper-text inversion that tells admins a role with no groups sees all pages when it actually only sees public pages. Both issues affect the admin UX for a security-critical feature and should be fixed before the feature ships.

src/routes/(manage)/manage/app/roles/+page.svelte has the null-reference bug in handleDeleteGroup and the incorrect helper text; src/lib/server/controllers/pagesController.ts is missing the 'admin' reserved-ID guard in CreateAccessGroup.

Important Files Changed

Filename Overview
migrations/20260508120000_add_access_groups.ts Adds three new tables (access_groups, page_access_groups, role_access_groups), seeds system groups, back-fills existing pages with the public group, and assigns the admin role to the admin group. Uses hasTable guards for idempotency. Down migration drops all three tables cleanly.
src/lib/server/controllers/pagesController.ts Adds access-group check logic (CheckPageAccess, GetAccessiblePages) and admin CRUD functions. The CreateAccessGroup guard only blocks the 'public' reserved ID, leaving 'admin' unprotected. CreatePage now always auto-assigns public before returning, but the REST API POST handler duplicates this logic.
src/routes/(manage)/manage/app/roles/+page.svelte Adds Access Groups sheet, group management dialogs, and role-group assignment. Contains two bugs: groupToDelete is nulled before cleanup code reads it (deleted group stays in selection Set), and the 'no groups selected' label incorrectly states the role sees all pages when it actually sees only public ones.
src/routes/(kener)/+page.server.ts Home page route now runs CheckPageAccess before loading dashboard data; correctly redirects to sign-in or returns 404 based on access result.
src/routes/(kener)/[page_path]/+page.server.ts Dynamic page route now resolves the page first, then gates on CheckPageAccess; returns 404 for denied access to avoid leaking page existence.
src/routes/(api)/api/v4/pages/+server.ts GET includes access_groups in page response; POST validates and applies provided access_groups, but duplicates the auto-public logic already handled inside CreatePage when no groups are specified.
src/routes/(api)/api/v4/access-groups/+server.ts New endpoint for listing and creating access groups; protected by the global apiAuthHandle hook. Input validation is present for both handlers.
src/lib/server/db/repositories/pages.ts Nine new repository methods for access group CRUD. Batch read (getAccessGroupsForPages) uses a single whereIn query. Writes use transactions. Implementation is clean and compatible with Knex's cross-DB abstractions.
src/routes/(manage)/manage/app/pages/[page_id]/+page.svelte Access Groups card added with checkbox-style group selection and a separate save action. Fetches on mount; save correctly guards with !currentPage. The admin group is excluded from the page-assignment UI (intentional).
src/lib/server/api-server/pages/get.ts Page switcher now calls GetAccessiblePages with the session user, filtering out pages the caller cannot see. Cookie-based session resolution is correctly guarded for anonymous requests.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Request hits page route] --> B{Page has 'public' group?}
    B -- Yes --> C[✅ Allow — no login needed]
    B -- No --> D{User logged in?}
    D -- No --> E[🔀 Redirect to sign-in]
    D -- Yes --> F{User role has 'admin' group?}
    F -- Yes --> G[✅ Allow — admin sees everything]
    F -- No --> H{Page has any access groups?}
    H -- No --> I[❌ Denied — 404]
    H -- Yes --> J{Role groups ∩ Page groups ≠ ∅?}
    J -- Yes --> K[✅ Allow]
    J -- No --> L[❌ Denied — 404]

    style C fill:#22c55e,color:#fff
    style G fill:#22c55e,color:#fff
    style K fill:#22c55e,color:#fff
    style E fill:#f59e0b,color:#fff
    style I fill:#ef4444,color:#fff
    style L fill:#ef4444,color:#fff
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[Request hits page route] --> B{Page has 'public' group?}
    B -- Yes --> C[✅ Allow — no login needed]
    B -- No --> D{User logged in?}
    D -- No --> E[🔀 Redirect to sign-in]
    D -- Yes --> F{User role has 'admin' group?}
    F -- Yes --> G[✅ Allow — admin sees everything]
    F -- No --> H{Page has any access groups?}
    H -- No --> I[❌ Denied — 404]
    H -- Yes --> J{Role groups ∩ Page groups ≠ ∅?}
    J -- Yes --> K[✅ Allow]
    J -- No --> L[❌ Denied — 404]

    style C fill:#22c55e,color:#fff
    style G fill:#22c55e,color:#fff
    style K fill:#22c55e,color:#fff
    style E fill:#f59e0b,color:#fff
    style I fill:#ef4444,color:#fff
    style L fill:#ef4444,color:#fff
Loading

Comments Outside Diff (3)

  1. src/routes/(manage)/manage/app/roles/+page.svelte, line 1303-1307 (link)

    P1 Misleading "no groups" label — wrong access semantics

    The tooltip says a role with no groups selected can "see all pages (including non-public ones)," but the actual logic in CheckPageAccess / GetAccessiblePages is the opposite: a role with no groups in role_access_groups fails both rule 3 (admin group check) and rules 5–6 (overlap check), so it can only see pages in the public group. An admin reading this message would believe leaving groups empty is equivalent to "allow all," configure it that way, then discover their users are silently locked out of all non-public pages. The label should say something like "No groups selected — this role can only see public pages."

  2. src/routes/(manage)/manage/app/roles/+page.svelte, line 1207-1227 (link)

    P1 groupToDelete is nulled before the cleanup code that reads it

    groupToDelete is set to null on line 1213, but the conditional on line 1217 that removes the deleted group from roleAccessGroupIds then reads groupToDelete?.id || "". Because groupToDelete is already null, the optional chain returns undefined and the fallback "" is used. has("") will always be false, so the group is never removed from the selected Set. The in-memory selection state becomes stale — the deleted group remains "checked" even after deletion, and will be re-submitted to setRoleAccessGroups on the next save.

  3. src/routes/(api)/api/v4/pages/+server.ts, line 733-739 (link)

    P2 Auto-public is applied twice when access_groups is omitted from a POST /api/v4/pages request

    CreatePage (in pagesController.ts) already calls db.setPageAccessGroups(newPage.id, ["public"]) when autoPublicPages is truthy, then this else branch executes the identical logic a second time. The second call is a no-op (same result) so there is no data corruption, but it runs two DB transactions unnecessarily and duplicates the policy in two places. If the auto-public rule is ever changed in CreatePage, this copy will silently diverge. Consider removing the else branch here and trusting CreatePage to handle the default.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Reviews (1): Last reviewed commit: "Merge upstream/main, resolve import conf..." | Re-trigger Greptile

Comment on lines +351 to +355

/**
* Create a new access group
*/
export async function CreateAccessGroup(data: { id: string; group_name: string; description?: string }) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Only "public" is explicitly reserved — "admin" is not guarded

The function blocks creation of a group named "public" but not "admin". Both are system groups with special semantics. If the seeded "admin" row were absent (manual DB cleanup, failed migration), a caller could create a non-system "admin" group (is_system = 0), which is then deletable through the normal delete path, silently stripping admin-level page access from all roles. The guard should also reject data.id === "admin" with a matching error.

Comment on lines +958 to +964
<Sheet.Description>
{#if accessGroupsRole?.readonly === 1}
Select which page access groups this role can view. Leave empty to allow access to all pages.
{:else}
Select which page access groups this role can view. Leave empty to allow access to all pages.
{/if}
</Sheet.Description>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Both branches of this {#if} block are identical — the readonly check has no effect and the description is misleading (empty groups ≠ "access all pages"). Either remove the conditional or write distinct descriptions for read-only vs editable roles.

Suggested change
<Sheet.Description>
{#if accessGroupsRole?.readonly === 1}
Select which page access groups this role can view. Leave empty to allow access to all pages.
{:else}
Select which page access groups this role can view. Leave empty to allow access to all pages.
{/if}
</Sheet.Description>
<Sheet.Description>
Select which page access groups this role can view. Roles without any group can only see public pages.
</Sheet.Description>

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 15

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
src/routes/(api)/api/v4/pages/[page_path]/+server.ts (2)

32-45: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Guard access-group reads and writes with VerifyAPIKey().

The route now exposes page visibility groups and allows replacing them, but the handler does not perform the required API-key check. As per coding guidelines, src/routes/(api)/**/*.ts: Use API authentication via VerifyAPIKey() imported from $lib/server/controllers/apiController.

Also applies to: 257-273

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/`(api)/api/v4/pages/[page_path]/+server.ts around lines 32 - 45,
The page API handler is exposing and modifying access-group data without the
required API authentication. Update the route in the `+server.ts` handler to
call `VerifyAPIKey()` from `$lib/server/controllers/apiController` before
reading or writing page access groups, and ensure both the access-group fetch
and any replacement logic are gated behind that check.

Source: Coding guidelines


236-273: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Validate access_groups before applying any PATCH mutations.

Invalid group IDs are checked only after page fields and monitor mappings may already be updated, so a request can return 400 while persisting partial changes. Validate body.access_groups type and IDs before db.updatePage() / monitor replacement, then apply all writes atomically if possible.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/`(api)/api/v4/pages/[page_path]/+server.ts around lines 236 - 273,
Validate body.access_groups and its IDs before performing any PATCH writes in
the page update handler, so invalid groups do not leave partial changes behind.
In the +server route, move the access-group existence check ahead of
db.updatePage(), db.deletePageMonitorsByPageId(), and addMonitorToPage(), and
only call SetPageAccessGroups after all validation passes. If possible, keep the
page update, monitor replacement, and access-group assignment in one atomic flow
so the request either fully succeeds or fully fails.
src/routes/(api)/api/v4/pages/+server.ts (2)

31-44: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Guard the new access-group API surface with VerifyAPIKey().

This route now returns page access-group assignments and accepts visibility assignments on creation, but the handler does not perform the required API-key check. As per coding guidelines, src/routes/(api)/**/*.ts: Use API authentication via VerifyAPIKey() imported from $lib/server/controllers/apiController.

Also applies to: 179-203

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/`(api)/api/v4/pages/+server.ts around lines 31 - 44, The page API
handler is exposing access-group data without the required API authentication.
Update the `+server.ts` route to call `VerifyAPIKey()` from
`$lib/server/controllers/apiController` before returning page details or
accepting visibility/access-group assignments, and keep the check in the main
handler path that builds the response with `GetPageAccessGroups`.

Source: Coding guidelines


165-195: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Validate access groups before creating the page.

An invalid group ID returns 400 after db.createPage() and monitor inserts have already run, leaving a page created from a failed request. Move access-group validation before any writes, or wrap page/monitor/group writes in one transaction.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/`(api)/api/v4/pages/+server.ts around lines 165 - 195, The page
creation flow in the page route currently validates access groups only after
db.createPage() and monitor inserts have already run, so a bad group ID can
leave partial writes behind. Move the access-group validation logic in the page
handler before any database writes, or make the page creation, addMonitorToPage,
and SetPageAccessGroups steps run in a single transaction so they all succeed or
fail together.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/lib/allPerms.ts`:
- Around line 227-228: The deleteAccessGroup permission mapping currently only
uses roles.write, which allows cascading changes to page_access_groups without
page-level authorization. Update the permission configuration in allPerms so
deleteAccessGroup also requires pages.write, or adjust the dispatcher to enforce
both roles.write and pages.write when handling this action. Use the
deleteAccessGroup mapping in allPerms as the place to locate and fix the check.

In `@src/lib/server/controllers/pagesController.ts`:
- Around line 402-418: Validate the incoming group_ids in SetPageAccessGroups
and SetRoleAccessGroups before calling db.setPageAccessGroups or
db.setRoleAccessGroups. De-duplicate the array, fetch the allowed IDs via
getAllAccessGroups(), and reject any unknown IDs so the controller never writes
invalid or duplicate assignments that can trigger FK or composite-key errors.
- Around line 37-42: The page creation flow in pagesController’s createPage path
is not atomic because db.createPage succeeds before db.setPageAccessGroups, so a
failure can leave a page created without its default visibility group. Fix this
by making both writes part of one transaction in the same controller flow, or by
adding compensation to delete the newly created page and rethrow if
setPageAccessGroups fails. Use the createPage, setPageAccessGroups, and
GetSiteDataByKey logic to locate the affected block.
- Around line 360-365: After normalizing the group ID in pagesController,
revalidate the resulting data.id before proceeding because values like "!!!" can
normalize to an empty string; also treat "admin" as a reserved ID alongside
"public" and reject both in the same validation path. Update the existing ID
normalization/validation logic around the data.id assignment so the create-group
flow fails fast for empty normalized IDs and reserved names.

In `@src/lib/server/db/repositories/pages.ts`:
- Around line 201-203: The getAllAccessGroups repository contract is missing the
is_system field, which forces unsafe casting in the controller and weakens the
DeleteAccessGroup guard. Update the return type in
PagesRepository.getAllAccessGroups to include is_system, and ensure the query
still returns that column from access_groups so downstream callers can rely on
the typed value without casting.

In `@src/routes/`(api)/api/v4/access-groups/+server.ts:
- Around line 13-56: The POST handler currently only validates the request body,
but the documented reserved-group rule is incomplete because CreateAccessGroup
only blocks public; add a centralized check in POST and/or inside
CreateAccessGroup to reject admin the same way as public before any persistence
occurs. Use the existing CreateAccessGroup contract and the access-groups POST
request flow to ensure both reserved IDs are denied consistently with a
BAD_REQUEST response.
- Around line 16-76: The POST and GET handlers in access-groups/+server.ts are
missing API-key protection, so both listing and creating groups can be reached
without authentication. Import VerifyAPIKey from
$lib/server/controllers/apiController and call it at the start of both
RequestHandler functions before any db access or CreateAccessGroup invocation,
returning the auth failure response if verification fails.

In `@src/routes/`(api)/api/v4/pages/+server.ts:
- Around line 179-201: The access_groups handling in the page creation flow
should reject malformed input instead of treating it like a missing field.
Update the logic around the access_groups validation in +server.ts so that when
body.access_groups is defined but not an array, it returns a 400 BAD_REQUEST
response rather than falling through to the auto-public branch; keep the
existing SetPageAccessGroups and autoPublicPages behavior only for valid arrays
or when access_groups is truly absent.

In `@src/routes/`(kener)/[page_path]/+page.server.ts:
- Around line 16-22: The access handling in the page loader should not redirect
anonymous users to signin for non-public paths, because that reveals whether the
page exists. Update the logic in the page access check flow around
CheckPageAccess so that "login_required" is treated like "denied" and returns
the same 404 "Page Not Found" response unless the request is coming from a
trusted login flow. Keep the redirect behavior only for explicit authenticated
login navigation, and make sure the response for inaccessible and nonexistent
paths is indistinguishable.

In `@src/routes/`(manage)/manage/api/+server.ts:
- Around line 433-438: Validate the manage action payload before calling the
RBAC replacement helpers, since `SetPageAccessGroups` and `SetRoleAccessGroups`
currently receive `data.page_id`, `data.roleId`, and `data.group_ids` directly.
Add checks in the `action` dispatch block in `+server.ts` to ensure the IDs are
present and well-formed, and verify every group in `data.group_ids` exists
before invoking those helpers. Keep the validation close to the
`setPageAccessGroups` and `setRoleAccessGroups` branches so bad requests are
rejected before any RBAC assignment changes are attempted.

In `@src/routes/`(manage)/manage/app/pages/[page_id]/+page.svelte:
- Around line 1065-1074: The row in the group access list is using nested
interactive controls because Checkbox.Root is placed inside Button, which can
break accessibility and keyboard behavior. Update the page access UI in the
+page.svelte section that renders each group row so the container is not a
Button wrapping the checkbox; instead make the row a non-interactive
label/container or let Checkbox.Root own the interaction via its own
onCheckedChange while preserving the existing toggleAccessGroup(group.id)
behavior and selected styling.

In `@src/routes/`(manage)/manage/app/roles/+page.svelte:
- Around line 976-1001: The empty-selection copy in the roles page is
inconsistent: the text in the access-groups section and the
`roleAccessGroupIds.size === 0` message describe opposite permissions. Update
the wording in the `+page.svelte` roles UI so both messages match the actual
backend behavior and the intended access model, using the `roleAccessGroupIds`
empty-state logic and the access-group description block as the references.
- Around line 980-987: The role access group row is nesting an interactive
Checkbox.Root inside a Button, which should be removed. Update the component in
+page.svelte so the group selection is driven by a label/container row or by
wiring Checkbox.Root directly with onCheckedChange, and keep the click target
and checkbox state aligned through toggleAccessGroup(group.id) and
roleAccessGroupIds.has(group.id) without wrapping one control inside the other.
- Around line 959-987: The access-group editor in +page.svelte still allows
changing and saving role page-access settings even when the role is readonly or
the user lacks assignment rights. Update the controls around the role access
sheet to use the same authorization guard already used elsewhere in this
component (for example, the readonly/assignment check that drives the
permissions sheet) so the toggle buttons and save action are disabled or hidden
for protected roles; make sure the logic is applied consistently in the
access-group row rendering and the save handler paths tied to the role access
modal.
- Around line 453-466: The cleanup in handleDeleteGroup is using groupToDelete
after it has already been cleared, so the deleted group ID is lost and
roleAccessGroupIds may not be updated correctly. Save the deleted group’s id in
a local variable before setting groupToDelete to null, then use that saved id
when checking and removing it from roleAccessGroupIds inside handleDeleteGroup.

---

Outside diff comments:
In `@src/routes/`(api)/api/v4/pages/[page_path]/+server.ts:
- Around line 32-45: The page API handler is exposing and modifying access-group
data without the required API authentication. Update the route in the
`+server.ts` handler to call `VerifyAPIKey()` from
`$lib/server/controllers/apiController` before reading or writing page access
groups, and ensure both the access-group fetch and any replacement logic are
gated behind that check.
- Around line 236-273: Validate body.access_groups and its IDs before performing
any PATCH writes in the page update handler, so invalid groups do not leave
partial changes behind. In the +server route, move the access-group existence
check ahead of db.updatePage(), db.deletePageMonitorsByPageId(), and
addMonitorToPage(), and only call SetPageAccessGroups after all validation
passes. If possible, keep the page update, monitor replacement, and access-group
assignment in one atomic flow so the request either fully succeeds or fully
fails.

In `@src/routes/`(api)/api/v4/pages/+server.ts:
- Around line 31-44: The page API handler is exposing access-group data without
the required API authentication. Update the `+server.ts` route to call
`VerifyAPIKey()` from `$lib/server/controllers/apiController` before returning
page details or accepting visibility/access-group assignments, and keep the
check in the main handler path that builds the response with
`GetPageAccessGroups`.
- Around line 165-195: The page creation flow in the page route currently
validates access groups only after db.createPage() and monitor inserts have
already run, so a bad group ID can leave partial writes behind. Move the
access-group validation logic in the page handler before any database writes, or
make the page creation, addMonitorToPage, and SetPageAccessGroups steps run in a
single transaction so they all succeed or fail together.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 6d7fc8b8-ff65-4b86-8101-56b241a3e90c

📥 Commits

Reviewing files that changed from the base of the PR and between ef46836 and 24b7923.

📒 Files selected for processing (18)
  • migrations/20260508120000_add_access_groups.ts
  • src/lib/allPerms.ts
  • src/lib/server/api-server/pages/get.ts
  • src/lib/server/controllers/pagesController.ts
  • src/lib/server/controllers/siteDataKeys.ts
  • src/lib/server/db/dbimpl.ts
  • src/lib/server/db/repositories/pages.ts
  • src/lib/server/db/seedSiteData.ts
  • src/lib/types/api.ts
  • src/routes/(api)/api/v4/access-groups/+server.ts
  • src/routes/(api)/api/v4/pages/+server.ts
  • src/routes/(api)/api/v4/pages/[page_path]/+server.ts
  • src/routes/(kener)/+page.server.ts
  • src/routes/(kener)/[page_path]/+page.server.ts
  • src/routes/(manage)/manage/api/+server.ts
  • src/routes/(manage)/manage/app/pages/[page_id]/+page.svelte
  • src/routes/(manage)/manage/app/roles/+page.svelte
  • src/routes/(manage)/manage/app/site-configurations/+page.svelte

Comment thread src/lib/allPerms.ts
Comment on lines +227 to +228
createAccessGroup: "roles.write",
deleteAccessGroup: "roles.write",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Require page-write permission when deleting access groups.

deleteAccessGroup can cascade-remove page_access_groups, so roles.write alone lets a role manager change page visibility. Add a pages.write check for this action, or require both roles.write and pages.write in the dispatcher.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/allPerms.ts` around lines 227 - 228, The deleteAccessGroup permission
mapping currently only uses roles.write, which allows cascading changes to
page_access_groups without page-level authorization. Update the permission
configuration in allPerms so deleteAccessGroup also requires pages.write, or
adjust the dispatcher to enforce both roles.write and pages.write when handling
this action. Use the deleteAccessGroup mapping in allPerms as the place to
locate and fix the check.

Comment on lines +37 to +42
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"]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Keep page creation and default access assignment atomic.

If setPageAccessGroups fails after createPage, the request errors but the page remains created without its intended visibility group; retrying then hits the duplicate path check. Wrap both writes in one transaction or compensate by deleting the new page before rethrowing.

Localized compensation option
   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"]);
+  try {
+    // 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"]);
+    }
+  } catch (error) {
+    await db.deletePage(newPage.id);
+    throw error;
   }
 
   return newPage;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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"]);
const newPage = await db.createPage(data);
try {
// 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"]);
}
} catch (error) {
await db.deletePage(newPage.id);
throw error;
}
return newPage;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/server/controllers/pagesController.ts` around lines 37 - 42, The page
creation flow in pagesController’s createPage path is not atomic because
db.createPage succeeds before db.setPageAccessGroups, so a failure can leave a
page created without its default visibility group. Fix this by making both
writes part of one transaction in the same controller flow, or by adding
compensation to delete the newly created page and rethrow if setPageAccessGroups
fails. Use the createPage, setPageAccessGroups, and GetSiteDataByKey logic to
locate the affected block.

Comment on lines +360 to +365
// 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'");
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Revalidate normalized IDs and reserve admin.

An ID like "!!!" normalizes to "" after the required-field check, and admin is also reserved by the access logic as a global override group. Reject both after normalization.

Suggested fix
   // 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'");
+  if (!data.id) {
+    throw new Error("id must contain at least one URL-safe character");
+  }
+
+  if (data.id === PUBLIC_GROUP || data.id === ADMIN_GROUP) {
+    throw new Error(`Cannot create a group with the reserved ID '${data.id}'`);
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/server/controllers/pagesController.ts` around lines 360 - 365, After
normalizing the group ID in pagesController, revalidate the resulting data.id
before proceeding because values like "!!!" can normalize to an empty string;
also treat "admin" as a reserved ID alongside "public" and reject both in the
same validation path. Update the existing ID normalization/validation logic
around the data.id assignment so the create-group flow fails fast for empty
normalized IDs and reserved names.

Comment on lines +402 to +418
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<string[]> {
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Validate assignment group IDs before writing.

The manage API passes group_ids directly here; invalid IDs become dialect-specific FK errors, and duplicates can violate the composite primary key. De-duplicate and verify IDs against getAllAccessGroups() before calling the repository.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/server/controllers/pagesController.ts` around lines 402 - 418,
Validate the incoming group_ids in SetPageAccessGroups and SetRoleAccessGroups
before calling db.setPageAccessGroups or db.setRoleAccessGroups. De-duplicate
the array, fetch the allowed IDs via getAllAccessGroups(), and reject any
unknown IDs so the controller never writes invalid or duplicate assignments that
can trigger FK or composite-key errors.

Comment on lines +201 to +203
async getAllAccessGroups(): Promise<Array<{ id: string; group_name: string; description: string | null }>> {
return await this.knex("access_groups").orderBy("id", "asc");
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Include is_system in the repository contract.

DeleteAccessGroup depends on is_system, but this method’s return type omits it, forcing an unsafe cast in the controller and making the system-group deletion guard fragile if this query is later narrowed.

Suggested adjustment
-  async getAllAccessGroups(): Promise<Array<{ id: string; group_name: string; description: string | null }>> {
+  async getAllAccessGroups(): Promise<
+    Array<{ id: string; group_name: string; description: string | null; is_system: number }>
+  > {
     return await this.knex("access_groups").orderBy("id", "asc");
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async getAllAccessGroups(): Promise<Array<{ id: string; group_name: string; description: string | null }>> {
return await this.knex("access_groups").orderBy("id", "asc");
}
async getAllAccessGroups(): Promise<
Array<{ id: string; group_name: string; description: string | null; is_system: number }>
> {
return await this.knex("access_groups").orderBy("id", "asc");
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/server/db/repositories/pages.ts` around lines 201 - 203, The
getAllAccessGroups repository contract is missing the is_system field, which
forces unsafe casting in the controller and weakens the DeleteAccessGroup guard.
Update the return type in PagesRepository.getAllAccessGroups to include
is_system, and ensure the query still returns that column from access_groups so
downstream callers can rely on the typed value without casting.

Comment on lines +1065 to +1074
<Button
variant={pageAccessGroupIds.has(group.id) ? "outline" : "ghost"}
class="h-auto w-full justify-start gap-3 p-3 text-left {pageAccessGroupIds.has(group.id)
? 'border-primary bg-primary/5'
: ''}"
onclick={() => toggleAccessGroup(group.id)}
>
<Checkbox.Root
checked={pageAccessGroupIds.has(group.id)}
/>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Avoid nesting the checkbox control inside a button.

Checkbox.Root is itself an interactive control, so placing it inside Button creates nested interactive elements and can break keyboard/screen-reader behavior. Make the row a label/container or handle the checkbox’s own onCheckedChange directly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/`(manage)/manage/app/pages/[page_id]/+page.svelte around lines
1065 - 1074, The row in the group access list is using nested interactive
controls because Checkbox.Root is placed inside Button, which can break
accessibility and keyboard behavior. Update the page access UI in the
+page.svelte section that renders each group row so the container is not a
Button wrapping the checkbox; instead make the row a non-interactive
label/container or let Checkbox.Root own the interaction via its own
onCheckedChange while preserving the existing toggleAccessGroup(group.id)
behavior and selected styling.

Comment on lines +453 to +466
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Remove the deleted group before clearing groupToDelete.

Line 460 sets groupToDelete = null, so the cleanup at Lines 463-466 always checks/deletes "" and leaves the deleted group selected in roleAccessGroupIds. Save the ID first, then remove it from the current selection.

Proposed fix
 async function handleDeleteGroup() {
   if (!groupToDelete) return;
+  const deletedGroupId = groupToDelete.id;
   deletingGroup = true;
   try {
-    await apiCall("deleteAccessGroup", { id: groupToDelete.id });
+    await apiCall("deleteAccessGroup", { id: deletedGroupId });
     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 || "")) {
+    if (roleAccessGroupIds.has(deletedGroupId)) {
       const next = new Set(roleAccessGroupIds);
-      next.delete(groupToDelete?.id || "");
+      next.delete(deletedGroupId);
       roleAccessGroupIds = next;
     }
+    groupToDelete = null;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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;
async function handleDeleteGroup() {
if (!groupToDelete) return;
const deletedGroupId = groupToDelete.id;
deletingGroup = true;
try {
await apiCall("deleteAccessGroup", { id: deletedGroupId });
toast.success("Access group deleted");
showDeleteGroupDialog = false;
allAccessGroups = await apiCall("getAccessGroups");
// Remove from current selection if it was selected
if (roleAccessGroupIds.has(deletedGroupId)) {
const next = new Set(roleAccessGroupIds);
next.delete(deletedGroupId);
roleAccessGroupIds = next;
}
groupToDelete = null;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/`(manage)/manage/app/roles/+page.svelte around lines 453 - 466,
The cleanup in handleDeleteGroup is using groupToDelete after it has already
been cleared, so the deleted group ID is lost and roleAccessGroupIds may not be
updated correctly. Save the deleted group’s id in a local variable before
setting groupToDelete to null, then use that saved id when checking and removing
it from roleAccessGroupIds inside handleDeleteGroup.

Comment on lines +959 to +987
{#if accessGroupsRole?.readonly === 1}
Select which page access groups this role can view. Leave empty to allow access to all pages.
{:else}
Select which page access groups this role can view. Leave empty to allow access to all pages.
{/if}
</Sheet.Description>
</Sheet.Header>
<div class="p-4">
{#if loadingAccessGroups}
<div class="flex items-center justify-center p-8">
<Spinner class="h-6 w-6" />
</div>
{:else}
<div class="space-y-3">
{#if allAccessGroups.length === 0}
<p class="text-muted-foreground text-sm">No access groups configured yet.</p>
{:else}
<p class="text-muted-foreground mb-3 text-xs">
Roles without access groups can only see public pages. Select groups below to grant access to non-public pages. The admin role has a built-in "admin" group that grants access to all pages.
</p>
{#each allAccessGroups.filter((g) => g.id !== "public") as group (group.id)}
<Button
variant={roleAccessGroupIds.has(group.id) ? "outline" : "ghost"}
class="h-auto w-full justify-start gap-3 p-3 text-left {roleAccessGroupIds.has(group.id)
? 'border-primary bg-primary/5'
: ''}"
onclick={() => toggleAccessGroup(group.id)}
>
<Checkbox.Root checked={roleAccessGroupIds.has(group.id)} />

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Respect readonly roles and write permissions for page-access edits.

The permissions sheet disables readonly roles and users without assignment rights, but this sheet still allows toggling and saving access groups. Gate the row controls and save action with the same kind of authorization check to avoid mutating protected role access.

Also applies to: 1047-1054

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/`(manage)/manage/app/roles/+page.svelte around lines 959 - 987,
The access-group editor in +page.svelte still allows changing and saving role
page-access settings even when the role is readonly or the user lacks assignment
rights. Update the controls around the role access sheet to use the same
authorization guard already used elsewhere in this component (for example, the
readonly/assignment check that drives the permissions sheet) so the toggle
buttons and save action are disabled or hidden for protected roles; make sure
the logic is applied consistently in the access-group row rendering and the save
handler paths tied to the role access modal.

Comment on lines +976 to +1001
<p class="text-muted-foreground mb-3 text-xs">
Roles without access groups can only see public pages. Select groups below to grant access to non-public pages. The admin role has a built-in "admin" group that grants access to all pages.
</p>
{#each allAccessGroups.filter((g) => g.id !== "public") as group (group.id)}
<Button
variant={roleAccessGroupIds.has(group.id) ? "outline" : "ghost"}
class="h-auto w-full justify-start gap-3 p-3 text-left {roleAccessGroupIds.has(group.id)
? 'border-primary bg-primary/5'
: ''}"
onclick={() => toggleAccessGroup(group.id)}
>
<Checkbox.Root checked={roleAccessGroupIds.has(group.id)} />
<div class="flex flex-col">
<span class="text-sm font-medium">{group.group_name}</span>
{#if group.description}
<span class="text-muted-foreground text-xs">{group.description}</span>
{/if}
</div>
</Button>
{/each}
{/if}

{#if roleAccessGroupIds.size === 0}
<p class="text-muted-foreground text-xs">
No groups selected — this role can see all pages (including non-public ones).
</p>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Make the empty-selection semantics consistent.

Lines 976-978 say roles without access groups can only see public pages, while Lines 999-1001 say they can see all pages. Align this copy with the backend behavior and PR objective before admins configure tenant access from contradictory guidance.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/`(manage)/manage/app/roles/+page.svelte around lines 976 - 1001,
The empty-selection copy in the roles page is inconsistent: the text in the
access-groups section and the `roleAccessGroupIds.size === 0` message describe
opposite permissions. Update the wording in the `+page.svelte` roles UI so both
messages match the actual backend behavior and the intended access model, using
the `roleAccessGroupIds` empty-state logic and the access-group description
block as the references.

Comment on lines +980 to +987
<Button
variant={roleAccessGroupIds.has(group.id) ? "outline" : "ghost"}
class="h-auto w-full justify-start gap-3 p-3 text-left {roleAccessGroupIds.has(group.id)
? 'border-primary bg-primary/5'
: ''}"
onclick={() => toggleAccessGroup(group.id)}
>
<Checkbox.Root checked={roleAccessGroupIds.has(group.id)} />

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Avoid nesting the checkbox control inside a button.

As in the page editor, Checkbox.Root inside Button creates nested interactive controls. Use a label/container row or wire Checkbox.Root directly via onCheckedChange.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/`(manage)/manage/app/roles/+page.svelte around lines 980 - 987,
The role access group row is nesting an interactive Checkbox.Root inside a
Button, which should be removed. Update the component in +page.svelte so the
group selection is driven by a label/container row or by wiring Checkbox.Root
directly with onCheckedChange, and keep the click target and checkbox state
aligned through toggleAccessGroup(group.id) and roleAccessGroupIds.has(group.id)
without wrapping one control inside the other.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature request: Per-user page access restrictions for internal pages

1 participant