From cf18ffb92405b6b690c3d102566ffb7e6be72ca6 Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Mon, 20 Apr 2026 11:40:55 -0500 Subject: [PATCH 1/9] Edit user groups page to inertia --- .../scripts/generate-vue-wrappers.js | 11 + .../src/components/checkbox/checkbox.ts | 5 +- .../craftcms-cp/src/styles/shared/tokens.css | 1 + resources/js/components/PermissionList.vue | 109 ++++++++ .../js/pages/SettingsUserGroupsEditPage.vue | 241 ++++++++++++++++++ .../js/pages/SettingsUserGroupsIndexPage.vue | 9 +- resources/js/types/index.ts | 9 + resources/js/utils/permissions.ts | 27 ++ routes/actions.php | 9 - routes/cp.php | 1 + .../Settings/UserGroupsController.php | 23 +- src/Http/Responses/CpScreenResponse.php | 117 ++++----- src/User/Data/PermissionGroup.php | 2 + 13 files changed, 469 insertions(+), 95 deletions(-) create mode 100644 resources/js/components/PermissionList.vue create mode 100644 resources/js/pages/SettingsUserGroupsEditPage.vue create mode 100644 resources/js/utils/permissions.ts diff --git a/packages/craftcms-cp/scripts/generate-vue-wrappers.js b/packages/craftcms-cp/scripts/generate-vue-wrappers.js index f494c43792e..7e190849fc7 100644 --- a/packages/craftcms-cp/scripts/generate-vue-wrappers.js +++ b/packages/craftcms-cp/scripts/generate-vue-wrappers.js @@ -230,6 +230,10 @@ function generateValueWrapper(component) { }); const model = defineModel<${component.modelType}>(); + + defineProps<{ + error?: null | string + }>() `; diff --git a/packages/craftcms-cp/src/components/checkbox/checkbox.ts b/packages/craftcms-cp/src/components/checkbox/checkbox.ts index 191568c8b4c..7f3ebca79b5 100644 --- a/packages/craftcms-cp/src/components/checkbox/checkbox.ts +++ b/packages/craftcms-cp/src/components/checkbox/checkbox.ts @@ -8,9 +8,10 @@ export default class CraftCheckbox extends LionCheckbox { css` /* same as radio, potentially consolidate */ :host { + --_gap-x: var(--gap-x, --c-spacing-md); display: grid; align-items: center; - gap: 0 var(--c-spacing-md); + gap: 0 var(--_gap-x); grid-template-areas: 'input label' '. help-text'; grid-template-columns: auto 1fr; grid-template-rows: repeat(2, auto); @@ -36,6 +37,8 @@ export default class CraftCheckbox extends LionCheckbox { var(--c-form-control-border-color) ); border-radius: var(--c-input-radius, var(--c-radius-sm)); + width: var(--c-size-control-2xs); + height: var(--c-size-control-2xs); } .choice-field__help-text { diff --git a/packages/craftcms-cp/src/styles/shared/tokens.css b/packages/craftcms-cp/src/styles/shared/tokens.css index 39c24cb3697..409c8a93059 100644 --- a/packages/craftcms-cp/src/styles/shared/tokens.css +++ b/packages/craftcms-cp/src/styles/shared/tokens.css @@ -137,6 +137,7 @@ --c-size-icon-lg: calc(22rem / 16); --c-size-icon-xl: calc(30rem / 16); + --c-size-control-2xs: calc(14rem / 16); --c-size-control-xs: calc(16rem / 16); --c-size-control-sm: calc(24rem / 16); --c-size-control-md: calc(34rem / 16); diff --git a/resources/js/components/PermissionList.vue b/resources/js/components/PermissionList.vue new file mode 100644 index 00000000000..454d9178268 --- /dev/null +++ b/resources/js/components/PermissionList.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/resources/js/pages/SettingsUserGroupsEditPage.vue b/resources/js/pages/SettingsUserGroupsEditPage.vue new file mode 100644 index 00000000000..e7ba963b5ce --- /dev/null +++ b/resources/js/pages/SettingsUserGroupsEditPage.vue @@ -0,0 +1,241 @@ + + + + + diff --git a/resources/js/pages/SettingsUserGroupsIndexPage.vue b/resources/js/pages/SettingsUserGroupsIndexPage.vue index a558f2ce211..e8228f042d1 100644 --- a/resources/js/pages/SettingsUserGroupsIndexPage.vue +++ b/resources/js/pages/SettingsUserGroupsIndexPage.vue @@ -10,14 +10,7 @@ import {createCraftColumnHelper} from '@/components/AdminTable/createCraftColumnHelper'; import DeleteButton from '@/components/AdminTable/DeleteButton.vue'; import {router} from '@inertiajs/vue3'; - - interface UserGroup { - id: number; - name: string; - handle: string; - description: string | null; - uid: string; - } + import type {UserGroup} from '@/types'; const props = defineProps<{ groups: Array; diff --git a/resources/js/types/index.ts b/resources/js/types/index.ts index 16717e9f4af..fd0c79f106c 100644 --- a/resources/js/types/index.ts +++ b/resources/js/types/index.ts @@ -169,3 +169,12 @@ export interface PaginationData { from: number; to: number; } + +export interface UserGroup { + id: number; + name: string; + handle: string; + description: string | null; + uid: string; + permissions?: Array; +} diff --git a/resources/js/utils/permissions.ts b/resources/js/utils/permissions.ts new file mode 100644 index 00000000000..d83fedb2a53 --- /dev/null +++ b/resources/js/utils/permissions.ts @@ -0,0 +1,27 @@ +export interface PermissionItem { + key: string; + nested: Record; + label: string; + info?: string; + warning?: string; +} + +export function hasNested(item: PermissionItem) { + return ( + item.nested && + typeof item.nested === 'object' && + !Array.isArray(item.nested) && + Object.keys(item.nested).length > 0 + ); +} + +export function getNestedKeys(item: PermissionItem | undefined): Array { + if (!item || !hasNested(item)) { + return []; + } + + return Object.values(item.nested).flatMap((child: PermissionItem) => [ + child.key.toLowerCase(), + ...getNestedKeys(child), + ]); +} diff --git a/routes/actions.php b/routes/actions.php index fa08471d875..6d5863d5f9f 100644 --- a/routes/actions.php +++ b/routes/actions.php @@ -55,7 +55,6 @@ use CraftCms\Cms\Http\Controllers\Settings\ImageTransformsController; use CraftCms\Cms\Http\Controllers\Settings\RoutesController; use CraftCms\Cms\Http\Controllers\Settings\SectionsController; -use CraftCms\Cms\Http\Controllers\Settings\UserGroupsController; use CraftCms\Cms\Http\Controllers\Settings\UserSettingsController; use CraftCms\Cms\Http\Controllers\Settings\VolumesController; use CraftCms\Cms\Http\Controllers\StructuresController; @@ -455,14 +454,6 @@ Route::post('users/verify-password', [PasswordController::class, 'verifyPassword']); Route::post('users/save-field-layout', SaveUsersFieldLayoutController::class); - // User groups - Route::middleware([ - RequireAdminChanges::class, - RequireEdition::class.':'.Edition::Team->value, - ])->group(function () { - Route::post('user-settings/save-group', [UserGroupsController::class, 'store']); - }); - // User settings Route::middleware([ RequireAdminChanges::class, diff --git a/routes/cp.php b/routes/cp.php index f28110cb10d..69af07c9004 100644 --- a/routes/cp.php +++ b/routes/cp.php @@ -237,6 +237,7 @@ RequireAdminChanges::class, ])->group(function () { Route::get('settings/users/groups/new', [UserGroupsController::class, 'create']); + Route::post('settings/users/groups/{groupId}', [UserGroupsController::class, 'store'])->whereNumber('groupId'); Route::delete('settings/users/groups/{groupId}', [UserGroupsController::class, 'destroy'])->whereNumber('groupId'); }); Route::get('settings/users/groups/{userGroup}', [UserGroupsController::class, 'edit']); diff --git a/src/Http/Controllers/Settings/UserGroupsController.php b/src/Http/Controllers/Settings/UserGroupsController.php index 7d312de70bf..d0fb9149d8f 100644 --- a/src/Http/Controllers/Settings/UserGroupsController.php +++ b/src/Http/Controllers/Settings/UserGroupsController.php @@ -60,12 +60,12 @@ public function index() ]); } - public function create(): CpScreenResponse + public function create(UserPermissions $userPermissions): CpScreenResponse { $crumbs = [ ['label' => t('Settings'), 'url' => 'settings'], ['label' => t('Users'), 'url' => 'settings/users'], - ['label' => t('User Groups'), 'url' => 'settings/users'], + ['label' => t('User Groups')], ]; return new CpScreenResponse() @@ -78,13 +78,13 @@ public function create(): CpScreenResponse ]) ->action('user-settings/save-group') ->redirectUrl('settings/users') - ->contentTemplate('settings/users/groups/_edit.twig', [ + ->inertiaPage('SettingsUserGroupsEditPage', [ 'group' => new UserGroup, - 'readOnly' => $this->readOnly, + 'permissions' => $userPermissions->getAllPermissions(), ]); } - public function edit(UserGroupModel $userGroup): CpScreenResponse|View + public function edit(UserGroupModel $userGroup, UserPermissions $userPermissions): CpScreenResponse|View { if (Edition::get() === Edition::Team) { $group = $this->userGroups->getTeamGroup(); @@ -114,9 +114,12 @@ public function edit(UserGroupModel $userGroup): CpScreenResponse|View ]) ->action('user-settings/save-group') ->redirectUrl('settings/users') - ->contentTemplate('settings/users/groups/_edit.twig', [ - 'group' => $group, - 'readOnly' => $this->readOnly, + ->inertiaPage('SettingsUserGroupsEditPage', [ + 'group' => [ + 'id' => $group->id, + ...$group->getConfig(true), + ], + 'permissions' => $userPermissions->getAllPermissions(), ]) ->prepareScreen(function (CpScreenResponse $response, string $containerId) { HtmlStack::jsWithVars( @@ -133,10 +136,10 @@ public function edit(UserGroupModel $userGroup): CpScreenResponse|View }); } - public function store(Request $request, UserPermissions $userPermissions): Response + public function store(Request $request, UserPermissions $userPermissions, int $groupId): Response { $userGroupData = new UserGroup; - $userGroupData->id = $request->integer('id', $request->input('groupId')); + $userGroupData->id = $groupId; $userGroupData->name = $request->input('name'); $userGroupData->handle = $request->input('handle'); $userGroupData->description = $request->input('description'); diff --git a/src/Http/Responses/CpScreenResponse.php b/src/Http/Responses/CpScreenResponse.php index e299fe00101..4bd1e7ff956 100644 --- a/src/Http/Responses/CpScreenResponse.php +++ b/src/Http/Responses/CpScreenResponse.php @@ -719,10 +719,6 @@ public function errorSummaryTemplate(string $template, array $variables = []): s public function toResponse($request): Response { - if ($this->inertiaPage) { - return $this->inertiaResponse($request); - } - if ($request->wantsJson()) { return $this->jsonResponse($request); } @@ -730,28 +726,6 @@ public function toResponse($request): Response return $this->response($request); } - private function inertiaResponse(Request $request): Response - { - if ($this->prepareScreen) { - ($this->prepareScreen)($this, $request); - } - - $crumbs = $this->crumbs; - if ($this->title) { - $crumbs[] = ['label' => $this->title]; - } - - $props = array_filter([ - 'title' => $this->title, - 'crumbs' => $crumbs, - ], fn ($value) => $value !== null); - - return Inertia::render( - $this->inertiaPage, - [...$props, ...$this->inertiaProps], - )->toResponse($request); - } - private function jsonResponse(Request $request): JsonResponse { $namespace = Str::random(10); @@ -869,50 +843,59 @@ private function response(Request $request): Response Craft::$app->getView()->registerAssetBundle(ContentWindowAsset::class); } + $templateProps = [ + 'docTitle' => $docTitle, + 'title' => $this->title, + 'selectedSubnavItem' => $this->selectedSubnavItem, + 'crumbs' => array_map(function (array $crumb): array { + if (isset($crumb['url'])) { + $crumb['url'] = Url::cpUrl($crumb['url']); + } + + return $crumb; + }, $crumbs ?? []), + 'contextMenu' => $this->contextMenu(), + 'toolbar' => $toolbar, + 'actionMenu' => $this->actionMenu(config: [ + 'hiddenLabel' => t('Actions'), + 'buttonAttributes' => [ + 'id' => 'action-btn', + 'class' => ['action-btn', 'hairline-dark', 'm'], + 'title' => t('Actions'), + ], + ]), + 'submitButtonLabel' => $this->submitButtonLabel, + 'additionalButtons' => $addlButtons, + 'tabs' => $this->tabs, + 'fullPageForm' => $isForm, + 'mainAttributes' => $this->mainAttributes, + 'mainFormAttributes' => $this->formAttributes, + 'formActions' => array_map(function (array $action): array { + if (isset($action['redirect'])) { + $action['redirect'] = Crypt::encrypt($action['redirect']); + } + + return $action; + }, $altActions ?? []), + 'saveShortcutRedirect' => $this->saveShortcutRedirectUrl, + 'contentNotice' => $notice, + 'content' => $content, + 'details' => $sidebar, + 'sidebar' => $pageSidebar, + 'errorSummary' => $errorSummary, + ]; + + if ($this->inertiaPage) { + return Inertia::render($this->inertiaPage, [ + ...$templateProps, + ...($this->inertiaProps ?? []), + ])->toResponse($request); + } + // Render and return the template return response(pageTemplate( '_layouts/cp', - [ - 'docTitle' => $docTitle, - 'title' => $this->title, - 'selectedSubnavItem' => $this->selectedSubnavItem, - 'crumbs' => array_map(function (array $crumb): array { - if (isset($crumb['url'])) { - $crumb['url'] = Url::cpUrl($crumb['url']); - } - - return $crumb; - }, $crumbs ?? []), - 'contextMenu' => $this->contextMenu(), - 'toolbar' => $toolbar, - 'actionMenu' => $this->actionMenu(config: [ - 'hiddenLabel' => t('Actions'), - 'buttonAttributes' => [ - 'id' => 'action-btn', - 'class' => ['action-btn', 'hairline-dark', 'm'], - 'title' => t('Actions'), - ], - ]), - 'submitButtonLabel' => $this->submitButtonLabel, - 'additionalButtons' => $addlButtons, - 'tabs' => $this->tabs, - 'fullPageForm' => $isForm, - 'mainAttributes' => $this->mainAttributes, - 'mainFormAttributes' => $this->formAttributes, - 'formActions' => array_map(function (array $action): array { - if (isset($action['redirect'])) { - $action['redirect'] = Crypt::encrypt($action['redirect']); - } - - return $action; - }, $altActions ?? []), - 'saveShortcutRedirect' => $this->saveShortcutRedirectUrl, - 'contentNotice' => $notice, - 'content' => $content, - 'details' => $sidebar, - 'sidebar' => $pageSidebar, - 'errorSummary' => $errorSummary, - ], + $templateProps, TemplateMode::Cp )); } diff --git a/src/User/Data/PermissionGroup.php b/src/User/Data/PermissionGroup.php index 127db6fedfc..c9d1239e452 100644 --- a/src/User/Data/PermissionGroup.php +++ b/src/User/Data/PermissionGroup.php @@ -4,6 +4,7 @@ namespace CraftCms\Cms\User\Data; +use CraftCms\Cms\Support\Str; use Illuminate\Contracts\Support\Arrayable; use Illuminate\Support\Collection; @@ -19,6 +20,7 @@ public function toArray(): array { return [ 'heading' => $this->heading, + 'handle' => Str::toHandle($this->heading), 'permissions' => $this->permissions->keyBy('key')->toArray(), ]; } From d613c22b1b40283b649794aaea9053327470032e Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Mon, 20 Apr 2026 11:51:18 -0500 Subject: [PATCH 2/9] logical properties --- resources/js/components/PermissionList.vue | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/resources/js/components/PermissionList.vue b/resources/js/components/PermissionList.vue index 454d9178268..cf5d9bff549 100644 --- a/resources/js/components/PermissionList.vue +++ b/resources/js/components/PermissionList.vue @@ -100,8 +100,10 @@ content: ''; position: absolute; // Position the indicator halfway from the top of the checkbox - top: calc(1lh / 2); - left: calc(var(--c-size-control-2xs) + (var(--c-spacing) * 2)); + inset-block-start: calc(1lh / 2); + inset-inline-start: calc( + var(--c-size-control-2xs) + (var(--c-spacing) * 2) + ); width: calc(var(--gap-x) - (var(--c-spacing) * 3.5)); height: 1px; background-color: var(--c-color-neutral-border-quiet); From 8f2641b895f2d6473827e9921759d3fa83fdc2ad Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Mon, 20 Apr 2026 16:19:31 -0500 Subject: [PATCH 3/9] Consolidate a bit of logic --- resources/js/components/ActionMenu.vue | 56 ++-- resources/js/components/InlineFlash.vue | 38 +++ resources/js/components/form/ErrorSummary.vue | 22 ++ resources/js/composables/useFlash.ts | 17 ++ resources/js/layout/AppLayout.vue | 109 +++++-- .../js/pages/SettingsUserGroupsEditPage.vue | 266 ++++++++---------- resources/js/types/index.ts | 5 +- routes/cp.php | 8 +- .../Settings/UserGroupsController.php | 15 +- src/Http/RespondsWithFlash.php | 4 +- src/Http/Responses/CpScreenResponse.php | 1 + 11 files changed, 332 insertions(+), 209 deletions(-) create mode 100644 resources/js/components/InlineFlash.vue create mode 100644 resources/js/components/form/ErrorSummary.vue create mode 100644 resources/js/composables/useFlash.ts diff --git a/resources/js/components/ActionMenu.vue b/resources/js/components/ActionMenu.vue index 57b9e30c117..f5bba5026ff 100644 --- a/resources/js/components/ActionMenu.vue +++ b/resources/js/components/ActionMenu.vue @@ -3,7 +3,6 @@ import {computed} from 'vue'; import type {ActionItem} from '@/types'; - const props = withDefaults( defineProps<{ icon?: string; @@ -31,16 +30,18 @@ diff --git a/resources/js/components/InlineFlash.vue b/resources/js/components/InlineFlash.vue new file mode 100644 index 00000000000..3652d9f6f80 --- /dev/null +++ b/resources/js/components/InlineFlash.vue @@ -0,0 +1,38 @@ + + + + + diff --git a/resources/js/components/form/ErrorSummary.vue b/resources/js/components/form/ErrorSummary.vue new file mode 100644 index 00000000000..d317079431b --- /dev/null +++ b/resources/js/components/form/ErrorSummary.vue @@ -0,0 +1,22 @@ + + + + + diff --git a/resources/js/composables/useFlash.ts b/resources/js/composables/useFlash.ts new file mode 100644 index 00000000000..a13bf82c9ee --- /dev/null +++ b/resources/js/composables/useFlash.ts @@ -0,0 +1,17 @@ +import {usePage} from '@inertiajs/vue3'; +import {computed} from 'vue'; + +export function useFlash() { + const page = usePage<{ + flash: { + success: string | null; + error: string | null; + }; + }>(); + + const flash = computed(() => page.props.flash); + const successFlash = computed(() => flash.value.success); + const errorFlash = computed(() => flash.value.error); + + return {flash, successFlash, errorFlash}; +} diff --git a/resources/js/layout/AppLayout.vue b/resources/js/layout/AppLayout.vue index b452e9d0b70..ca679a3e44b 100644 --- a/resources/js/layout/AppLayout.vue +++ b/resources/js/layout/AppLayout.vue @@ -4,36 +4,48 @@ import {computed, reactive, ref, useTemplateRef, watch} from 'vue'; import CpSidebar from '@/components/CpSidebar.vue'; import {useMediaQuery} from '@vueuse/core'; - import {Head, usePage} from '@inertiajs/vue3'; + import {Head, type InertiaForm, usePage} from '@inertiajs/vue3'; import VarDump from '@/components/VarDump.vue'; import Breadcrumbs from '@/components/Breadcrumbs.vue'; import {useAnnouncer} from '@/composables/useAnnouncer'; import LiveRegion from '@/components/LiveRegion.vue'; import {useAppendHtml} from '@/composables/useAppendHtml'; + import ActionMenu from '@/components/ActionMenu.vue'; + import type {ActionItem} from '@/types'; + import {useFlash} from '@/composables/useFlash'; + import InlineFlash from '@/components/InlineFlash.vue'; + import ErrorSummary from '@/components/form/ErrorSummary.vue'; + interface SaveOptions { + redirect?: boolean; + } + + const emit = defineEmits<{ + (e: 'save', options?: Partial): void; + }>(); const props = withDefaults( defineProps<{ title?: string; debug?: any; fullWidth?: boolean; + form?: InertiaForm | null; + formActions?: Array; }>(), - {fullWidth: false, crumbs: () => []} + {fullWidth: false, crumbs: () => [], form: null} ); const page = usePage<{ title: string; - flash: { - success: string | null; - error: string | null; - }; + readOnly: boolean; crumbs?: Array<{ url?: string; label: string; }> | null; }>(); - const errorFlash = computed(() => page.props.flash?.error); - const successFlash = computed(() => page.props.flash?.success); + + const {errorFlash, successFlash} = useFlash(); const crumbs = computed(() => page.props.crumbs ?? null); + const readOnly = computed(() => page.props.readOnly); const sidebarToggle = useTemplateRef('sidebarToggle'); const {announcement, announce} = useAnnouncer(); @@ -155,25 +167,76 @@ - -
-
-
- -

{{ pageTitle }}

-
- -
+ + +
+
+
+ +

{{ pageTitle }}

+
+ +
+ +
+ + + +
+
+
+ +
- -
- -
+
diff --git a/resources/js/pages/SettingsUserGroupsEditPage.vue b/resources/js/pages/SettingsUserGroupsEditPage.vue index e7ba963b5ce..b95317dfeaf 100644 --- a/resources/js/pages/SettingsUserGroupsEditPage.vue +++ b/resources/js/pages/SettingsUserGroupsEditPage.vue @@ -1,17 +1,16 @@ - +