-
-
- {{ set.heading }}
-
-
-
-
- {{ t('Deselect all') }}
-
-
- {{ t('Select all') }}
-
-
-
-
diff --git a/resources/templates/graphql/schemas/_edit.twig b/resources/templates/graphql/schemas/_edit.twig
deleted file mode 100644
index a9fb84ed2fe..00000000000
--- a/resources/templates/graphql/schemas/_edit.twig
+++ /dev/null
@@ -1,166 +0,0 @@
-{% extends "_layouts/cp" %}
-
-{% set selectedSubnavItem = 'schemas' %}
-
-{% set fullPageForm = true %}
-
-{% set formActions = [
- {
- label: 'Save and continue editing'|t('app'),
- redirect: (schema.isPublic ? 'graphql/schemas/public' : 'graphql/schemas/{id}')|hash,
- shortcut: true,
- retainScroll: true,
- },
-] %}
-
-{% set crumbs = [
- { label: "GraphQL Schemas"|t('app'), url: url('graphql/schemas') }
-] %}
-
-{% import "_includes/forms" as forms %}
-
-{% macro permissionList(schema, permissions, id, disabled, oldPermissions) %}
-
- {% from "_includes/forms" import checkbox %}
- {% from _self import permissionList %}
-
-
-
- {% for permissionName, props in permissions %}
- {% if oldPermissions is not null %}
- {% set checked = permissionName in oldPermissions %}
- {% elseif schema.has(permissionName) %}
- {% set checked = true %}
- {% else %}
- {% set checked = false %}
- {% endif %}
-
- -
- {{ checkbox({
- label: raw(props.label|md(inlineOnly=true, encode=true)),
- name: 'permissions[]',
- value: permissionName,
- checked: checked,
- disabled: disabled
- }) }}
-
- {% if props.info ?? false %}
-
{{ props.info }}
- {% endif %}
-
- {% if props.warning ?? false %}
- {{ props.warning }}
- {% endif %}
-
- {% if props.nested ?? false %}
- {{ permissionList(schema, props.nested, permissionName~'-nested', not checked, oldPermissions) }}
- {% endif %}
-
- {% endfor %}
-
-{% endmacro %}
-
-{% from _self import permissionList %}
-{% set oldPermissions = session('_old_input') ? old('permissions', []) : null %}
-
-
-{% do registerLegacyAsset("CraftCms\\Cms\\View\\LegacyAssets\\UserPermissionsAsset") %}
-
-{% block content %}
- {{ actionInput(schema.isPublic ? 'graphql/save-public-schema' : 'graphql/save-schema') }}
- {{ redirectInput('graphql/schemas') }}
- {% if schema.id %}{{ hiddenInput('schemaId', schema.id) }}{% endif %}
-
- {% if not schema.isPublic %}
- {{ forms.textField({
- first: true,
- label: "Name"|t('app'),
- instructions: "What this schema will be called in the control panel."|t('app'),
- id: 'name',
- name: 'name',
- value: old('name', schema.name),
- errors: schema.errors.get('name'),
- autofocus: true,
- required: true
- }) }}
-
-
- {% endif %}
-
-
{{ 'Choose the available content for querying with this schema:'|t('app') }}
-
- {% set schemaComponents = Gql.getAllSchemaComponents() %}
-
- {% for category, catPermissions in schemaComponents.queries|filter %}
- {% set headingId = "content-heading-#{random()}" %}
-
-
{{ category }}
-
-
- {{ permissionList(schema, catPermissions, null, false, oldPermissions) }}
-
- {% endfor %}
-
-
-
{{ 'Choose the available mutations for this schema:'|t('app') }}
-
- {% for category, catPermissions in schemaComponents.mutations|filter %}
- {% set headingId = "mutation-heading-#{random()}" %}
-
-
{{ category }}
-
-
- {{ permissionList(schema, catPermissions, null, false, oldPermissions) }}
-
- {% endfor %}
-
-
-
{{ 'Choose optional features available to this schema:'|t('app') }}
-
-
- {{ permissionList(schema, {
- 'directive:parseRefs': {
- label: '{name} directive'|t('app', {
- name: '`@parseRefs`',
- }),
- warning: 'Can be exploited to reveal sensitive content by information disclosure attacks.'|t('app'),
- },
- 'directive:transform': not config('craft.general.disableGraphqlTransformDirective') ? {
- label: '{name} directive'|t('app', {
- name: '`@transform`',
- }),
- warning: 'Can be exploited by DoS attacks.'|t('app'),
- },
- }|filter, null, false, oldPermissions) }}
-
-
-{% endblock %}
-
-{% block details %}
- {% if schema.isPublic %}
-
- {{ forms.lightswitchField({
- label: 'Enabled'|t('app'),
- id: 'enabled',
- name: 'enabled',
- on: old('enabled', token.enabled),
- }) }}
-
- {{ forms.dateTimeField({
- label: "Expiry Date"|t('app'),
- id: 'expiryDate',
- name: 'expiryDate',
- value: old('expiryDate', token.expiryDate ? token.expiryDate : null),
- errors: token.errors.get('expiryDate')
- }) }}
-
- {% endif %}
-{% endblock %}
-
-{% js %}
- $('.user-permissions').each((i, wrapper) => {
- new Craft.UserPermissions(wrapper);
- });
-
- new Craft.ElevatedSessionForm('#main-form');
-{% endjs %}
diff --git a/resources/templates/graphql/tokens/_edit.twig b/resources/templates/graphql/tokens/_edit.twig
deleted file mode 100644
index 2a8435323ef..00000000000
--- a/resources/templates/graphql/tokens/_edit.twig
+++ /dev/null
@@ -1,165 +0,0 @@
-{% extends "_layouts/cp" %}
-
-{% set selectedSubnavItem = 'tokens' %}
-
-{% set fullPageForm = true %}
-
-{% set crumbs = [
- { label: "GraphQL Tokens"|t('app'), url: url('graphql/tokens') }
-] %}
-
-{% import "_includes/forms" as forms %}
-
-{% block content %}
-
- {{ redirectInput('graphql/tokens') }}
- {% if token.id %}
{% endif %}
-
- {{ forms.textField({
- first: true,
- label: "Name"|t('app'),
- instructions: "What this token will be called in the control panel."|t('app'),
- id: 'name',
- name: 'name',
- value: old('name', token.name),
- errors: token.errors.get('name'),
- autofocus: true,
- required: true,
- }) }}
-
- {% set schemaInput = schemaOptions
- ? forms.selectField({
- name: 'schema',
- id: 'schema',
- options: schemaOptions,
- value: old('schema', token.schemaId),
- })
- : tag('p', {
- class: ['warning', 'with-icon'],
- text: 'No schemas exist yet to assign to this token.'|t('app'),
- })
- %}
-
- {{ forms.field({
- id: 'schema',
- label: 'GraphQL Schema',
- instructions: 'Choose which GraphQL schema this token has access to.',
- }, schemaInput) }}
-
-
-
- {% embed '_includes/forms/field' with {
- label: 'Authorization Header'|t('app'),
- instructions: 'The `Authorization` header that should be sent with GraphQL API requests to use this token.'|t('app'),
- id: 'auth-header',
- } %}
- {% block input %}
- {% import '_includes/forms' as forms %}
-
- {% embed '_includes/forms/copytext' with {
- id: 'auth-header',
- buttonId: 'copy-btn',
- value: 'Authorization: Bearer ' ~ (accessToken ?? '••••••••••••••••••••••••••••••••'),
- errors: token.errors.get('accessToken'),
- class: ['code', not accessToken ? 'disabled']|filter,
- size: 54,
- } %}
- {# don't register the default JS #}
- {% block js %}{% endblock %}
- {% endembed %}
- {{ hiddenInput('accessToken', accessToken, {
- id: 'access-token',
- disabled: not accessToken,
- }) }}
-
-
-
- {% endblock %}
- {% endembed %}
-{% endblock %}
-
-{% block details %}
-
- {{ forms.lightswitchField({
- label: 'Enabled'|t('app'),
- id: 'enabled',
- name: 'enabled',
- on: old('enabled', token.enabled),
- }) }}
-
- {{ forms.dateTimeField({
- label: "Expiry Date"|t('app'),
- id: 'expiryDate',
- name: 'expiryDate',
- value: old('expiryDate', token.expiryDate ? token.expiryDate : null),
- errors: token.errors.get('expiryDate')
- }) }}
-
-{% endblock %}
-
-{% js %}
- var $headerInput = $('#auth-header');
- var $tokenInput = $('#access-token');
- var $regenBtn = $('#regen-btn');
- var regenerating = false;
-
- function copyHeader() {
- $headerInput[0].select();
- document.execCommand('copy');
- Craft.cp.displayNotice("{{ 'Copied to clipboard.'|t('app')|e('js') }}");
- }
-
- $headerInput.on('click', function() {
- if (!$headerInput.hasClass('disabled')) {
- this.select();
- }
- });
-
- $('#copy-btn').on('click', function() {
- if (!$headerInput.hasClass('disabled')) {
- copyHeader();
- } else {
- Craft.elevatedSessionManager.requireElevatedSession(function() {
- $('#token-spinner').removeClass('hidden');
- var data = {{ {tokenUid: token.uid}|json_encode|raw }};
- Craft.sendActionRequest('POST', 'graphql/fetch-token', {data})
- .then((response) => {
- $('#token-spinner').addClass('hidden');
- $headerInput
- .val('Authorization: Bearer ' + response.data.accessToken)
- .removeClass('disabled');
- copyHeader();
- })
- .finally(() => {
- $('#token-spinner').addClass('hidden');
- });
- });
- }
- });
-
- $regenBtn.on('click', function() {
- if (regenerating) {
- return;
- }
- regenerating = true;
- $('#token-spinner').removeClass('hidden');
- $regenBtn.addClass('active');
-
- Craft.sendActionRequest('POST', 'graphql/generate-token')
- .then((response) => {
- $headerInput
- .val('Authorization: Bearer ' + response.data.accessToken)
- .removeClass('disabled');
- $tokenInput
- .val(response.data.accessToken)
- .prop('disabled', false);
- $regenBtn.removeClass('active');
- regenerating = false;
- })
- .finally(() => {
- $('#token-spinner').addClass('hidden');
- });
- });
-
- new Craft.ElevatedSessionForm('#main-form');
-{% endjs %}
diff --git a/routes/actions.php b/routes/actions.php
index 8536fb47457..cfda131975d 100644
--- a/routes/actions.php
+++ b/routes/actions.php
@@ -55,8 +55,6 @@
use CraftCms\Cms\Http\Controllers\Entries\StoreEntryController;
use CraftCms\Cms\Http\Controllers\FieldsController;
use CraftCms\Cms\Http\Controllers\Gql\ApiController as GqlApiController;
-use CraftCms\Cms\Http\Controllers\Gql\SchemasController as GqlSchemasController;
-use CraftCms\Cms\Http\Controllers\Gql\TokensController as GqlTokensController;
use CraftCms\Cms\Http\Controllers\IconController;
use CraftCms\Cms\Http\Controllers\InstallController;
use CraftCms\Cms\Http\Controllers\MatrixController;
@@ -338,24 +336,6 @@
// FindAndReplace
Route::post('utilities/find-and-replace-perform-action', FindAndReplaceController::class);
- // GraphQL
- Route::middleware([RequireAdmin::class])->group(function () {
- Route::post('graphql/generate-token', [GqlTokensController::class, 'generate']);
-
- Route::middleware('password.confirm')->group(function () {
- Route::post('graphql/save-token', [GqlTokensController::class, 'store']);
- Route::post('graphql/fetch-token', [GqlTokensController::class, 'fetch']);
- });
- });
-
- Route::middleware([RequireAdminChanges::class])->group(function () {
-
- Route::middleware('password.confirm')->group(function () {
- Route::post('graphql/save-schema', [GqlSchemasController::class, 'save']);
- Route::post('graphql/save-public-schema', [GqlSchemasController::class, 'savePublic']);
- });
- });
-
// Matrix
Route::post('matrix/default-table-column-options', [MatrixController::class, 'defaultTableColumnOptions']);
Route::post('matrix/create-entry', [MatrixController::class, 'createEntry']);
diff --git a/routes/cp.php b/routes/cp.php
index 206fc6effd0..f3d8116a269 100644
--- a/routes/cp.php
+++ b/routes/cp.php
@@ -205,18 +205,34 @@
// GraphQL
Route::get('graphql', GqlIndexController::class);
Route::get('graphiql', GraphiqlController::class);
- Route::get('graphql/tokens', [TokensController::class, 'index']);
- Route::get('graphql/tokens/new', [TokensController::class, 'create']);
- Route::get('graphql/tokens/{tokenId}', [TokensController::class, 'edit'])->whereNumber('tokenId');
+
+ Route::prefix('graphql/tokens')->name('graphql.tokens.')->group(function () {
+ Route::get('/', [TokensController::class, 'index'])->name('index');
+ Route::get('new', [TokensController::class, 'create'])->name('create');
+ Route::get('{tokenId}', [TokensController::class, 'edit'])->whereNumber('tokenId')->name('edit');
+ Route::post('generate', [TokensController::class, 'generate'])->name('generate');
+
+ Route::middleware('password.confirm')->group(function () {
+ Route::post('/', [TokensController::class, 'store'])->name('store');
+ Route::patch('{tokenId}', [TokensController::class, 'update'])->whereNumber('tokenId')->name('update');
+ Route::post('{tokenId}/access-token', [TokensController::class, 'accessToken'])->whereNumber('tokenId')->name('accessToken');
+ });
+ });
Route::middleware(RequireAdminChanges::class)->group(function () {
- Route::get('graphql/schemas', [SchemasController::class, 'index']);
- Route::get('graphql/schemas/new', [SchemasController::class, 'create']);
- Route::get('graphql/schemas/public', [SchemasController::class, 'editPublic']);
- Route::get('graphql/schemas/{schemaId}', [SchemasController::class, 'edit'])->whereNumber('schemaId');
- Route::delete('graphql/schemas/{schemaId}', [SchemasController::class, 'destroy'])->whereNumber('schemaId');
+ Route::prefix('graphql/schemas')->name('graphql.schemas.')->group(function () {
+ Route::get('/', [SchemasController::class, 'index'])->name('index');
+ Route::get('new', [SchemasController::class, 'create'])->name('create');
+ Route::get('{schemaId}', [SchemasController::class, 'edit'])->where('schemaId', 'public|\d+')->name('edit');
+ Route::delete('{schemaId}', [SchemasController::class, 'destroy'])->whereNumber('schemaId')->name('destroy');
+
+ Route::middleware('password.confirm')->group(function () {
+ Route::post('/', [SchemasController::class, 'store'])->name('store');
+ Route::patch('{schemaId}', [SchemasController::class, 'update'])->where('schemaId', 'public|\d+')->name('update');
+ });
+ });
- Route::delete('graphql/tokens/{tokenId}', [TokensController::class, 'destroy'])->whereNumber('tokenId');
+ Route::delete('graphql/tokens/{tokenId}', [TokensController::class, 'destroy'])->whereNumber('tokenId')->name('graphql.tokens.destroy');
});
// Plugins
diff --git a/src/Http/Controllers/Gql/SchemasController.php b/src/Http/Controllers/Gql/SchemasController.php
index 62f91b5f10b..f11c83d325f 100644
--- a/src/Http/Controllers/Gql/SchemasController.php
+++ b/src/Http/Controllers/Gql/SchemasController.php
@@ -8,11 +8,14 @@
use CraftCms\Cms\Gql\Data\GqlToken;
use CraftCms\Cms\Gql\Gql;
use CraftCms\Cms\Http\RespondsWithFlash;
+use CraftCms\Cms\Http\Responses\CpScreenResponse;
use CraftCms\Cms\Support\DateTimeHelper;
-use CraftCms\Cms\Support\Flash;
+use CraftCms\Cms\Support\Facades\HtmlStack;
use CraftCms\Cms\Support\Url;
-use Illuminate\Contracts\View\View;
+use CraftCms\Cms\User\Data\Permission;
+use CraftCms\Cms\User\Data\PermissionGroup;
use Illuminate\Http\Request;
+use Illuminate\Support\Collection;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
@@ -33,7 +36,7 @@ public function index()
// Ensure the public schema exists so the table stays aligned with the legacy UI.
$this->gql->getPublicSchema();
- return Inertia::render('graphql/Schemas', [
+ return Inertia::render('graphql/schemas/Index', [
'crumbs' => fn () => [
['label' => t('GraphQL'), 'url' => Url::cpUrl('graphql/schemas')],
['label' => t('Schemas')],
@@ -43,40 +46,39 @@ public function index()
]);
}
- public function create(): View
+ public function create(): CpScreenResponse
{
- return $this->renderEditSchema(new GqlSchema);
+ return $this->editScreen(new GqlSchema);
}
- public function edit(int $schemaId): View
+ public function edit(string|int $schemaId): CpScreenResponse
{
- $schema = $this->gql->getSchemaById($schemaId);
+ [$schema, $token] = $this->resolveSchema($schemaId);
- abort_if(is_null($schema), 404, 'Schema not found');
-
- return $this->renderEditSchema($schema);
+ return $this->editScreen($schema, $token);
}
- public function editPublic(): View
+ public function store(Request $request): Response
{
- return $this->renderEditPublicSchema(
- $this->gql->getPublicSchema(),
- $this->gql->getPublicToken(),
- );
+ return $this->saveSchema($request, new GqlSchema);
}
- public function save(Request $request): Response
+ public function update(Request $request, string|int $schemaId): Response
{
- $schemaId = $request->input('schemaId');
+ [$schema, $token] = $this->resolveSchema($schemaId);
- if ($schemaId) {
- $schema = $this->gql->getSchemaById((int) $schemaId);
+ return $this->saveSchema($request, $schema, $token);
+ }
- abort_if(is_null($schema), 404, 'Schema not found');
- } else {
- $schema = new GqlSchema;
- }
+ public function destroy(Request $request, int $schemaId): Response
+ {
+ $this->gql->deleteSchemaById($schemaId);
+ return $this->asSuccess(t('Schema deleted.'));
+ }
+
+ private function saveSchema(Request $request, GqlSchema $schema, ?GqlToken $token = null): Response
+ {
if ($request->has('name')) {
$schema->name = $request->input('name');
}
@@ -85,22 +87,19 @@ public function save(Request $request): Response
$schema->scope = is_array($permissions) ? $permissions : [$permissions];
if (! $this->gql->saveSchema($schema)) {
- return $this->invalidSchemaResponse($request, $schema, t('Couldn’t save schema.'));
+ return $this->asModelFailure($schema, t('Couldn’t save schema.'), 'schema', array_filter([
+ 'token' => $token?->toArray(),
+ ]));
}
- return $this->asModelSuccess($schema, t('Schema saved.'), 'schema');
- }
-
- public function savePublic(Request $request): View|Response
- {
- $schema = $this->gql->getPublicSchema();
- $token = $this->gql->getPublicToken();
-
- $permissions = $request->input('permissions', []);
- $schema->scope = is_array($permissions) ? $permissions : [$permissions];
-
- if (! $this->gql->saveSchema($schema)) {
- return $this->invalidPublicSchemaSchemaResponse($request, $schema, $token, t('Couldn’t save schema.'));
+ if (! $token) {
+ return $this->asModelSuccess(
+ $schema,
+ t('Schema saved.'),
+ 'schema',
+ redirect: $this->getPostedRedirectUrl($schema)
+ ?? Url::cpUrl("graphql/schemas/$schema->id"),
+ );
}
$token->enabled = (bool) $request->input('enabled');
@@ -110,86 +109,124 @@ public function savePublic(Request $request): View|Response
}
if (! $this->gql->saveToken($token)) {
- return $this->invalidPublicSchemaTokenResponse($request, $schema, $token, t('Couldn’t save public schema settings.'));
+ return $this->asModelFailure($token, t('Couldn’t save public schema settings.'), 'token', [
+ 'schema' => $schema->toArray(),
+ ]);
}
return $this->asSuccess(t('Schema saved.'));
}
- public function destroy(Request $request, int $schemaId): Response
+ private function resolveSchema(string|int $schemaId): array
{
- $this->gql->deleteSchemaById($schemaId);
+ if ($schemaId === 'public') {
+ $schema = $this->gql->getPublicSchema();
+ $token = $this->gql->getPublicToken();
- return $this->asSuccess(t('Schema deleted.'));
- }
+ abort_if(! $schema || ! $token, 404, 'Public schema not found');
- private function invalidSchemaResponse(Request $request, GqlSchema $schema, string $message): Response
- {
- if ($request->expectsJson()) {
- return $this->asModelFailure($schema, $message, 'schema');
+ return [$schema, $token];
}
- Flash::error($message);
+ $schema = $this->gql->getSchemaById((int) $schemaId);
- return response($this->renderEditSchema($schema));
- }
-
- private function invalidPublicSchemaSchemaResponse(
- Request $request,
- GqlSchema $schema,
- GqlToken $token,
- string $message,
- ): Response|View {
- if ($request->expectsJson()) {
- return $this->asModelFailure($schema, $message, 'schema', [
- 'token' => $token->toArray(),
- ]);
- }
-
- Flash::error($message);
+ abort_if(is_null($schema), 404, 'Schema not found');
- return $this->renderEditPublicSchema($schema, $token);
+ return [$schema, null];
}
- private function invalidPublicSchemaTokenResponse(
- Request $request,
- GqlSchema $schema,
- GqlToken $token,
- string $message,
- ): View|Response {
- if ($request->expectsJson()) {
- return $this->asModelFailure($token, $message, 'token', [
+ private function editScreen(GqlSchema $schema, ?GqlToken $token = null): CpScreenResponse
+ {
+ $title = $schema->isPublic
+ ? t('Edit the public GraphQL schema')
+ : ($schema->id
+ ? trim((string) $schema->name) ?: t('Edit GraphQL Schema')
+ : t('Create a new GraphQL Schema'));
+
+ return new CpScreenResponse()
+ ->title($title)
+ ->selectedSubnavItem('schemas')
+ ->crumbs([
+ ['label' => t('GraphQL Schemas'), 'url' => 'graphql/schemas'],
+ ['label' => $title],
+ ])
+ ->redirectUrl('graphql/schemas')
+ ->inertiaPage('graphql/schemas/Edit', [
'schema' => $schema->toArray(),
- ]);
- }
-
- Flash::error($message);
-
- return $this->renderEditPublicSchema($schema, $token);
+ 'token' => $token ? [
+ 'id' => $token->id,
+ 'enabled' => $token->enabled,
+ 'expiryDate' => $token->expiryDate?->format('Y-m-d\TH:i'),
+ ] : null,
+ 'permissions' => $this->schemaPermissionGroups(),
+ ])
+ ->prepareScreen(function (CpScreenResponse $response, string $containerId) {
+ HtmlStack::jsWithVars(
+ fn ($containerId) => <<
*/
+ private function schemaPermissionGroups(): Collection
{
- $name = trim((string) $schema->name) ?: null;
-
- $title = $schema->id
- ? $name ?? t('Edit GraphQL Schema')
- : $name ?? t('Create a new GraphQL Schema');
+ $schemaComponents = $this->gql->getAllSchemaComponents();
+ $optionalPermissions = [
+ 'directive:parseRefs' => [
+ 'label' => t('{name} directive', [
+ 'name' => '@parseRefs',
+ ]),
+ 'warning' => t('Can be exploited to reveal sensitive content by information disclosure attacks.'),
+ ],
+ ];
+
+ if (! config('craft.general.disableGraphqlTransformDirective')) {
+ $optionalPermissions['directive:transform'] = [
+ 'label' => t('{name} directive', [
+ 'name' => '@transform',
+ ]),
+ 'warning' => t('Can be exploited by DoS attacks.'),
+ ];
+ }
- return view('graphql.schemas._edit', compact(
- 'schema',
- 'title',
- ));
+ return $this->permissionGroups($schemaComponents['queries'])
+ ->merge($this->permissionGroups($schemaComponents['mutations']))
+ ->push(new PermissionGroup(
+ t('Optional Features'),
+ $this->permissionList($optionalPermissions),
+ ));
}
- private function renderEditPublicSchema(GqlSchema $schema, GqlToken $token): View
+ /** @return Collection */
+ private function permissionGroups(array $categories): Collection
{
- $title = t('Edit the public GraphQL schema');
+ return collect($categories)
+ ->filter()
+ ->map(fn (array $permissions, string $heading) => new PermissionGroup(
+ $heading,
+ $this->permissionList($permissions),
+ ))
+ ->values();
+ }
- return view('graphql.schemas._edit', compact(
- 'schema',
- 'token',
- 'title',
- ));
+ /** @return Collection */
+ private function permissionList(array $permissions): Collection
+ {
+ return collect($permissions)
+ ->map(fn (array $props, string $key) => new Permission(
+ key: $key,
+ label: $props['label'] ?? $key,
+ info: $props['info'] ?? null,
+ warning: $props['warning'] ?? null,
+ nested: isset($props['nested'])
+ ? $this->permissionList($props['nested'])
+ : new Collection,
+ ))
+ ->values();
}
}
diff --git a/src/Http/Controllers/Gql/TokensController.php b/src/Http/Controllers/Gql/TokensController.php
index 8fddcdac8bc..c03bbd33fd8 100644
--- a/src/Http/Controllers/Gql/TokensController.php
+++ b/src/Http/Controllers/Gql/TokensController.php
@@ -8,13 +8,13 @@
use CraftCms\Cms\Gql\Gql;
use CraftCms\Cms\Gql\Resources\GqlTokenResource;
use CraftCms\Cms\Http\RespondsWithFlash;
+use CraftCms\Cms\Http\Responses\CpScreenResponse;
use CraftCms\Cms\Support\DateTimeHelper;
-use CraftCms\Cms\Support\Flash;
-use Illuminate\Contracts\View\View;
+use CraftCms\Cms\Support\Facades\HtmlStack;
+use CraftCms\Cms\Support\Url;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
-use InvalidArgumentException;
use Symfony\Component\HttpFoundation\Response;
use function CraftCms\Cms\t;
@@ -31,61 +31,42 @@ public function __construct(
public function index()
{
- return Inertia::render('graphql/Tokens', [
+ return Inertia::render('graphql/tokens/Index', [
+ 'crumbs' => fn () => [
+ ['label' => t('GraphQL'), 'url' => Url::cpUrl('graphql/tokens')],
+ ['label' => t('Tokens')],
+ ],
+ 'title' => t('GraphQL Tokens'),
'tokens' => GqlTokenResource::collection($this->gql->getTokens()),
]);
}
- public function create(): View
+ public function create(): CpScreenResponse
{
- return $this->renderEditToken(new GqlToken, accessToken: $this->gql->generateToken());
+ return $this->editScreen(new GqlToken, accessToken: $this->gql->generateToken());
}
- public function edit(int $tokenId): View
+ public function edit(int $tokenId): CpScreenResponse
{
$token = $this->gql->getTokenById($tokenId);
- if (! $token || $token->getIsPublic()) {
- abort(404, 'Token not found');
- }
+ abort_if(! $token || $token->getIsPublic(), 404, 'Token not found');
- return $this->renderEditToken($token);
+ return $this->editScreen($token);
}
public function store(Request $request): Response
{
- $tokenId = $request->input('tokenId');
-
- if ($tokenId) {
- $token = $this->gql->getTokenById((int) $tokenId);
-
- abort_if(! $token, 404, 'Token not found');
- } else {
- $token = new GqlToken;
- }
-
- if ($request->has('name')) {
- $token->name = $request->input('name');
- }
-
- if ($request->has('accessToken')) {
- $token->accessToken = $request->input('accessToken');
- }
-
- $token->enabled = (bool) $request->input('enabled');
-
- $schemaId = $request->input('schema');
- $token->schemaId = is_numeric($schemaId) ? (int) $schemaId : null;
+ return $this->saveToken($request, new GqlToken);
+ }
- if ($request->has('expiryDate')) {
- $token->expiryDate = DateTimeHelper::toDateTime($request->input('expiryDate')) ?: null;
- }
+ public function update(Request $request, int $tokenId): Response
+ {
+ $token = $this->gql->getTokenById($tokenId);
- if (! $this->gql->saveToken($token)) {
- return $this->invalidTokenResponse($request, $token, t('Couldn’t save token.'));
- }
+ abort_if(! $token || $token->getIsPublic(), 404, 'Token not found');
- return $this->asModelSuccess($token, t('Schema saved.'), 'token');
+ return $this->saveToken($request, $token);
}
public function destroy(Request $request, int $tokenId): Response
@@ -95,61 +76,113 @@ public function destroy(Request $request, int $tokenId): Response
return $this->asSuccess(t('Token deleted.'));
}
- public function fetch(Request $request): JsonResponse
+ public function accessToken(int $tokenId): JsonResponse
{
- abort_unless($request->expectsJson(), 400, 'Request must accept JSON in response');
-
- $tokenUid = $request->validate([
- 'tokenUid' => ['required', 'string'],
- ])['tokenUid'];
+ $token = $this->gql->getTokenById($tokenId);
- try {
- $token = $this->gql->getTokenByUid($tokenUid);
- } catch (InvalidArgumentException) {
- abort(400, 'Invalid schema UID.');
- }
+ abort_if(! $token || $token->getIsPublic(), 404, 'Token not found');
return new JsonResponse([
'accessToken' => $token->accessToken,
]);
}
- public function generate(Request $request): JsonResponse
+ public function generate(): JsonResponse
{
- abort_unless($request->expectsJson(), 400, 'Request must accept JSON in response');
-
return new JsonResponse([
'accessToken' => $this->gql->generateToken(),
]);
}
- private function invalidTokenResponse(Request $request, GqlToken $token, string $message): Response
+ private function saveToken(Request $request, GqlToken $token): Response
{
- if ($request->expectsJson()) {
- return $this->asModelFailure($token, $message, 'token');
+ if ($request->has('name')) {
+ $token->name = $request->input('name');
}
- Flash::error($message);
+ if ($request->filled('accessToken')) {
+ $token->accessToken = $request->input('accessToken');
+ }
- return response($this->renderEditToken(
+ $token->enabled = (bool) $request->input('enabled');
+
+ $schemaId = $request->input('schema');
+ $token->schemaId = is_numeric($schemaId) ? (int) $schemaId : null;
+
+ if ($request->has('expiryDate')) {
+ $token->expiryDate = DateTimeHelper::toDateTime($request->input('expiryDate')) ?: null;
+ }
+
+ if (! $this->gql->saveToken($token)) {
+ return $this->asModelFailure($token, t('Couldn’t save token.'), 'token');
+ }
+
+ return $this->asModelSuccess(
$token,
- accessToken: $token->id ? null : ($token->accessToken ?: $this->gql->generateToken()),
- ));
+ t('Token saved.'),
+ 'token',
+ redirect: $this->getPostedRedirectUrl($token)
+ ?? Url::cpUrl("graphql/tokens/$token->id"),
+ );
+ }
+
+ private function editScreen(GqlToken $token, ?string $accessToken = null): CpScreenResponse
+ {
+ $name = trim((string) $token->name) ?: null;
+
+ $title = $token->id
+ ? $name ?? t('Edit GraphQL Token')
+ : $name ?? t('Create a new GraphQL token');
+
+ return new CpScreenResponse()
+ ->title($title)
+ ->selectedSubnavItem('tokens')
+ ->crumbs([
+ ['label' => t('GraphQL Tokens'), 'url' => 'graphql/tokens'],
+ ['label' => $title],
+ ])
+ ->redirectUrl('graphql/tokens')
+ ->inertiaPage('graphql/tokens/Edit', [
+ 'token' => $this->tokenData($token),
+ 'accessToken' => $accessToken,
+ 'schemaOptions' => $this->schemaOptions($token),
+ ])
+ ->prepareScreen(function (CpScreenResponse $response, string $containerId) {
+ HtmlStack::jsWithVars(
+ fn ($containerId) => << $token->id,
+ 'uid' => $token->uid,
+ 'name' => $token->name,
+ 'schemaId' => $token->schemaId,
+ 'enabled' => $token->enabled,
+ 'expiryDate' => $token->expiryDate?->format('Y-m-d\TH:i'),
+ ];
}
- private function renderEditToken(GqlToken $token, ?string $accessToken = null): View
+ private function schemaOptions(GqlToken $token): array
{
- $schemas = $this->gql->getSchemas();
$publicSchema = $this->gql->getPublicSchema();
$schemaOptions = [];
- foreach ($schemas as $schema) {
- if (! $publicSchema || $schema->id !== $publicSchema->id) {
- $schemaOptions[] = [
- 'label' => $schema->name,
- 'value' => $schema->id,
- ];
+ foreach ($this->gql->getSchemas() as $schema) {
+ if ($publicSchema && $schema->id === $publicSchema->id) {
+ continue;
}
+
+ $schemaOptions[] = [
+ 'label' => $schema->name,
+ 'value' => (string) $schema->id,
+ ];
}
if ($token->id && ! $token->schemaId && $schemaOptions !== []) {
@@ -159,17 +192,6 @@ private function renderEditToken(GqlToken $token, ?string $accessToken = null):
]);
}
- $name = trim((string) $token->name) ?: null;
-
- $title = $token->id
- ? $name ?? t('Edit GraphQL Token')
- : $name ?? t('Create a new GraphQL token');
-
- return view('graphql.tokens._edit', compact(
- 'token',
- 'title',
- 'accessToken',
- 'schemaOptions',
- ));
+ return $schemaOptions;
}
}
diff --git a/src/User/Data/Permission.php b/src/User/Data/Permission.php
index 5b7d87b4c71..34b5ffe2137 100644
--- a/src/User/Data/Permission.php
+++ b/src/User/Data/Permission.php
@@ -14,7 +14,7 @@ public function __construct(
public string $label,
public ?string $info = null,
public ?string $warning = null,
- /** @var Collection */
+ /** @var Collection */
public Collection $nested = new Collection,
) {}
diff --git a/src/User/Data/PermissionGroup.php b/src/User/Data/PermissionGroup.php
index c9d1239e452..1ab438e9482 100644
--- a/src/User/Data/PermissionGroup.php
+++ b/src/User/Data/PermissionGroup.php
@@ -12,16 +12,37 @@ class PermissionGroup implements Arrayable
{
public function __construct(
public string $heading,
- /** @var Collection */
+ /** @var Collection */
public Collection $permissions = new Collection,
) {}
+ public string $handle {
+ get => Str::toHandle($this->heading);
+ }
+
+ /** @var string[] */
+ public array $keys {
+ get => $this->permissionKeys($this->permissions);
+ }
+
public function toArray(): array
{
return [
'heading' => $this->heading,
- 'handle' => Str::toHandle($this->heading),
+ 'handle' => $this->handle,
'permissions' => $this->permissions->keyBy('key')->toArray(),
+ 'keys' => $this->keys,
];
}
+
+ /** @param Collection $permissions */
+ private function permissionKeys(Collection $permissions): array
+ {
+ return $permissions
+ ->flatMap(fn (Permission $permission) => [
+ $permission->key,
+ ...$this->permissionKeys($permission->nested),
+ ])
+ ->all();
+ }
}
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/Http/Controllers/Gql/SchemasControllerTest.php b/tests/Feature/Http/Controllers/Gql/SchemasControllerTest.php
index 3436cced4f7..aed524796db 100644
--- a/tests/Feature/Http/Controllers/Gql/SchemasControllerTest.php
+++ b/tests/Feature/Http/Controllers/Gql/SchemasControllerTest.php
@@ -8,17 +8,21 @@
use CraftCms\Cms\Http\Controllers\Gql\SchemasController;
use CraftCms\Cms\Support\DateTimeHelper;
use CraftCms\Cms\Support\Facades\Gql;
+use CraftCms\Cms\Support\Url;
use CraftCms\Cms\User\Elements\User;
use Illuminate\Routing\Exceptions\UrlGenerationException;
use Illuminate\Support\Facades\Auth;
+use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Session;
use Inertia\Testing\AssertableInertia;
use function CraftCms\Cms\cp_url;
-use function CraftCms\Cms\t;
use function Pest\Laravel\actingAs;
use function Pest\Laravel\deleteJson;
use function Pest\Laravel\get;
+use function Pest\Laravel\patch;
+use function Pest\Laravel\patchJson;
+use function Pest\Laravel\post;
use function Pest\Laravel\postJson;
beforeEach(function () {
@@ -48,12 +52,12 @@
get(cp_url('graphql/schemas/public'))->assertForbidden();
get(action([SchemasController::class, 'edit'], ['schemaId' => $schema->id]))->assertForbidden();
- postJson(cp_url('actions/graphql/save-schema'), [
+ postJson(action([SchemasController::class, 'store']), [
'name' => 'Protected Schema',
'permissions' => schemaControllerScope(),
])->assertForbidden();
- postJson(cp_url('actions/graphql/save-public-schema'), [
+ patchJson(action([SchemasController::class, 'update'], ['schemaId' => 'public']), [
'permissions' => schemaControllerScope(),
'enabled' => true,
])->assertForbidden();
@@ -68,41 +72,57 @@
get(action([SchemasController::class, 'index']))->assertForbidden();
get(action([SchemasController::class, 'create']))->assertForbidden();
get(action([SchemasController::class, 'edit'], ['schemaId' => $schema->id]))->assertForbidden();
- get(action([SchemasController::class, 'editPublic']))->assertForbidden();
+ get(action([SchemasController::class, 'edit'], ['schemaId' => 'public']))->assertForbidden();
});
it('renders the schema index, create, edit, and public edit screens', function () {
- $schema = createSchemaForSchemasControllerTest();
+ $schema = createSchemaForSchemasControllerTest([
+ 'scope' => ['directive:parseRefs'],
+ ]);
get(action([SchemasController::class, 'index']))
- ->assertInertia(fn (AssertableInertia $page) => $page->component('graphql/Schemas'));
+ ->assertInertia(fn (AssertableInertia $page) => $page->component('graphql/schemas/Index'));
get(action([SchemasController::class, 'create']))
- ->assertOk()
- ->assertViewIs('graphql.schemas._edit')
- ->assertViewHas('schema')
- ->assertViewHas('title', t('Create a new GraphQL Schema'));
+ ->assertInertia(fn (AssertableInertia $page) => $page
+ ->component('graphql/schemas/Edit')
+ ->where('schema.id', null)
+ ->where('title', 'Create a new GraphQL Schema')
+ ->where('permissions', fn ($permissions) => collect($permissions)
+ ->pluck('permissions')
+ ->contains(fn (array $groupPermissions) => array_key_exists('directive:parseRefs', $groupPermissions))));
get(action([SchemasController::class, 'edit'], ['schemaId' => $schema->id]))
- ->assertOk()
- ->assertViewIs('graphql.schemas._edit')
- ->assertViewHas('schema', fn ($viewSchema) => $viewSchema->id === $schema->id)
- ->assertViewHas('title', $schema->name);
-
- get(action([SchemasController::class, 'editPublic']))
- ->assertOk()
- ->assertViewIs('graphql.schemas._edit')
- ->assertViewHas('schema')
- ->assertViewHas('token')
- ->assertViewHas('title', t('Edit the public GraphQL schema'));
+ ->assertInertia(fn (AssertableInertia $page) => $page
+ ->component('graphql/schemas/Edit')
+ ->where('schema.id', $schema->id)
+ ->where('schema.scope', ['directive:parseRefs'])
+ ->where('title', $schema->name));
+
+ get(action([SchemasController::class, 'edit'], ['schemaId' => 'public']))
+ ->assertInertia(fn (AssertableInertia $page) => $page
+ ->component('graphql/schemas/Edit')
+ ->where('schema.isPublic', true)
+ ->has('token')
+ ->where('title', 'Edit the public GraphQL schema'));
});
-it('updates an existing schema via the save action', function () {
- $schema = createSchemaForSchemasControllerTest();
- $updatedScope = schemaControllerScope();
+it('creates and updates schemas', function () {
+ $scope = schemaControllerScope();
+
+ postJson(action([SchemasController::class, 'store']), [
+ 'name' => 'Created Schema',
+ 'permissions' => $scope,
+ ])->assertOk();
+
+ $schema = collect(Gql::getSchemas())->first(fn (GqlSchema $schema) => $schema->name === 'Created Schema');
+
+ expect($schema)->not->toBeNull()
+ ->and($schema?->scope)->toBe($scope);
- postJson(action([SchemasController::class, 'save']), [
- 'schemaId' => $schema->id,
+ $updatedScope = ['directive:parseRefs'];
+
+ patchJson(action([SchemasController::class, 'update'], ['schemaId' => $schema->id]), [
'name' => 'Renamed Schema',
'permissions' => $updatedScope,
])->assertOk();
@@ -113,9 +133,31 @@
->and($updatedSchema?->scope)->toBe($updatedScope);
});
-it('returns not found when saving an unknown schema id', function () {
- postJson(action([SchemasController::class, 'save']), [
- 'schemaId' => 999999,
+it('redirects to the saved schema edit page when saving and continuing', function () {
+ $response = post(action([SchemasController::class, 'store']), [
+ 'name' => 'Continued Schema',
+ 'permissions' => schemaControllerScope(),
+ ]);
+
+ $schema = collect(Gql::getSchemas())->first(fn (GqlSchema $schema) => $schema->name === 'Continued Schema');
+
+ expect($schema)->not->toBeNull();
+
+ $response->assertRedirect(Url::cpUrl("graphql/schemas/$schema->id"));
+});
+
+it('redirects to the schema index when saving normally', function () {
+ $schema = createSchemaForSchemasControllerTest();
+
+ patch(action([SchemasController::class, 'update'], ['schemaId' => $schema->id]), [
+ 'name' => 'Normally Saved Schema',
+ 'permissions' => schemaControllerScope(),
+ 'redirect' => Crypt::encrypt('graphql/schemas'),
+ ])->assertRedirect(Url::cpUrl('graphql/schemas'));
+});
+
+it('returns not found when updating an unknown schema id', function () {
+ patchJson(action([SchemasController::class, 'update'], ['schemaId' => 999999]), [
'name' => 'Missing Schema',
'permissions' => schemaControllerScope(),
])->assertNotFound();
@@ -125,7 +167,7 @@
$expiryDate = '2026-12-31 15:30';
$expectedExpiryDate = DateTimeHelper::toDateTime($expiryDate);
- postJson(action([SchemasController::class, 'savePublic']), [
+ patchJson(action([SchemasController::class, 'update'], ['schemaId' => 'public']), [
'permissions' => schemaControllerScope(),
'enabled' => true,
'expiryDate' => $expiryDate,
@@ -139,15 +181,22 @@
->and($publicToken?->expiryDate?->getTimestamp())->toBe($expectedExpiryDate?->getTimestamp());
});
-it('requires password confirmation for save-schema and save-public-schema', function () {
+it('requires password confirmation for schema mutations', function () {
+ $schema = createSchemaForSchemasControllerTest();
+
Session::forget('auth.password_confirmed_at');
- postJson(cp_url('actions/graphql/save-schema'), [
+ postJson(action([SchemasController::class, 'store']), [
+ 'name' => 'Protected Schema',
+ 'permissions' => schemaControllerScope(),
+ ])->assertStatus(423);
+
+ patchJson(action([SchemasController::class, 'update'], ['schemaId' => $schema->id]), [
'name' => 'Protected Schema',
'permissions' => schemaControllerScope(),
])->assertStatus(423);
- postJson(cp_url('actions/graphql/save-public-schema'), [
+ patchJson(action([SchemasController::class, 'update'], ['schemaId' => 'public']), [
'permissions' => schemaControllerScope(),
'enabled' => true,
])->assertStatus(423);
diff --git a/tests/Feature/Http/Controllers/Gql/TokensControllerTest.php b/tests/Feature/Http/Controllers/Gql/TokensControllerTest.php
index 83a2660a0d0..fccb185be2b 100644
--- a/tests/Feature/Http/Controllers/Gql/TokensControllerTest.php
+++ b/tests/Feature/Http/Controllers/Gql/TokensControllerTest.php
@@ -9,6 +9,7 @@
use CraftCms\Cms\Http\Controllers\Gql\TokensController;
use CraftCms\Cms\Support\DateTimeHelper;
use CraftCms\Cms\Support\Facades\Gql;
+use CraftCms\Cms\Support\Url;
use CraftCms\Cms\User\Elements\User;
use Illuminate\Routing\Exceptions\UrlGenerationException;
use Illuminate\Support\Facades\Auth;
@@ -16,10 +17,10 @@
use Inertia\Testing\AssertableInertia;
use function CraftCms\Cms\cp_url;
-use function CraftCms\Cms\t;
use function Pest\Laravel\actingAs;
use function Pest\Laravel\deleteJson;
use function Pest\Laravel\get;
+use function Pest\Laravel\patchJson;
use function Pest\Laravel\post;
use function Pest\Laravel\postJson;
@@ -48,22 +49,16 @@
get(cp_url('graphql/tokens/new'))->assertForbidden();
get(action([TokensController::class, 'edit'], ['tokenId' => $token->id]))->assertForbidden();
- postJson(cp_url('actions/graphql/save-token'), [
+ postJson(action([TokensController::class, 'store']), [
'name' => 'Protected token',
'accessToken' => 'protected-token',
'enabled' => true,
'schema' => createSchemaForTokensControllerTest()->id,
])->assertForbidden();
- postJson(cp_url('actions/graphql/fetch-token'), [
- 'tokenUid' => $token->uid,
- ])->assertForbidden();
-
- postJson(cp_url('actions/graphql/generate-token'))->assertForbidden();
-
- deleteJson(action([TokensController::class, 'destroy'], [
- 'tokenId' => $token->id,
- ]))->assertForbidden();
+ postJson(action([TokensController::class, 'accessToken'], ['tokenId' => $token->id]))->assertForbidden();
+ postJson(action([TokensController::class, 'generate']))->assertForbidden();
+ deleteJson(action([TokensController::class, 'destroy'], ['tokenId' => $token->id]))->assertForbidden();
});
it('allows token pages and actions without admin changes', function () {
@@ -73,7 +68,7 @@
get(action([TokensController::class, 'index']))->assertOk();
get(action([TokensController::class, 'create']))->assertOk();
- postJson(cp_url('actions/graphql/save-token'), [
+ postJson(action([TokensController::class, 'store']), [
'name' => 'No Admin Changes Token',
'accessToken' => 'no-admin-changes-token',
'enabled' => true,
@@ -89,23 +84,24 @@
$publicSchema = Gql::getPublicSchema();
get(action([TokensController::class, 'index']))
- ->assertInertia(fn (AssertableInertia $page) => $page->component('graphql/Tokens'));
+ ->assertInertia(fn (AssertableInertia $page) => $page->component('graphql/tokens/Index'));
get(action([TokensController::class, 'create']))
- ->assertOk()
- ->assertViewIs('graphql.tokens._edit')
- ->assertViewHas('token')
- ->assertViewHas('accessToken', fn ($accessToken) => is_string($accessToken) && $accessToken !== '')
- ->assertViewHas('schemaOptions', fn (array $options) => collect($options)
- ->pluck('value')
- ->doesntContain($publicSchema?->id))
- ->assertViewHas('title', t('Create a new GraphQL token'));
+ ->assertInertia(fn (AssertableInertia $page) => $page
+ ->component('graphql/tokens/Edit')
+ ->where('token.id', null)
+ ->where('title', 'Create a new GraphQL token')
+ ->where('accessToken', fn ($accessToken) => is_string($accessToken) && $accessToken !== '')
+ ->where('schemaOptions', fn ($options) => collect($options)
+ ->pluck('value')
+ ->doesntContain($publicSchema?->id)));
get(action([TokensController::class, 'edit'], ['tokenId' => $token->id]))
- ->assertOk()
- ->assertViewIs('graphql.tokens._edit')
- ->assertViewHas('token', fn ($viewToken) => $viewToken->id === $token->id)
- ->assertViewHas('title', $token->name);
+ ->assertInertia(fn (AssertableInertia $page) => $page
+ ->component('graphql/tokens/Edit')
+ ->where('token.id', $token->id)
+ ->where('title', $token->name)
+ ->where('accessToken', null));
});
it('shows a blank schema option when editing a token without a schema', function () {
@@ -113,8 +109,9 @@
$token = createTokenForTokensControllerTest(overrides: ['schemaId' => null]);
get(action([TokensController::class, 'edit'], ['tokenId' => $token->id]))
- ->assertOk()
- ->assertViewHas('schemaOptions', fn (array $options) => $options[0]['value'] === '');
+ ->assertInertia(fn (AssertableInertia $page) => $page
+ ->component('graphql/tokens/Edit')
+ ->where('schemaOptions.0.value', ''));
});
it('returns not found for invalid, public, or missing token ids', function () {
@@ -129,37 +126,34 @@
get(action([TokensController::class, 'edit'], ['tokenId' => $publicToken->id]))->assertNotFound();
- postJson(cp_url('actions/graphql/save-token'), [
- 'tokenId' => 999999,
+ patchJson(action([TokensController::class, 'update'], ['tokenId' => 999999]), [
'name' => 'Missing Token',
'accessToken' => 'missing-token',
'enabled' => true,
])->assertNotFound();
});
-it('requires password confirmation for save-token and fetch-token', function () {
+it('requires password confirmation for saving and revealing tokens', function () {
$schema = createSchemaForTokensControllerTest();
$token = createTokenForTokensControllerTest($schema);
Session::forget('auth.password_confirmed_at');
- postJson(cp_url('actions/graphql/save-token'), [
+ postJson(action([TokensController::class, 'store']), [
'name' => 'Protected token',
'accessToken' => 'protected-token',
'enabled' => true,
'schema' => $schema->id,
])->assertStatus(423);
- postJson(cp_url('actions/graphql/fetch-token'), [
- 'tokenUid' => $token->uid,
- ])->assertStatus(423);
+ postJson(action([TokensController::class, 'accessToken'], ['tokenId' => $token->id]))->assertStatus(423);
});
-it('saves, updates, fetches, generates, and deletes tokens', function () {
+it('saves, updates, reveals, generates, and deletes tokens', function () {
$schema = createSchemaForTokensControllerTest();
$updatedSchema = createSchemaForTokensControllerTest();
- postJson(cp_url('actions/graphql/save-token'), [
+ postJson(action([TokensController::class, 'store']), [
'name' => 'API Token',
'accessToken' => 'api-token-1',
'enabled' => true,
@@ -170,8 +164,7 @@
expect($token)->not->toBeNull();
- postJson(cp_url('actions/graphql/save-token'), [
- 'tokenId' => $token->id,
+ patchJson(action([TokensController::class, 'update'], ['tokenId' => $token->id]), [
'name' => 'Updated API Token',
'accessToken' => 'api-token-2',
'enabled' => false,
@@ -187,10 +180,8 @@
->and($token?->schemaId)->toBe($updatedSchema->id)
->and($token?->expiryDate?->getTimestamp())->toBe(DateTimeHelper::toDateTime('2026-12-31 15:30')?->getTimestamp());
- postJson(cp_url('actions/graphql/save-token'), [
- 'tokenId' => $token->id,
+ patchJson(action([TokensController::class, 'update'], ['tokenId' => $token->id]), [
'name' => 'Updated API Token',
- 'accessToken' => 'api-token-2',
'enabled' => false,
'schema' => $updatedSchema->id,
'expiryDate' => '',
@@ -198,33 +189,43 @@
$token = Gql::getTokenById($token->id);
- expect($token?->expiryDate)->toBeNull();
+ expect($token?->expiryDate)->toBeNull()
+ ->and($token?->accessToken)->toBe('api-token-2');
- postJson(cp_url('actions/graphql/fetch-token'), [
- 'tokenUid' => $token->uid,
- ])->assertOk()
+ postJson(action([TokensController::class, 'accessToken'], ['tokenId' => $token->id]))
+ ->assertOk()
->assertJsonPath('accessToken', 'api-token-2');
- postJson(cp_url('actions/graphql/generate-token'))
+ postJson(action([TokensController::class, 'generate']))
->assertOk()
->assertJsonStructure(['accessToken']);
- deleteJson(action([TokensController::class, 'destroy'], [$token->id]))->assertOk();
+ deleteJson(action([TokensController::class, 'destroy'], ['tokenId' => $token->id]))->assertOk();
expect(Gql::getTokenById($token->id))->toBeNull();
});
-it('re-renders the token edit screen for html failures and returns model errors for json', function () {
+it('redirects to the saved token edit page when saving and continuing', function () {
$schema = createSchemaForTokensControllerTest();
- post(cp_url('actions/graphql/save-token'), [
- 'accessToken' => 'missing-name-token',
+ $response = post(action([TokensController::class, 'store']), [
+ 'name' => 'Continued Token',
+ 'accessToken' => 'continued-token',
'enabled' => true,
'schema' => $schema->id,
- ])->assertOk()
- ->assertSee(t('Create a new GraphQL token'));
+ ]);
+
+ $token = Gql::getTokenByName('Continued Token');
+
+ expect($token)->not->toBeNull();
+
+ $response->assertRedirect(Url::cpUrl("graphql/tokens/$token->id"));
+});
- postJson(cp_url('actions/graphql/save-token'), [
+it('returns model errors for json validation failures', function () {
+ $schema = createSchemaForTokensControllerTest();
+
+ postJson(action([TokensController::class, 'store']), [
'accessToken' => 'missing-name-token-json',
'enabled' => true,
'schema' => $schema->id,
@@ -237,22 +238,9 @@
]);
});
-it('requires json requests for fetch and generate and validates delete payloads', function () {
- $token = createTokenForTokensControllerTest();
-
- post(cp_url('actions/graphql/fetch-token'), [
- 'tokenUid' => $token->uid,
- ])->assertBadRequest();
-
- post(cp_url('actions/graphql/generate-token'))->assertBadRequest();
-
- expect(fn () => action([TokensController::class, 'destroy'], []))->toThrow(UrlGenerationException::class);
-});
-
-it('returns bad request for invalid token uids', function () {
- postJson(cp_url('actions/graphql/fetch-token'), [
- 'tokenUid' => 'missing-token-uid',
- ])->assertBadRequest();
+it('requires a tokenId before deletion', function () {
+ expect(fn () => deleteJson(action([TokensController::class, 'destroy']), []))
+ ->toThrow(UrlGenerationException::class);
});
function tokenControllerScope(): array
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');
+});
diff --git a/workbench/.env.example b/workbench/.env.example
index ae6107fa147..45a02fd3a90 100644
--- a/workbench/.env.example
+++ b/workbench/.env.example
@@ -4,13 +4,10 @@ APP_ENV=local
CRAFT_SECURITY_KEY=UPzGqJJMCTM4n07jkqaFNaVoof6j_Xfo
APP_KEY=base64:6BDzvwO4hrN7Twv6/gaQ6oZMB/CvpUArZXho189U75E=
-DB_CONNECTION=pgsql
-DB_HOST=127.0.0.1
-DB_PORT=5432
-DB_DATABASE=craft_tests_foo
-DB_USERNAME=postgres
-DB_PASSWORD=password
-DB_SCHEMA=public
+APP_URL=http://127.0.0.1:8000
+
+DB_CONNECTION=sqlite
+DB_DATABASE=workbench/database/database.sqlite
FROM_EMAIL_NAME="Craft CMS"
FROM_EMAIL_ADDRESS=info@craftcms.com
diff --git a/workbench/app/Providers/TypeScriptTransformerServiceProvider.php b/workbench/app/Providers/TypeScriptTransformerServiceProvider.php
index ac9e5f208bd..353b732dc22 100644
--- a/workbench/app/Providers/TypeScriptTransformerServiceProvider.php
+++ b/workbench/app/Providers/TypeScriptTransformerServiceProvider.php
@@ -5,9 +5,13 @@
namespace Workbench\App\Providers;
use CraftCms\Cms\Cp\Data\NavItem;
+use CraftCms\Cms\Gql\Data\GqlSchema;
+use CraftCms\Cms\Gql\Data\GqlToken;
use CraftCms\Cms\Image\Data\ImageTransform;
use CraftCms\Cms\Route\Data\Route;
use CraftCms\Cms\Update\Data\Updates;
+use CraftCms\Cms\User\Data\Permission;
+use CraftCms\Cms\User\Data\PermissionGroup;
use CraftCms\Cms\User\Data\UserSettings;
use DateTimeInterface;
use Spatie\LaravelTypeScriptTransformer\TypeScriptTransformerApplicationServiceProvider;
@@ -27,10 +31,14 @@ protected function configure(TypeScriptTransformerConfigFactory $config): void
->replaceType(DateTimeInterface::class, 'string')
->provider(new ClassListTransformedProvider(
[
+ GqlSchema::class,
+ GqlToken::class,
ImageTransform::class,
NavItem::class,
- Updates::class,
+ Permission::class,
+ PermissionGroup::class,
Route::class,
+ Updates::class,
UserSettings::class,
],
[
diff --git a/workbench/app/TypeScript/ClassListClassTransformer.php b/workbench/app/TypeScript/ClassListClassTransformer.php
index f30cff5dc96..495a050deb9 100644
--- a/workbench/app/TypeScript/ClassListClassTransformer.php
+++ b/workbench/app/TypeScript/ClassListClassTransformer.php
@@ -4,7 +4,11 @@
namespace Workbench\App\TypeScript;
+use Illuminate\Database\Eloquent\Collection as EloquentCollection;
+use Illuminate\Support\Collection;
+use Spatie\TypeScriptTransformer\ClassPropertyProcessors\FixArrayLikeStructuresClassPropertyProcessor;
use Spatie\TypeScriptTransformer\PhpNodes\PhpClassNode;
+use Spatie\TypeScriptTransformer\Transformers\ClassPropertyProcessors\ClassPropertyProcessor;
use Spatie\TypeScriptTransformer\Transformers\ClassTransformer;
class ClassListClassTransformer extends ClassTransformer
@@ -13,4 +17,22 @@ protected function shouldTransform(PhpClassNode $phpClassNode): bool
{
return true;
}
+
+ /** @return array */
+ #[\Override]
+ protected function classPropertyProcessors(): array
+ {
+ $processors = parent::classPropertyProcessors();
+
+ foreach ($processors as $processor) {
+ if ($processor instanceof FixArrayLikeStructuresClassPropertyProcessor) {
+ $processor->replaceArrayLikeClass(
+ Collection::class,
+ EloquentCollection::class,
+ );
+ }
+ }
+
+ return $processors;
+ }
}