rivalex/clearance
A drop-in Laravel admin panel for spatie/laravel-permission. Manage permissions, roles, guards and settings via Livewire 4 + Flux UI, with contextual authorization, parent-bounded ceiling roles, and optional super admin.
rivalex/lingua. Clearance's own CSS uses a class-based dark: variant that follows the host app's <html class="dark"> ancestor; hosts with no class-based dark toggle (or whose real OS/browser scheme doesn't match a deliberately-dark host layout) previously rendered Clearance unstyled/illegible even though the rest of the page was fine. config('clearance.dark_mode.force') (env CLEARANCE_FORCE_DARK_MODE) sets the default; a new "Appearance" card in /clearance/settings (Livewire\Settings\AppearanceSettings, DB-backed via ClearanceSettings::get/set('dark_mode.force', ...)) overrides it live, same DB-over-config precedence as Lingua. New anonymous component <x-clearance::dark-scope> (resources/views/components/dark-scope.blade.php) wraps the resolved value in an optional .dark ancestor div around the existing .clearance scope div; every one of the 25 Blade views that previously opened with a bare <div class="clearance ..."> now opens with <x-clearance::dark-scope ...> instead (content and any extra utility classes on the wrapper preserved 1:1).dark: variant was OS-preference-based, not class-based — resources/css/clearance.css imported tailwindcss without ever declaring [@custom-variant](https://github.com/custom-variant) dark (&:where(.dark, .dark *)); (present in Lingua's CSS entry, missing here), so every dark: utility Clearance itself authors compiled under Tailwind v4's default strategy — [@media](https://github.com/media) (prefers-color-scheme: dark) — completely ignoring any .dark class on <html>. Confirmed live against rivalex.test/clearance: with <html class="dark"> already present (this host's layout is deliberately dark-only), borders/nav pills/muted text using Clearance's own dark: classes still rendered in their light-mode colors, because the compiled CSS gated them on the OS color-scheme media query instead. Added the same [@custom-variant](https://github.com/custom-variant) dark declaration Lingua uses; rebuilt src/dist/css/clearance.min.css now contains zero prefers-color-scheme rules and the expected :where(.dark, .dark *) selectors, verified against the live page (border/nav theming now correct with force/.dark set, independent of OS scheme).<flux:card> stat tiles rendered an unstyled white background with invisible (white-on-white) numbers, even after the [@custom-variant](https://github.com/custom-variant) dark fix above. Root cause is narrower and unrelated to the OS-vs-class issue: Flux's own component markup (vendor/livewire/flux/stubs/resources/views/flux/card/index.blade.php) uses a compound utility, dark:[:where(&)]:bg-white/10 (a custom dark: variant stacked on an arbitrary [:where(&)]: bracket variant). Confirmed both livewire/flux (v2.15.0) and tailwindcss/[@tailwindcss](https://github.com/tailwindcss)/vite (v4.3.3) are already latest, so this isn't a version-lag issue — verified directly against the compiled CSS that sibling utilities from the same Flux template line compile fine individually ([:where(&)]:bg-white, [:where(&)]:p-6, [:where(&)]:rounded-xl, plain-stacked dark:border-white/10), but the doubly-stacked dark:[:where(&)]:bg-white/10 never gets generated at all — a genuine Tailwind v4 limitation combining a [@custom-variant](https://github.com/custom-variant)-defined variant with an arbitrary bracket variant on the same utility. Worked around in resources/css/clearance.css with a plain (non-arbitrary) [data-flux-card] { [@apply](https://github.com/apply) dark:bg-white/10; } override, keyed off the stable data-flux-card attribute Flux's card root div already carries — compiles correctly (:where(.dark, .dark *) .clearance [data-flux-card]) and doesn't touch light mode. Verified live: all four dashboard stat tiles now show correct numbers and translucent-dark backgrounds.update-changelog.yml CI workflow hardcoded ref: main (both the actions/checkout step and stefanzweifel/git-auto-commit-action's branch:) — this repo's only branch is master (git ls-remote --heads origin confirms no main ref exists), so every run since this workflow was added failed at checkout (git fetch ... refs/heads/main* finding nothing, retried 3x, exit 1). Both hardcoded to master.codecov/patch failing at 60.00% vs a 78.02% target on the dark-mode commit — new AppearanceSettings.php shipped with zero tests. Added tests/Unit/T67AppearanceSettingsTest.php (mount defaults, config-fallback, DB-overrides-config precedence, save persistence for both true/false) — Livewire/Settings/AppearanceSettings now at 100% coverage; full suite 381/381 passing._ in segments (V6) — PermissionService::validate()'s gruppo-azione regex constrained each segment to [a-z0-9], so any name containing _ was rejected as an invalid symbol regardless of naming_separator config (e.g. user_profile-manage failed). Segments now accept [a-z0-9_]; the separator itself, spaces, dots, and all other symbols remain rejected. Compounding client-side bug: the custom-ability Alpine slugify() helper in permission-form.blade.php stripped _ to - before the value ever reached the server, so typing an underscore into the custom-ability pill input silently became a dash even after the backend fix. Both the regex (src/Services/PermissionService.php) and the JS slugifier (resources/views/livewire/permissions/permission-form.blade.php) now preserve _. SPEC.md §C and V6 updated to document underscore as a valid segment character. New Pest cases in T3PermissionServiceTest cover accepted underscore names and rejected other-symbol names (@, #, space, !).First public release. rivalex/clearance is a Livewire 4 + Flux UI admin panel for managing permissions, roles, and contextual authorization on top of spatie/laravel-permission, installable via a single artisan command.
PHP ^8.3 · Laravel ^12.0|^13.0 · spatie/laravel-permission ^6.0 · livewire/livewire ^3.0|^4.0 · livewire/flux ^2.14.
<x-clearance::nav /> component (resources/views/components/nav.blade.php) rendering a Dashboard/Permissions/Roles/Guards/Settings switcher, placed immediately below the page header on all five top-level panel pages (dashboard.blade.php, guards/guard-manager.blade.php, permissions/permission-manager.blade.php, roles/role-manager.blade.php, settings/manager.blade.php). Modeled on the equivalent rivalex/lingua nav component: flux:button per route, active route highlighted via request()->routeIs(). New ui.common.nav.* translation keys added across all 9 bundled locales.PRODUCT.md, visual tokens (OKLCH ink/red/tinted-neutral ramps, typography, component rules) in DESIGN.md + .impeccable/design.json sidecar, generated from the existing clearance.css [@theme](https://github.com/theme) tokens.roles.form.advanced progressive disclosure — role creation/edit form now collapses scope, context types, and parent-role selection behind a native <details>/<summary> "Advanced settings" section (auto-expanded when a contextual scope or parent role is already set), reducing the always-visible control count on the form./clearance/*) or as individually embeddable Livewire components.$user->canIn(), $user->hasRoleIn() (via the HasClearance trait), the [@canin](https://github.com/canin)/[@hasrolein](https://github.com/hasrolein) Blade directives, and the ContextService/Clearance facade for use outside a User model context. Resolution merges contextual-role grants with global-role grants, then applies per-context forced_on/forced_off overrides.RoleMeta.parent_role_id). Child permissions exceeding the parent's ceiling are silently trimmed; a role cannot be both a parent and a child (single-level enforcement).clearance-{section}-write permission per panel section (permissions, roles, guards, settings, users), seeded automatically by the installer and independent from the coarse clearance-access read permission.super_admin role synced with all clearance-* permissions, with alias detection for pre-existing admin-like roles during install, and an opt-in Gate::before() bypass (super_admin_gate_bypass, default false).modules.users) — per-user panel for global and contextual role assignment, plus per-context permission overrides (clr_ctx_overrides), scoped to a specific context model instance.show_icons toggle.auth.guards at boot, restricted to an allow-listed set of drivers.clearance:install command — publishes config and migrations, detects and bootstraps spatie/laravel-permission if absent, seeds all clearance-* permissions and the super_admin role, and supports --user, --role, --super-admin-role, and --force.clearance:backfill command — adopts Clearance into an existing Spatie installation: seeds display metadata and role scope for pre-existing roles, and imports guards from config/auth.php; supports --only and --dry-run.HasClearance trait (contextual auth methods, includes Spatie's HasRoles), HasPermissionGroups trait (permission-group UI accessors for custom Permission subclasses), ClearanceContextable contract (custom context labels), and reusable Blade components (x-clearance::message, clipboard, validation-errors, icon set).SvgSanitizer, an allow-list-based sanitizer (tags, attributes, on* handlers, javascript: URIs, CSS injection vectors) applied to every user-supplied icon before storage and at render time.This release ships after a dedicated pre-publication security audit and two follow-up hardening passes. Highlights:
forced_off overrides are deny-authoritative — they now win even when the same permission is also granted via a global role (previously the override could be silently defeated).clearance-* permissions — enforced both for direct per-user grants and for role-level permission syncing.clearance-* permissions are protected end-to-end: they cannot be renamed, deleted, or created/added to any role outside the package's own install flow.[@canin](https://github.com/canin)/[@hasrolein](https://github.com/hasrolein) fail closed (return false, not a 500) for unauthenticated guests.naming_separator is validated at boot against a strict allow-list, closing the only path by which it reaches raw SQL fragments.roles/permissions tables fail with an actionable message if run before clearance:install, instead of a cryptic foreign-key error.composer audit clean).Full audit trail, findings, and fixes: docs/plans/security-audit/plan.md.
clearance.layout config was dead — changing it had no effect on any panel page. Livewire 4 only wraps a full-page component's rendered view in a layout when ->layout() is explicitly called on it, otherwise it silently falls back to the host app's config('livewire.component_layout'); nothing in Clearance ever called ->layout(), so the package's own layout config key (and its doc comment referencing the wrong Livewire config name) was read nowhere and had zero effect regardless of value. Added Rivalex\Clearance\Concerns\HasClearanceLayout (applies config('clearance.layout') via ->layout() only when set, otherwise leaves Livewire's own default untouched) and wired it into every full-page component's render()/placeholder(): Dashboard, Roles\RoleManager, Guards\GuardManager, Permissions\PermissionManager, Settings\SettingsManager, Users\UserClearanceManager. Corrected the config comment to reference livewire.component_layout. Covered by tests/Unit/T11ClearanceLayoutTest.php.role-form.blade.php and permission-form.blade.php permission pills used class="hidden" on their checkbox input, removing them from the tab order and killing the focus ring; selected state was color-only. Now uses sr-only (focusable, screen-reader visible), a has-[:focus-visible]:ring-2 focus ring on the pill, and a checkmark icon as a non-color selected-state signal.zinc-* raw Tailwind utilities instead of the brand neutral ramp — 413 occurrences across 25 Blade view files bypassed the tinted hue-260 --color-gray-* ramp defined in clearance.css in favor of Tailwind's default untinted zinc palette. Migrated to gray-* project-wide (Flux component color="zinc" props left untouched — that's Flux's own neutral-palette API, unrelated to the raw utility drift).color="green" primary Save/Add buttons (guard, permission, role create/edit triggers) reverted to the default ink primary; amber/sky informational badges (locked-role badge, guard-type badge, contextual-role-context badge) converted to neutral; checkbox accent colors switched from text-sky-600 to the ink token.resources/images/clearance-mark-horizzontale.svg renamed to clearance-mark-horizontal.svg; references updated in branding.blade.php and README.md.role-form.blade.php collided with the danger signal — granted CRUD-category pills still rendered green/red/amber (create/read/update=green, delete=red, custom=amber); a granted delete ability used the same red reserved for destructive actions, collapsing "you hold this ability" and "this is dangerous" into one signal. Granted pills (including the locked/always-on variant) now fill with the ink token + checkmark regardless of category; delete-type abilities are marked by a small trash icon instead of the danger hue.users/contextual-roles-panel.blade.php listed a role's permissions once as a disabled read-only grid and again as toggleable "extra permissions" checkboxes, with no indication of what checking the second grid actually does (it explicitly pins that permission to this context so it survives future role changes — it does not grant anything the role doesn't already grant). Added an inline hint (user_clearance.extra_perms.extra_perms_hint, all 9 locales) clarifying the relationship instead of restructuring the two lists, since collapsing them would misrepresent genuinely distinct override semantics.new-permission-modal/new-role-modal use a static Livewire :key, so Flux reuses the same component instance every time the modal reopens instead of remounting it; PermissionForm::save() and RoleForm::save() closed the modal and dispatched their *-saved event but never reset the component's public state. Both now explicitly reset the create-mode fields (and validation state) right after a successful create; edit mode is unaffected.Dashboard.php built a raw SELECT DISTINCT SUBSTR(...) subquery and called ->count() on it; Eloquent's aggregate count() discards custom select() columns and runs a plain COUNT(*), so the "distinct" never applied and the stat silently equaled permissions_count. Replaced with Permission::all()->pluck('permission_group')->unique()->count(), matching the grouping already used by HasPermissionGroups::getPermissionGroupAttribute().text-align:right CSS-inheritance leak into inline (non-teleported) modals — Flux modals in this app render in place rather than teleporting to <body>, so a modal declared inside a <td class="text-right"> action cell had its heading/description inherit the cell's right alignment. Found and fixed on guards/guard-manager.blade.php and roles/role-manager.blade.php, then found again (same pattern, different files) on settings/role-meta-table.blade.php and settings/guard-meta-table.blade.php (the "Edit metadata" modal on the Settings page). Fixed by moving the alignment onto an inner flex justify-end wrapper and dropping text-right from the <td> itself.role-form.blade.php had [@if](https://github.com/if)($isSuperAdmin) disabled [@endif](https://github.com/endif) written directly inside the attribute list of a self-closing <flux:input> / <flux:checkbox> tag; Blade's component-tag compiler couldn't match the tag as a result and left it uncompiled/unrendered, while the sibling [@if](https://github.com/if)/[@endif](https://github.com/endif) statement still got processed as plain PHP by a separate compiler pass — so the field silently disappeared instead of erroring. Replaced with the standard :disabled="$isSuperAdmin" dynamic-attribute binding (already the working pattern in guard-form.blade.php).md:w-[52rem] width — Flux ships an un-layered default modal rule (max-width: 36rem) that wins over any Tailwind [@layer](https://github.com/layer) utilities class regardless of specificity or source order; a modal requesting a wider layout was silently clamped back to 36rem. Fixed by forcing the width/max-width utilities with Tailwind v4's ! important suffix (md:w-[52rem]! md:max-w-[52rem]!); npm run build re-run to ship the updated src/dist/css/clearance.min.css.border-b, action buttons separated by a border-t and laid out flex justify-between (single-action modals use justify-end). Applied to roles/delete-role.blade.php (all three confirm branches), permissions/delete-permission.blade.php, permissions/permission-form.blade.php, guards/guard-form.blade.php (also converted from a raw <header><h3>/<p></header> to flux:heading/flux:text — the only modal in the package not using Flux's typography components), settings/edit-meta.blade.php, users/assign-role-modal.blade.php, and users/remove-assignment-modal.blade.php.<x-clearance::nav /> picked the "active" tab via request()->routeIs(), which only works during the request that actually resolves the named route. Dashboard, RoleManager, GuardManager, and SettingsManager are all #[Lazy] Livewire components: their real content (including the nav) renders over Livewire's internal follow-up "component" request, not the original page request, so routeIs() always returned false there and every tab rendered as unselected ghost — only the non-lazy PermissionManager page ever highlighted correctly. Fixed by having each page pass its own tab explicitly (<x-clearance::nav active="roles" /> etc.) instead of inferring it from the ambient request.data-checked + ability-color pattern already working in permission-form.blade.php (added the missing .crud-pills.amber CSS rule). The "locked" always-granted pills (e.g. clearance-* for super_admin) intentionally keep the ink fill — granted-and-immutable stays a distinct signal from granted-and-toggleable.Hierarchy Livewire module left over from an earlier design (superseded by ceiling roles) that referenced deleted classes.composer analyse from running.User model is missing HasClearance/HasRoles.super_admin_gate_bypass now defaults to false (previously true). Applications relying on the global Gate bypass must opt in explicitly.ContextService::resolveFor() deny-override semantics: a forced_off override now always denies, even against a global-role grant (see Security above).RoleService::syncPermissions() and UserClearanceService::syncGlobalExtraPermissions()/syncContextualExtraPermissions() gained a required leading Authenticatable $actor parameter to enforce the privilege-escalation ceiling. Any code calling these services directly must pass the acting user.^8.3; Laravel 10.x support dropped, 13.x added.rivalex/lingua. Clearance's own CSS uses a class-based dark: variant that follows the host app's <html class="dark"> ancestor; hosts with no class-based dark toggle (or whose real OS/browser scheme doesn't match a deliberately-dark host layout) previously rendered Clearance unstyled/illegible even though the rest of the page was fine. config('clearance.dark_mode.force') (env CLEARANCE_FORCE_DARK_MODE) sets the default; a new "Appearance" card in /clearance/settings (Livewire\Settings\AppearanceSettings, DB-backed via ClearanceSettings::get/set('dark_mode.force', ...)) overrides it live, same DB-over-config precedence as Lingua. New anonymous component <x-clearance::dark-scope> (resources/views/components/dark-scope.blade.php) wraps the resolved value in an optional .dark ancestor div around the existing .clearance scope div; every one of the 25 Blade views that previously opened with a bare <div class="clearance ..."> now opens with <x-clearance::dark-scope ...> instead (content and any extra utility classes on the wrapper preserved 1:1).dark: variant was OS-preference-based, not class-based — resources/css/clearance.css imported tailwindcss without ever declaring [@custom-variant](https://github.com/custom-variant) dark (&:where(.dark, .dark *)); (present in Lingua's CSS entry, missing here), so every dark: utility Clearance itself authors compiled under Tailwind v4's default strategy — [@media](https://github.com/media) (prefers-color-scheme: dark) — completely ignoring any .dark class on <html>. Confirmed live against rivalex.test/clearance: with <html class="dark"> already present (this host's layout is deliberately dark-only), borders/nav pills/muted text using Clearance's own dark: classes still rendered in their light-mode colors, because the compiled CSS gated them on the OS color-scheme media query instead. Added the same [@custom-variant](https://github.com/custom-variant) dark declaration Lingua uses; rebuilt src/dist/css/clearance.min.css now contains zero prefers-color-scheme rules and the expected :where(.dark, .dark *) selectors, verified against the live page (border/nav theming now correct with force/.dark set, independent of OS scheme).<flux:card> stat tiles rendered an unstyled white background with invisible (white-on-white) numbers, even after the [@custom-variant](https://github.com/custom-variant) dark fix above. Root cause is narrower and unrelated to the OS-vs-class issue: Flux's own component markup (vendor/livewire/flux/stubs/resources/views/flux/card/index.blade.php) uses a compound utility, dark:[:where(&)]:bg-white/10 (a custom dark: variant stacked on an arbitrary [:where(&)]: bracket variant). Confirmed both livewire/flux (v2.15.0) and tailwindcss/[@tailwindcss](https://github.com/tailwindcss)/vite (v4.3.3) are already latest, so this isn't a version-lag issue — verified directly against the compiled CSS that sibling utilities from the same Flux template line compile fine individually ([:where(&)]:bg-white, [:where(&)]:p-6, [:where(&)]:rounded-xl, plain-stacked dark:border-white/10), but the doubly-stacked dark:[:where(&)]:bg-white/10 never gets generated at all — a genuine Tailwind v4 limitation combining a [@custom-variant](https://github.com/custom-variant)-defined variant with an arbitrary bracket variant on the same utility. Worked around in resources/css/clearance.css with a plain (non-arbitrary) [data-flux-card] { [@apply](https://github.com/apply) dark:bg-white/10; } override, keyed off the stable data-flux-card attribute Flux's card root div already carries — compiles correctly (:where(.dark, .dark *) .clearance [data-flux-card]) and doesn't touch light mode. Verified live: all four dashboard stat tiles now show correct numbers and translucent-dark backgrounds.update-changelog.yml CI workflow hardcoded ref: main (both the actions/checkout step and stefanzweifel/git-auto-commit-action's branch:) — this repo's only branch is master (git ls-remote --heads origin confirms no main ref exists), so every run since this workflow was added failed at checkout (git fetch ... refs/heads/main* finding nothing, retried 3x, exit 1). Both hardcoded to master.codecov/patch failing at 60.00% vs a 78.02% target on the dark-mode commit — new AppearanceSettings.php shipped with zero tests. Added tests/Unit/T67AppearanceSettingsTest.php (mount defaults, config-fallback, DB-overrides-config precedence, save persistence for both true/false) — Livewire/Settings/AppearanceSettings now at 100% coverage; full suite 381/381 passing.How can I help you explore Laravel packages today?