|null */ private ?array $blueprintLanguages = null; /** * Map of primary subtag => shipped region-suffixed locale codes, e.g. * `['en' => ['en-US'], 'de' => ['de-DE']]`. Cached per request. * * @var array>|null */ private ?array $regionVariantIndex = null; private function disabledLangIndex(): DisabledPluginLangIndex { return $this->disabledLangIndex ??= new DisabledPluginLangIndex($this->grav); } /** * Resolve the language chain for blueprint label translation. Prefers the * authenticated user's `adminLanguage` preference (which the SPA picks), * with 'en' as a fallback so any keys not yet translated still come * through in English instead of being humanized. * * Why this is needed: Grav's `Language::translate()` falls back to the * site's active content language when called with no `$languages` hint — * that's typically 'en' even for an admin user who has selected Hebrew * for their UI. The dict endpoint (`/translations/{lang}`) already * accepts an explicit language, so admin-next's client-side i18n works, * but blueprint labels are pre-resolved server-side here. * * @return array */ private function resolveBlueprintLanguages(ServerRequestInterface $request): array { if ($this->blueprintLanguages !== null) { return $this->blueprintLanguages; } $lang = 'en'; try { $user = $this->getUser($request); $resolver = new PreferencesResolver($this->grav, $this->config); $effective = $resolver->resolve($user, false)['effective'] ?? []; $candidate = $effective['adminLanguage'] ?? null; if (is_string($candidate) && $candidate !== '') { $lang = $candidate; } } catch (Throwable) { // Unauthenticated or resolver failure — fall back to English. } return $this->blueprintLanguages = $this->expandLanguageChain($lang); } /** * Build the translation fallback chain for a requested admin language. * * The requested language comes first and English is the universal tail * fallback. Each entry is then expanded to include any region-suffixed * variant that ships on disk: admin2 stores its dictionary under e.g. * `en-US.yaml` (not `en.yaml`), and Grav indexes plugin language files by * the filename's locale code. Without this expansion a user whose * preference is the bare 2-char `en` never reaches admin2's `en-US` strings * and every blueprint label/help falls through to the humaniser * (getgrav/grav-admin-next#1). Expanding `en` → `['en', 'en-US']` (and * likewise `de` → `['de', 'de-DE']`) lets the shipped region file serve the * bare code, so no duplicate `en.yaml` is needed. * * @return array */ private function expandLanguageChain(string $lang): array { $chain = []; foreach ([$lang, 'en'] as $code) { foreach (array_merge([$code], $this->regionVariantsFor($code)) as $candidate) { if (!in_array($candidate, $chain, true)) { $chain[] = $candidate; } } } return $chain; } /** * Region-suffixed locale codes shipped for a bare primary subtag, e.g. * `en` => `['en-US']`. Already-regioned codes (containing `-`) need no * expansion and return an empty list. * * @return array */ private function regionVariantsFor(string $code): array { if (str_contains($code, '-')) { return []; } return $this->buildRegionVariantIndex()[$code] ?? []; } /** * Discover shipped region variants from admin2's languages directory (where * the SPA's translation dictionary lives). Cached for the request. * * @return array> */ private function buildRegionVariantIndex(): array { if ($this->regionVariantIndex !== null) { return $this->regionVariantIndex; } $index = []; $dir = $this->grav['locator']->findResource('plugin://admin2/languages') ?: (defined('GRAV_ROOT') ? GRAV_ROOT . '/user/plugins/admin2/languages' : null); if (is_string($dir) && is_dir($dir)) { foreach (glob($dir . '/*.yaml') ?: [] as $file) { $localeCode = basename($file, '.yaml'); $dash = strpos($localeCode, '-'); if ($dash !== false) { $index[substr($localeCode, 0, $dash)][] = $localeCode; } } } return $this->regionVariantIndex = $index; } /** * Whitelist of callable patterns allowed by the resolve endpoint. * Only static methods from known Grav namespaces are permitted. */ private const RESOLVE_ALLOWED_NAMESPACES = [ 'Grav\\Common\\', 'Grav\\Plugin\\', ]; /** * GET /data/resolve?callable=\Grav\Common\Page\Pages::pageTypes * * Generic endpoint for resolving data-options@ directives used in blueprints. * Returns the array result of calling a whitelisted static PHP method. * Client should cache responses — these are effectively static data. */ public function resolveData(ServerRequestInterface $request): ResponseInterface { $this->requirePermission($request, 'api.pages.read'); $query = $request->getQueryParams(); $callable = $query['callable'] ?? null; if (!$callable || !is_string($callable)) { throw new ValidationException(['callable' => ['The callable query parameter is required.']]); } $callable = ltrim($callable, '\\'); // Validate against whitelist $allowed = false; foreach (self::RESOLVE_ALLOWED_NAMESPACES as $ns) { if (str_starts_with($callable, $ns)) { $allowed = true; break; } } if (!$allowed) { throw new ValidationException(['callable' => ['Callable is not in the allowed namespace list.']]); } // Ensure Pages subsystem for Page-related callables if (str_contains($callable, 'Page')) { $this->ensurePagesEnabled(); } if (!str_contains($callable, '::')) { throw new ValidationException(['callable' => ['Callable must be in Class::method format.']]); } [$class, $method] = explode('::', $callable, 2); $class = '\\' . $class; if (!class_exists($class) || !method_exists($class, $method)) { // admin-classic ships permission-filtered page-type wrappers // (\Grav\Plugin\AdminPlugin::pagesTypes / ::pagesModularTypes). // admin-next is designed to run without admin-classic, but a // blueprint or a stale compiled-blueprint cache can still reference // those callables — in which case the class isn't loaded and the // hard guard below would 500 the template selector // (grav-plugin-admin2#41). Fall back to core's always-available // equivalent rather than throwing. if (in_array($method, ['pagesTypes', 'pagesModularTypes'], true)) { $type = $method === 'pagesModularTypes' ? 'modular' : 'standard'; return ApiResponse::create($this->normalizeOptions(Pages::pageTypes($type))); } throw new NotFoundException("Callable '{$callable}' not found."); } // For pageTypes(), pass the type arg so it returns standard or modular if ($method === 'pageTypes') { $type = $query['type'] ?? 'standard'; $result = $class::$method($type); } else { $result = $class::$method(); } if (!is_array($result)) { return ApiResponse::create([]); } return ApiResponse::create($this->normalizeOptions($result)); } /** * Normalize a [key => label] map to the [{value, label}] format the * admin-next SelectField expects for `data-options@` results. * * @param array $options * @return list */ private function normalizeOptions(array $options): array { $normalized = []; foreach ($options as $key => $label) { $normalized[] = [ 'value' => (string) $key, 'label' => is_string($label) ? $label : (string) $key, ]; } return $normalized; } /** * GET /blueprints/pages - List available page blueprints (templates). */ public function pageTypes(ServerRequestInterface $request): ResponseInterface { $this->requirePermission($request, 'api.pages.read'); $this->resolveBlueprintLanguages($request); $this->ensurePagesEnabled(); // `?modular=true` returns modular templates (those whose Twig template // file is prefixed with `_`, intended as sub-pages of a modular parent) // instead of regular page templates. Mirrors the split classic admin // makes between "Add Page" and "Add Module". $params = $request->getQueryParams(); $modular = isset($params['modular']) && in_array(strtolower((string) $params['modular']), ['1', 'true', 'yes'], true); $types = $modular ? Pages::modularTypes() : Pages::types(); $result = []; foreach ($types as $type => $label) { $result[] = [ 'type' => $type, 'label' => is_string($label) ? $label : $type, ]; } return ApiResponse::create($result); } /** * GET /blueprints/pages/{template} - Get resolved blueprint for a page template. */ public function pageBlueprint(ServerRequestInterface $request): ResponseInterface { $this->requirePermission($request, 'api.pages.read'); $this->resolveBlueprintLanguages($request); $template = $this->getRouteParam($request, 'template'); $blueprint = $this->loadPageBlueprint($template, $this->getUser($request)); if (!$blueprint) { throw new NotFoundException("Blueprint for template '{$template}' not found."); } $data = $this->serializeBlueprint($blueprint, $template); // Fire event to allow plugins to modify the serialized blueprint fields // (e.g., editor-pro overrides editor/markdown field types). The // explicit `context` discriminator lets listeners gate behavior to a // specific blueprint family (e.g. ai-translate annotates only pages). $event = new Event([ 'context' => 'page', 'fields' => $data['fields'], 'template' => $template, 'user' => $this->getUser($request), ]); $this->grav->fireEvent('onApiBlueprintResolved', $event); $data['fields'] = $event['fields']; return ApiResponse::create($data); } /** * GET /blueprints/plugins/{plugin} - Get resolved blueprint for a plugin. */ public function pluginBlueprint(ServerRequestInterface $request): ResponseInterface { $this->requirePermission($request, 'api.config.read'); $this->resolveBlueprintLanguages($request); $pluginName = $this->getRouteParam($request, 'plugin'); $pluginPath = $this->grav['locator']->findResource("plugin://{$pluginName}"); if (!$pluginPath || !file_exists($pluginPath . '/blueprints.yaml')) { throw new NotFoundException("Blueprint for plugin '{$pluginName}' not found."); } $blueprint = new Blueprint($pluginPath . '/blueprints.yaml'); $blueprint->load(); $data = $this->serializeBlueprint($blueprint, $pluginName); // Fire event to allow plugins to modify serialized fields $event = new Event([ 'context' => 'plugin', 'fields' => $data['fields'], 'plugin' => $pluginName, 'user' => $this->getUser($request), ]); $this->grav->fireEvent('onApiBlueprintResolved', $event); $data['fields'] = $event['fields']; return ApiResponse::create($data); } /** * GET /blueprints/themes/{theme} - Get resolved blueprint for a theme. */ public function themeBlueprint(ServerRequestInterface $request): ResponseInterface { $this->requirePermission($request, 'api.config.read'); $this->resolveBlueprintLanguages($request); $themeName = $this->getRouteParam($request, 'theme'); $themesPath = $this->grav['locator']->findResource('themes://'); $themePath = $themesPath . '/' . $themeName; if (!is_dir($themePath) || !file_exists($themePath . '/blueprints.yaml')) { throw new NotFoundException("Blueprint for theme '{$themeName}' not found."); } $blueprint = new Blueprint($themePath . '/blueprints.yaml'); $blueprint->load(); $data = $this->serializeBlueprint($blueprint, $themeName); // Fire event so plugins can extend / annotate theme blueprints, with // an explicit `context` discriminator so listeners (e.g. ai-translate) // can scope behavior to a specific blueprint family. $event = new Event([ 'context' => 'theme', 'fields' => $data['fields'], 'theme' => $themeName, 'user' => $this->getUser($request), ]); $this->grav->fireEvent('onApiBlueprintResolved', $event); $data['fields'] = $event['fields']; return ApiResponse::create($data); } /** * GET /blueprints/users - Get the user account blueprint. */ public function userBlueprint(ServerRequestInterface $request): ResponseInterface { // The user blueprint is just the form schema, not user data — every // authenticated user needs it to render their own profile form, even // those without api.users.read. $this->requirePermission($request, 'api.access'); $this->resolveBlueprintLanguages($request); $blueprintPath = $this->grav['locator']->findResource('blueprints://user/account.yaml'); if (!$blueprintPath) { $blueprintPath = $this->grav['locator']->findResource('system://blueprints/user/account.yaml'); } if (!$blueprintPath) { throw new NotFoundException('User account blueprint not found.'); } $blueprint = new Blueprint($blueprintPath); $blueprint->load(); $data = $this->serializeBlueprint($blueprint, 'account'); // Fire event so plugins can extend the user blueprint (e.g. admin2 // injects the account-state toggle, since core's account.yaml has // no field for it). $event = new Event([ 'context' => 'account', 'fields' => $data['fields'], 'template' => 'account', 'user' => $this->getUser($request), ]); $this->grav->fireEvent('onApiBlueprintResolved', $event); $data['fields'] = $event['fields']; return ApiResponse::create($data); } /** * GET /blueprints/groups - User group edit blueprint (user/group.yaml). */ public function groupBlueprint(ServerRequestInterface $request): ResponseInterface { return $this->loadGroupBlueprint($request, 'group', 'group'); } /** * GET /blueprints/groups/new - User group creation blueprint (user/group_new.yaml). */ public function groupNewBlueprint(ServerRequestInterface $request): ResponseInterface { return $this->loadGroupBlueprint($request, 'group_new', 'group_new'); } private function loadGroupBlueprint( ServerRequestInterface $request, string $name, string $context, ): ResponseInterface { $this->requirePermission($request, 'api.users.read'); $this->resolveBlueprintLanguages($request); $path = $this->grav['locator']->findResource("blueprints://user/{$name}.yaml") ?: $this->grav['locator']->findResource("system://blueprints/user/{$name}.yaml"); if (!$path) { throw new NotFoundException("Group blueprint '{$name}' not found."); } $blueprint = new Blueprint($path); $blueprint->load(); $data = $this->serializeBlueprint($blueprint, $name); $event = new Event([ 'context' => $context, 'fields' => $data['fields'], 'template' => $name, 'user' => $this->getUser($request), ]); $this->grav->fireEvent('onApiBlueprintResolved', $event); $data['fields'] = $event['fields']; return ApiResponse::create($data); } /** * GET /blueprints/config/accounts - Flex accounts configuration blueprint * (the form behind the "Configuration" tab on the Users page). * * Delegates to FlexDirectory::getDirectoryBlueprint() — the same code path * admin-classic uses. That loads blueprints://flex/shared/configure.yaml * (the Caching tab) as the base and embeds the user-accounts blueprint's * `blueprints.configure.fields` (Compatibility tab via import@) as sibling * tabs. Reimplementing this by hand would silently drop the Caching tab * (the shared form isn't reachable from the user-accounts blueprint alone). */ public function accountsConfigBlueprint(ServerRequestInterface $request): ResponseInterface { $this->requirePermission($request, 'api.config.read'); $this->resolveBlueprintLanguages($request); $flex = $this->grav['flex_objects'] ?? null; if (!$flex) { throw new NotFoundException('Flex Objects is not available — Accounts configuration requires it.'); } $directory = $flex->getDirectory('user-accounts'); if (!$directory) { throw new NotFoundException('user-accounts flex directory is not registered.'); } $blueprint = $directory->getDirectoryBlueprint(); $data = $this->serializeBlueprint($blueprint, 'accounts'); if (empty($data['title'])) { $data['title'] = 'Accounts Configuration'; } $event = new Event([ 'context' => 'config', 'fields' => $data['fields'], 'template' => 'accounts', 'user' => $this->getUser($request), ]); $this->grav->fireEvent('onApiBlueprintResolved', $event); $data['fields'] = $event['fields']; return ApiResponse::create($data); } /** * GET /blueprints/users/permissions - Get all registered permission actions. */ public function permissionsBlueprint(ServerRequestInterface $request): ResponseInterface { $this->requirePermission($request, 'api.users.read'); $this->resolveBlueprintLanguages($request); /** @var \Grav\Framework\Acl\Permissions $permissions */ $permissions = $this->grav['permissions']; $sections = []; foreach ($permissions as $name => $action) { $sections[] = $this->serializePermissionAction($action, $name); } return ApiResponse::create($sections); } /** * Recursively serialize a permission action and its children. */ private function serializePermissionAction(object $action, string $name): array { $rawLabel = $action->label ?? $name; $label = $this->translateLabel($rawLabel); $data = [ 'name' => $name, 'label' => $label, ]; // Check for child actions $children = []; if ($action instanceof \IteratorAggregate || $action instanceof \Traversable) { foreach ($action as $child) { // Use $child->name which has the full dotted path (e.g. "admin.login") $children[] = $this->serializePermissionAction($child, $child->name ?? $name); } } if ($children) { $data['children'] = $children; } return $data; } /** * Translate a blueprint / permission label string. * * Lookup order, ICU-first: * 1. `ICU.` — admin2's authoritative namespace (Grav 2 convention). * 2. `` — flat lookup, for legacy plugins that ship PLUGIN_ADMIN.* * under the Grav 1 convention (form, login, flex-objects, etc.). * 3. `PLUGIN_API.` — last-resort api-plugin namespace. * 4. Humanizer over the key itself. * * ICU is checked first by design: admin classic's plugin folder may still * be present in dev installs (disabled, mid-migration) and Grav core's * `flattenByLang()` reads every plugin's lang files regardless of enabled * state. Without the ICU-first order, admin classic's flat values would * shadow admin2's ICU ports — a per-key drift that's hard to spot. Putting * ICU first makes admin2 the source of truth for any key it ships, and * lets the flat lookup serve as a transition fallback for keys admin2 * hasn't ported (or that legitimate 3rd-party plugins ship under * PLUGIN_ADMIN.* for shared-vocabulary labels). */ protected function translateLabel(string $label): string { $lang = $this->grav['language']; // Use the per-request language chain (set by serializeBlueprint / // pageTypes / etc.) so labels resolve against the user's chosen // admin language, not the site's default content language. Falls // back to no override when no endpoint primed the chain — that // preserves Grav's normal lookup behaviour for any non-blueprint // caller (e.g. test code) that calls translateLabel() directly. $languages = $this->blueprintLanguages; $primary = $languages[0] ?? ($lang->getLanguage() ?: 'en'); // If it looks like a language key (e.g. PLUGIN_ADMIN.ACCESS_SITE), try to translate if (str_contains($label, '.') && strtoupper($label) === $label) { $icuKey = 'ICU.' . $label; $icuTranslated = $lang->translate($icuKey, $languages); if ($icuTranslated !== $icuKey) { return $icuTranslated; } // admin2 consolidated its shared PLUGIN_ADMIN vocabulary into the // ICU.ADMIN_NEXT namespace so the translation service — scoped to // ADMIN_NEXT — actually translates it into every locale. Blueprints // (and 160+ plugins) still reference the public PLUGIN_ADMIN.* keys, // so alias them onto ICU.ADMIN_NEXT.* here. A handful of nav-word // keys (GROUPS/MEDIA/PAGES/SETTINGS/SYSTEM) resolve to a nested map // under ADMIN_NEXT rather than a string; the is_string guard lets // those fall through to the humaniser (which yields the right word). if (str_starts_with($label, 'PLUGIN_ADMIN.')) { $aliasKey = 'ICU.ADMIN_NEXT.' . substr($label, strlen('PLUGIN_ADMIN.')); // array_support=true returns the raw node instead of casting an // array to string, so a key that lands on a nested namespace // (GROUPS/MEDIA/PAGES/SETTINGS/SYSTEM) comes back as an array and // is skipped here rather than blowing up on "Array to string". $aliasTranslated = $lang->translate($aliasKey, $languages, true); if (is_string($aliasTranslated) && $aliasTranslated !== $aliasKey) { return $aliasTranslated; } } // Skip the flat lookup if the only source for this key is a disabled // plugin — a disabled plugin shouldn't influence what admin2 renders. if (!$this->disabledLangIndex()->isDisabledOnly($label, $primary)) { $translated = $lang->translate($label, $languages); if ($translated !== $label) { return $translated; } } // Try API plugin namespace as fallback $key = substr($label, strrpos($label, '.') + 1); $apiTranslated = $lang->translate('PLUGIN_API.' . $key, $languages); if ($apiTranslated !== 'PLUGIN_API.' . $key) { return $apiTranslated; } } // If the label is still a raw key, derive a human-readable name from the permission name if (strtoupper($label) === $label && str_contains($label, '_')) { // PLUGIN_ADMIN.ACCESS_ADMIN_CONFIGURATION -> Configuration $parts = explode('.', $label); $last = end($parts); // Remove ACCESS_ prefix $last = preg_replace('/^ACCESS_(?:ADMIN_|SITE_)?/', '', $last); return ucwords(strtolower(str_replace('_', ' ', $last))); } return $label; } /** * GET /blueprints/plugins/{plugin}/pages/{pageId} - Get custom page blueprint for a plugin. */ public function pluginPageBlueprint(ServerRequestInterface $request): ResponseInterface { $this->requirePermission($request, 'api.config.read'); $this->resolveBlueprintLanguages($request); $plugin = $this->getRouteParam($request, 'plugin'); $pageId = $this->getRouteParam($request, 'pageId'); $pluginPath = $this->grav['locator']->findResource("plugin://{$plugin}"); if (!$pluginPath) { throw new NotFoundException("Plugin '{$plugin}' not found."); } $blueprintFile = $pluginPath . '/admin/blueprints/' . basename($pageId) . '.yaml'; // Fallback: when the dedicated admin/blueprints/{pageId}.yaml is missing // and the page id matches the plugin slug, treat the plugin's main // blueprints.yaml as the page blueprint. Lets plugins whose admin-next // settings page is just the existing plugin form skip maintaining a // duplicate YAML — algolia-pro keeps its dedicated page blueprint, but // simpler plugins (git-sync) reuse the one they already have. if (!file_exists($blueprintFile) && $pageId === $plugin && file_exists($pluginPath . '/blueprints.yaml')) { $blueprintFile = $pluginPath . '/blueprints.yaml'; } if (!file_exists($blueprintFile)) { throw new NotFoundException("Page blueprint '{$pageId}' not found for plugin '{$plugin}'."); } $blueprint = new Blueprint($blueprintFile); $blueprint->load(); $data = $this->serializeBlueprint($blueprint, $pageId); // Fire event so plugins (notably flex-objects) can extend plugin // page blueprints — e.g. inject the shared Flex configure tabs // (Caching) when the owning plugin manages a Flex directory. $event = new Event([ 'context' => 'plugin-page', 'fields' => $data['fields'], 'plugin' => $plugin, 'page_id' => $pageId, 'user' => $this->getUser($request), ]); $this->grav->fireEvent('onApiBlueprintResolved', $event); $data['fields'] = $event['fields']; return ApiResponse::create($data); } /** * GET /blueprints/config/{scope} - Get blueprint for system/site config. */ public function configBlueprint(ServerRequestInterface $request): ResponseInterface { $this->requirePermission($request, 'api.config.read'); $this->resolveBlueprintLanguages($request); $scope = $this->getRouteParam($request, 'scope'); // Core scopes ship system blueprints; custom scopes are site-authored // top-level configs (the cookbook "add a custom yaml file" recipe). Any // other scope — including core/system blueprints like `streams` — is // rejected. See {@see ConfigScopes::isCustom()} for the security gate. if (!in_array($scope, ConfigScopes::CORE, true) && !ConfigScopes::isCustom($this->grav, $scope)) { throw new NotFoundException("Config blueprint scope '{$scope}' not found."); } // Use the blueprints:// stream to find config blueprints so that // plugin overrides (e.g., admin's media.yaml) are resolved correctly. $realPath = $this->grav['locator']->findResource("blueprints://config/{$scope}.yaml"); if (!$realPath) { // Fallback to system blueprints directly $realPath = $this->grav['locator']->findResource("system://blueprints/config/{$scope}.yaml"); } if (!$realPath) { throw new NotFoundException("Config blueprint for '{$scope}' not found."); } $blueprint = new Blueprint($realPath); $blueprint->load(); return ApiResponse::create($this->serializeBlueprint($blueprint, $scope)); } /** * Load a fully-resolved page blueprint via Grav core's standard pipeline. * * Delegates to Pages::blueprints() (= Blueprints::loadFile() → Blueprint::load()->init()) * — the same path admin-classic uses. This honors every BlueprintForm * directive (replace@, unset@, replace-@, ordering@, import@ with * inline insertion, @extends with context, config-default@, etc.), and * fires onBlueprintCreated so plugins can extend the result. * * Earlier versions hand-rolled YAML merging here to dodge a perceived * memory-exhaustion risk in the full pipeline. In practice Grav core * runs this code on every page edit in admin-classic without trouble, * and the hand-rolled path silently dropped most BlueprintForm directives * (see grav-plugin-admin2#3). */ private function loadPageBlueprint(string $template, ?UserInterface $user = null): ?Blueprint { $this->ensurePagesEnabled(); /** @var Pages $pages */ $pages = $this->grav['pages']; try { $blueprint = $pages->blueprints($template); } catch (\RuntimeException) { return null; } // An orphan template — one with no blueprint of its own, e.g. a page // left on a template that the current theme doesn't define after a // theme switch — resolves to an empty blueprint with no fields. Grav // core only falls back to `default` when the lookup *throws*, which a // missing blueprint file does not: it returns the empty blueprint // instead. Mirror admin-classic and fall back to the default page // blueprint so the editor always shows the standard page form rather // than a blank pane. if (!$blueprint->fields()) { try { $blueprint = $pages->blueprints('default'); } catch (\RuntimeException) { return null; } } $this->injectSecurityTab($blueprint, $user); return $blueprint; } /** * Inject the page Security tab into a resolved page blueprint. * * Page-type blueprints (default.yaml etc.) don't carry the Security tab — * in admin-classic it's the Flex pages wrapper (blueprints://flex/pages.yaml) * that adds it via `import@: { type: partials/security }`. Admin-next loads * the plain page-type blueprint instead, so the tab goes missing. We * replicate the Flex wrapper here: load the same security partial and embed * it as a tab, positioned right after `advanced` to match classic ordering. * * The partial only sets frontmatter (header.access, header.permissions.*) * that grav-core already understands — nothing else changes. * * The partial's `_admin` (Page Permissions) section carries a * `security@: {or: [admin.super, admin.configuration.pages]}` gate. Core * evaluates that against `$grav['user']`, but during an API request that's * the guest user — so the gate fails for everyone and stamps the section * with `validate: ignore`. We evaluate the gate ourselves against the real * authenticated API user, accepting the API authority equivalents * (api.super / api.config): authorized users get the section clean and * editable, everyone else only sees the ungated Page Access section. */ private function injectSecurityTab(Blueprint $blueprint, ?UserInterface $user = null): void { // Only page blueprints that wrap their fields in a `tabs` container can // host the Security tab. Skip anything with a different layout. $tabs = $blueprint->get('form/fields/tabs'); if (!is_array($tabs) || ($tabs['type'] ?? null) !== 'tabs') { return; } // Respect a template/plugin that already defines its own Security tab. if ($blueprint->get('form/fields/tabs/fields/security') !== null) { return; } try { $security = new Blueprint('partials/security'); $security->setContext('blueprints://pages'); $security->load()->init(); } catch (Throwable) { return; } $securityFields = $security->fields(); if (empty($securityFields)) { return; } // Gate the Page Permissions section on API authority. `_site` (Page // Access) is ungated and always shown. $canManagePermissions = $user !== null && ($this->isSuperAdmin($user) || $this->hasPermission($user, 'api.config')); if (isset($securityFields['_admin'])) { if ($canManagePermissions) { // Clear the guest-induced `validate: ignore` so the section is // fully editable (baseline has no ignore flags of its own). $this->clearValidateIgnore($securityFields['_admin']); } else { unset($securityFields['_admin']); } } if (empty($securityFields)) { return; } // Turn the two `acl_picker` fields (Page Access, Page Groups) into the // dedicated admin-next web components with their dropdown options baked // in server-side. See decorateAclPickerFields() for why. $this->decorateAclPickerFields($securityFields); $securityTab = [ 'type' => 'tab', 'title' => 'PLUGIN_ADMIN.SECURITY', 'fields' => $securityFields, ]; // Insert after the core `advanced` tab so the order matches classic // (Content, Options, Advanced, Security, …plugin tabs). Fall back to // appending if no `advanced` tab is present. $rebuilt = []; $inserted = false; foreach ((array) ($tabs['fields'] ?? []) as $key => $value) { $rebuilt[$key] = $value; if ($key === 'advanced') { $rebuilt['security'] = $securityTab; $inserted = true; } } if (!$inserted) { $rebuilt['security'] = $securityTab; } $blueprint->set('form/fields/tabs/fields', $rebuilt); } /** * Recursively remove the `validate: ignore` flag that core's blueprint * init stamps on a `security@`-gated field (and its children) when the * gate fails. Leaves the rest of each `validate` block intact. */ private function clearValidateIgnore(array &$field): void { if (isset($field['validate']) && is_array($field['validate'])) { unset($field['validate']['ignore']); if ($field['validate'] === []) { unset($field['validate']); } } if (isset($field['fields']) && is_array($field['fields'])) { foreach ($field['fields'] as &$child) { if (is_array($child)) { $this->clearValidateIgnore($child); } } unset($child); } } /** * Replace the page security `acl_picker` fields with their admin-next web * components and bake their dropdown options in server-side. * * admin-next's native FieldRenderer claims `acl_picker` before the custom * field registry, and `data_type` (access vs permissions) isn't part of * the serialized field props — so a stock `acl_picker` can't render the * classic row picker. We remap each field to a distinct custom type that * falls through to the plugin web component: * - data_type: access → `acl-access` (Allowed/Denied per action) * - data_type: permissions → `acl-permissions` (CRUD per group) * * The option lists (access actions / user groups) need `$grav['permissions']` * and the groups directory, and the access-actions endpoint is gated on * `api.users.read` which a page editor may not hold — so we resolve them * here and attach as `options`, sparing the component an extra (possibly * forbidden) round-trip. */ private function decorateAclPickerFields(array &$fields): void { foreach ($fields as $key => &$field) { if (!is_array($field)) { continue; } $type = $field['type'] ?? null; if ($type === 'acl_picker') { $dataType = $field['data_type'] ?? null; if ($dataType === 'access') { $field['type'] = 'acl-access'; $field['options'] = $this->buildAccessActionOptions(); } elseif ($dataType === 'permissions') { $field['type'] = 'acl-permissions'; $field['options'] = $this->buildGroupOptions(); } unset($field['data_type']); } if (isset($field['fields']) && is_array($field['fields'])) { $this->decorateAclPickerFields($field['fields']); } } unset($field); } /** * Resolve the option list for a `users` field — every account that meets * the field's access/group requirements. Config props on the field: * * access: api.pages.write # min permission (string or list, any-of) * groups: [editors, authors] # group membership (string or list, any-of) * * With neither set, every account is listed. Super admins (API or classic) * always qualify. The value stored is the username, so existing plain * username-array fields round-trip unchanged. * * @return array username => label, insertion order preserved */ private function resolveUserFieldOptions(array $field): array { $accessList = $this->toStringList($field['access'] ?? null); $groupList = $this->toStringList($field['groups'] ?? null); $options = []; try { $accounts = $this->grav['accounts'] ?? null; if (!$accounts) { return $options; } foreach ($this->getAccountUsernames() as $username) { $account = $accounts->load($username); if (!$account || !$account->exists()) { continue; } if (!$this->userMeetsRequirements($account, $accessList, $groupList)) { continue; } $fullname = (string) ($account->get('fullname') ?? ''); $options[(string) $username] = $fullname !== '' ? sprintf('%s (%s)', $fullname, $username) : (string) $username; } } catch (Throwable) { // Fall through with whatever was collected. } return $options; } /** * Whether an account satisfies a `users` field's access/group filter. * Empty filter → everyone qualifies; super admins always qualify. * * @param list $accessList * @param list $groupList */ private function userMeetsRequirements(object $account, array $accessList, array $groupList): bool { if (!$accessList && !$groupList) { return true; } if ($this->isSuperAdmin($account) || (bool) $account->get('access.admin.super')) { return true; } foreach ($accessList as $permission) { if ($this->hasPermission($account, $permission)) { return true; } } if ($groupList) { $userGroups = (array) $account->get('groups', []); foreach ($groupList as $group) { if (in_array($group, $userGroups, true)) { return true; } } } return false; } /** * Normalize a scalar-or-list blueprint config value into a list of * non-empty strings. * * @return list */ private function toStringList(mixed $value): array { if ($value === null || $value === '') { return []; } return array_values(array_filter( array_map(static fn ($v) => (string) $v, (array) $value), static fn (string $s) => $s !== '', )); } /** * Enumerate user-account usernames from the accounts storage directory. * Mirrors UsersController's listing without depending on its private API. * * @return list */ private function getAccountUsernames(): array { $locator = $this->grav['locator']; $dir = $locator->findResource('account://', true) ?: $locator->findResource('user://accounts', true); if (!$dir || !is_dir($dir)) { return []; } $usernames = []; foreach (new \DirectoryIterator($dir) as $file) { if ($file->isDot() || !$file->isFile() || $file->getExtension() !== 'yaml') { continue; } $usernames[] = $file->getBasename('.yaml'); } sort($usernames); return $usernames; } /** * Build the Page Access dropdown options from the registered ACL actions, * e.g. `admin.login` → "Login to Admin (admin.login)". Mirrors the * `data_type: access` option list in admin-classic's acl_picker. * * @return array value => label, insertion order preserved */ private function buildAccessActionOptions(): array { $options = []; try { $permissions = $this->grav['permissions'] ?? null; if ($permissions && method_exists($permissions, 'getInstances')) { foreach ($permissions->getInstances() as $action) { $name = $action->name ?? null; if (!$name || ($action->visible ?? true) === false) { continue; } // Short label only — the picker shows the dotted action // name (the option value) as secondary text and derives the // tree nesting from it. $options[(string) $name] = $this->translateLabel($action->label ?? $name); } } } catch (Throwable) { // Fall through with whatever was collected. } return $options; } /** * Build the Page Groups dropdown options: every user group plus the two * special ACL targets that grav-core understands for pages. Mirrors the * `data_type: permissions` option list in admin-classic's acl_picker. * * @return array value => label, insertion order preserved */ private function buildGroupOptions(): array { $options = []; try { $flex = $this->grav['flex'] ?? $this->grav['flex_objects'] ?? null; $directory = $flex && method_exists($flex, 'getDirectory') ? $flex->getDirectory('user-groups') : null; if ($directory) { foreach ($directory->getCollection() as $key => $group) { $name = (is_object($group) && method_exists($group, 'get') ? $group->get('groupname') : null) ?: (string) $key; $label = (is_object($group) && method_exists($group, 'get') ? $group->get('readableName') : null) ?: $name; $options[(string) $name] = (string) $label; } } } catch (Throwable) { // Fall through to config-based enumeration. } if (!$options) { foreach ((array) $this->grav['config']->get('groups', []) as $name => $group) { $label = is_array($group) ? ($group['readableName'] ?? $name) : $name; $options[(string) $name] = (string) $label; } } // Special ACL targets understood by grav-core for page permissions. $options['authors'] = $this->translateLabel('PLUGIN_ADMIN.PAGE_AUTHORS') . ' (Special)'; $options['defaults'] = 'Default ACL (Special)'; return $options; } /** * Ensure the Pages subsystem is initialized. * Many data-options@ directives reference Pages:: methods that need this. */ protected function ensurePagesEnabled(): void { if ($this->pagesEnabled) { return; } $pages = $this->grav['pages']; if (method_exists($pages, 'enablePages')) { $pages->enablePages(); } $this->pagesEnabled = true; } protected bool $pagesEnabled = false; /** * Resolve a data-*@ directive by calling the referenced PHP callable. * Supports format: '\Grav\Common\Utils::timezones' or ['method', 'args'] */ protected function resolveDataDirective(mixed $directive): ?array { try { $callable = is_array($directive) ? ($directive[0] ?? null) : $directive; if (!is_string($callable)) { return null; } $callable = ltrim($callable, '\\'); // Parse Class::method format if (str_contains($callable, '::')) { [$class, $method] = explode('::', $callable, 2); $class = '\\' . $class; // Ensure Pages subsystem is available for Page-related callables if (str_contains($class, 'Page')) { $this->ensurePagesEnabled(); } if (class_exists($class) && method_exists($class, $method)) { // pageTypes() needs a type arg. Use the current serialization // context (modular if we're serializing a `modular/*` blueprint, // standard otherwise) so the template selector gets the right // list baked in. if ($method === 'pageTypes') { $result = $class::$method($this->pageTypeContext); } else { $result = $class::$method(); } return is_array($result) ? $result : null; } } return null; } catch (\Throwable) { return null; } } /** * Serialize a Blueprint object into a JSON-friendly structure. */ /** * Page-type context for the current serialization pass. Read by * resolveDataDirective() when expanding `Pages::pageTypes` so a modular * template's blueprint gets the modular template list instead of the * default 'standard' list. */ private string $pageTypeContext = 'standard'; protected function serializeBlueprint(Blueprint $blueprint, string $name): array { $form = $blueprint->form(); $fields = $blueprint->fields(); // Modular page templates live under `modular/` (e.g. `modular/hero`). // Track this so Pages::pageTypes resolves to the modular list for the // template field instead of the standard list. $this->pageTypeContext = str_starts_with($name, 'modular/') ? 'modular' : 'standard'; return [ 'name' => $name, 'title' => $form['title'] ?? $blueprint->get('name') ?? $name, 'type' => $blueprint->get('type') ?? null, 'child_type' => $blueprint->get('child_type') ?? null, 'validation' => $form['validation'] ?? 'loose', 'fields' => $this->serializeFields($fields), ]; } /** * Recursively serialize blueprint fields into a structure * suitable for client-side form rendering. */ protected function serializeFields(array $fields, string $prefix = '', string $parent = ''): array { $result = []; foreach ($fields as $name => $field) { if (!is_array($field)) { continue; } $type = $field['type'] ?? null; // Leading-dot relative naming. A child keyed `.optionA` binds under // its container's own name rather than the (transparent) layout // prefix, so `.optionA` inside a section named `header.sectionName` // resolves to `header.sectionName.optionA` and saves nested. This // mirrors core's BlueprintSchema::getFieldKey(); without it the bare // `.optionA` reached the SPA and its values never saved. if (is_string($name) && isset($name[0]) && $name[0] === '.') { $base = $parent !== '' ? $parent : rtrim($prefix, '.'); $fieldPath = $base !== '' ? $base . $name : substr($name, 1); } else { $fieldPath = $prefix !== '' ? "{$prefix}.{$name}" : (string) $name; } // `users` field type: a reusable, permission-filtered user picker. // Resolve its dropdown options from the field's own `access:` / // `groups:` config so any blueprint can drop one in without extra // server code. Stuffing the options back onto $field lets the // normal options pipeline (translate + assoc→array) handle them. if ($type === 'users') { $field['options'] = $this->resolveUserFieldOptions($field); } $serialized = [ 'name' => $fieldPath, 'type' => $type ?? 'text', ]; // Copy standard properties $props = [ 'label', 'help', 'placeholder', 'default', 'description', 'content', 'size', 'classes', 'id', 'style', 'title', 'text', 'disabled', 'readonly', 'toggleable', 'highlight', 'minlength', 'maxlength', 'min', 'max', 'step', 'rows', 'cols', 'multiple', 'yaml', 'markdown', 'prepend', 'append', 'underline', 'options', 'selectize', 'value_only', 'create', 'destination', 'accept', 'random_name', 'avoid_overwriting', 'filesize', 'limit', 'use', 'key', 'controls', 'collapsed', 'show_all', 'show_modular', 'show_root', 'show_slug', 'placeholder_key', 'placeholder_value', 'value_type', 'btnLabel', 'placement', 'sortby', 'sortby_dir', 'sort', 'collapsible', 'min_height', 'selectunique', 'condition', 'wrapper_classes', 'provider', 'translate', 'page_field', 'page_template', 'success_msg', 'error_msg', // pagemediaselect / filepicker 'preview_images', 'preview_image', 'on_demand', 'folder', 'filter', 'self', 'display', 'resize', 'media_picker_field', // colorpicker — opt out of the alpha slider with `alpha: false`. 'alpha', ]; foreach ($props as $prop) { if (isset($field[$prop])) { $serialized[$prop] = $field[$prop]; } } // Translate string properties that may contain language keys foreach (['label', 'title', 'description', 'help', 'placeholder', 'text', 'content', 'success_msg', 'error_msg'] as $textProp) { if (isset($serialized[$textProp]) && is_string($serialized[$textProp])) { $serialized[$textProp] = $this->translateLabel($serialized[$textProp]); } } // Translate option labels if (isset($serialized['options']) && is_array($serialized['options'])) { foreach ($serialized['options'] as $optKey => $optLabel) { if (is_string($optLabel)) { $serialized['options'][$optKey] = $this->translateLabel($optLabel); } } } // Resolve data-options@ directives (dynamic options from PHP callables). // Grav core's Blueprint::dynamicData() may have already populated // $serialized['options'] using a stateless call; we replace it with // our resolution because we have page-type context for pageTypes. if (isset($field['data-options@'])) { $directive = $field['data-options@']; $resolved = $this->resolveDataDirective($directive); if ($resolved !== null && count($resolved) > 0) { $serialized['options'] = $resolved; } else { // Include the directive reference so client can resolve via /data/resolve $serialized['data_options'] = is_string($directive) ? $directive : ($directive[0] ?? null); } } // Convert options from {key: label} object to [{value, label}] array // to preserve insertion order (JS re-sorts numeric object keys) if (isset($serialized['options']) && is_array($serialized['options'])) { $ordered = []; foreach ($serialized['options'] as $optKey => $optLabel) { $ordered[] = [ 'value' => $this->normalizeOptionScalar($optKey), 'label' => $this->normalizeOptionScalar($optLabel), ]; } $serialized['options'] = $ordered; } // Validation rules if (isset($field['validate']) && is_array($field['validate'])) { $serialized['validate'] = $field['validate']; } // Handle nested fields (structural containers) if (isset($field['fields']) && is_array($field['fields'])) { // For layout containers, don't add prefix (fields bind to their own names) $layoutTypes = ['tabs', 'tab', 'section', 'fieldset', 'columns', 'column', 'page-exists', 'elements', 'element']; $childPrefix = in_array($type, $layoutTypes, true) ? $prefix : $fieldPath; // Always pass this field's resolved name as the parent so any // leading-dot children bind under it, even when the container is // a transparent layout type that leaves $childPrefix untouched. $serialized['fields'] = $this->serializeFields($field['fields'], $childPrefix, $fieldPath); } $result[] = $serialized; } return $result; } /** * Stringify an option key or label for the client. * * With strict YAML (system.strict_mode.yaml_compat: false) Grav parses * blueprints with the native YAML 1.1 parser, which reads unquoted * Yes/No/On/Off/y/n option labels as booleans. Left as booleans they * render as a blank button or a literal "true"; mapping them back to * Yes/No keeps these (Grav 1.7-era) blueprints working without asking * authors — or end users — to quote every label. Option keys are never * booleans (PHP casts bool array keys to 1/0), so they just stringify. */ private function normalizeOptionScalar(mixed $value): string { if (is_bool($value)) { return $value ? 'Yes' : 'No'; } return (string) $value; } }