From 0bbe2433e57145f610f7da821cfe7c5f2e8401a9 Mon Sep 17 00:00:00 2001 From: Twyco Date: Wed, 16 Sep 2026 13:01:50 +0200 Subject: [PATCH] feat(KAN-48): rebuild project access and permission assignment --- app/Actions/Permissions/PermissionService.php | 36 ++ app/Http/Controllers/ProjectController.php | 19 +- .../projects/ProjectAccessEditorDialog.vue | 199 ++++++ .../projects/ProjectPermissionBadges.vue | 26 + resources/js/locales/en.ts | 54 ++ resources/js/pages/projects/Access.vue | 596 ++++++++++-------- resources/js/types/page-props.ts | 2 + resources/js/types/project-access.ts | 19 + tests/Feature/Projects/ProjectAccessTest.php | 153 +++++ .../Feature/Projects/ProjectAccessUiTest.php | 19 + tests/Fixtures/project-access-browser.mjs | 189 ++++++ tests/Fixtures/project-access-preview.mjs | 15 + tests/Fixtures/project-access.mjs | 229 +++++++ 13 files changed, 1267 insertions(+), 289 deletions(-) create mode 100644 resources/js/components/projects/ProjectAccessEditorDialog.vue create mode 100644 resources/js/components/projects/ProjectPermissionBadges.vue create mode 100644 resources/js/types/project-access.ts create mode 100644 tests/Feature/Projects/ProjectAccessTest.php create mode 100644 tests/Feature/Projects/ProjectAccessUiTest.php create mode 100644 tests/Fixtures/project-access-browser.mjs create mode 100644 tests/Fixtures/project-access-preview.mjs create mode 100644 tests/Fixtures/project-access.mjs diff --git a/app/Actions/Permissions/PermissionService.php b/app/Actions/Permissions/PermissionService.php index 5ab9acf..208438d 100644 --- a/app/Actions/Permissions/PermissionService.php +++ b/app/Actions/Permissions/PermissionService.php @@ -8,6 +8,7 @@ use App\Models\Project; use App\Models\Team; use App\Models\User; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; @@ -113,6 +114,41 @@ class PermissionService ); } + /** + * @param EloquentCollection $users + * @return array{directPermissions: array, teamPermissions: array, effectivePermissions: array, globalAdminUserIds: array} + */ + public function projectAccessMap(EloquentCollection $users, Project $project): array + { + $users->loadMissing('teams'); + $directPermissions = $this->assignmentMap(User::class, $project); + $teamPermissions = $this->assignmentMap(Team::class, $project); + $globalPermissions = $this->assignmentMap(User::class); + $effectivePermissions = []; + $globalAdminUserIds = []; + + foreach ($users as $user) { + $permissions = $directPermissions[$user->id] ?? []; + + foreach ($user->teams as $team) { + $permissions = array_merge($permissions, $teamPermissions[$team->id] ?? []); + } + + if (in_array(Permission::GlobalAdmin->value, $globalPermissions[$user->id] ?? [], true)) { + $permissions[] = Permission::GlobalAdmin->value; + $globalAdminUserIds[] = $user->id; + } + + $expanded = $this->expandPermissions(array_map(Permission::from(...), $permissions)); + $effectivePermissions[$user->id] = Permission::values(array_values(array_filter( + Permission::projectAssignable(), + fn (Permission $permission): bool => in_array($permission, $expanded, true), + ))); + } + + return compact('directPermissions', 'teamPermissions', 'effectivePermissions', 'globalAdminUserIds'); + } + /** * @return array> */ diff --git a/app/Http/Controllers/ProjectController.php b/app/Http/Controllers/ProjectController.php index d895e0f..ad847c7 100644 --- a/app/Http/Controllers/ProjectController.php +++ b/app/Http/Controllers/ProjectController.php @@ -182,25 +182,22 @@ class ProjectController extends Controller 403, ); + $users = User::query()->orderBy('name')->get(); + return Inertia::render('projects/Access', [ - 'directPermissions' => $this->permissionService->assignmentMap(User::class, $project), + ...$this->permissionService->projectAccessMap($users, $project), 'permissionOptions' => PermissionOptionResource::collection( $this->permissionService->permissionOptions(User::class, Project::class), )->resolve(), 'project' => ProjectResource::make($project)->resolve(), 'status' => session('status'), - 'teamPermissions' => $this->permissionService->assignmentMap(Team::class, $project), 'teams' => TeamResource::collection( Team::query() ->with('users') ->orderBy('name') ->get(), )->resolve(), - 'users' => UserResource::collection( - User::query() - ->orderBy('name') - ->get(), - )->resolve(), + 'users' => UserResource::collection($users)->resolve(), ]); } @@ -220,7 +217,9 @@ class ProjectController extends Controller $this->permissionService->sync($user, $project, $permissions); - return to_route('projects.access', $project) + return ($this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project) + ? to_route('projects.access', $project) + : to_route('projects.index')) ->with('status', "{$user->name} permissions were updated."); } @@ -239,7 +238,9 @@ class ProjectController extends Controller $this->permissionService->sync($team, $project, $permissions); - return to_route('projects.access', $project) + return ($this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project) + ? to_route('projects.access', $project) + : to_route('projects.index')) ->with('status', "{$team->name} permissions were updated."); } diff --git a/resources/js/components/projects/ProjectAccessEditorDialog.vue b/resources/js/components/projects/ProjectAccessEditorDialog.vue new file mode 100644 index 0000000..908fdd1 --- /dev/null +++ b/resources/js/components/projects/ProjectAccessEditorDialog.vue @@ -0,0 +1,199 @@ + + + diff --git a/resources/js/components/projects/ProjectPermissionBadges.vue b/resources/js/components/projects/ProjectPermissionBadges.vue new file mode 100644 index 0000000..736e3af --- /dev/null +++ b/resources/js/components/projects/ProjectPermissionBadges.vue @@ -0,0 +1,26 @@ + + + diff --git a/resources/js/locales/en.ts b/resources/js/locales/en.ts index 20cd86b..9895050 100644 --- a/resources/js/locales/en.ts +++ b/resources/js/locales/en.ts @@ -1,4 +1,58 @@ export const en = { + 'projectAccess.pageTitle': '{project} · Project access', + 'projectAccess.title': 'Project access', + 'projectAccess.description': + 'Manage who can access {project} and see where their permissions come from.', + 'projectAccess.back': 'Back to project', + 'projectAccess.saved': 'Project permissions saved.', + 'projectAccess.howItWorks': 'How project access works', + 'projectAccess.additiveHelp': + 'Direct user permissions and permissions from every team are combined. Write includes read; administrator includes write and read.', + 'projectAccess.limitsHelp': + 'Project administrators can change project assignments here. Global permissions and team membership are managed separately.', + 'projectAccess.search': 'Search people or teams', + 'projectAccess.people': 'People and effective access', + 'projectAccess.effective': 'Effective permissions', + 'projectAccess.noAccess': 'No project access', + 'projectAccess.globalSource': + 'Global administrator · all project permissions', + 'projectAccess.direct': 'Direct:', + 'projectAccess.none': 'None', + 'projectAccess.viaTeam': 'Via {name}:', + 'projectAccess.editFor': 'Edit access for {name}', + 'projectAccess.editDirect': 'Edit direct access', + 'projectAccess.editTeam': 'Edit team access', + 'projectAccess.teamPermissions': 'Team permissions', + 'projectAccess.directPermissions': 'Direct permissions', + 'projectAccess.teamHelp': + 'These project permissions apply to every current and future member of the team.', + 'projectAccess.directHelp': + 'Change only this person’s direct project permissions. Team and global permissions continue to apply.', + 'projectAccess.noPeopleResults': 'No matching people', + 'projectAccess.noPeople': 'No people available', + 'projectAccess.noTeamResults': 'No matching teams', + 'projectAccess.noTeams': 'No teams yet', + 'projectAccess.noTeamsHelp': + 'Create a team and add members before assigning shared project access.', + 'projectAccess.noTeamAccess': 'No project permissions assigned', + 'projectAccess.read': 'Read', + 'projectAccess.write': 'Write', + 'projectAccess.admin': 'Administrator', + 'projectAccess.readHelp': 'View the project and its tickets.', + 'projectAccess.writeHelp': 'Create and edit tickets. Includes read access.', + 'projectAccess.adminHelp': + 'Manage project access and project administration. Includes write and read access.', + 'projectAccess.clearHelp': + 'Clear all selections to remove this assignment. Access granted by other teams, direct assignments or global administration is preserved.', + 'projectAccess.globalHelp': + 'This person is a global administrator. Changing these project assignments cannot remove their global access.', + 'projectAccess.selfHelp': + 'If you remove your own administrator access, you will return to Projects.', + 'projectAccess.save': 'Save permissions', + 'projectAccess.forbidden': 'Project access can no longer be changed', + 'projectAccess.forbiddenHelp': + 'Your administrator permission may have changed. Close this dialog and reopen the project to check your current access.', + 'notifications.title': 'Notifications', 'notifications.pageTitle': '{project} · Notifications', 'notifications.description': diff --git a/resources/js/pages/projects/Access.vue b/resources/js/pages/projects/Access.vue index fb7b412..4e4fa76 100644 --- a/resources/js/pages/projects/Access.vue +++ b/resources/js/pages/projects/Access.vue @@ -1,308 +1,344 @@ diff --git a/resources/js/types/page-props.ts b/resources/js/types/page-props.ts index e6fb2f9..66dc13a 100644 --- a/resources/js/types/page-props.ts +++ b/resources/js/types/page-props.ts @@ -117,6 +117,8 @@ export type PermissionOption = { export type ProjectAccessPageProps = { directPermissions: Record; + effectivePermissions: Record; + globalAdminUserIds: number[]; permissionOptions: PermissionOption[]; project: ProjectModel; status?: string | null; diff --git a/resources/js/types/project-access.ts b/resources/js/types/project-access.ts new file mode 100644 index 0000000..8f92380 --- /dev/null +++ b/resources/js/types/project-access.ts @@ -0,0 +1,19 @@ +export const projectPermissionLabels = { + 'project:read': 'projectAccess.read', + 'project:write': 'projectAccess.write', + 'project:admin': 'projectAccess.admin', +} as const; + +export const projectPermissionDescriptions = { + 'project:read': 'projectAccess.readHelp', + 'project:write': 'projectAccess.writeHelp', + 'project:admin': 'projectAccess.adminHelp', +} as const; + +export type ProjectAccessTarget = { + kind: 'user' | 'team'; + id: number; + name: string; + permissions: string[]; + globalAdmin?: boolean; +}; diff --git a/tests/Feature/Projects/ProjectAccessTest.php b/tests/Feature/Projects/ProjectAccessTest.php new file mode 100644 index 0000000..067d8df --- /dev/null +++ b/tests/Feature/Projects/ProjectAccessTest.php @@ -0,0 +1,153 @@ +admin()->create(); + $writer = User::factory()->create(); + $member = User::factory()->create(); + $outsider = User::factory()->create(); + $project = Project::factory()->create(); + $otherProject = Project::factory()->create(); + $team = Team::factory()->create(); + $otherTeam = Team::factory()->create(); + $team->users()->attach([$writer->id, $member->id]); + $otherTeam->users()->attach($writer); + $service = app(PermissionService::class); + $service->sync($writer, $project, [Permission::ProjectRead]); + $service->sync($team, $project, [Permission::ProjectWrite]); + $service->sync($otherTeam, $project, [Permission::ProjectRead]); + $service->sync($outsider, $otherProject, [Permission::ProjectAdmin]); + $service->sync($outsider, null, [Permission::ProjectCreate]); + $service->sync($outsider, $team, [Permission::TeamAdmin]); + + $map = $service->projectAccessMap(User::all(), $project); + foreach (User::all() as $user) { + $expected = array_values(array_filter(Permission::projectAssignable(), fn (Permission $permission): bool => $service->allows($user, $permission, $project))); + $this->assertSame(Permission::values($expected), $map['effectivePermissions'][$user->id]); + } + $this->assertSame([], $map['effectivePermissions'][$outsider->id]); + $this->assertSame(['project:read', 'project:write'], $map['effectivePermissions'][$writer->id]); + $this->assertSame([$admin->id], $map['globalAdminUserIds']); + $this->actingAs($admin)->get(route('projects.access', $project))->assertOk() + ->assertInertia(fn (Assert $page) => $page->component('projects/Access') + ->where("effectivePermissions.{$writer->id}", ['project:read', 'project:write']) + ->where("directPermissions.{$writer->id}", ['project:read']) + ->where("teamPermissions.{$team->id}", ['project:write']) + ->where('globalAdminUserIds', [$admin->id])); + } + + public function test_bulk_access_summary_uses_a_bounded_number_of_queries(): void + { + $project = Project::factory()->create(); + User::factory()->create(); + $service = app(PermissionService::class); + $countQueries = function () use ($service, $project): int { + $users = User::all(); + DB::enableQueryLog(); + DB::flushQueryLog(); + $service->projectAccessMap($users, $project); + $count = count(DB::getQueryLog()); + DB::disableQueryLog(); + + return $count; + }; + $initialCount = $countQueries(); + $users = User::factory()->count(10)->create(); + $team = Team::factory()->create(); + $team->users()->attach($users); + $service->sync($team, $project, [Permission::ProjectWrite]); + $this->assertSame($initialCount, $countQueries()); + $this->assertLessThanOrEqual(4, $initialCount); + } + + public function test_readers_writers_and_unrelated_admins_cannot_manage_project_access(): void + { + $project = Project::factory()->create(); + $other = Project::factory()->create(); + $member = User::factory()->create(); + $team = Team::factory()->create(); + $service = app(PermissionService::class); + $service->sync($member, $project, [Permission::ProjectRead]); + $service->sync($team, $project, [Permission::ProjectWrite]); + foreach ([[], [Permission::ProjectRead], [Permission::ProjectWrite]] as $permissions) { + $actor = User::factory()->create(); + $service->sync($actor, $project, $permissions); + $service->sync($actor, $other, [Permission::ProjectAdmin]); + $this->actingAs($actor)->get(route('projects.access', $project))->assertForbidden(); + foreach ([['users', $member], ['teams', $team]] as [$kind, $assignee]) { + $this->patch(route("projects.permissions.{$kind}.update", [$project, $assignee]), ['permissions' => ['project:admin']])->assertForbidden(); + } + } + $this->assertSame(['project:read'], $service->assignmentMap(User::class, $project)[$member->id]); + $this->assertSame(['project:write'], $service->assignmentMap(Team::class, $project)[$team->id]); + } + + public function test_invalid_assignments_preserve_both_user_and_team_permissions(): void + { + $admin = User::factory()->admin()->create(); + $project = Project::factory()->create(); + $service = app(PermissionService::class); + foreach ([['users', User::factory()->create()], ['teams', Team::factory()->create()]] as [$kind, $assignee]) { + $service->sync($assignee, $project, [Permission::ProjectRead]); + foreach ([['global:admin'], ['team:admin'], ['unknown'], ['project:read', 'project:read']] as $permissions) { + $this->actingAs($admin)->patch(route("projects.permissions.{$kind}.update", [$project, $assignee]), ['permissions' => $permissions]) + ->assertSessionHasErrors('permissions.0'); + $this->assertSame(['project:read'], $service->assignmentMap($assignee::class, $project)[$assignee->id]); + } + } + } + + public function test_clearing_assignments_preserves_access_from_other_sources(): void + { + $admin = User::factory()->admin()->create(); + $member = User::factory()->create(); + $project = Project::factory()->create(); + $team = Team::factory()->create(); + $team->users()->attach($member); + $service = app(PermissionService::class); + $service->sync($member, $project, [Permission::ProjectRead]); + $service->sync($team, $project, [Permission::ProjectWrite]); + $this->actingAs($admin)->patch(route('projects.permissions.users.update', [$project, $member]), ['permissions' => []]) + ->assertRedirect(route('projects.access', $project))->assertSessionHasNoErrors(); + $this->assertTrue($service->allows($member, Permission::ProjectWrite, $project)); + $service->sync($member, $project, [Permission::ProjectRead]); + $this->patch(route('projects.permissions.teams.update', [$project, $team]), ['permissions' => []]) + ->assertRedirect(route('projects.access', $project))->assertSessionHasNoErrors(); + $this->assertTrue($service->allows($member, Permission::ProjectRead, $project)); + $this->assertFalse($service->allows($member, Permission::ProjectWrite, $project)); + } + + public function test_removing_own_admin_access_redirects_to_projects_for_direct_and_team_grants(): void + { + $service = app(PermissionService::class); + foreach (['users', 'teams'] as $kind) { + $actor = User::factory()->create(); + $project = Project::factory()->create(); + $assignee = $actor; + if ($kind === 'teams') { + $assignee = Team::factory()->create(); + $assignee->users()->attach($actor); + } + $service->sync($assignee, $project, [Permission::ProjectAdmin]); + $this->actingAs($actor)->patch(route("projects.permissions.{$kind}.update", [$project, $assignee]), ['permissions' => []]) + ->assertRedirect(route('projects.index'))->assertSessionHasNoErrors(); + $this->assertFalse($service->allows($actor, Permission::ProjectAdmin, $project)); + } + } +} diff --git a/tests/Feature/Projects/ProjectAccessUiTest.php b/tests/Feature/Projects/ProjectAccessUiTest.php new file mode 100644 index 0000000..909f269 --- /dev/null +++ b/tests/Feature/Projects/ProjectAccessUiTest.php @@ -0,0 +1,19 @@ +setTimeout(60); + $process->run(); + + $this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput()); + $this->assertStringContainsString('Project access presentation checks passed.', $process->getOutput()); + } +} diff --git a/tests/Fixtures/project-access-browser.mjs b/tests/Fixtures/project-access-browser.mjs new file mode 100644 index 0000000..50cf2e9 --- /dev/null +++ b/tests/Fixtures/project-access-browser.mjs @@ -0,0 +1,189 @@ +import assert from 'node:assert/strict'; +import process from 'node:process'; +const { chromium } = await import( + process.env.PLAYWRIGHT_MODULE ?? 'playwright' +); +const browser = await chromium.launch({ channel: 'chrome', headless: true }); +const context = await browser.newContext({ reducedMotion: 'reduce' }); +const page = await context.newPage(); +page.setDefaultTimeout(10000); +const errors = []; +page.on('pageerror', (error) => errors.push(error.message)); +const base = 'http://127.0.0.1:4198'; +const result = (value) => + context.addCookies([{ name: 'access_result', value, url: base }]); +const visit = async (query = '') => { + await result('success'); + await page.goto(base + '/projects/1/access?reset' + query); + await page + .getByRole('heading', { name: 'Project access', exact: true }) + .waitFor(); +}; +const button = (name) => page.getByRole('button', { name, exact: true }); +const dialog = () => page.getByRole('dialog'); +const checkbox = (name) => + dialog().getByRole('checkbox', { name, exact: true }); +const state = (value) => + dialog() + .locator('[data-form-state="' + value + '"]') + .waitFor({ state: 'visible' }); +const noOverflow = async () => + assert.equal( + await page.evaluate( + () => document.documentElement.scrollWidth <= innerWidth, + ), + true, + ); +const save = async () => { + const request = page.waitForRequest((r) => r.method() === 'PATCH'); + await dialog() + .getByRole('button', { name: 'Save permissions', exact: true }) + .click(); + return await request; +}; +try { + for (const width of [1440, 390, 320]) { + await page.setViewportSize({ width, height: 1000 }); + for (const theme of ['light', 'dark']) { + await page.emulateMedia({ colorScheme: theme }); + await visit(); + await noOverflow(); + const trigger = button('Edit access for Sam Taylor'); + await trigger.focus(); + await page.keyboard.press('Enter'); + await dialog().waitFor(); + await noOverflow(); + assert.equal(await checkbox('Read').isChecked(), true); + assert.equal( + await checkbox('Write').isChecked(), + false, + 'Inherited write must not be editable as a direct grant', + ); + await checkbox('Write').focus(); + await page.keyboard.press('Space'); + assert.equal(await checkbox('Write').isChecked(), true); + await page.keyboard.press('Space'); + await page.keyboard.press('Tab'); + assert.equal( + await dialog().evaluate((el) => + el.contains(document.activeElement), + ), + true, + ); + await page.screenshot({ + path: + '/private/tmp/kan48-dialog-' + width + '-' + theme + '.png', + }); + await page.keyboard.press('Escape'); + await dialog().waitFor({ state: 'hidden' }); + assert.equal( + await trigger.evaluate((el) => el === document.activeElement), + true, + ); + await page.screenshot({ + path: '/private/tmp/kan48-' + width + '-' + theme + '.png', + fullPage: true, + }); + } + } + await page.locator('#access-search').fill('SAM@EXAMPLE'); + assert.equal(await page.locator('[data-user-id]').count(), 1); + await page.getByText('No matching teams', { exact: true }).waitFor(); + await page.locator('#access-search').fill('unmatched'); + await page.getByText('No matching people', { exact: true }).waitFor(); + await visit('&empty&noUsers'); + await page.getByText('No teams yet', { exact: true }).waitFor(); + await page.getByText('No people available', { exact: true }).waitFor(); + await visit(); + await button('Edit access for Alex Morgan').click(); + await dialog() + .getByText('This person is a global administrator.', { exact: false }) + .waitFor(); + await page.keyboard.press('Escape'); + await button('Edit access for Sam Taylor').click(); + await checkbox('Read').uncheck(); + const request = await save(); + assert.equal( + new URL(request.url()).pathname, + '/projects/1/permissions/users/2', + ); + assert.deepEqual(request.postDataJSON(), { permissions: [] }); + await dialog().waitFor({ state: 'hidden' }); + await page + .locator('[data-user-id="2"]') + .getByText('Write', { exact: true }) + .first() + .waitFor(); + await button('Edit access for Sam Taylor').click(); + assert.equal(await checkbox('Read').isChecked(), false); + await page.keyboard.press('Escape'); + await button('Edit access for Platform team').click(); + await checkbox('Write').uncheck(); + await save(); + await dialog().waitFor({ state: 'hidden' }); + await page + .locator('[data-user-id="2"]') + .getByText('No project access', { exact: true }) + .waitFor(); + + await button('Edit access for Sam Taylor').click(); + await checkbox('Administrator').check(); + await result('validation'); + await save(); + await state('validation'); + await dialog() + .getByText('This permission is unavailable.', { exact: true }) + .waitFor(); + assert.equal(await checkbox('Administrator').isChecked(), true); + assert.equal( + await dialog().locator('fieldset').getAttribute('aria-invalid'), + 'true', + ); + await result('server'); + await save(); + await state('server'); + await result('success'); + await page.route('**/permissions/users/2', (route) => + route.abort('failed'), + ); + await save(); + await state('network'); + await page.unroute('**/permissions/users/2'); + await dialog() + .getByRole('button', { name: 'Try again', exact: true }) + .click(); + await dialog().waitFor({ state: 'hidden' }); + await page + .locator('[data-user-id="2"]') + .getByText('Administrator', { exact: true }) + .first() + .waitFor(); + await button('Edit access for Sam Taylor').click(); + assert.equal(await checkbox('Administrator').isChecked(), true); + await result('forbidden'); + await save(); + await state('server'); + await dialog() + .getByText('Project access can no longer be changed', { exact: true }) + .waitFor(); + assert.equal( + await dialog().getByRole('button', { name: 'Try again' }).count(), + 0, + ); + await page.keyboard.press('Escape'); + await visit(); + await button('Edit access for Sam Taylor').click(); + await checkbox('Write').check(); + page.once('dialog', (d) => d.dismiss()); + await page.keyboard.press('Escape'); + assert.equal(await dialog().isVisible(), true); + page.once('dialog', (d) => d.accept()); + await page.keyboard.press('Escape'); + await dialog().waitFor({ state: 'hidden' }); + assert.deepEqual(errors, []); + console.log( + 'Project access browser checks passed: direct/team updates and clearing, effective access refresh, errors/retry, unsaved changes, keyboard, 320/390/1440px, both themes.', + ); +} finally { + await browser.close(); +} diff --git a/tests/Fixtures/project-access-preview.mjs b/tests/Fixtures/project-access-preview.mjs new file mode 100644 index 0000000..a02203e --- /dev/null +++ b/tests/Fixtures/project-access-preview.mjs @@ -0,0 +1,15 @@ +import { createApp, h } from 'vue'; +import { createInertiaApp } from '@inertiajs/vue3'; +import '../../resources/css/app.css'; +import Access from '../../resources/js/pages/projects/Access.vue'; +import { createAppearance } from '../../resources/js/composables/useAppearance'; +import { createLocalization } from '../../resources/js/lib/localization'; +createInertiaApp({ + resolve: () => Access, + setup: ({ el, App, props, plugin }) => + createApp({ render: () => h(App, props) }) + .use(plugin) + .use(createLocalization()) + .use(createAppearance()) + .mount(el), +}); diff --git a/tests/Fixtures/project-access.mjs b/tests/Fixtures/project-access.mjs new file mode 100644 index 0000000..a0cc1ea --- /dev/null +++ b/tests/Fixtures/project-access.mjs @@ -0,0 +1,229 @@ +import assert from 'node:assert/strict'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; +import vue from '@vitejs/plugin-vue'; +import tailwind from '@tailwindcss/vite'; +import { createServer } from 'vite'; +import { createSSRApp, h } from 'vue'; +import { renderToString } from 'vue/server-renderer'; +import { createInertiaApp } from '@inertiajs/vue3'; +const serve = process.argv.includes('--serve'); +const server = await createServer({ + configFile: false, + cacheDir: 'node_modules/.vite/project-access-fixture', + plugins: [vue(), ...(serve ? [tailwind()] : [])], + resolve: { + alias: { + '@': fileURLToPath(new URL('../../resources/js', import.meta.url)), + }, + }, + server: serve + ? { host: '127.0.0.1', port: 4198, strictPort: true, hmr: false } + : { middlewareMode: true, hmr: false, watch: null }, + appType: 'custom', +}); +const users = [ + { id: 1, name: 'Alex Morgan', email: 'alex@example.test', avatar: null }, + { id: 2, name: 'Sam Taylor', email: 'sam@example.test', avatar: null }, + { + id: 3, + name: 'Jordan Longname With Several Names', + email: 'jordan.long.email.address@example.test', + avatar: null, + }, +]; +function initial() { + return { + project: { id: 1, name: 'Core platform', key: 'CORE' }, + users, + status: null, + globalAdminUserIds: [1], + directPermissions: { 2: ['project:read'] }, + teamPermissions: { 1: ['project:write'] }, + effectivePermissions: { + 1: ['project:read', 'project:write', 'project:admin'], + 2: ['project:read', 'project:write'], + 3: [], + }, + teams: [ + { id: 1, name: 'Platform team', user_ids: [2], users: [users[1]] }, + { id: 2, name: 'Delivery team', user_ids: [], users: [] }, + ], + permissionOptions: ['read', 'write', 'admin'].map((name) => ({ + value: 'project:' + name, + label: name, + })), + }; +} +const makePage = (extra = {}) => ({ + component: 'Access', + url: '/projects/1/access', + version: null, + clearHistory: false, + encryptHistory: false, + props: { + name: 'KanbanGit', + errors: {}, + sidebarOpen: true, + appearance: 'system', + auth: { + user: users[0], + permissions: { + globalAdmin: false, + projectCreate: false, + canManageTeams: true, + }, + }, + navigation: { project: null }, + ...initial(), + ...extra, + }, +}); +if (serve) { + let data = initial(); + server.middlewares.use(async (request, response, next) => { + if (!request.url.startsWith('/projects/1/')) { + return next(); + } + const url = new URL(request.url, 'http://localhost'); + if (url.searchParams.has('reset')) { + data = initial(); + } + if (url.searchParams.has('noUsers')) { + data.users = []; + } + if (url.searchParams.has('empty')) { + data.teams = []; + } + let errors = {}; + if (request.method !== 'GET') { + const result = + request.headers.cookie?.match(/access_result=(\w+)/)?.[1] ?? + 'success'; + if (['server', 'forbidden'].includes(result)) { + response.statusCode = result === 'forbidden' ? 403 : 503; + response.end('Simulated failure'); + return; + } + let body = ''; + for await (const chunk of request) { + body += chunk; + } + const submitted = JSON.parse(body || '{}'); + if (result === 'validation') { + errors = { 'permissions.0': 'This permission is unavailable.' }; + } else { + const parts = url.pathname.split('/'); + const kind = parts[4]; + const id = parts[5]; + data[ + kind === 'users' ? 'directPermissions' : 'teamPermissions' + ][id] = submitted.permissions; + for (const user of users) { + const grants = [ + ...(data.directPermissions[user.id] ?? []), + ...data.teams + .filter((team) => team.user_ids.includes(user.id)) + .flatMap( + (team) => data.teamPermissions[team.id] ?? [], + ), + ]; + data.effectivePermissions[user.id] = + data.globalAdminUserIds.includes(user.id) || + grants.includes('project:admin') + ? ['project:read', 'project:write', 'project:admin'] + : grants.includes('project:write') + ? ['project:read', 'project:write'] + : grants.includes('project:read') + ? ['project:read'] + : []; + } + } + if (Object.keys(errors).length === 0) { + data.status = 'Saved'; + } + } + const page = makePage({ ...data, errors }); + response.setHeader('Vary', 'X-Inertia'); + if (request.headers['x-inertia']) { + response.setHeader('X-Inertia', 'true'); + response.setHeader('Content-Type', 'application/json'); + response.end(JSON.stringify(page)); + return; + } + const escaped = JSON.stringify(page) + .replaceAll('&', '&') + .replaceAll('"', '"') + .replaceAll('<', '<'); + response.setHeader('Content-Type', 'text/html'); + response.end( + await server.transformIndexHtml( + request.url, + '
', + ), + ); + }); + await server.listen(); + console.log('Project access fixture ready.'); +} else { + try { + const { createLocalization } = await server.ssrLoadModule( + '/resources/js/lib/localization.ts', + ); + const { createAppearance } = await server.ssrLoadModule( + '/resources/js/composables/useAppearance.ts', + ); + const { en } = await server.ssrLoadModule( + '/resources/js/locales/en.ts', + ); + const component = ( + await server.ssrLoadModule( + '/resources/js/pages/projects/Access.vue', + ) + ).default; + const render = async (extra = {}, locale = createLocalization()) => + ( + await createInertiaApp({ + page: makePage(extra), + resolve: () => component, + render: renderToString, + setup: ({ App, props, plugin }) => + createSSRApp({ render: () => h(App, props) }) + .use(plugin) + .use(locale) + .use(createAppearance()), + }) + ).body; + const html = await render(); + assert.match(html, /People and effective access/); + assert.match(html, /Global administrator · all project permissions/); + assert.match(html, /Via Platform team:/); + assert.match(html, /No project access/); + assert.match(html, /Edit access for Sam Taylor/); + assert.match(html, /No project permissions assigned/); + assert.doesNotMatch(html, /