diff --git a/resources/js/components/teams/TeamEditorDialog.vue b/resources/js/components/teams/TeamEditorDialog.vue new file mode 100644 index 0000000..68814af --- /dev/null +++ b/resources/js/components/teams/TeamEditorDialog.vue @@ -0,0 +1,351 @@ + + + diff --git a/resources/js/locales/en.ts b/resources/js/locales/en.ts index 33ce85c..20cd86b 100644 --- a/resources/js/locales/en.ts +++ b/resources/js/locales/en.ts @@ -172,6 +172,52 @@ export const en = { 'templates.deleteImpact': '{count} linked projects will stop inheriting from this template. Their current configuration and tickets are kept.', + 'teams.eyebrow': 'People & access', + 'teams.description': + 'Bring people together in reusable teams. Manage membership and the permissions to administer each team.', + 'teams.create': 'Create team', + 'teams.createDescription': + 'Group people into a team to share project access.', + 'teams.name': 'Team name', + 'teams.namePlaceholder': 'Platform team', + 'teams.yourTeams': 'Teams you manage', + 'teams.search': 'Search teams', + 'teams.clearSearch': 'Clear search', + 'teams.empty': 'No teams to manage', + 'teams.emptyCreate': 'Create a team to start organizing your people.', + 'teams.emptyDescription': + 'No teams are available for your current permissions.', + 'teams.noResults': 'No matching teams', + 'teams.noResultsDescription': 'Try another team name or clear your search.', + 'teams.manageMembers': 'Manage members', + 'teams.manageMembersFor': 'Manage members of {name}', + 'teams.permissionsFor': 'Manage permissions for {name}', + 'teams.members': 'Members', + 'teams.noMembers': 'No members yet.', + 'teams.membersHelp': + 'Select the people who belong to this team. Changes apply when you save.', + 'teams.selected': '{count} selected', + 'teams.searchPeople': 'Find people', + 'teams.searchPeoplePlaceholder': 'Search by name or email', + 'teams.noPeople': 'No matching people.', + 'teams.saveMembers': 'Save members', + 'teams.permissions': 'Team permissions', + 'teams.permissionsHelp': + 'Direct team permissions are separate from membership. Choose any user to review or change their assignments.', + 'teams.person': 'User', + 'teams.choosePerson': 'Choose a user', + 'teams.directPermissions': 'Direct permissions', + 'teams.permissionInvite': 'Team invite', + 'teams.permissionManage': 'Team manage', + 'teams.permissionAdmin': 'Team admin', + 'teams.permissionsClearHelp': + 'Clear every checkbox to remove direct permissions. Other existing access remains in effect.', + 'teams.savePermissions': 'Save permissions', + 'teams.updated': 'Team changes saved.', + 'teams.forbidden': 'You no longer have permission', + 'teams.forbiddenDescription': + 'Your changes have not been saved. Close this dialog and reload the page to check your current access.', + 'teams.memberCount': { one: '{count} member', other: '{count} members' }, 'workflow.title': 'Board workflow', 'workflow.pageTitle': '{project} ยท Board workflow', 'workflow.description': diff --git a/resources/js/pages/teams/Index.vue b/resources/js/pages/teams/Index.vue index d14385f..6ed7469 100644 --- a/resources/js/pages/teams/Index.vue +++ b/resources/js/pages/teams/Index.vue @@ -1,356 +1,204 @@ diff --git a/resources/js/types/teams.ts b/resources/js/types/teams.ts new file mode 100644 index 0000000..8b7c887 --- /dev/null +++ b/resources/js/types/teams.ts @@ -0,0 +1,14 @@ +import type { PermissionOption } from '@/types'; +import type { TeamModel } from '@/types/models/team-model'; +import type { UserModel } from '@/types/models/user-model'; + +export type TeamEditorTarget = + | { kind: 'create' } + | { kind: 'members' | 'permissions'; team: TeamModel }; + +export type TeamEditorProps = { + target: TeamEditorTarget | null; + users: UserModel[]; + permissionOptions: PermissionOption[]; + teamPermissions: Record>; +}; diff --git a/tests/Feature/Teams/TeamControllerTest.php b/tests/Feature/Teams/TeamControllerTest.php index 8f1b568..a94879c 100644 --- a/tests/Feature/Teams/TeamControllerTest.php +++ b/tests/Feature/Teams/TeamControllerTest.php @@ -214,4 +214,89 @@ class TeamControllerTest extends TestCase 'scope_type' => $team->getMorphClass(), ]); } + + public function test_team_managers_only_see_their_manageable_teams(): void + { + $manager = User::factory()->create(); + $managed = Team::factory()->create(); + $other = Team::factory()->create(); + $other->users()->attach($manager); + $this->syncTeamPermissions($managed, $manager, ['team:manage']); + + $this->actingAs($manager)->get(route('teams.index')) + ->assertInertia(fn (Assert $page) => $page + ->has('teams', 1) + ->where('teams.0.id', $managed->id) + ->where('canCreateTeams', false) + ); + + $this->actingAs($manager)->patch(route('teams.members.update', $other), ['user_ids' => []]) + ->assertForbidden(); + $this->actingAs($manager)->patch(route('teams.permissions.users.update', [$other, $manager]), ['permissions' => ['team:admin']]) + ->assertForbidden(); + $this->assertDatabaseHas('team_user', ['team_id' => $other->id, 'user_id' => $manager->id]); + $this->assertDatabaseMissing('permission_assignments', ['scope_id' => $other->id, 'scope_type' => $other->getMorphClass()]); + } + + public function test_invalid_team_names_are_rejected(): void + { + $admin = User::factory()->admin()->create(); + $team = Team::factory()->create(); + + foreach (['', $team->name, str_repeat('a', 256)] as $name) { + $this->actingAs($admin)->from(route('teams.index'))->post(route('teams.store'), ['name' => $name]) + ->assertRedirect(route('teams.index'))->assertSessionHasErrors('name'); + } + $this->assertDatabaseCount('teams', 1); + } + + public function test_invalid_members_preserve_existing_membership(): void + { + $admin = User::factory()->admin()->create(); + $member = User::factory()->create(); + $team = Team::factory()->create(); + $team->users()->attach($member); + + foreach ([[$member->id, $member->id], [999999], ['invalid']] as $ids) { + $this->actingAs($admin)->patch(route('teams.members.update', $team), ['user_ids' => $ids]) + ->assertSessionHasErrors('user_ids.0'); + $this->assertSame([$member->id], $team->users()->pluck('users.id')->all()); + } + } + + public function test_membership_and_direct_permissions_can_be_cleared_independently(): void + { + $manager = User::factory()->create(); + $member = User::factory()->create(); + $team = Team::factory()->create(); + $team->users()->attach($member); + $this->syncTeamPermissions($team, $manager, ['team:manage']); + $this->syncTeamPermissions($team, $member, ['team:invite']); + + $this->actingAs($manager)->patch(route('teams.members.update', $team), ['user_ids' => []]) + ->assertRedirect(route('teams.index'))->assertSessionHasNoErrors(); + $this->assertDatabaseMissing('team_user', ['team_id' => $team->id]); + $this->assertDatabaseHas('permission_assignments', ['assignee_id' => $member->id, 'scope_id' => $team->id, 'permission' => 'team:invite']); + + $team->users()->attach($member); + $this->actingAs($manager)->patch(route('teams.permissions.users.update', [$team, $member]), ['permissions' => []]) + ->assertRedirect(route('teams.index'))->assertSessionHasNoErrors(); + $this->assertDatabaseHas('team_user', ['team_id' => $team->id, 'user_id' => $member->id]); + $this->assertDatabaseMissing('permission_assignments', ['assignee_id' => $member->id, 'scope_id' => $team->id, 'scope_type' => $team->getMorphClass()]); + } + + public function test_invalid_permissions_preserve_existing_assignments(): void + { + $admin = User::factory()->admin()->create(); + $member = User::factory()->create(); + $team = Team::factory()->create(); + $this->syncTeamPermissions($team, $member, ['team:invite']); + + foreach ([['global:admin'], ['team:manage', 'team:manage'], ['unknown']] as $permissions) { + $this->actingAs($admin)->patch(route('teams.permissions.users.update', [$team, $member]), ['permissions' => $permissions]) + ->assertSessionHasErrors('permissions.0'); + $this->assertDatabaseHas('permission_assignments', ['assignee_id' => $member->id, 'scope_id' => $team->id, 'permission' => 'team:invite']); + $this->assertDatabaseMissing('permission_assignments', ['assignee_id' => $member->id, 'permission' => 'global:admin']); + } + } } diff --git a/tests/Feature/Teams/TeamPresentationTest.php b/tests/Feature/Teams/TeamPresentationTest.php new file mode 100644 index 0000000..8aa8f38 --- /dev/null +++ b/tests/Feature/Teams/TeamPresentationTest.php @@ -0,0 +1,19 @@ +setTimeout(60); + $process->run(); + + $this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput()); + $this->assertStringContainsString('Teams presentation checks passed.', $process->getOutput()); + } +} diff --git a/tests/Fixtures/teams-browser.mjs b/tests/Fixtures/teams-browser.mjs new file mode 100644 index 0000000..f7c4acf --- /dev/null +++ b/tests/Fixtures/teams-browser.mjs @@ -0,0 +1,200 @@ +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:4197'; +const result = (value) => + context.addCookies([{ name: 'teams_result', value, url: base }]); +const visit = async (query = '') => { + await result('success'); + await page.goto(base + '/teams?reset' + query); + await page.getByRole('heading', { name: 'Teams', exact: true }).waitFor(); +}; +const button = (name) => page.getByRole('button', { name, exact: true }); +const dialog = () => page.getByRole('dialog'); +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 (label) => { + const request = page.waitForRequest((r) => + ['POST', 'PATCH'].includes(r.method()), + ); + await dialog().getByRole('button', { name: label, exact: true }).click(); + return (await request).postDataJSON(); +}; +try { + for (const width of [1440, 390, 320]) { + await page.setViewportSize({ width, height: 900 }); + for (const theme of ['light', 'dark']) { + await page.emulateMedia({ colorScheme: theme }); + await visit(); + await noOverflow(); + await page.screenshot({ + path: '/private/tmp/kan47-' + width + '-' + theme + '.png', + fullPage: true, + }); + const trigger = button('Manage members of Platform team'); + await trigger.focus(); + await page.keyboard.press('Enter'); + await dialog().waitFor(); + await page.keyboard.press('Tab'); + assert.equal( + await dialog().evaluate((el) => + el.contains(document.activeElement), + ), + true, + ); + await noOverflow(); + await page.screenshot({ + path: + '/private/tmp/kan47-members-' + + width + + '-' + + theme + + '.png', + fullPage: true, + }); + await page.keyboard.press('Escape'); + await dialog().waitFor({ state: 'hidden' }); + assert.equal( + await trigger.evaluate((el) => document.activeElement === el), + true, + ); + } + } + await visit('&manager'); + assert.equal(await button('Create team').count(), 0); + await visit('&empty'); + await page.getByText('No teams to manage', { exact: true }).waitFor(); + await visit(); + await page.getByLabel('Search teams', { exact: true }).fill('missing'); + await page.getByText('No matching teams', { exact: true }).waitFor(); + await button('Clear search').click(); + await button('Create team').click(); + await page.getByLabel('Team name', { exact: true }).fill('New team'); + await result('validation'); + assert.deepEqual(await save('Create team'), { name: 'New team' }); + await state('validation'); + assert.equal( + await page.getByLabel('Team name', { exact: true }).inputValue(), + 'New team', + ); + await page + .getByText('This team name is already taken.', { exact: true }) + .waitFor(); + await result('success'); + await save('Create team'); + await dialog().waitFor({ state: 'hidden' }); + await page + .getByRole('heading', { name: 'New team', exact: true }) + .waitFor(); + + await button('Manage members of Platform team').click(); + const person = (name) => + dialog().getByRole('checkbox', { name: new RegExp(name) }); + await page.getByLabel('Find people').fill('sam@'); + await person('Sam Taylor').check(); + await page.getByLabel('Find people').fill(''); + assert.equal(await person('Alex Morgan').isChecked(), true); + let dismissed = false; + page.once('dialog', async (d) => { + dismissed = true; + await d.dismiss(); + }); + await dialog().getByRole('button', { name: 'Cancel', exact: true }).click(); + assert.equal(dismissed, true); + assert.equal(await dialog().isVisible(), true); + await result('validation'); + assert.deepEqual(await save('Save members'), { user_ids: [1, 2] }); + await state('validation'); + await page + .getByText('This user is unavailable.', { exact: true }) + .waitFor(); + assert.equal(await person('Sam Taylor').isChecked(), true); + await page.route('**/teams/1/members', (route) => route.abort('failed')); + await save('Save members'); + await state('network'); + await page.unroute('**/teams/1/members'); + await result('server'); + await save('Save members'); + await state('server'); + await result('success'); + await dialog() + .getByRole('button', { name: 'Try again', exact: true }) + .click(); + await dialog().waitFor({ state: 'hidden' }); + await button('Manage members of Platform team').click(); + await person('Alex Morgan').uncheck(); + await person('Sam Taylor').uncheck(); + assert.deepEqual(await save('Save members'), { user_ids: [] }); + await dialog().waitFor({ state: 'hidden' }); + + await button('Manage permissions for Platform team').click(); + assert.equal( + await dialog() + .getByRole('button', { name: 'Save permissions', exact: true }) + .isDisabled(), + true, + ); + await page.getByLabel('User', { exact: true }).selectOption('2'); + assert.equal(await person('Team invite').isChecked(), true); + await person('Team manage').check(); + page.once('dialog', (d) => d.dismiss()); + await page.getByLabel('User', { exact: true }).selectOption('3'); + assert.equal( + await page.getByLabel('User', { exact: true }).inputValue(), + '2', + ); + await result('validation'); + assert.deepEqual(await save('Save permissions'), { + permissions: ['team:invite', 'team:manage'], + }); + await state('validation'); + await page + .getByText('This permission is unavailable.', { exact: true }) + .waitFor(); + await result('forbidden'); + await save('Save permissions'); + await state('server'); + await page + .getByText('You no longer have permission', { exact: true }) + .waitFor(); + assert.equal( + await dialog() + .getByRole('button', { name: 'Try again', exact: true }) + .count(), + 0, + ); + assert.equal(await person('Team manage').isChecked(), true); + await result('success'); + await save('Save permissions'); + await dialog().waitFor({ state: 'hidden' }); + await button('Manage permissions for Platform team').click(); + await page.getByLabel('User', { exact: true }).selectOption('2'); + assert.equal(await person('Team manage').isChecked(), true); + await person('Team invite').uncheck(); + await person('Team manage').uncheck(); + assert.deepEqual(await save('Save permissions'), { permissions: [] }); + await dialog().waitFor({ state: 'hidden' }); + assert.deepEqual(errors, []); + console.log( + 'Teams browser checks passed: creation, membership, permissions, empty selections, validation, network/server/403, draft protection, focus, keyboard, themes and mobile.', + ); +} finally { + await browser.close(); +} diff --git a/tests/Fixtures/teams-preview.mjs b/tests/Fixtures/teams-preview.mjs new file mode 100644 index 0000000..dcf8ea1 --- /dev/null +++ b/tests/Fixtures/teams-preview.mjs @@ -0,0 +1,15 @@ +import { createApp, h } from 'vue'; +import { createInertiaApp } from '@inertiajs/vue3'; +import '../../resources/css/app.css'; +import Teams from '../../resources/js/pages/teams/Index.vue'; +import { createAppearance } from '../../resources/js/composables/useAppearance'; +import { createLocalization } from '../../resources/js/lib/localization'; +createInertiaApp({ + resolve: () => Teams, + setup: ({ el, App, props, plugin }) => + createApp({ render: () => h(App, props) }) + .use(plugin) + .use(createLocalization()) + .use(createAppearance()) + .mount(el), +}); diff --git a/tests/Fixtures/teams.mjs b/tests/Fixtures/teams.mjs new file mode 100644 index 0000000..4e033e5 --- /dev/null +++ b/tests/Fixtures/teams.mjs @@ -0,0 +1,220 @@ +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/teams-fixture', + plugins: [vue(), ...(serve ? [tailwind()] : [])], + resolve: { + alias: { + '@': fileURLToPath(new URL('../../resources/js', import.meta.url)), + }, + }, + server: serve + ? { host: '127.0.0.1', port: 4197, 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 { + canCreateTeams: true, + users, + status: null, + teams: [ + { id: 1, name: 'Platform team', user_ids: [1], users: [users[0]] }, + { id: 2, name: 'Delivery team', user_ids: [], users: [] }, + ], + teamPermissions: { 1: { 2: ['team:invite'] } }, + permissionOptions: ['invite', 'manage', 'admin'].map((name) => ({ + value: 'team:' + name, + label: 'Team ' + name, + })), + }; +} +const makePage = (extra = {}) => ({ + component: 'Teams', + url: '/teams', + 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('/teams')) { + return next(); + } + const url = new URL(request.url, 'http://localhost'); + if (url.searchParams.has('reset')) { + data = initial(); + } + if (url.searchParams.has('manager')) { + data.canCreateTeams = false; + } + if (url.searchParams.has('empty')) { + data.teams = []; + } + let errors = {}; + if (request.method !== 'GET') { + const result = + request.headers.cookie?.match(/teams_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 = url.pathname.endsWith('/members') + ? { 'user_ids.0': 'This user is unavailable.' } + : url.pathname.includes('/permissions/') + ? { 'permissions.0': 'This permission is unavailable.' } + : { name: 'This team name is already taken.' }; + } else if (request.method === 'POST') { + data.teams.push({ + id: 3, + name: submitted.name, + user_ids: [], + users: [], + }); + } else if (url.pathname.endsWith('/members')) { + const team = data.teams.find( + (team) => team.id === Number(url.pathname.split('/')[2]), + ); + team.user_ids = submitted.user_ids; + team.users = users.filter((user) => + team.user_ids.includes(user.id), + ); + } else { + const [, , teamId, , , userId] = url.pathname.split('/'); + data.teamPermissions[teamId] ??= {}; + data.teamPermissions[teamId][userId] = submitted.permissions; + } + 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('Teams 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/teams/Index.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, /Platform team/); + assert.match(html, /1 member/); + assert.match(html, /0 members/); + assert.match(html, /Manage members of Platform team/); + assert.match(html, /Manage permissions for Platform team/); + assert.match(html, /No members yet/); + assert.match(html, /Create team/); + assert.doesNotMatch(html, /Global admin|Hold Cmd|