Feat(Chore): Implemented Audit-Logs, Department, Principal.
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Search, Plus, MoreHorizontal, Users, Loader2, Trash2, Edit2 } from "lucide-react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api, Department } from "@/lib/api";
|
||||
import { useCurrentOrganizationId } from "@/hooks/useCurrentOrganization";
|
||||
|
||||
export default function DepartmentsPage() {
|
||||
const params = useParams<{ orgId?: string }>();
|
||||
const { orgId: fallbackOrgId } = useCurrentOrganizationId();
|
||||
const orgId = params.orgId || fallbackOrgId;
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [departments, setDepartments] = useState<Department[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||
const [editingDept, setEditingDept] = useState<Department | null>(null);
|
||||
const [formData, setFormData] = useState({ name: "", description: "" });
|
||||
|
||||
const fetchDepartments = async (currentOrgId: string) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const response = await api.organizations.getDepartments(currentOrgId);
|
||||
setDepartments(response.departments || []);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch departments:", err);
|
||||
setError("Failed to load departments. Please try again.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setError(null);
|
||||
setDepartments([]);
|
||||
if (!orgId) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
fetchDepartments(orgId);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [orgId]);
|
||||
|
||||
const handleCreateDepartment = async () => {
|
||||
if (!orgId || !formData.name.trim()) return;
|
||||
|
||||
try {
|
||||
await api.organizations.createDepartment(
|
||||
orgId,
|
||||
formData.name,
|
||||
formData.description || undefined
|
||||
);
|
||||
setFormData({ name: "", description: "" });
|
||||
setIsCreateDialogOpen(false);
|
||||
await fetchDepartments(orgId);
|
||||
} catch (err) {
|
||||
console.error("Failed to create department:");
|
||||
setError("Failed to create department.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateDepartment = async () => {
|
||||
if (!orgId || !editingDept || !formData.name.trim()) return;
|
||||
|
||||
try {
|
||||
await api.organizations.updateDepartment(
|
||||
orgId,
|
||||
editingDept.id,
|
||||
{
|
||||
name: formData.name,
|
||||
description: formData.description || undefined,
|
||||
}
|
||||
);
|
||||
setFormData({ name: "", description: "" });
|
||||
setEditingDept(null);
|
||||
setIsEditDialogOpen(false);
|
||||
await fetchDepartments(orgId);
|
||||
} catch (err) {
|
||||
console.error("Failed to update department:");
|
||||
setError("Failed to update department.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteDepartment = async (deptId: string) => {
|
||||
if (!orgId || !confirm("Are you sure you want to delete this department?")) return;
|
||||
|
||||
try {
|
||||
await api.organizations.deleteDepartment(orgId, deptId);
|
||||
await fetchDepartments(orgId);
|
||||
} catch (err) {
|
||||
console.error("Failed to delete department:");
|
||||
setError("Failed to delete department.");
|
||||
}
|
||||
};
|
||||
|
||||
const openEditDialog = (dept: Department) => {
|
||||
setEditingDept(dept);
|
||||
setFormData({ name: dept.name, description: dept.description || "" });
|
||||
setIsEditDialogOpen(true);
|
||||
};
|
||||
|
||||
const filteredDepartments = departments.filter((dept) => {
|
||||
const searchLower = search.toLowerCase();
|
||||
return (
|
||||
dept.name.toLowerCase().includes(searchLower) ||
|
||||
(dept.description?.toLowerCase().includes(searchLower) ?? false)
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="page-title">Departments</h1>
|
||||
<p className="page-description">
|
||||
Manage departments and organize team members
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => { setFormData({ name: "", description: "" }); setIsCreateDialogOpen(true); }}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Create Department
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search departments..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-10 max-w-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-muted-foreground">Loading departments...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-8 text-center text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
) : filteredDepartments.length === 0 ? (
|
||||
<div className="p-8 text-center text-muted-foreground">
|
||||
No departments found
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{filteredDepartments.map((dept) => (
|
||||
<div key={dept.id} className="p-4 flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-accent/10 text-accent flex items-center justify-center flex-shrink-0">
|
||||
<Users className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium text-foreground">
|
||||
{dept.name}
|
||||
</p>
|
||||
</div>
|
||||
{dept.description && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{dept.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
Created {new Date(dept.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => openEditDialog(dept)}>
|
||||
<Edit2 className="w-4 h-4 mr-2" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => handleDeleteDepartment(dept.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Create Department Dialog */}
|
||||
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Department</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new department to organize team members
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="dept-name">Department Name</Label>
|
||||
<Input
|
||||
id="dept-name"
|
||||
placeholder="e.g., Engineering"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="dept-desc">Description</Label>
|
||||
<Textarea
|
||||
id="dept-desc"
|
||||
placeholder="Optional description..."
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsCreateDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreateDepartment}>
|
||||
Create Department
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Edit Department Dialog */}
|
||||
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Department</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update department information
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="edit-dept-name">Department Name</Label>
|
||||
<Input
|
||||
id="edit-dept-name"
|
||||
placeholder="e.g., Engineering"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="edit-dept-desc">Description</Label>
|
||||
<Textarea
|
||||
id="edit-dept-desc"
|
||||
placeholder="Optional description..."
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsEditDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleUpdateDepartment}>
|
||||
Save Changes
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+125
-135
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { Search, Plus, MoreHorizontal, Shield, User, Mail, Clock } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Search, Plus, MoreHorizontal, Shield, User, Mail, Clock, Loader2 } from "lucide-react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
@@ -13,59 +14,62 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { api, OrganizationMember } from "@/lib/api";
|
||||
import { useCurrentOrganizationId } from "@/hooks/useCurrentOrganization";
|
||||
|
||||
const members = [
|
||||
{
|
||||
id: "1",
|
||||
name: "John Doe",
|
||||
email: "john@example.com",
|
||||
role: "admin",
|
||||
status: "active",
|
||||
lastActive: "2 hours ago",
|
||||
avatar: null,
|
||||
initials: "JD",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Jane Smith",
|
||||
email: "jane@example.com",
|
||||
role: "member",
|
||||
status: "active",
|
||||
lastActive: "5 minutes ago",
|
||||
avatar: null,
|
||||
initials: "JS",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "Bob Wilson",
|
||||
email: "bob@example.com",
|
||||
role: "member",
|
||||
status: "disabled",
|
||||
lastActive: "3 days ago",
|
||||
avatar: null,
|
||||
initials: "BW",
|
||||
},
|
||||
];
|
||||
|
||||
const pendingInvites = [
|
||||
{
|
||||
id: "1",
|
||||
email: "alice@example.com",
|
||||
role: "member",
|
||||
sentAt: "2 days ago",
|
||||
expiresAt: "5 days",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
email: "charlie@example.com",
|
||||
role: "admin",
|
||||
sentAt: "1 hour ago",
|
||||
expiresAt: "7 days",
|
||||
},
|
||||
];
|
||||
const getInitials = (name: string | null | undefined): string => {
|
||||
if (!name) return "?";
|
||||
return name
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
};
|
||||
|
||||
export default function MembersPage() {
|
||||
const params = useParams<{ orgId?: string }>();
|
||||
const { orgId: fallbackOrgId } = useCurrentOrganizationId();
|
||||
const orgId = params.orgId || fallbackOrgId;
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [members, setMembers] = useState<OrganizationMember[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setError(null);
|
||||
setMembers([]);
|
||||
if (!orgId) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchMembers = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const response = await api.organizations.getMembers(orgId);
|
||||
setMembers(response.members || []);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch members:", err);
|
||||
setError("Failed to load members. Please try again.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchMembers();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [orgId]);
|
||||
|
||||
const filteredMembers = members.filter((member) => {
|
||||
const searchLower = search.toLowerCase();
|
||||
return (
|
||||
(member.user?.full_name?.toLowerCase().includes(searchLower) ?? false) ||
|
||||
(member.user?.email.toLowerCase().includes(searchLower) ?? false)
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
@@ -88,7 +92,7 @@ export default function MembersPage() {
|
||||
Members ({members.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="invites">
|
||||
Pending Invites ({pendingInvites.length})
|
||||
Pending Invites (0)
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -107,63 +111,76 @@ export default function MembersPage() {
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="divide-y">
|
||||
{members.map((member) => (
|
||||
<div key={member.id} className="p-4 flex items-center gap-4">
|
||||
<Avatar className="w-10 h-10">
|
||||
<AvatarImage src={member.avatar || undefined} />
|
||||
<AvatarFallback className="bg-primary text-primary-foreground">
|
||||
{member.initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium text-foreground truncate">
|
||||
{member.name}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-muted-foreground">Loading members...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-8 text-center text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
) : filteredMembers.length === 0 ? (
|
||||
<div className="p-8 text-center text-muted-foreground">
|
||||
No members found
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{filteredMembers.map((member) => (
|
||||
<div key={member.id} className="p-4 flex items-center gap-4">
|
||||
<Avatar className="w-10 h-10">
|
||||
<AvatarImage src={member.user?.avatar_url || undefined} />
|
||||
<AvatarFallback className="bg-primary text-primary-foreground">
|
||||
{getInitials(member.user?.full_name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium text-foreground truncate">
|
||||
{member.user?.full_name || member.user?.email}
|
||||
</p>
|
||||
{member.role === "admin" && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
<Shield className="w-3 h-3 mr-1" />
|
||||
Admin
|
||||
</Badge>
|
||||
)}
|
||||
{member.role === "owner" && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
<Shield className="w-3 h-3 mr-1" />
|
||||
Owner
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{member.user?.email}
|
||||
</p>
|
||||
{member.role === "admin" && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
<Shield className="w-3 h-3 mr-1" />
|
||||
Admin
|
||||
</Badge>
|
||||
)}
|
||||
{member.status === "disabled" && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
Disabled
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{member.email}
|
||||
</p>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem>
|
||||
<User className="w-4 h-4 mr-2" />
|
||||
View profile
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Shield className="w-4 h-4 mr-2" />
|
||||
Change role
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-destructive">
|
||||
Remove member
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground hidden sm:block">
|
||||
Active {member.lastActive}
|
||||
</p>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem>
|
||||
<User className="w-4 h-4 mr-2" />
|
||||
View profile
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Shield className="w-4 h-4 mr-2" />
|
||||
Change role
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-destructive">
|
||||
{member.status === "active" ? "Disable" : "Enable"} account
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
@@ -171,35 +188,8 @@ export default function MembersPage() {
|
||||
<TabsContent value="invites">
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="divide-y">
|
||||
{pendingInvites.map((invite) => (
|
||||
<div key={invite.id} className="p-4 flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center">
|
||||
<Mail className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-foreground truncate">
|
||||
{invite.email}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>Invited as {invite.role}</span>
|
||||
<span>•</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
Expires in {invite.expiresAt}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm">
|
||||
Resend
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="text-destructive">
|
||||
Revoke
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="p-8 text-center text-muted-foreground">
|
||||
No pending invitations
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { Plus, Key, ExternalLink, MoreHorizontal, Copy, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Plus, Key, MoreHorizontal, Copy, Trash2, Loader2, AlertCircle, CheckCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -21,39 +21,75 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
const clients = [
|
||||
{
|
||||
id: "1",
|
||||
name: "GitLab",
|
||||
clientId: "gitlab_prod_xxxxxxxxxxxxx",
|
||||
redirectUris: ["https://gitlab.example.com/callback"],
|
||||
scopes: ["openid", "profile", "email"],
|
||||
createdAt: "2024-01-10",
|
||||
lastUsed: "2 hours ago",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Grafana",
|
||||
clientId: "grafana_prod_xxxxxxxxxxxxx",
|
||||
redirectUris: ["https://grafana.example.com/login/generic_oauth"],
|
||||
scopes: ["openid", "profile"],
|
||||
createdAt: "2024-01-08",
|
||||
lastUsed: "5 minutes ago",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "OAuth2 Proxy",
|
||||
clientId: "oauth2proxy_xxxxxxxxxxxxx",
|
||||
redirectUris: ["https://auth.example.com/oauth2/callback"],
|
||||
scopes: ["openid", "profile", "email", "groups"],
|
||||
createdAt: "2024-01-05",
|
||||
lastUsed: "1 day ago",
|
||||
},
|
||||
];
|
||||
import { api, OIDCClient, OIDCClientWithSecret } from "@/lib/api";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
export default function OIDCClientsPage() {
|
||||
const { toast } = useToast();
|
||||
const [orgId, setOrgId] = useState<string | null>(null);
|
||||
const [clients, setClients] = useState<OIDCClient[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [newSecret, setNewSecret] = useState<{ clientId: string; secret: string } | null>(null);
|
||||
|
||||
const nameRef = useRef<HTMLInputElement>(null);
|
||||
const urisRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const loadData = (id: string) => {
|
||||
api.organizations.getClients(id)
|
||||
.then((data) => setClients(data.clients))
|
||||
.catch(() => toast({ title: "Error", description: "Failed to load OIDC clients.", variant: "destructive" }))
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
api.users.organizations()
|
||||
.then((data) => {
|
||||
if (!data.organizations.length) { setIsLoading(false); return; }
|
||||
const id = data.organizations[0].id;
|
||||
setOrgId(id);
|
||||
loadData(id);
|
||||
})
|
||||
.catch(() => { setIsLoading(false); });
|
||||
}, []);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!orgId || !nameRef.current || !urisRef.current) return;
|
||||
const name = nameRef.current.value.trim();
|
||||
const uris = urisRef.current.value.trim().split(/[\n,]+/).map((u) => u.trim()).filter(Boolean);
|
||||
if (!name || !uris.length) return;
|
||||
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const result = await api.organizations.createClient(orgId, name, uris);
|
||||
const created = result.client as OIDCClientWithSecret;
|
||||
setClients((prev) => [...prev, created]);
|
||||
setNewSecret({ clientId: created.client_id, secret: created.client_secret });
|
||||
setIsCreateOpen(false);
|
||||
} catch {
|
||||
toast({ title: "Error", description: "Failed to create client.", variant: "destructive" });
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (clientId: string) => {
|
||||
if (!orgId) return;
|
||||
try {
|
||||
await api.organizations.deleteClient(orgId, clientId);
|
||||
setClients((prev) => prev.filter((c) => c.id !== clientId));
|
||||
toast({ title: "Client deleted", description: "OIDC client deactivated successfully." });
|
||||
} catch {
|
||||
toast({ title: "Error", description: "Failed to delete client.", variant: "destructive" });
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text).then(() =>
|
||||
toast({ title: "Copied", description: "Copied to clipboard." })
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
@@ -81,7 +117,7 @@ export default function OIDCClientsPage() {
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="clientName">Client name</Label>
|
||||
<Input id="clientName" placeholder="My Application" />
|
||||
<Input id="clientName" placeholder="My Application" ref={nameRef} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="redirectUris">Redirect URIs</Label>
|
||||
@@ -89,17 +125,18 @@ export default function OIDCClientsPage() {
|
||||
id="redirectUris"
|
||||
placeholder="https://myapp.example.com/callback"
|
||||
className="min-h-[80px]"
|
||||
ref={urisRef}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
One URI per line. These are the allowed callback URLs.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setIsCreateOpen(false)}>
|
||||
<Button variant="outline" onClick={() => setIsCreateOpen(false)} disabled={isCreating}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => setIsCreateOpen(false)}>
|
||||
Create client
|
||||
<Button onClick={handleCreate} disabled={isCreating}>
|
||||
{isCreating ? <><Loader2 className="w-4 h-4 mr-2 animate-spin" />Creating...</> : "Create client"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -107,71 +144,101 @@ export default function OIDCClientsPage() {
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{clients.map((client) => (
|
||||
<Card key={client.id}>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<Key className="w-6 h-6 text-primary" />
|
||||
{/* Show new client secret once */}
|
||||
{newSecret && (
|
||||
<Card className="mb-4 border-success/50 bg-success/5">
|
||||
<CardContent className="p-4 flex items-start gap-3">
|
||||
<CheckCircle className="w-5 h-5 text-success mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-foreground">Client created — save your secret now</p>
|
||||
<p className="text-sm text-muted-foreground mb-2">This secret will not be shown again.</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs bg-muted px-2 py-1 rounded font-mono break-all">{newSecret.secret}</code>
|
||||
<Button variant="ghost" size="icon" className="w-6 h-6 flex-shrink-0" onClick={() => copyToClipboard(newSecret.secret)}>
|
||||
<Copy className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="w-6 h-6" onClick={() => setNewSecret(null)}>×</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : clients.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="text-center py-12">
|
||||
<AlertCircle className="w-10 h-10 mx-auto mb-3 text-muted-foreground/50" />
|
||||
<p className="text-muted-foreground">No OIDC clients configured yet.</p>
|
||||
<Button className="mt-4" onClick={() => setIsCreateOpen(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add your first client
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{clients.map((client) => (
|
||||
<Card key={client.id}>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<Key className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground">{client.name}</h3>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<code className="text-xs bg-muted px-2 py-1 rounded font-mono">
|
||||
{client.client_id}
|
||||
</code>
|
||||
<Button variant="ghost" size="icon" className="w-6 h-6" onClick={() => copyToClipboard(client.client_id)}>
|
||||
<Copy className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1 mt-3">
|
||||
{(client.scopes ?? []).map((scope) => (
|
||||
<Badge key={scope} variant="secondary" className="text-xs">
|
||||
{scope}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground">{client.name}</h3>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<code className="text-xs bg-muted px-2 py-1 rounded font-mono">
|
||||
{client.clientId}
|
||||
</code>
|
||||
<Button variant="ghost" size="icon" className="w-6 h-6">
|
||||
<Copy className="w-3 h-3" />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1 mt-3">
|
||||
{client.scopes.map((scope) => (
|
||||
<Badge key={scope} variant="secondary" className="text-xs">
|
||||
{scope}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => handleDelete(client.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Delete client
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="mt-4 pt-4 border-t flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>
|
||||
Created {new Date(client.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{(client.redirect_uris ?? []).length} redirect URI{(client.redirect_uris ?? []).length !== 1 ? "s" : ""}
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem>
|
||||
<ExternalLink className="w-4 h-4 mr-2" />
|
||||
View details
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Rotate secret
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-destructive">
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Delete client
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="mt-4 pt-4 border-t flex items-center justify-between text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-4">
|
||||
<span>Created {client.createdAt}</span>
|
||||
<span>•</span>
|
||||
<span>Last used {client.lastUsed}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{client.redirectUris.length} redirect URI{client.redirectUris.length > 1 ? "s" : ""}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+130
-103
@@ -1,90 +1,77 @@
|
||||
import { useState } from "react";
|
||||
import { Search, Filter, Download, User, Settings, Key, UserPlus, AlertTriangle } from "lucide-react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Search, Filter, Download, User, Settings, Key, UserPlus, AlertTriangle, Loader2 } from "lucide-react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { api, AuditLogEntry } from "@/lib/api";
|
||||
import { useCurrentOrganizationId } from "@/hooks/useCurrentOrganization";
|
||||
|
||||
const auditEvents = [
|
||||
{
|
||||
id: "1",
|
||||
type: "member_invited",
|
||||
actor: "John Doe",
|
||||
target: "alice@example.com",
|
||||
timestamp: "2024-01-15T10:30:00Z",
|
||||
details: "Invited as member",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
type: "policy_changed",
|
||||
actor: "John Doe",
|
||||
target: "Password Policy",
|
||||
timestamp: "2024-01-15T09:00:00Z",
|
||||
details: "Minimum length changed from 8 to 12",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
type: "member_disabled",
|
||||
actor: "Jane Smith",
|
||||
target: "bob@example.com",
|
||||
timestamp: "2024-01-14T15:45:00Z",
|
||||
details: "Account disabled",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
type: "client_created",
|
||||
actor: "John Doe",
|
||||
target: "GitLab",
|
||||
timestamp: "2024-01-14T12:00:00Z",
|
||||
details: "OIDC client created",
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
type: "role_changed",
|
||||
actor: "John Doe",
|
||||
target: "jane@example.com",
|
||||
timestamp: "2024-01-13T09:00:00Z",
|
||||
details: "Role changed from member to admin",
|
||||
},
|
||||
];
|
||||
|
||||
const getEventIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case "member_invited":
|
||||
case "role_changed":
|
||||
return <UserPlus className="w-4 h-4" />;
|
||||
case "policy_changed":
|
||||
return <Settings className="w-4 h-4" />;
|
||||
case "member_disabled":
|
||||
return <AlertTriangle className="w-4 h-4" />;
|
||||
case "client_created":
|
||||
return <Key className="w-4 h-4" />;
|
||||
default:
|
||||
return <User className="w-4 h-4" />;
|
||||
const getEventIcon = (action: string) => {
|
||||
if (action.includes("member") || action.includes("MEMBER")) {
|
||||
return <UserPlus className="w-4 h-4" />;
|
||||
}
|
||||
if (action.includes("policy") || action.includes("POLICY") || action.includes("mfa")) {
|
||||
return <Settings className="w-4 h-4" />;
|
||||
}
|
||||
if (action.includes("delete") || action.includes("DELETE") || action.includes("disable")) {
|
||||
return <AlertTriangle className="w-4 h-4" />;
|
||||
}
|
||||
if (action.includes("client") || action.includes("oidc") || action.includes("key")) {
|
||||
return <Key className="w-4 h-4" />;
|
||||
}
|
||||
return <User className="w-4 h-4" />;
|
||||
};
|
||||
|
||||
const getEventTitle = (type: string) => {
|
||||
switch (type) {
|
||||
case "member_invited":
|
||||
return "Member invited";
|
||||
case "policy_changed":
|
||||
return "Policy changed";
|
||||
case "member_disabled":
|
||||
return "Member disabled";
|
||||
case "client_created":
|
||||
return "OIDC client created";
|
||||
case "role_changed":
|
||||
return "Role changed";
|
||||
default:
|
||||
return "Event";
|
||||
}
|
||||
const getEventTitle = (action: string) => {
|
||||
const parts = action.split(".");
|
||||
return parts.map(p => p.charAt(0).toUpperCase() + p.slice(1)).join(" ");
|
||||
};
|
||||
|
||||
const getActionCategory = (action: string): string => {
|
||||
if (action.includes("member") || action.includes("MEMBER")) return "members";
|
||||
if (action.includes("policy") || action.includes("POLICY") || action.includes("mfa")) return "policies";
|
||||
if (action.includes("client") || action.includes("OIDC")) return "clients";
|
||||
return "other";
|
||||
};
|
||||
|
||||
export default function OrgAuditPage() {
|
||||
const params = useParams<{ orgId?: string }>();
|
||||
const { orgId: fallbackOrgId } = useCurrentOrganizationId();
|
||||
const orgId = params.orgId || fallbackOrgId;
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState("all");
|
||||
const [auditLogs, setAuditLogs] = useState<AuditLogEntry[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchAuditLogs = useCallback(async (currentOrgId: string) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const response = await api.organizations.getAuditLogs(currentOrgId);
|
||||
setAuditLogs(response.audit_logs || []);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch audit logs:", err);
|
||||
setError("Failed to load audit logs. Please try again.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setError(null);
|
||||
setAuditLogs([]);
|
||||
if (!orgId) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
fetchAuditLogs(orgId);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [orgId]);
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
@@ -96,6 +83,20 @@ export default function OrgAuditPage() {
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
const filteredLogs = auditLogs.filter((log) => {
|
||||
const matchesSearch =
|
||||
search === "" ||
|
||||
log.description?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
log.action.toLowerCase().includes(search.toLowerCase()) ||
|
||||
log.user?.email.toLowerCase().includes(search.toLowerCase());
|
||||
|
||||
const matchesFilter =
|
||||
typeFilter === "all" ||
|
||||
getActionCategory(log.action) === typeFilter;
|
||||
|
||||
return matchesSearch && matchesFilter;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
@@ -137,39 +138,65 @@ export default function OrgAuditPage() {
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="divide-y">
|
||||
{auditEvents.map((event) => (
|
||||
<div key={event.id} className="p-4 flex items-start gap-4">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 ${
|
||||
event.type === "member_disabled"
|
||||
? "bg-destructive/10 text-destructive"
|
||||
: "bg-accent/10 text-accent"
|
||||
}`}
|
||||
>
|
||||
{getEventIcon(event.type)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className="font-medium text-foreground">
|
||||
{getEventTitle(event.type)}
|
||||
</p>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{event.target}
|
||||
</Badge>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-muted-foreground">Loading audit logs...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-8 text-center text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
) : filteredLogs.length === 0 ? (
|
||||
<div className="p-8 text-center text-muted-foreground">
|
||||
No audit events found
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{filteredLogs.map((log) => (
|
||||
<div key={log.id} className="p-4 flex items-start gap-4">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 ${
|
||||
!log.success
|
||||
? "bg-destructive/10 text-destructive"
|
||||
: "bg-accent/10 text-accent"
|
||||
}`}
|
||||
>
|
||||
{getEventIcon(log.action)}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
<span>by {event.actor}</span>
|
||||
<span className="mx-2">•</span>
|
||||
<span>{event.details}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className="font-medium text-foreground">
|
||||
{getEventTitle(log.action)}
|
||||
</p>
|
||||
{log.resource_type && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{log.resource_type}
|
||||
</Badge>
|
||||
)}
|
||||
{!log.success && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
Failed
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
<span>by {log.user?.full_name || log.user?.email || "System"}</span>
|
||||
{log.description && (
|
||||
<>
|
||||
<span className="mx-2">•</span>
|
||||
<span>{log.description}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
{formatDate(log.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
{formatDate(event.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,33 @@
|
||||
import { Building2, Users, Shield, Key, ArrowRight, TrendingUp } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Building2, Users, Shield, Key, ArrowRight, TrendingUp, Loader2 } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { api, Organization, OIDCClient } from "@/lib/api";
|
||||
|
||||
export default function OrgOverviewPage() {
|
||||
// Mock organization data
|
||||
const org = {
|
||||
name: "Acme Corp",
|
||||
createdAt: "January 2024",
|
||||
stats: {
|
||||
totalMembers: 24,
|
||||
activeToday: 18,
|
||||
pendingInvites: 3,
|
||||
oidcClients: 5,
|
||||
},
|
||||
};
|
||||
const [org, setOrg] = useState<Organization | null>(null);
|
||||
const [memberCount, setMemberCount] = useState<number>(0);
|
||||
const [clientCount, setClientCount] = useState<number>(0);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
api.users.organizations()
|
||||
.then(async (data) => {
|
||||
if (!data.organizations.length) return;
|
||||
const first = data.organizations[0];
|
||||
setOrg(first);
|
||||
|
||||
const [membersResp, clientsResp] = await Promise.allSettled([
|
||||
api.organizations.getMembers(first.id),
|
||||
api.organizations.getClients(first.id),
|
||||
]);
|
||||
|
||||
if (membersResp.status === "fulfilled") setMemberCount(membersResp.value.count);
|
||||
if (clientsResp.status === "fulfilled") setClientCount((clientsResp.value as { clients: OIDCClient[]; count: number }).count);
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
const quickLinks = [
|
||||
{
|
||||
@@ -37,6 +50,18 @@ export default function OrgOverviewPage() {
|
||||
},
|
||||
];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="page-container flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const createdAt = org?.created_at
|
||||
? new Date(org.created_at).toLocaleDateString("en-US", { month: "long", year: "numeric" })
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
@@ -45,42 +70,20 @@ export default function OrgOverviewPage() {
|
||||
<Building2 className="w-7 h-7 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="page-title">{org.name}</h1>
|
||||
<p className="page-description">Created {org.createdAt}</p>
|
||||
<h1 className="page-title">{org?.name ?? "Organization"}</h1>
|
||||
{createdAt && <p className="page-description">Created {createdAt}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid gap-4 grid-cols-2 lg:grid-cols-4 mb-8">
|
||||
<div className="grid gap-4 grid-cols-2 lg:grid-cols-3 mb-8">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Total Members</p>
|
||||
<p className="text-2xl font-semibold">{org.stats.totalMembers}</p>
|
||||
</div>
|
||||
<Users className="w-8 h-8 text-muted-foreground/30" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Active Today</p>
|
||||
<p className="text-2xl font-semibold">{org.stats.activeToday}</p>
|
||||
</div>
|
||||
<TrendingUp className="w-8 h-8 text-muted-foreground/30" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Pending Invites</p>
|
||||
<p className="text-2xl font-semibold">{org.stats.pendingInvites}</p>
|
||||
<p className="text-2xl font-semibold">{memberCount}</p>
|
||||
</div>
|
||||
<Users className="w-8 h-8 text-muted-foreground/30" />
|
||||
</div>
|
||||
@@ -91,12 +94,23 @@ export default function OrgOverviewPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">OIDC Clients</p>
|
||||
<p className="text-2xl font-semibold">{org.stats.oidcClients}</p>
|
||||
<p className="text-2xl font-semibold">{clientCount}</p>
|
||||
</div>
|
||||
<Key className="w-8 h-8 text-muted-foreground/30" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Org ID</p>
|
||||
<p className="text-xs font-mono text-muted-foreground mt-1 truncate max-w-[140px]">{org?.id ?? "—"}</p>
|
||||
</div>
|
||||
<TrendingUp className="w-8 h-8 text-muted-foreground/30" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Quick Links */}
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Search, Plus, MoreHorizontal, Users, Loader2, Trash2, Edit2, Link as LinkIcon, Unlink } from "lucide-react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api, Principal, Department } from "@/lib/api";
|
||||
import { useCurrentOrganizationId } from "@/hooks/useCurrentOrganization";
|
||||
|
||||
export default function PrincipalsPage() {
|
||||
const params = useParams<{ orgId?: string }>();
|
||||
const { orgId: fallbackOrgId } = useCurrentOrganizationId();
|
||||
const orgId = params.orgId || fallbackOrgId;
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [principals, setPrincipals] = useState<Principal[]>([]);
|
||||
const [departments, setDepartments] = useState<Department[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||
const [isLinkDialogOpen, setIsLinkDialogOpen] = useState(false);
|
||||
const [editingPrincipal, setEditingPrincipal] = useState<Principal | null>(null);
|
||||
const [selectedPrincipalForLink, setSelectedPrincipalForLink] = useState<Principal | null>(null);
|
||||
const [selectedDepartmentId, setSelectedDepartmentId] = useState("");
|
||||
const [formData, setFormData] = useState({ name: "", description: "" });
|
||||
|
||||
useEffect(() => {
|
||||
setError(null);
|
||||
setPrincipals([]);
|
||||
setDepartments([]);
|
||||
if (!orgId) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
fetchData(orgId);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [orgId]);
|
||||
|
||||
const fetchData = async (currentOrgId: string) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const [principalsRes, deptRes] = await Promise.all([
|
||||
api.organizations.getPrincipals(currentOrgId),
|
||||
api.organizations.getDepartments(currentOrgId),
|
||||
]);
|
||||
setPrincipals(principalsRes.principals || []);
|
||||
setDepartments(deptRes.departments || []);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch data:", err);
|
||||
setError("Failed to load data. Please try again.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreatePrincipal = async () => {
|
||||
if (!orgId || !formData.name.trim()) return;
|
||||
|
||||
try {
|
||||
await api.organizations.createPrincipal(
|
||||
orgId,
|
||||
formData.name,
|
||||
formData.description || undefined
|
||||
);
|
||||
setFormData({ name: "", description: "" });
|
||||
setIsCreateDialogOpen(false);
|
||||
await fetchData(orgId);
|
||||
} catch (err) {
|
||||
console.error("Failed to create principal:");
|
||||
setError("Failed to create principal.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdatePrincipal = async () => {
|
||||
if (!orgId || !editingPrincipal || !formData.name.trim()) return;
|
||||
|
||||
try {
|
||||
await api.organizations.updatePrincipal(
|
||||
orgId,
|
||||
editingPrincipal.id,
|
||||
{
|
||||
name: formData.name,
|
||||
description: formData.description || undefined,
|
||||
}
|
||||
);
|
||||
setFormData({ name: "", description: "" });
|
||||
setEditingPrincipal(null);
|
||||
setIsEditDialogOpen(false);
|
||||
await fetchData(orgId);
|
||||
} catch (err) {
|
||||
console.error("Failed to update principal:");
|
||||
setError("Failed to update principal.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePrincipal = async (principalId: string) => {
|
||||
if (!orgId || !confirm("Are you sure you want to delete this principal?")) return;
|
||||
|
||||
try {
|
||||
await api.organizations.deletePrincipal(orgId, principalId);
|
||||
await fetchData(orgId);
|
||||
} catch (err) {
|
||||
console.error("Failed to delete principal:");
|
||||
setError("Failed to delete principal.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleLinkPrincipal = async () => {
|
||||
if (!orgId || !selectedPrincipalForLink || !selectedDepartmentId) return;
|
||||
|
||||
try {
|
||||
await api.organizations.linkPrincipalToDepartment(
|
||||
orgId,
|
||||
selectedPrincipalForLink.id,
|
||||
selectedDepartmentId
|
||||
);
|
||||
setSelectedPrincipalForLink(null);
|
||||
setSelectedDepartmentId("");
|
||||
setIsLinkDialogOpen(false);
|
||||
await fetchData(orgId);
|
||||
} catch (err) {
|
||||
console.error("Failed to link principal:");
|
||||
setError("Failed to link principal to department.");
|
||||
}
|
||||
};
|
||||
|
||||
const openEditDialog = (principal: Principal) => {
|
||||
setEditingPrincipal(principal);
|
||||
setFormData({ name: principal.name, description: principal.description || "" });
|
||||
setIsEditDialogOpen(true);
|
||||
};
|
||||
|
||||
const openLinkDialog = (principal: Principal) => {
|
||||
setSelectedPrincipalForLink(principal);
|
||||
setSelectedDepartmentId("");
|
||||
setIsLinkDialogOpen(true);
|
||||
};
|
||||
|
||||
const filteredPrincipals = principals.filter((principal) => {
|
||||
const searchLower = search.toLowerCase();
|
||||
return (
|
||||
principal.name.toLowerCase().includes(searchLower) ||
|
||||
(principal.description?.toLowerCase().includes(searchLower) ?? false)
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="page-title">Principals</h1>
|
||||
<p className="page-description">
|
||||
Manage principals and link them to departments
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => { setFormData({ name: "", description: "" }); setIsCreateDialogOpen(true); }}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Create Principal
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search principals..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-10 max-w-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-muted-foreground">Loading principals...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-8 text-center text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
) : filteredPrincipals.length === 0 ? (
|
||||
<div className="p-8 text-center text-muted-foreground">
|
||||
No principals found
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{filteredPrincipals.map((principal) => (
|
||||
<div key={principal.id} className="p-4 flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-purple-100 dark:bg-purple-900 text-purple-600 dark:text-purple-300 flex items-center justify-center flex-shrink-0">
|
||||
<Users className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium text-foreground">
|
||||
{principal.name}
|
||||
</p>
|
||||
</div>
|
||||
{principal.description && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{principal.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
Created {new Date(principal.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => openEditDialog(principal)}>
|
||||
<Edit2 className="w-4 h-4 mr-2" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => openLinkDialog(principal)}>
|
||||
<LinkIcon className="w-4 h-4 mr-2" />
|
||||
Link to Department
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => handleDeletePrincipal(principal.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Create Principal Dialog */}
|
||||
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Principal</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new principal to manage access and permissions
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="principal-name">Principal Name</Label>
|
||||
<Input
|
||||
id="principal-name"
|
||||
placeholder="e.g., Backend Team"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="principal-desc">Description</Label>
|
||||
<Textarea
|
||||
id="principal-desc"
|
||||
placeholder="Optional description..."
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsCreateDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreatePrincipal}>
|
||||
Create Principal
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Edit Principal Dialog */}
|
||||
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Principal</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update principal information
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="edit-principal-name">Principal Name</Label>
|
||||
<Input
|
||||
id="edit-principal-name"
|
||||
placeholder="e.g., Backend Team"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="edit-principal-desc">Description</Label>
|
||||
<Textarea
|
||||
id="edit-principal-desc"
|
||||
placeholder="Optional description..."
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsEditDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleUpdatePrincipal}>
|
||||
Save Changes
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Link Principal to Department Dialog */}
|
||||
<Dialog open={isLinkDialogOpen} onOpenChange={setIsLinkDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Link Principal to Department</DialogTitle>
|
||||
<DialogDescription>
|
||||
Associate this principal with a department
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="dept-select">Select Department</Label>
|
||||
<Select value={selectedDepartmentId} onValueChange={setSelectedDepartmentId}>
|
||||
<SelectTrigger id="dept-select">
|
||||
<SelectValue placeholder="Choose a department..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{departments.map((dept) => (
|
||||
<SelectItem key={dept.id} value={dept.id}>
|
||||
{dept.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsLinkDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleLinkPrincipal} disabled={!selectedDepartmentId}>
|
||||
Link Principal
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user