Backend: - auth/permissions.go: full permission matrix with RequirePermission/RequireRole middleware, CanEditCase, CanDeleteDocument helpers - auth/context.go: add user role to request context - auth/middleware.go: resolve role alongside tenant in auth flow - auth/tenant_resolver.go: verify membership + resolve role for X-Tenant-ID - handlers/case_assignments.go: CRUD for case-level user assignments - handlers/tenant_handler.go: UpdateMemberRole, GetMe (/api/me) endpoints - handlers/documents.go: permission-based delete (own vs all) - router/router.go: permission-wrapped routes for all endpoints - services/case_assignment_service.go: assign/unassign with tenant validation - services/tenant_service.go: UpdateMemberRole with owner protection - models/case_assignment.go: CaseAssignment model Database: - user_tenants.role: CHECK constraint (owner/partner/associate/paralegal/secretary) - case_assignments table: case_id, user_id, role (lead/team/viewer) - Migrated existing admin->partner, member->associate Frontend: - usePermissions hook: fetches /api/me, provides can() helper - TeamSettings: 5-role dropdown, role change, permission-gated invite - CaseAssignments: new component for case-level team management - Sidebar: conditionally hides AI/Settings based on permissions - Cases page: hides "Neue Akte" button for non-authorized roles - Case detail: new "Mitarbeiter" tab for assignment management
221 lines
8.1 KiB
TypeScript
221 lines
8.1 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { toast } from "sonner";
|
|
import { UserPlus, Trash2, Crown, Scale, Briefcase, FileText, Phone } from "lucide-react";
|
|
import { api } from "@/lib/api";
|
|
import type { UserTenant, UserRole } from "@/lib/types";
|
|
import { ROLE_LABELS } from "@/lib/types";
|
|
import { Skeleton } from "@/components/ui/Skeleton";
|
|
import { EmptyState } from "@/components/ui/EmptyState";
|
|
import { usePermissions } from "@/lib/hooks/usePermissions";
|
|
|
|
const ROLE_CONFIG: Record<UserRole, { label: string; icon: typeof Crown }> = {
|
|
owner: { label: ROLE_LABELS.owner, icon: Crown },
|
|
partner: { label: ROLE_LABELS.partner, icon: Scale },
|
|
associate: { label: ROLE_LABELS.associate, icon: Briefcase },
|
|
paralegal: { label: ROLE_LABELS.paralegal, icon: FileText },
|
|
secretary: { label: ROLE_LABELS.secretary, icon: Phone },
|
|
};
|
|
|
|
const INVITE_ROLES: UserRole[] = ["partner", "associate", "paralegal", "secretary"];
|
|
|
|
export function TeamSettings() {
|
|
const queryClient = useQueryClient();
|
|
const { can, role: myRole } = usePermissions();
|
|
const tenantId =
|
|
typeof window !== "undefined"
|
|
? localStorage.getItem("kanzlai_tenant_id")
|
|
: null;
|
|
|
|
const [email, setEmail] = useState("");
|
|
const [role, setRole] = useState<string>("associate");
|
|
|
|
const canManageTeam = can("manage_team");
|
|
|
|
const {
|
|
data: members,
|
|
isLoading,
|
|
error,
|
|
} = useQuery({
|
|
queryKey: ["tenant-members", tenantId],
|
|
queryFn: () =>
|
|
api.get<UserTenant[]>(`/tenants/${tenantId}/members`),
|
|
enabled: !!tenantId,
|
|
});
|
|
|
|
const inviteMutation = useMutation({
|
|
mutationFn: (data: { email: string; role: string }) =>
|
|
api.post(`/tenants/${tenantId}/invite`, data),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["tenant-members"] });
|
|
setEmail("");
|
|
setRole("associate");
|
|
toast.success("Benutzer eingeladen");
|
|
},
|
|
onError: (err: { error?: string }) => {
|
|
toast.error(err.error || "Fehler beim Einladen");
|
|
},
|
|
});
|
|
|
|
const removeMutation = useMutation({
|
|
mutationFn: (userId: string) =>
|
|
api.delete(`/tenants/${tenantId}/members/${userId}`),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["tenant-members"] });
|
|
toast.success("Mitglied entfernt");
|
|
},
|
|
onError: (err: { error?: string }) => {
|
|
toast.error(err.error || "Fehler beim Entfernen");
|
|
},
|
|
});
|
|
|
|
const updateRoleMutation = useMutation({
|
|
mutationFn: ({ userId, newRole }: { userId: string; newRole: string }) =>
|
|
api.put(`/tenants/${tenantId}/members/${userId}/role`, { role: newRole }),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["tenant-members"] });
|
|
queryClient.invalidateQueries({ queryKey: ["me"] });
|
|
toast.success("Rolle aktualisiert");
|
|
},
|
|
onError: (err: { error?: string }) => {
|
|
toast.error(err.error || "Fehler beim Aktualisieren der Rolle");
|
|
},
|
|
});
|
|
|
|
const handleInvite = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!email.trim()) return;
|
|
inviteMutation.mutate({ email: email.trim(), role });
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="space-y-3">
|
|
<Skeleton className="h-10 w-full" />
|
|
<Skeleton className="h-10 w-full" />
|
|
<Skeleton className="h-10 w-full" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<EmptyState
|
|
icon={Briefcase}
|
|
title="Fehler beim Laden"
|
|
description="Team-Mitglieder konnten nicht geladen werden."
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Invite Form — only for owners/partners */}
|
|
{canManageTeam && (
|
|
<form onSubmit={handleInvite} className="flex flex-col gap-3 sm:flex-row">
|
|
<input
|
|
type="email"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
placeholder="name@example.com"
|
|
className="flex-1 rounded-md border border-neutral-200 px-3 py-1.5 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400"
|
|
/>
|
|
<select
|
|
value={role}
|
|
onChange={(e) => setRole(e.target.value)}
|
|
className="rounded-md border border-neutral-200 px-2 py-1.5 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400"
|
|
>
|
|
{INVITE_ROLES.map((r) => (
|
|
<option key={r} value={r}>
|
|
{ROLE_LABELS[r]}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<button
|
|
type="submit"
|
|
disabled={inviteMutation.isPending || !email.trim()}
|
|
className="inline-flex items-center gap-1.5 rounded-md bg-neutral-900 px-4 py-1.5 text-sm font-medium text-white hover:bg-neutral-800 disabled:opacity-50"
|
|
>
|
|
<UserPlus className="h-3.5 w-3.5" />
|
|
{inviteMutation.isPending ? "Einladen..." : "Einladen"}
|
|
</button>
|
|
</form>
|
|
)}
|
|
|
|
{/* Members List */}
|
|
{Array.isArray(members) && members.length > 0 ? (
|
|
<div className="overflow-hidden rounded-md border border-neutral-200">
|
|
{members.map((member, i) => {
|
|
const roleKey = (member.role as UserRole) || "associate";
|
|
const roleInfo = ROLE_CONFIG[roleKey] || ROLE_CONFIG.associate;
|
|
const RoleIcon = roleInfo.icon;
|
|
return (
|
|
<div
|
|
key={member.user_id}
|
|
className={`flex items-center justify-between px-4 py-3 ${
|
|
i < members.length - 1 ? "border-b border-neutral-100" : ""
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-neutral-100">
|
|
<RoleIcon className="h-4 w-4 text-neutral-500" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-medium text-neutral-900">
|
|
{member.user_id.slice(0, 8)}...
|
|
</p>
|
|
<p className="text-xs text-neutral-500">{roleInfo.label}</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{/* Role dropdown — only for owners/partners, not for the member's own row if they are owner */}
|
|
{canManageTeam && member.role !== "owner" && (
|
|
<select
|
|
value={member.role}
|
|
onChange={(e) =>
|
|
updateRoleMutation.mutate({
|
|
userId: member.user_id,
|
|
newRole: e.target.value,
|
|
})
|
|
}
|
|
disabled={updateRoleMutation.isPending}
|
|
className="rounded-md border border-neutral-200 px-2 py-1 text-xs outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400"
|
|
>
|
|
{myRole === "owner" && (
|
|
<option value="owner">{ROLE_LABELS.owner}</option>
|
|
)}
|
|
{INVITE_ROLES.map((r) => (
|
|
<option key={r} value={r}>
|
|
{ROLE_LABELS[r]}
|
|
</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
{canManageTeam && member.role !== "owner" && (
|
|
<button
|
|
onClick={() => removeMutation.mutate(member.user_id)}
|
|
disabled={removeMutation.isPending}
|
|
className="rounded-md p-1.5 text-neutral-400 hover:bg-red-50 hover:text-red-600 disabled:opacity-50"
|
|
title="Mitglied entfernen"
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
<EmptyState
|
|
icon={Briefcase}
|
|
title="Noch keine Mitglieder"
|
|
description="Laden Sie Teammitglieder per E-Mail ein."
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|