From e0dc6f2ea800ff88f14c001a92a46669048f0db5 Mon Sep 17 00:00:00 2001 From: Rias Date: Sat, 27 Jun 2026 09:17:20 +0200 Subject: [PATCH 1/8] Port GraphQL screens to Inertia --- resources/js/common/components/MainNav.vue | 4 +- .../permissions/components/PermissionList.vue | 68 +++++- .../permissions/helpers/permissions.ts | 16 +- resources/js/pages/graphql/schemas/Edit.vue | 119 ++++++++++ .../{Schemas.vue => schemas/Index.vue} | 11 +- resources/js/pages/graphql/tokens/Edit.vue | 197 ++++++++++++++++ .../graphql/{Tokens.vue => tokens/Index.vue} | 18 +- .../js/pages/settings/users/groups/Edit.vue | 78 +------ .../templates/graphql/schemas/_edit.twig | 166 ------------- resources/templates/graphql/tokens/_edit.twig | 165 ------------- routes/actions.php | 20 -- routes/cp.php | 34 ++- .../Controllers/Gql/SchemasController.php | 218 ++++++++++-------- src/Http/Controllers/Gql/TokensController.php | 180 ++++++++------- src/User/Data/Permission.php | 2 +- src/User/Data/PermissionGroup.php | 24 +- .../Controllers/Gql/SchemasControllerTest.php | 88 ++++--- .../Controllers/Gql/TokensControllerTest.php | 115 ++++----- workbench/.env.example | 11 +- .../TypeScriptTransformerServiceProvider.php | 10 +- .../TypeScript/ClassListClassTransformer.php | 22 ++ 21 files changed, 798 insertions(+), 768 deletions(-) create mode 100644 resources/js/pages/graphql/schemas/Edit.vue rename resources/js/pages/graphql/{Schemas.vue => schemas/Index.vue} (93%) create mode 100644 resources/js/pages/graphql/tokens/Edit.vue rename resources/js/pages/graphql/{Tokens.vue => tokens/Index.vue} (90%) delete mode 100644 resources/templates/graphql/schemas/_edit.twig delete mode 100644 resources/templates/graphql/tokens/_edit.twig diff --git a/resources/js/common/components/MainNav.vue b/resources/js/common/components/MainNav.vue index 2c729384ea1..df86f58133b 100644 --- a/resources/js/common/components/MainNav.vue +++ b/resources/js/common/components/MainNav.vue @@ -22,7 +22,7 @@ :key="item.url" :icon="item.icon" :href="item.url" - :active="item.sel" + :active="item.selected" :indicator="!!item.badgeCount" > {{ item.label }} @@ -32,7 +32,7 @@ diff --git a/resources/js/modules/permissions/components/PermissionList.vue b/resources/js/modules/permissions/components/PermissionList.vue index a479b165ef0..9fe1f692d76 100644 --- a/resources/js/modules/permissions/components/PermissionList.vue +++ b/resources/js/modules/permissions/components/PermissionList.vue @@ -1,4 +1,5 @@ - - diff --git a/resources/js/pages/graphql/tokens/Edit.vue b/resources/js/pages/graphql/tokens/Edit.vue index 1fe1f48fc5e..79cb71f3644 100644 --- a/resources/js/pages/graphql/tokens/Edit.vue +++ b/resources/js/pages/graphql/tokens/Edit.vue @@ -171,35 +171,24 @@ - - From 77989dfe3d73ef9921d770f1397325ff45c9d985 Mon Sep 17 00:00:00 2001 From: Rias Date: Sat, 27 Jun 2026 17:12:34 +0200 Subject: [PATCH 7/8] Not lowercasing broke user permissions, add a preserveCase option instead --- .../permissions/components/PermissionList.vue | 42 ++++++++++++------- resources/js/pages/graphql/schemas/Edit.vue | 1 + 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/resources/js/modules/permissions/components/PermissionList.vue b/resources/js/modules/permissions/components/PermissionList.vue index 9fe1f692d76..878d45bd735 100644 --- a/resources/js/modules/permissions/components/PermissionList.vue +++ b/resources/js/modules/permissions/components/PermissionList.vue @@ -16,6 +16,7 @@ permissions?: Record; heading?: string; permissionKeys?: Array; + preserveCase?: boolean; disabled?: boolean; level?: number; }>(), @@ -23,11 +24,20 @@ permissions: () => ({}), modelValue: () => [], permissionKeys: () => [], + preserveCase: false, disabled: false, level: 0, } ); + function permissionKey(key: string) { + return props.preserveCase ? key : key.toLowerCase(); + } + + function normalizePermissionKeys(keys: Array) { + return keys.map(permissionKey); + } + function allSelected() { if (!props.permissionKeys.length) { return false; @@ -35,12 +45,16 @@ const selected = new Set(props.modelValue); - return props.permissionKeys.every((key) => selected.has(key)); + return normalizePermissionKeys(props.permissionKeys).every((key) => + selected.has(key) + ); } function toggleAll() { + const keys = normalizePermissionKeys(props.permissionKeys); + if (allSelected()) { - const keysToRemove = new Set(props.permissionKeys); + const keysToRemove = new Set(keys); emit( 'update:modelValue', props.modelValue.filter((key) => !keysToRemove.has(key)) @@ -48,20 +62,19 @@ return; } - emit('update:modelValue', [ - ...new Set([...props.modelValue, ...props.permissionKeys]), - ]); + emit('update:modelValue', [...new Set([...props.modelValue, ...keys])]); } function toggleItem(key: string) { - const index = props.modelValue.indexOf(key); + const normalizedKey = permissionKey(key); + const index = props.modelValue.indexOf(normalizedKey); + if (index === -1) { - emit('update:modelValue', [...props.modelValue, key]); + emit('update:modelValue', [...props.modelValue, normalizedKey]); } else { - const keysToRemove = new Set([ - key, - ...getNestedKeys(props.permissions[key]), - ]); + const keysToRemove = new Set( + normalizePermissionKeys([key, ...getNestedKeys(props.permissions[key])]) + ); emit( 'update:modelValue', props.modelValue.filter((v) => !keysToRemove.has(v)) @@ -103,8 +116,8 @@
  • diff --git a/resources/js/pages/graphql/schemas/Edit.vue b/resources/js/pages/graphql/schemas/Edit.vue index 1651271865d..3b80f81a4b5 100644 --- a/resources/js/pages/graphql/schemas/Edit.vue +++ b/resources/js/pages/graphql/schemas/Edit.vue @@ -92,6 +92,7 @@ :heading="group.heading" :permissions="group.permissions" :permission-keys="group.keys" + :preserve-case="true" v-model="form.permissions" :disabled="readOnly" /> From d272acfe5ac0f8663b4e53561a5466369e6e763a Mon Sep 17 00:00:00 2001 From: Rias Date: Sat, 27 Jun 2026 17:30:24 +0200 Subject: [PATCH 8/8] Canonicalize user permissions on the backend --- .../permissions/components/PermissionList.vue | 42 ++++-------- resources/js/pages/graphql/schemas/Edit.vue | 1 - src/User/UserPermissions.php | 67 ++++++++++++------- tests/Feature/User/UserPermissionsTest.php | 42 ++++++++++++ 4 files changed, 100 insertions(+), 52 deletions(-) diff --git a/resources/js/modules/permissions/components/PermissionList.vue b/resources/js/modules/permissions/components/PermissionList.vue index 878d45bd735..9fe1f692d76 100644 --- a/resources/js/modules/permissions/components/PermissionList.vue +++ b/resources/js/modules/permissions/components/PermissionList.vue @@ -16,7 +16,6 @@ permissions?: Record; heading?: string; permissionKeys?: Array; - preserveCase?: boolean; disabled?: boolean; level?: number; }>(), @@ -24,20 +23,11 @@ permissions: () => ({}), modelValue: () => [], permissionKeys: () => [], - preserveCase: false, disabled: false, level: 0, } ); - function permissionKey(key: string) { - return props.preserveCase ? key : key.toLowerCase(); - } - - function normalizePermissionKeys(keys: Array) { - return keys.map(permissionKey); - } - function allSelected() { if (!props.permissionKeys.length) { return false; @@ -45,16 +35,12 @@ const selected = new Set(props.modelValue); - return normalizePermissionKeys(props.permissionKeys).every((key) => - selected.has(key) - ); + return props.permissionKeys.every((key) => selected.has(key)); } function toggleAll() { - const keys = normalizePermissionKeys(props.permissionKeys); - if (allSelected()) { - const keysToRemove = new Set(keys); + const keysToRemove = new Set(props.permissionKeys); emit( 'update:modelValue', props.modelValue.filter((key) => !keysToRemove.has(key)) @@ -62,19 +48,20 @@ return; } - emit('update:modelValue', [...new Set([...props.modelValue, ...keys])]); + emit('update:modelValue', [ + ...new Set([...props.modelValue, ...props.permissionKeys]), + ]); } function toggleItem(key: string) { - const normalizedKey = permissionKey(key); - const index = props.modelValue.indexOf(normalizedKey); - + const index = props.modelValue.indexOf(key); if (index === -1) { - emit('update:modelValue', [...props.modelValue, normalizedKey]); + emit('update:modelValue', [...props.modelValue, key]); } else { - const keysToRemove = new Set( - normalizePermissionKeys([key, ...getNestedKeys(props.permissions[key])]) - ); + const keysToRemove = new Set([ + key, + ...getNestedKeys(props.permissions[key]), + ]); emit( 'update:modelValue', props.modelValue.filter((v) => !keysToRemove.has(v)) @@ -116,8 +103,8 @@
  • diff --git a/resources/js/pages/graphql/schemas/Edit.vue b/resources/js/pages/graphql/schemas/Edit.vue index 3b80f81a4b5..1651271865d 100644 --- a/resources/js/pages/graphql/schemas/Edit.vue +++ b/resources/js/pages/graphql/schemas/Edit.vue @@ -92,7 +92,6 @@ :heading="group.heading" :permissions="group.permissions" :permission-keys="group.keys" - :preserve-case="true" v-model="form.permissions" :disabled="readOnly" /> diff --git a/src/User/UserPermissions.php b/src/User/UserPermissions.php index 403b3194bd8..904039cbc04 100644 --- a/src/User/UserPermissions.php +++ b/src/User/UserPermissions.php @@ -54,11 +54,11 @@ class UserPermissions private Collection $allPermissions; /** - * @var Collection + * @var Collection * * @see validatePermission() */ - private Collection $allPermissionNames; + private Collection $permissionNamesByLowercase; /** * @var Collection> @@ -150,7 +150,8 @@ public function getPermissionsByGroupId(int $groupId): Collection fn () => $this->createUserPermissionsQuery() ->join(new Alias(Table::USERPERMISSIONS_USERGROUPS, 'p_g'), 'p_g.permissionId', 'p.id') ->where('p_g.groupId', $groupId) - ->pluck('p.name'), + ->pluck('p.name') + ->map(fn (string $permission) => $this->canonicalPermissionName($permission)), ); } @@ -171,7 +172,8 @@ public function getGroupPermissionsByUserId(int $userId): Collection ->join(new Alias(Table::USERPERMISSIONS_USERGROUPS, 'p_g'), 'p_g.permissionId', 'p.id') ->join(new Alias(Table::USERGROUPS_USERS, 'g_u'), 'g_u.groupId', 'p_g.groupId') ->where('g_u.userId', $userId) - ->pluck('p.name'); + ->pluck('p.name') + ->map(fn (string $permission) => $this->canonicalPermissionName($permission)); } /** @@ -179,7 +181,7 @@ public function getGroupPermissionsByUserId(int $userId): Collection */ public function doesGroupHavePermission(int $groupId, string $checkPermission): bool { - return $this->getPermissionsByGroupId($groupId)->containsStrict(strtolower($checkPermission)); + return $this->getPermissionsByGroupId($groupId)->containsStrict($this->canonicalPermissionName($checkPermission)); } /** @@ -191,9 +193,6 @@ public function saveGroupPermissions(int $groupId, array $permissions): bool { Edition::require(Edition::Team); - // Lowercase permissions - $permissions = array_map(strtolower(...), $permissions); - // Filter out any orphaned permissions $permissions = $this->filterOrphanedPermissions($permissions); @@ -233,7 +232,8 @@ function () use ($userId) { $userPermissions = $this->createUserPermissionsQuery() ->join(new Alias(Table::USERPERMISSIONS_USERS, 'p_u'), 'p_u.permissionId', 'p.id') ->where('p_u.userId', $userId) - ->pluck('p.name'); + ->pluck('p.name') + ->map(fn (string $permission) => $this->canonicalPermissionName($permission)); } else { $userPermissions = []; } @@ -245,11 +245,7 @@ function () use ($userId) { public function validatePermission(string $permission): bool { - if (! isset($this->allPermissionNames)) { - $this->allPermissionNames = $this->getAllPermissions()->flatMap(fn (PermissionGroup $group) => $this->collectPermissionNames($group->permissions)); - } - - return $this->allPermissionNames->contains(strtolower($permission)); + return $this->permissionNamesByLowercase()->has(strtolower($permission)); } /** @@ -259,7 +255,7 @@ public function validatePermission(string $permission): bool private function collectPermissionNames(Collection $permissions): Collection { return $permissions->flatMap(function (Permission $permission) { - $names = collect([strtolower($permission->key)]); + $names = collect([$permission->key]); if ($permission->nested->isNotEmpty()) { return $names->merge($this->collectPermissionNames($permission->nested)); @@ -278,7 +274,7 @@ public function doesUserHavePermission(int $userId, string $checkPermission): bo return true; } - return $this->getPermissionsByUserId($userId)->containsStrict(strtolower($checkPermission)); + return $this->getPermissionsByUserId($userId)->containsStrict($this->canonicalPermissionName($checkPermission)); } /** @@ -295,9 +291,6 @@ public function saveUserPermissions(int $userId, array $permissions): bool ->where('userId', $userId) ->delete(); - // Lowercase the permissions - $permissions = array_map(strtolower(...), $permissions); - // Filter out any orphaned permissions $groupPermissions = $this->getGroupPermissionsByUserId($userId); $permissions = $this->filterOrphanedPermissions($permissions, $groupPermissions->all()); @@ -355,6 +348,7 @@ public function handleChangedGroupPermissions(ConfigEvent $event): void $groupPermissionVals = []; if ($permissions) { + $permissions = $this->canonicalPermissionNames((array) $permissions); $now = now(); foreach ($permissions as $permissionName) { @@ -781,6 +775,8 @@ private function filterUnassignablePermissions(Collection $permissions, ?User $u */ private function filterOrphanedPermissions(array $postedPermissions, array $groupPermissions = []): array { + $postedPermissions = $this->canonicalPermissionNames($postedPermissions); + $groupPermissions = $this->canonicalPermissionNames($groupPermissions); $filteredPermissions = []; if (! empty($postedPermissions)) { @@ -812,7 +808,7 @@ private function findSelectedPermissions( $hasAssignedPermissions = false; foreach ($permissionsGroup as $permission) { - $key = strtolower($permission->key); + $key = $permission->key; // Should the user have this permission (either directly or via their group)? if ( @@ -848,14 +844,39 @@ private function findSelectedPermissions( */ private function getPermissionModelByName(string $permissionName): UserPermission { - // Permission names are always stored in lowercase - $permissionName = strtolower($permissionName); + $permissionName = $this->canonicalPermissionName($permissionName); + + if ($permissionModel = UserPermission::whereIn('name', [$permissionName, strtolower($permissionName)])->first()) { + return $permissionModel; + } return UserPermission::firstOrCreate([ 'name' => $permissionName, ]); } + /** @return Collection */ + private function permissionNamesByLowercase(): Collection + { + if (! isset($this->permissionNamesByLowercase)) { + $this->permissionNamesByLowercase = $this->getAllPermissions() + ->flatMap(fn (PermissionGroup $group) => $this->collectPermissionNames($group->permissions)) + ->mapWithKeys(fn (string $permission) => [strtolower($permission) => $permission]); + } + + return $this->permissionNamesByLowercase; + } + + private function canonicalPermissionName(string $permission): string + { + return $this->permissionNamesByLowercase()->get(strtolower($permission), $permission); + } + + private function canonicalPermissionNames(array $permissions): array + { + return array_values(array_unique(array_map($this->canonicalPermissionName(...), $permissions))); + } + private function createUserPermissionsQuery(): Builder { return DB::table(Table::USERPERMISSIONS, 'p')->select(['p.name']); @@ -868,7 +889,7 @@ public function reset(): void { unset( $this->allPermissions, - $this->allPermissionNames, + $this->permissionNamesByLowercase, $this->permissionsByGroupId, $this->permissionsByUserId, ); diff --git a/tests/Feature/User/UserPermissionsTest.php b/tests/Feature/User/UserPermissionsTest.php index 194f1e45f77..e0ff92e08f3 100644 --- a/tests/Feature/User/UserPermissionsTest.php +++ b/tests/Feature/User/UserPermissionsTest.php @@ -1,14 +1,17 @@ userPermissions->doesUserHavePermission($user->id, 'accessSiteWhenSystemIsOff'))->toBeTrue(); }); + +test('permissions are resolved with canonical casing', function () { + $user = User::find()->one(); + + $this->userPermissions->saveUserPermissions($user->id, ['accesssitewhensystemisoff']); + + expect($this->userPermissions->getPermissionsByUserId($user->id)->all()) + ->toContain('accessSiteWhenSystemIsOff'); + expect(DB::table(Table::USERPERMISSIONS)->where('name', 'accessSiteWhenSystemIsOff')->exists()) + ->toBeTrue(); + + DB::table(Table::USERPERMISSIONS_USERS) + ->where('userId', $user->id) + ->delete(); + + $now = now(); + $legacyPermissionId = DB::table(Table::USERPERMISSIONS) + ->where('name', 'accesscp') + ->value('id') ?? DB::table(Table::USERPERMISSIONS)->insertGetId([ + 'name' => 'accesscp', + 'dateCreated' => $now, + 'dateUpdated' => $now, + 'uid' => Str::uuid(), + ]); + + DB::table(Table::USERPERMISSIONS_USERS)->insert([ + 'permissionId' => $legacyPermissionId, + 'userId' => $user->id, + 'dateCreated' => $now, + 'dateUpdated' => $now, + 'uid' => Str::uuid(), + ]); + + $this->userPermissions->reset(); + + expect($this->userPermissions->doesUserHavePermission($user->id, 'accessCp'))->toBeTrue(); + expect($this->userPermissions->getPermissionsByUserId($user->id)->all()) + ->toContain('accessCp'); +});