KAN-48: Rebuild project access and permission assignment #146
@@ -8,6 +8,7 @@ use App\Models\Project;
|
|||||||
use App\Models\Team;
|
use App\Models\Team;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
@@ -113,6 +114,41 @@ class PermissionService
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param EloquentCollection<int, User> $users
|
||||||
|
* @return array{directPermissions: array, teamPermissions: array, effectivePermissions: array, globalAdminUserIds: array<int, int>}
|
||||||
|
*/
|
||||||
|
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<string, array<int, string>>
|
* @return array<string, array<int, string>>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -182,25 +182,22 @@ class ProjectController extends Controller
|
|||||||
403,
|
403,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$users = User::query()->orderBy('name')->get();
|
||||||
|
|
||||||
return Inertia::render('projects/Access', [
|
return Inertia::render('projects/Access', [
|
||||||
'directPermissions' => $this->permissionService->assignmentMap(User::class, $project),
|
...$this->permissionService->projectAccessMap($users, $project),
|
||||||
'permissionOptions' => PermissionOptionResource::collection(
|
'permissionOptions' => PermissionOptionResource::collection(
|
||||||
$this->permissionService->permissionOptions(User::class, Project::class),
|
$this->permissionService->permissionOptions(User::class, Project::class),
|
||||||
)->resolve(),
|
)->resolve(),
|
||||||
'project' => ProjectResource::make($project)->resolve(),
|
'project' => ProjectResource::make($project)->resolve(),
|
||||||
'status' => session('status'),
|
'status' => session('status'),
|
||||||
'teamPermissions' => $this->permissionService->assignmentMap(Team::class, $project),
|
|
||||||
'teams' => TeamResource::collection(
|
'teams' => TeamResource::collection(
|
||||||
Team::query()
|
Team::query()
|
||||||
->with('users')
|
->with('users')
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->get(),
|
->get(),
|
||||||
)->resolve(),
|
)->resolve(),
|
||||||
'users' => UserResource::collection(
|
'users' => UserResource::collection($users)->resolve(),
|
||||||
User::query()
|
|
||||||
->orderBy('name')
|
|
||||||
->get(),
|
|
||||||
)->resolve(),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,7 +217,9 @@ class ProjectController extends Controller
|
|||||||
|
|
||||||
$this->permissionService->sync($user, $project, $permissions);
|
$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.");
|
->with('status', "{$user->name} permissions were updated.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,7 +238,9 @@ class ProjectController extends Controller
|
|||||||
|
|
||||||
$this->permissionService->sync($team, $project, $permissions);
|
$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.");
|
->with('status', "{$team->name} permissions were updated.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useForm } from '@inertiajs/vue3';
|
||||||
|
import { computed, watch } from 'vue';
|
||||||
|
import {
|
||||||
|
updateTeamPermissions,
|
||||||
|
updateUserPermissions,
|
||||||
|
} from '@/actions/App/Http/Controllers/ProjectController';
|
||||||
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import type { PermissionOption } from '@/types';
|
||||||
|
import {
|
||||||
|
projectPermissionDescriptions,
|
||||||
|
projectPermissionLabels,
|
||||||
|
} from '@/types/project-access';
|
||||||
|
import type { ProjectAccessTarget } from '@/types/project-access';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
projectId: number;
|
||||||
|
target: ProjectAccessTarget | null;
|
||||||
|
permissionOptions: PermissionOption[];
|
||||||
|
}>();
|
||||||
|
const emit = defineEmits<{ close: [] }>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const form = useForm({ permissions: [] as string[] });
|
||||||
|
const protection = useFormProtection(form, {
|
||||||
|
isDirty: () => props.target !== null && form.isDirty,
|
||||||
|
});
|
||||||
|
const selectionError = computed(
|
||||||
|
() =>
|
||||||
|
Object.entries(form.errors).find(([key]) =>
|
||||||
|
key.startsWith('permissions'),
|
||||||
|
)?.[1],
|
||||||
|
);
|
||||||
|
const options = computed(() =>
|
||||||
|
props.permissionOptions
|
||||||
|
.filter((option) => option.value in projectPermissionLabels)
|
||||||
|
.map((option) => ({
|
||||||
|
value: option.value,
|
||||||
|
label: t(
|
||||||
|
projectPermissionLabels[
|
||||||
|
option.value as keyof typeof projectPermissionLabels
|
||||||
|
],
|
||||||
|
),
|
||||||
|
description: t(
|
||||||
|
projectPermissionDescriptions[
|
||||||
|
option.value as keyof typeof projectPermissionDescriptions
|
||||||
|
],
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
watch(
|
||||||
|
() => props.target,
|
||||||
|
(target) => {
|
||||||
|
form.defaults({ permissions: [...(target?.permissions ?? [])] });
|
||||||
|
form.resetAndClearErrors();
|
||||||
|
protection.clearFeedback();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
function close(open: boolean): void {
|
||||||
|
if (open || form.processing || !protection.confirmLeave()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
form.resetAndClearErrors();
|
||||||
|
emit('close');
|
||||||
|
}
|
||||||
|
function submit(): void {
|
||||||
|
if (!props.target || form.processing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const route =
|
||||||
|
props.target.kind === 'user'
|
||||||
|
? updateUserPermissions([props.projectId, props.target.id])
|
||||||
|
: updateTeamPermissions([props.projectId, props.target.id]);
|
||||||
|
protection.submit(route, {
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => {
|
||||||
|
form.defaults();
|
||||||
|
form.resetAndClearErrors();
|
||||||
|
emit('close');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog :open="target !== null" @update:open="close">
|
||||||
|
<DialogContent
|
||||||
|
:show-close-button="!form.processing"
|
||||||
|
@escape-key-down="form.processing && $event.preventDefault()"
|
||||||
|
@interact-outside="form.processing && $event.preventDefault()"
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle class="break-words">{{
|
||||||
|
t('projectAccess.editFor', { name: target?.name ?? '' })
|
||||||
|
}}</DialogTitle>
|
||||||
|
<DialogDescription>{{
|
||||||
|
t(
|
||||||
|
target?.kind === 'team'
|
||||||
|
? 'projectAccess.teamHelp'
|
||||||
|
: 'projectAccess.directHelp',
|
||||||
|
)
|
||||||
|
}}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form class="grid min-w-0 gap-5" @submit.prevent="submit">
|
||||||
|
<FormFeedback
|
||||||
|
:state="protection.state.value"
|
||||||
|
:title="
|
||||||
|
protection.responseStatus.value === 403
|
||||||
|
? t('projectAccess.forbidden')
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
:description="
|
||||||
|
protection.responseStatus.value === 403
|
||||||
|
? t('projectAccess.forbiddenHelp')
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
:retryable="protection.responseStatus.value !== 403"
|
||||||
|
@retry="submit"
|
||||||
|
/>
|
||||||
|
<fieldset
|
||||||
|
:disabled="form.processing"
|
||||||
|
class="grid min-w-0 gap-3"
|
||||||
|
:aria-invalid="!!selectionError"
|
||||||
|
aria-describedby="project-permission-error"
|
||||||
|
>
|
||||||
|
<legend class="mb-3 text-sm font-semibold">
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
target?.kind === 'team'
|
||||||
|
? 'projectAccess.teamPermissions'
|
||||||
|
: 'projectAccess.directPermissions',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</legend>
|
||||||
|
<label
|
||||||
|
v-for="option in options"
|
||||||
|
:key="option.value"
|
||||||
|
class="hover:bg-accent flex cursor-pointer items-start gap-3 rounded-lg border p-3"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="form.permissions"
|
||||||
|
type="checkbox"
|
||||||
|
:value="option.value"
|
||||||
|
:aria-label="option.label"
|
||||||
|
class="accent-primary focus-visible:ring-ring mt-0.5 size-5 shrink-0 focus-visible:ring-2"
|
||||||
|
/>
|
||||||
|
<span class="grid gap-1"
|
||||||
|
><span class="text-sm font-medium">{{
|
||||||
|
option.label
|
||||||
|
}}</span
|
||||||
|
><span class="text-muted-foreground text-xs">{{
|
||||||
|
option.description
|
||||||
|
}}</span></span
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
<InputError
|
||||||
|
id="project-permission-error"
|
||||||
|
:message="selectionError"
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
{{ t('projectAccess.clearHelp') }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="target?.globalAdmin"
|
||||||
|
class="bg-accent text-accent-foreground rounded-lg p-3 text-sm"
|
||||||
|
>
|
||||||
|
{{ t('projectAccess.globalHelp') }}
|
||||||
|
</p>
|
||||||
|
<p class="text-muted-foreground text-xs">
|
||||||
|
{{ t('projectAccess.selfHelp') }}
|
||||||
|
</p>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="form.processing"
|
||||||
|
@click="close(false)"
|
||||||
|
>{{ t('common.cancel') }}</Button
|
||||||
|
>
|
||||||
|
<Button type="submit" :disabled="form.processing">{{
|
||||||
|
t('projectAccess.save')
|
||||||
|
}}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import { projectPermissionLabels } from '@/types/project-access';
|
||||||
|
|
||||||
|
defineProps<{ permissions: string[]; emptyLabel: string }>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
<template
|
||||||
|
v-for="(label, value) in projectPermissionLabels"
|
||||||
|
:key="value"
|
||||||
|
>
|
||||||
|
<Badge v-if="permissions.includes(value)" variant="secondary">{{
|
||||||
|
t(label)
|
||||||
|
}}</Badge>
|
||||||
|
</template>
|
||||||
|
<span
|
||||||
|
v-if="permissions.length === 0"
|
||||||
|
class="text-muted-foreground text-sm"
|
||||||
|
>{{ emptyLabel }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,4 +1,58 @@
|
|||||||
export const en = {
|
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.title': 'Notifications',
|
||||||
'notifications.pageTitle': '{project} · Notifications',
|
'notifications.pageTitle': '{project} · Notifications',
|
||||||
'notifications.description':
|
'notifications.description':
|
||||||
|
|||||||
@@ -1,308 +1,344 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Form, Head, Link } from '@inertiajs/vue3';
|
import { Head, Link, usePage } from '@inertiajs/vue3';
|
||||||
import ProjectController from '@/actions/App/Http/Controllers/ProjectController';
|
import { ArrowLeft, Search, ShieldCheck, Users } from 'lucide-vue-next';
|
||||||
import Heading from '@/components/Heading.vue';
|
import { computed, ref } from 'vue';
|
||||||
|
import FeedbackState from '@/components/FeedbackState.vue';
|
||||||
|
import ProjectAccessEditorDialog from '@/components/projects/ProjectAccessEditorDialog.vue';
|
||||||
|
import ProjectPermissionBadges from '@/components/projects/ProjectPermissionBadges.vue';
|
||||||
|
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import { Input } from '@/components/ui/input';
|
||||||
Card,
|
import { Label } from '@/components/ui/label';
|
||||||
CardContent,
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@/components/ui/card';
|
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import { access, index, show } from '@/routes/projects/index';
|
import { access, index, show } from '@/routes/projects';
|
||||||
import { general as generalSettings } from '@/routes/projects/settings';
|
import { general } from '@/routes/projects/settings';
|
||||||
import type { BreadcrumbItem, ProjectAccessPageProps } from '@/types';
|
import type { BreadcrumbItem, ProjectAccessPageProps } from '@/types';
|
||||||
|
import type { ProjectAccessTarget } from '@/types/project-access';
|
||||||
|
|
||||||
const props = defineProps<ProjectAccessPageProps>();
|
const props = defineProps<ProjectAccessPageProps>();
|
||||||
|
const page = usePage();
|
||||||
const breadcrumbs: BreadcrumbItem[] = [
|
const { t } = useLocalization();
|
||||||
{
|
const search = ref('');
|
||||||
title: 'Projects',
|
const target = ref<ProjectAccessTarget | null>(null);
|
||||||
href: index(),
|
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||||
},
|
{ title: t('navigation.projects'), href: index() },
|
||||||
{
|
{ title: props.project.name, href: show(props.project.id) },
|
||||||
title: props.project.name,
|
{ title: t('navigation.access'), href: access(props.project.id) },
|
||||||
href: show(props.project.id),
|
]);
|
||||||
},
|
const matches = (value: string) =>
|
||||||
{
|
value.toLocaleLowerCase().includes(search.value.trim().toLocaleLowerCase());
|
||||||
title: 'Access',
|
const visibleUsers = computed(() =>
|
||||||
href: access(props.project.id),
|
props.users.filter((user) => matches(`${user.name} ${user.email}`)),
|
||||||
},
|
);
|
||||||
];
|
const visibleTeams = computed(() =>
|
||||||
|
props.teams.filter((team) => matches(team.name)),
|
||||||
const hasPermission = (
|
);
|
||||||
permissions: Record<string, string[]>,
|
const inheritedTeams = (userId: number) =>
|
||||||
actorId: number,
|
props.teams.filter(
|
||||||
permission: string,
|
(team) =>
|
||||||
) => (permissions[actorId] ?? []).includes(permission);
|
team.user_ids.includes(userId) &&
|
||||||
|
(props.teamPermissions[team.id] ?? []).length > 0,
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbs">
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
<Head :title="`${project.name} access`" />
|
<Head
|
||||||
|
:title="t('projectAccess.pageTitle', { project: project.name })"
|
||||||
<div class="flex flex-1 flex-col gap-6 p-4">
|
/>
|
||||||
<div
|
<main
|
||||||
|
class="mx-auto flex w-full min-w-0 max-w-7xl flex-1 flex-col gap-6 p-4 sm:p-6 lg:p-8"
|
||||||
|
>
|
||||||
|
<header
|
||||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||||
>
|
>
|
||||||
<div class="space-y-3">
|
<div class="grid min-w-0 gap-2">
|
||||||
<Badge variant="outline" class="px-3 py-1">
|
<p
|
||||||
|
class="text-primary break-all text-xs font-semibold uppercase tracking-wider"
|
||||||
|
>
|
||||||
{{ project.key }}
|
{{ project.key }}
|
||||||
</Badge>
|
</p>
|
||||||
<Heading
|
<h1 class="text-headline-mobile sm:text-headline">
|
||||||
:title="`${project.name} access`"
|
{{ t('projectAccess.title') }}
|
||||||
description="Assign direct user permissions or inherited team permissions for this project."
|
</h1>
|
||||||
/>
|
<p
|
||||||
|
class="text-muted-foreground max-w-2xl break-words text-sm"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
t('projectAccess.description', {
|
||||||
|
project: project.name,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex shrink-0 flex-wrap gap-2">
|
||||||
<div class="flex flex-wrap gap-3">
|
|
||||||
<Button
|
<Button
|
||||||
v-if="$page.props.auth.permissions.globalAdmin"
|
v-if="page.props.auth.permissions.globalAdmin"
|
||||||
as-child
|
as-child
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
><Link :href="general(project.id)">{{
|
||||||
|
t('navigation.general')
|
||||||
|
}}</Link></Button
|
||||||
|
>
|
||||||
|
<Button as-child variant="outline"
|
||||||
|
><Link :href="show(project.id)"
|
||||||
|
><ArrowLeft class="size-4" aria-hidden="true" />{{
|
||||||
|
t('projectAccess.back')
|
||||||
|
}}</Link
|
||||||
|
></Button
|
||||||
>
|
>
|
||||||
<Link :href="generalSettings(project.id)">
|
|
||||||
General settings
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button as-child variant="outline">
|
|
||||||
<Link :href="show(project.id)">Back to project</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
|
<FeedbackState
|
||||||
<div
|
|
||||||
v-if="status"
|
v-if="status"
|
||||||
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
|
state="success"
|
||||||
|
:title="t('projectAccess.saved')"
|
||||||
|
/>
|
||||||
|
<aside
|
||||||
|
class="bg-accent/60 flex items-start gap-3 rounded-xl border p-4"
|
||||||
|
:aria-label="t('projectAccess.howItWorks')"
|
||||||
>
|
>
|
||||||
{{ status }}
|
<ShieldCheck
|
||||||
|
class="text-primary mt-0.5 size-5 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div class="grid gap-1">
|
||||||
|
<h2 class="text-sm font-semibold">
|
||||||
|
{{ t('projectAccess.howItWorks') }}
|
||||||
|
</h2>
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
{{ t('projectAccess.additiveHelp') }}
|
||||||
|
</p>
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
{{ t('projectAccess.limitsHelp') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<div class="relative sm:max-w-md">
|
||||||
|
<Label for="access-search" class="sr-only">{{
|
||||||
|
t('projectAccess.search')
|
||||||
|
}}</Label
|
||||||
|
><Search
|
||||||
|
class="text-muted-foreground pointer-events-none absolute left-3 top-3 size-4"
|
||||||
|
aria-hidden="true"
|
||||||
|
/><Input
|
||||||
|
id="access-search"
|
||||||
|
v-model="search"
|
||||||
|
type="search"
|
||||||
|
class="pl-9"
|
||||||
|
:placeholder="t('projectAccess.search')"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<section
|
||||||
<Card class="gap-0 overflow-hidden">
|
class="grid min-w-0 gap-4"
|
||||||
<CardHeader class="border-b">
|
aria-labelledby="people-heading"
|
||||||
<CardTitle>User permissions</CardTitle>
|
>
|
||||||
<CardDescription>
|
<div class="flex items-center gap-2">
|
||||||
Direct assignments apply only to the selected user
|
<h2 id="people-heading" class="text-lg font-semibold">
|
||||||
inside this project.
|
{{ t('projectAccess.people') }}
|
||||||
</CardDescription>
|
</h2>
|
||||||
</CardHeader>
|
<Badge variant="secondary">{{ users.length }}</Badge>
|
||||||
|
</div>
|
||||||
<CardContent class="p-0">
|
<FeedbackState
|
||||||
<div class="overflow-x-auto">
|
v-if="visibleUsers.length === 0"
|
||||||
<table
|
state="empty"
|
||||||
class="divide-border min-w-full divide-y text-sm"
|
:title="
|
||||||
>
|
t(
|
||||||
<thead
|
users.length
|
||||||
class="bg-muted/40 text-muted-foreground text-left"
|
? 'projectAccess.noPeopleResults'
|
||||||
>
|
: 'projectAccess.noPeople',
|
||||||
<tr>
|
)
|
||||||
<th class="px-4 py-3 font-medium">User</th>
|
"
|
||||||
<th class="px-4 py-3 font-medium">Email</th>
|
/>
|
||||||
<th class="px-4 py-3 font-medium">
|
<ul
|
||||||
Permissions
|
v-else
|
||||||
</th>
|
class="bg-card min-w-0 divide-y rounded-xl border"
|
||||||
<th
|
:aria-label="t('projectAccess.people')"
|
||||||
class="px-4 py-3 text-right font-medium"
|
>
|
||||||
>
|
<li
|
||||||
Action
|
v-for="user in visibleUsers"
|
||||||
</th>
|
:key="user.id"
|
||||||
</tr>
|
class="grid min-w-0 gap-4 p-4 sm:p-5 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.3fr)_auto] lg:items-center"
|
||||||
</thead>
|
:data-user-id="user.id"
|
||||||
<tbody class="divide-border divide-y">
|
|
||||||
<tr
|
|
||||||
v-for="user in users"
|
|
||||||
:key="user.id"
|
|
||||||
class="bg-background align-top"
|
|
||||||
>
|
|
||||||
<td
|
|
||||||
class="text-foreground px-4 py-4 font-medium"
|
|
||||||
>
|
|
||||||
{{ user.name }}
|
|
||||||
</td>
|
|
||||||
<td class="text-muted-foreground px-4 py-4">
|
|
||||||
{{ user.email }}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-4">
|
|
||||||
<Form
|
|
||||||
v-bind="
|
|
||||||
ProjectController.updateUserPermissions.form(
|
|
||||||
[project.id, user.id],
|
|
||||||
)
|
|
||||||
"
|
|
||||||
class="flex flex-wrap gap-3"
|
|
||||||
v-slot="{ processing }"
|
|
||||||
>
|
|
||||||
<label
|
|
||||||
v-for="option in permissionOptions"
|
|
||||||
:key="`${user.id}-${option.value}`"
|
|
||||||
class="border-border inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
name="permissions[]"
|
|
||||||
:value="option.value"
|
|
||||||
:checked="
|
|
||||||
hasPermission(
|
|
||||||
directPermissions,
|
|
||||||
user.id,
|
|
||||||
option.value,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
class="border-input text-primary focus:ring-ring size-4 rounded"
|
|
||||||
/>
|
|
||||||
<span>{{ option.label }}</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div class="w-full sm:hidden" />
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
:disabled="processing"
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
class="text-muted-foreground px-4 py-4 text-right"
|
|
||||||
>
|
|
||||||
Direct
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card class="gap-0 overflow-hidden">
|
|
||||||
<CardHeader class="border-b">
|
|
||||||
<CardTitle>Team permissions</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Team assignments are inherited by every current and
|
|
||||||
future member of that team.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent class="p-0">
|
|
||||||
<div
|
|
||||||
v-if="teams.length === 0"
|
|
||||||
class="text-muted-foreground p-6 text-sm"
|
|
||||||
>
|
>
|
||||||
No teams exist yet. Create teams first to assign
|
<div class="flex min-w-0 items-center gap-3">
|
||||||
inherited permissions.
|
<UserAvatar :user="user" class="size-10 shrink-0" />
|
||||||
</div>
|
<div class="grid min-w-0 gap-1">
|
||||||
|
<h3 class="break-words text-sm font-semibold">
|
||||||
<div v-else class="overflow-x-auto">
|
{{ user.name }}
|
||||||
<table
|
</h3>
|
||||||
class="divide-border min-w-full divide-y text-sm"
|
<p
|
||||||
>
|
class="text-muted-foreground break-all text-xs"
|
||||||
<thead
|
|
||||||
class="bg-muted/40 text-muted-foreground text-left"
|
|
||||||
>
|
|
||||||
<tr>
|
|
||||||
<th class="px-4 py-3 font-medium">Team</th>
|
|
||||||
<th class="px-4 py-3 font-medium">
|
|
||||||
Members
|
|
||||||
</th>
|
|
||||||
<th class="px-4 py-3 font-medium">
|
|
||||||
Permissions
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
class="px-4 py-3 text-right font-medium"
|
|
||||||
>
|
|
||||||
Action
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-border divide-y">
|
|
||||||
<tr
|
|
||||||
v-for="team in teams"
|
|
||||||
:key="team.id"
|
|
||||||
class="bg-background align-top"
|
|
||||||
>
|
>
|
||||||
<td
|
{{ user.email }}
|
||||||
class="text-foreground px-4 py-4 font-medium"
|
</p>
|
||||||
>
|
</div>
|
||||||
{{ team.name }}
|
</div>
|
||||||
</td>
|
<div class="grid min-w-0 gap-2">
|
||||||
<td class="px-4 py-4">
|
<p
|
||||||
<div class="flex flex-wrap gap-2">
|
class="text-muted-foreground text-xs font-medium"
|
||||||
<Badge
|
>
|
||||||
v-for="member in team.users"
|
{{ t('projectAccess.effective') }}
|
||||||
:key="member.id"
|
</p>
|
||||||
variant="secondary"
|
<ProjectPermissionBadges
|
||||||
>
|
:permissions="
|
||||||
{{ member.name }}
|
effectivePermissions[user.id] ?? []
|
||||||
</Badge>
|
"
|
||||||
<span
|
:empty-label="t('projectAccess.noAccess')"
|
||||||
v-if="team.users.length === 0"
|
/>
|
||||||
class="text-muted-foreground"
|
<div
|
||||||
>
|
class="text-muted-foreground grid gap-1 text-xs"
|
||||||
No members assigned
|
>
|
||||||
</span>
|
<span
|
||||||
</div>
|
v-if="globalAdminUserIds.includes(user.id)"
|
||||||
</td>
|
>{{ t('projectAccess.globalSource') }}</span
|
||||||
<td class="px-4 py-4">
|
>
|
||||||
<Form
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
v-bind="
|
<span>{{ t('projectAccess.direct') }}</span
|
||||||
ProjectController.updateTeamPermissions.form(
|
><ProjectPermissionBadges
|
||||||
[project.id, team.id],
|
:permissions="
|
||||||
)
|
directPermissions[user.id] ?? []
|
||||||
"
|
"
|
||||||
class="flex flex-wrap gap-3"
|
:empty-label="t('projectAccess.none')"
|
||||||
v-slot="{ processing }"
|
/>
|
||||||
>
|
</div>
|
||||||
<label
|
<div
|
||||||
v-for="option in permissionOptions"
|
v-for="team in inheritedTeams(user.id)"
|
||||||
:key="`${team.id}-${option.value}`"
|
:key="team.id"
|
||||||
class="border-border inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm"
|
class="flex min-w-0 flex-wrap items-center gap-2"
|
||||||
>
|
>
|
||||||
<input
|
<span class="break-words">{{
|
||||||
type="checkbox"
|
t('projectAccess.viaTeam', {
|
||||||
name="permissions[]"
|
name: team.name,
|
||||||
:value="option.value"
|
})
|
||||||
:checked="
|
}}</span
|
||||||
hasPermission(
|
><ProjectPermissionBadges
|
||||||
teamPermissions,
|
:permissions="
|
||||||
team.id,
|
teamPermissions[team.id] ?? []
|
||||||
option.value,
|
"
|
||||||
)
|
:empty-label="t('projectAccess.none')"
|
||||||
"
|
/>
|
||||||
class="border-input text-primary focus:ring-ring size-4 rounded"
|
</div>
|
||||||
/>
|
</div>
|
||||||
<span>{{ option.label }}</span>
|
</div>
|
||||||
</label>
|
<Button
|
||||||
|
type="button"
|
||||||
<div class="w-full sm:hidden" />
|
variant="outline"
|
||||||
|
:aria-label="
|
||||||
<Button
|
t('projectAccess.editFor', { name: user.name })
|
||||||
type="submit"
|
"
|
||||||
size="sm"
|
@click="
|
||||||
variant="outline"
|
target = {
|
||||||
:disabled="processing"
|
kind: 'user',
|
||||||
>
|
id: user.id,
|
||||||
Save
|
name: user.name,
|
||||||
</Button>
|
permissions:
|
||||||
</Form>
|
directPermissions[user.id] ?? [],
|
||||||
</td>
|
globalAdmin: globalAdminUserIds.includes(
|
||||||
<td
|
user.id,
|
||||||
class="text-muted-foreground px-4 py-4 text-right"
|
),
|
||||||
>
|
}
|
||||||
Inherited
|
"
|
||||||
</td>
|
>{{ t('projectAccess.editDirect') }}</Button
|
||||||
</tr>
|
>
|
||||||
</tbody>
|
</li>
|
||||||
</table>
|
</ul>
|
||||||
|
</section>
|
||||||
|
<section class="grid min-w-0 gap-4" aria-labelledby="teams-heading">
|
||||||
|
<div class="grid gap-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<h2 id="teams-heading" class="text-lg font-semibold">
|
||||||
|
{{ t('projectAccess.teamPermissions') }}
|
||||||
|
</h2>
|
||||||
|
<Badge variant="secondary">{{ teams.length }}</Badge>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
<p class="text-muted-foreground text-sm">
|
||||||
</Card>
|
{{ t('projectAccess.teamHelp') }}
|
||||||
</div>
|
</p>
|
||||||
|
</div>
|
||||||
|
<FeedbackState
|
||||||
|
v-if="visibleTeams.length === 0"
|
||||||
|
state="empty"
|
||||||
|
:title="
|
||||||
|
t(
|
||||||
|
teams.length
|
||||||
|
? 'projectAccess.noTeamResults'
|
||||||
|
: 'projectAccess.noTeams',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:description="
|
||||||
|
teams.length
|
||||||
|
? undefined
|
||||||
|
: t('projectAccess.noTeamsHelp')
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<ul
|
||||||
|
v-else
|
||||||
|
class="grid min-w-0 gap-4 lg:grid-cols-2"
|
||||||
|
:aria-label="t('projectAccess.teamPermissions')"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
v-for="team in visibleTeams"
|
||||||
|
:key="team.id"
|
||||||
|
class="bg-card grid min-w-0 gap-4 rounded-xl border p-5"
|
||||||
|
:data-team-id="team.id"
|
||||||
|
>
|
||||||
|
<div class="flex min-w-0 items-center gap-3">
|
||||||
|
<span
|
||||||
|
class="bg-accent text-primary flex size-10 shrink-0 items-center justify-center rounded-lg"
|
||||||
|
><Users class="size-5" aria-hidden="true"
|
||||||
|
/></span>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h3 class="break-words text-sm font-semibold">
|
||||||
|
{{ team.name }}
|
||||||
|
</h3>
|
||||||
|
<p class="text-muted-foreground text-xs">
|
||||||
|
{{
|
||||||
|
t('teams.memberCount', {
|
||||||
|
count: team.users.length,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ProjectPermissionBadges
|
||||||
|
:permissions="teamPermissions[team.id] ?? []"
|
||||||
|
:empty-label="t('projectAccess.noTeamAccess')"
|
||||||
|
/>
|
||||||
|
<p class="text-muted-foreground break-words text-xs">
|
||||||
|
{{
|
||||||
|
team.users
|
||||||
|
.map((user) => user.name)
|
||||||
|
.join(', ') || t('teams.noMembers')
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
:aria-label="
|
||||||
|
t('projectAccess.editFor', { name: team.name })
|
||||||
|
"
|
||||||
|
@click="
|
||||||
|
target = {
|
||||||
|
kind: 'team',
|
||||||
|
id: team.id,
|
||||||
|
name: team.name,
|
||||||
|
permissions: teamPermissions[team.id] ?? [],
|
||||||
|
}
|
||||||
|
"
|
||||||
|
>{{ t('projectAccess.editTeam') }}</Button
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
<ProjectAccessEditorDialog
|
||||||
|
:project-id="project.id"
|
||||||
|
:target="target"
|
||||||
|
:permission-options="permissionOptions"
|
||||||
|
@close="target = null"
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -117,6 +117,8 @@ export type PermissionOption = {
|
|||||||
|
|
||||||
export type ProjectAccessPageProps = {
|
export type ProjectAccessPageProps = {
|
||||||
directPermissions: Record<string, string[]>;
|
directPermissions: Record<string, string[]>;
|
||||||
|
effectivePermissions: Record<string, string[]>;
|
||||||
|
globalAdminUserIds: number[];
|
||||||
permissionOptions: PermissionOption[];
|
permissionOptions: PermissionOption[];
|
||||||
project: ProjectModel;
|
project: ProjectModel;
|
||||||
status?: string | null;
|
status?: string | null;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Projects;
|
||||||
|
|
||||||
|
use App\Actions\Permissions\PermissionService;
|
||||||
|
use App\Enums\Permission;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Team;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Inertia\Testing\AssertableInertia as Assert;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class ProjectAccessTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_access_summary_matches_existing_permission_rules_and_excludes_other_scopes(): void
|
||||||
|
{
|
||||||
|
$admin = User::factory()->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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Projects;
|
||||||
|
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class ProjectAccessUiTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_access_overview_renders_sources_empty_states_and_localization(): void
|
||||||
|
{
|
||||||
|
$process = new Process(['node', 'tests/Fixtures/project-access.mjs'], base_path());
|
||||||
|
$process->setTimeout(60);
|
||||||
|
$process->run();
|
||||||
|
|
||||||
|
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||||
|
$this->assertStringContainsString('Project access presentation checks passed.', $process->getOutput());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app" data-page="' +
|
||||||
|
escaped +
|
||||||
|
'"></div><script type="module" src="/tests/Fixtures/project-access-preview.mjs"></script></body></html>',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
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, /<table|type="checkbox"/);
|
||||||
|
assert.doesNotMatch(html, /href="[^"]*settings\/general/);
|
||||||
|
assert.match(await render({ teams: [] }), /No teams yet/);
|
||||||
|
assert.match(await render({ users: [] }), /No people available/);
|
||||||
|
const translated = await render(
|
||||||
|
{},
|
||||||
|
createLocalization(
|
||||||
|
{ locale: 'de' },
|
||||||
|
{
|
||||||
|
de: {
|
||||||
|
...en,
|
||||||
|
'projectAccess.title': 'Projektzugriff',
|
||||||
|
'projectAccess.read': 'Lesen',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.match(translated, /Projektzugriff/);
|
||||||
|
assert.match(translated, /Lesen/);
|
||||||
|
console.log('Project access presentation checks passed.');
|
||||||
|
} finally {
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user