permissions refactoring

This commit is contained in:
2026-04-12 19:21:04 +02:00
parent 51014395a2
commit 9dcdfb981a
64 changed files with 1842 additions and 656 deletions
+5 -3
View File
@@ -23,9 +23,9 @@ import {
SidebarMenuItem,
} from '@/components/ui/sidebar';
import { dashboard } from '@/routes';
import { index as adminTeams } from '@/routes/admin/teams';
import { index as adminUsers } from '@/routes/admin/users';
import { index as projectsIndex } from '@/routes/projects';
import { index as teamsIndex } from '@/routes/teams';
import type { NavItem } from '@/types';
const page = usePage();
@@ -44,16 +44,18 @@ const mainNavItems = computed<NavItem[]>(() => {
},
];
if (page.props.auth.user.is_admin) {
if (page.props.auth.permissions.globalAdmin) {
items.push({
title: 'Users',
href: adminUsers(),
icon: Shield,
});
}
if (page.props.auth.permissions.canManageTeams) {
items.push({
title: 'Teams',
href: adminTeams(),
href: teamsIndex(),
icon: Users,
});
}
+76 -44
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3';
import { Shield, Users } from 'lucide-vue-next';
import { FolderPlus, Shield, Users } from 'lucide-vue-next';
import UserManagementController from '@/actions/App/Http/Controllers/Admin/UserManagementController';
import Heading from '@/components/Heading.vue';
import { Badge } from '@/components/ui/badge';
@@ -16,7 +16,7 @@ import AppLayout from '@/layouts/AppLayout.vue';
import { index as adminUsers } from '@/routes/admin/users';
import type { AdminUsersPageProps, BreadcrumbItem } from '@/types';
defineProps<AdminUsersPageProps>();
const props = defineProps<AdminUsersPageProps>();
const breadcrumbs: BreadcrumbItem[] = [
{
@@ -24,6 +24,9 @@ const breadcrumbs: BreadcrumbItem[] = [
href: adminUsers(),
},
];
const hasPermission = (actorId: number, permission: string) =>
(props.directPermissions[actorId] ?? []).includes(permission);
</script>
<template>
@@ -34,7 +37,7 @@ const breadcrumbs: BreadcrumbItem[] = [
<div class="flex items-start justify-between gap-4">
<Heading
title="User management"
description="Manage application-wide admin access for existing users."
description="Assign the global permissions that unlock platform-wide administration and project creation."
/>
<Badge variant="outline" class="gap-1.5 px-3 py-1">
@@ -54,8 +57,8 @@ const breadcrumbs: BreadcrumbItem[] = [
<CardHeader class="border-b">
<CardTitle>Users</CardTitle>
<CardDescription>
Promote trusted users to platform admins or remove admin
access when it is no longer needed.
Global permissions are assigned directly to users and
apply across the entire application.
</CardDescription>
</CardHeader>
@@ -68,16 +71,16 @@ const breadcrumbs: BreadcrumbItem[] = [
</div>
<div v-else class="overflow-x-auto">
<table
class="divide-border min-w-full divide-y text-sm"
>
<table class="divide-border min-w-full divide-y text-sm">
<thead
class="bg-muted/40 text-muted-foreground text-left"
>
<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">Role</th>
<th class="px-4 py-3 font-medium">
Current access
</th>
<th
class="px-4 py-3 text-right font-medium"
>
@@ -89,12 +92,10 @@ const breadcrumbs: BreadcrumbItem[] = [
<tr
v-for="user in users"
:key="user.id"
class="bg-background"
class="bg-background align-top"
>
<td class="px-4 py-4">
<div
class="text-foreground font-medium"
>
<div class="text-foreground font-medium">
{{ user.name }}
</div>
</td>
@@ -102,19 +103,43 @@ const breadcrumbs: BreadcrumbItem[] = [
{{ user.email }}
</td>
<td class="px-4 py-4">
<Badge
:variant="
user.is_admin
? 'default'
: 'secondary'
"
class="gap-1.5"
>
<Shield class="size-3.5" />
{{
user.is_admin ? 'Admin' : 'User'
}}
</Badge>
<div class="flex flex-wrap gap-2">
<Badge
v-if="
hasPermission(
user.id,
'global:admin',
)
"
class="gap-1.5"
>
<Shield class="size-3.5" />
Global admin
</Badge>
<Badge
v-if="
hasPermission(
user.id,
'project:create',
)
"
variant="secondary"
class="gap-1.5"
>
<FolderPlus class="size-3.5" />
Project create
</Badge>
<span
v-if="
(directPermissions[
user.id
] ?? []).length === 0
"
class="text-muted-foreground"
>
No direct global permissions
</span>
</div>
</td>
<td class="px-4 py-4">
<div class="flex justify-end">
@@ -124,33 +149,40 @@ const breadcrumbs: BreadcrumbItem[] = [
user.id,
)
"
class="flex flex-wrap justify-end gap-3"
v-slot="{ processing }"
>
<input
type="hidden"
name="is_admin"
:value="
user.is_admin
? '0'
: '1'
<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(
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="
user.is_admin
? 'outline'
: 'default'
"
variant="outline"
:disabled="processing"
>
{{
user.is_admin
? 'Remove admin'
: 'Make admin'
}}
Save
</Button>
</Form>
</div>
+1 -1
View File
@@ -60,7 +60,7 @@ const hasPermission = (
<div class="flex flex-wrap gap-3">
<Button
v-if="$page.props.auth.user.is_admin"
v-if="$page.props.auth.permissions.globalAdmin"
as-child
variant="outline"
>
+1 -1
View File
@@ -38,7 +38,7 @@ const breadcrumbs: BreadcrumbItem[] = [
description="Create and browse project spaces that will later host boards, backlog, and settings."
/>
<Button as-child>
<Button v-if="$page.props.auth.permissions.projectCreate" as-child>
<Link :href="create()">
<Plus class="size-4" />
New project
+1 -1
View File
@@ -107,7 +107,7 @@ const projectAreas = [
</Button>
<Button
v-if="$page.props.auth.user.is_admin"
v-if="$page.props.auth.permissions.globalAdmin"
as-child
variant="outline"
>
@@ -61,7 +61,7 @@ const breadcrumbs: BreadcrumbItem[] = [
<div class="flex flex-wrap gap-3">
<Button
v-if="$page.props.auth.user.is_admin"
v-if="$page.props.auth.permissions.globalAdmin"
as-child
variant="outline"
>
@@ -65,7 +65,7 @@ const breadcrumbs: BreadcrumbItem[] = [
<div class="flex flex-wrap gap-3">
<Button
v-if="$page.props.auth.user.is_admin"
v-if="$page.props.auth.permissions.globalAdmin"
as-child
variant="outline"
>
@@ -234,7 +234,7 @@ const submitUpdate = (): void => {
<div class="flex flex-wrap gap-3">
<Button
v-if="$page.props.auth.user.is_admin"
v-if="$page.props.auth.permissions.globalAdmin"
as-child
variant="outline"
>
+193 -8
View File
@@ -16,10 +16,10 @@ import {
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue';
import { index as teamsIndex } from '@/routes/admin/teams';
import { index as teamsIndex } from '@/routes/teams';
import type { BreadcrumbItem, TeamsIndexPageProps } from '@/types';
defineProps<TeamsIndexPageProps>();
const props = defineProps<TeamsIndexPageProps>();
const breadcrumbs: BreadcrumbItem[] = [
{
@@ -27,6 +27,15 @@ const breadcrumbs: BreadcrumbItem[] = [
href: teamsIndex(),
},
];
const hasPermission = (
permissions: Record<string, string[]>,
actorId: number,
permission: string,
) => (permissions[actorId] ?? []).includes(permission);
const permissionsForTeam = (teamId: number): Record<string, string[]> =>
props.teamPermissions[teamId] ?? {};
</script>
<template>
@@ -39,12 +48,16 @@ const breadcrumbs: BreadcrumbItem[] = [
>
<Heading
title="Teams"
description="Create reusable teams and manage member assignments for inherited project permissions."
description="Create reusable teams, manage memberships, and assign team-scoped permissions to users."
/>
<Badge variant="outline" class="gap-1.5 px-3 py-1">
<Shield class="size-3.5" />
Admin-only area
{{
canCreateTeams
? 'Global admin'
: 'Team-scoped access'
}}
</Badge>
</div>
@@ -55,8 +68,15 @@ const breadcrumbs: BreadcrumbItem[] = [
{{ status }}
</div>
<div class="grid gap-6 xl:grid-cols-[22rem_minmax(0,1fr)]">
<Card>
<div
class="grid gap-6"
:class="
canCreateTeams
? 'xl:grid-cols-[22rem_minmax(0,1fr)]'
: 'xl:grid-cols-1'
"
>
<Card v-if="canCreateTeams">
<CardHeader>
<CardTitle>Create team</CardTitle>
<CardDescription>
@@ -121,7 +141,7 @@ const breadcrumbs: BreadcrumbItem[] = [
</div>
</CardHeader>
<CardContent class="pt-6">
<CardContent class="space-y-6 pt-6">
<Form
v-bind="
TeamController.updateMembers.form(team.id)
@@ -164,12 +184,177 @@ const breadcrumbs: BreadcrumbItem[] = [
Save members
</Button>
</Form>
<div class="space-y-4 border-t pt-6">
<div class="space-y-1">
<h3 class="text-foreground font-medium">
Team permissions
</h3>
<p class="text-muted-foreground text-sm">
Direct user permissions on this team are
stored separately from team membership.
</p>
</div>
<div class="overflow-x-auto">
<table
class="divide-border min-w-full divide-y text-sm"
>
<thead
class="bg-muted/40 text-muted-foreground text-left"
>
<tr>
<th
class="px-4 py-3 font-medium"
>
User
</th>
<th
class="px-4 py-3 font-medium"
>
Current 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="user in users"
:key="`${team.id}-${user.id}`"
class="bg-background align-top"
>
<td class="px-4 py-4">
<div
class="text-foreground font-medium"
>
{{ user.name }}
</div>
<div
class="text-muted-foreground text-xs"
>
{{ user.email }}
</div>
</td>
<td class="px-4 py-4">
<div
class="flex flex-wrap gap-2"
>
<Badge
v-for="option in permissionOptions"
v-show="
hasPermission(
permissionsForTeam(
team.id,
),
user.id,
option.value,
)
"
:key="
`${team.id}-${user.id}-${option.value}-badge`
"
variant="secondary"
>
{{ option.label }}
</Badge>
<span
v-if="
(
permissionsForTeam(
team.id,
)[user.id] ??
[]
).length === 0
"
class="text-muted-foreground"
>
No direct team
permissions
</span>
</div>
</td>
<td class="px-4 py-4">
<div
class="flex justify-end"
>
<Form
v-bind="
TeamController.updateUserPermissions.form(
[
team.id,
user.id,
],
)
"
class="flex flex-wrap justify-end gap-3"
v-slot="{
processing,
}"
>
<label
v-for="option in permissionOptions"
:key="
`${team.id}-${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(
permissionsForTeam(
team.id,
),
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>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</CardContent>
</Card>
<Card v-if="teams.length === 0">
<CardContent class="text-muted-foreground p-6 text-sm">
No teams exist yet.
No teams are available for your current
permissions.
</CardContent>
</Card>
</div>
+7
View File
@@ -2,7 +2,14 @@ import type { UserModel } from '@/types/models/user-model';
export type User = UserModel;
export type AuthPermissions = {
canManageTeams: boolean;
globalAdmin: boolean;
projectCreate: boolean;
};
export type Auth = {
permissions: AuthPermissions;
user: User;
};
-1
View File
@@ -4,7 +4,6 @@ export type UserModel = {
email: string;
avatar?: string;
email_verified_at: string | null;
is_admin: boolean;
created_at: string;
updated_at: string;
};
+7 -2
View File
@@ -17,6 +17,8 @@ import type { UserGitConnectionModel } from '@/types/models/user-git-connection-
import type { UserModel } from '@/types/models/user-model';
export type AdminUsersPageProps = {
directPermissions: Record<string, string[]>;
permissionOptions: PermissionOption[];
users: UserModel[];
status?: string | null;
};
@@ -31,14 +33,14 @@ export type ProjectShowPageProps = {
project: ProjectModel;
};
export type ProjectPermissionOption = {
export type PermissionOption = {
label: string;
value: string;
};
export type ProjectAccessPageProps = {
directPermissions: Record<string, string[]>;
permissionOptions: ProjectPermissionOption[];
permissionOptions: PermissionOption[];
project: ProjectModel;
status?: string | null;
teamPermissions: Record<string, string[]>;
@@ -47,7 +49,10 @@ export type ProjectAccessPageProps = {
};
export type TeamsIndexPageProps = {
canCreateTeams: boolean;
permissionOptions: PermissionOption[];
status?: string | null;
teamPermissions: Record<string, Record<string, string[]>>;
teams: TeamModel[];
users: UserModel[];
};