Feat(Fix): Multi Org, Suspension, User Detail

Multi Org switch, members suspend/unsuspend status, delete account, next serial, show email in user member search
This commit is contained in:
2026-03-02 23:55:47 +05:45
parent 6cab506603
commit b97937f080
16 changed files with 2011 additions and 298 deletions
+5 -1
View File
@@ -128,7 +128,7 @@ function CADetailCard({ ca, onEdit, onRotate, onDelete }: CADetailCardProps) {
<CardContent className="space-y-4">
{/* Stats — hidden for system CAs (we have no cert records for them) */}
{!isSystem && (
<div className="grid grid-cols-3 gap-3 text-center">
<div className="grid grid-cols-4 gap-3 text-center">
<div className="p-2 bg-muted rounded-lg">
<p className="text-lg font-semibold">{ca.active_certs}</p>
<p className="text-xs text-muted-foreground">Active certs</p>
@@ -141,6 +141,10 @@ function CADetailCard({ ca, onEdit, onRotate, onDelete }: CADetailCardProps) {
<p className="text-lg font-semibold">{ca.default_cert_validity_hours}h</p>
<p className="text-xs text-muted-foreground">Default validity</p>
</div>
<div className="p-2 bg-muted rounded-lg">
<p className="text-lg font-semibold">{ca.next_serial_number ?? '—'}</p>
<p className="text-xs text-muted-foreground">Next serial</p>
</div>
</div>
)}
+3 -12
View File
@@ -9,7 +9,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { api, OrgComplianceMember, create403Handler } from "@/lib/api";
import { useQuery, useMutation } from "@tanstack/react-query";
import { useToast } from "@/hooks/use-toast";
import { useOrganizations } from "@/hooks/useOrganizations";
import { useOrg } from "@/contexts/OrgContext";
const STATUS_CONFIG: Record<string, { label: string; color: string; icon: typeof Clock }> = {
compliant: {
@@ -47,19 +47,10 @@ const STATUS_CONFIG: Record<string, { label: string; color: string; icon: typeof
export default function CompliancePage() {
const navigate = useNavigate();
const { toast } = useToast();
const [currentOrgId, setCurrentOrgId] = useState<string | null>(null);
const { selectedOrgId: currentOrgId } = useOrg();
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("all");
// Fetch organizations to get current org
const { data: organizations, isLoading: orgsLoading } = useOrganizations();
useEffect(() => {
if (organizations && organizations.length > 0) {
setCurrentOrgId(organizations[0].id);
}
}, [organizations]);
// Fetch compliance data
const { data: complianceData, isLoading: complianceLoading } = useQuery({
queryKey: ['org-compliance', currentOrgId],
@@ -101,7 +92,7 @@ export default function CompliancePage() {
suspended: complianceData?.members?.filter(m => m.status === 'suspended').length || 0,
};
if (orgsLoading || complianceLoading) {
if (complianceLoading) {
return (
<div className="page-container">
<div className="flex items-center justify-center py-12">
+12 -5
View File
@@ -344,11 +344,18 @@ function DepartmentMembersPanel({ orgId, deptId }: { orgId: string; deptId: stri
className="flex-1 h-8 rounded-md border border-input bg-background px-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="">Select org member to add</option>
{available.map((m) => (
<option key={m.user_id} value={m.user_id}>
{m.user?.full_name || m.user?.email || m.user_id}
</option>
))}
{available.map((m) => {
const email = m.user?.email || "";
const name = m.user?.full_name;
const label = name && email
? `${name} (${email})`
: email || m.user_id;
return (
<option key={m.user_id} value={m.user_id}>
{label}
</option>
);
})}
</select>
<Button size="sm" onClick={handleAdd} disabled={!selectedUserId || isAdding}>
{isAdding ? <Loader2 className="w-3 h-3 animate-spin mr-1" /> : <UserPlus className="w-3 h-3 mr-1" />}
File diff suppressed because it is too large Load Diff
+6 -10
View File
@@ -23,10 +23,11 @@ import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { api, OIDCClient, OIDCClientWithSecret } from "@/lib/api";
import { useToast } from "@/hooks/use-toast";
import { useOrg } from "@/contexts/OrgContext";
export default function OIDCClientsPage() {
const { toast } = useToast();
const [orgId, setOrgId] = useState<string | null>(null);
const { selectedOrgId: orgId } = useOrg();
const [clients, setClients] = useState<OIDCClient[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isCreateOpen, setIsCreateOpen] = useState(false);
@@ -44,15 +45,10 @@ export default function OIDCClientsPage() {
};
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); });
}, []);
if (!orgId) { setIsLoading(false); return; }
setIsLoading(true);
loadData(orgId);
}, [orgId]);
const handleCreate = async () => {
if (!orgId || !nameRef.current || !urisRef.current) return;
+159 -23
View File
@@ -1,33 +1,82 @@
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 } from "@/components/ui/card";
import { api, Organization, OIDCClient } from "@/lib/api";
import { Building2, Users, Shield, Key, ArrowRight, TrendingUp, Loader2, Trash2, AlertTriangle, ArrowLeftRight } from "lucide-react";
import { Link, useNavigate } from "react-router-dom";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { api, OIDCClient, ApiError } from "@/lib/api";
import { useOrg } from "@/contexts/OrgContext";import { useOrganizations } from "@/hooks/useOrganizations";
import { toast } from "@/hooks/use-toast";
export default function OrgOverviewPage() {
const [org, setOrg] = useState<Organization | null>(null);
const navigate = useNavigate();
const { selectedOrg, selectOrg } = useOrg();
const { refetch: refetchOrgs } = useOrganizations();
const [memberCount, setMemberCount] = useState<number>(0);
const [clientCount, setClientCount] = useState<number>(0);
const [isLoading, setIsLoading] = useState(true);
// Delete org dialog state
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const isOwner = selectedOrg?.role === "owner";
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)
if (!selectedOrg) return;
setIsLoading(true);
Promise.allSettled([
api.organizations.getMembers(selectedOrg.id),
api.organizations.getClients(selectedOrg.id),
]).then(([membersResp, clientsResp]) => {
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));
}, []);
}, [selectedOrg?.id]);
const handleDeleteOrg = async () => {
if (!selectedOrg) return;
setIsDeleting(true);
try {
await api.organizations.deleteOrganization(selectedOrg.id);
toast({ title: "Organization deleted", description: `"${selectedOrg.name}" has been deleted.` });
setDeleteDialogOpen(false);
// Refresh org list; context will auto-select next available org
const result = await refetchOrgs();
const remaining = result.data ?? [];
if (remaining.length > 0) {
selectOrg(remaining[0]);
navigate("/org");
} else {
navigate("/org-setup");
}
} catch (err) {
if (err instanceof ApiError && err.type === "ORG_HAS_MEMBERS") {
toast({
title: "Cannot delete organization",
description: "This organization still has other members. Transfer ownership or remove all members first.",
variant: "destructive",
});
} else {
toast({
title: "Deletion failed",
description: err instanceof ApiError ? err.message : "An unexpected error occurred.",
variant: "destructive",
});
}
setDeleteDialogOpen(false);
} finally {
setIsDeleting(false);
}
};
const quickLinks = [
{
@@ -50,7 +99,7 @@ export default function OrgOverviewPage() {
},
];
if (isLoading) {
if (isLoading && !selectedOrg) {
return (
<div className="page-container flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
@@ -58,6 +107,7 @@ export default function OrgOverviewPage() {
);
}
const org = selectedOrg;
const createdAt = org?.created_at
? new Date(org.created_at).toLocaleDateString("en-US", { month: "long", year: "numeric" })
: "";
@@ -115,7 +165,7 @@ export default function OrgOverviewPage() {
{/* Quick Links */}
<h2 className="text-lg font-semibold mb-4">Quick Actions</h2>
<div className="grid gap-4 md:grid-cols-3">
<div className="grid gap-4 md:grid-cols-3 mb-8">
{quickLinks.map((link) => (
<Link key={link.href} to={link.href}>
<Card className="h-full hover:border-accent/50 transition-colors cursor-pointer group">
@@ -133,6 +183,92 @@ export default function OrgOverviewPage() {
</Link>
))}
</div>
{/* Danger Zone — owners only */}
{isOwner && (
<Card className="border-destructive/40">
<CardHeader>
<CardTitle className="text-base text-destructive flex items-center gap-2">
<AlertTriangle className="w-4 h-4" />
Danger Zone
</CardTitle>
<CardDescription>
Irreversible actions for this organization. Proceed with caution.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Transfer ownership hint */}
<div className="flex items-center justify-between rounded-lg border border-border p-4">
<div>
<p className="text-sm font-medium">Transfer Ownership</p>
<p className="text-xs text-muted-foreground mt-0.5">
Pass ownership to another member before deleting the organization.
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => navigate("/org/members")}
>
<ArrowLeftRight className="w-4 h-4 mr-2" />
Go to Members
</Button>
</div>
{/* Delete organization */}
<div className="flex items-center justify-between rounded-lg border border-destructive/30 bg-destructive/5 p-4">
<div>
<p className="text-sm font-medium text-destructive">Delete Organization</p>
<p className="text-xs text-muted-foreground mt-0.5">
Permanently deletes this organization.{" "}
{memberCount > 1
? `You must remove all ${memberCount - 1} other member${memberCount > 2 ? "s" : ""} first.`
: "This action cannot be undone."}
</p>
</div>
<Button
variant="destructive"
size="sm"
onClick={() => setDeleteDialogOpen(true)}
disabled={memberCount > 1}
>
<Trash2 className="w-4 h-4 mr-2" />
Delete
</Button>
</div>
</CardContent>
</Card>
)}
{/* Delete confirmation dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-destructive">
<Trash2 className="w-5 h-5" />
Delete "{org?.name}"?
</DialogTitle>
<DialogDescription>
This will permanently delete the organization and all associated
data. This action <strong>cannot be undone</strong>.
</DialogDescription>
</DialogHeader>
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
<AlertTriangle className="w-4 h-4 inline mr-2" />
You are about to delete <strong>{org?.name}</strong>. All settings,
policies, OIDC clients, and CA configurations will be lost.
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)} disabled={isDeleting}>
Cancel
</Button>
<Button variant="destructive" onClick={handleDeleteOrg} disabled={isDeleting}>
{isDeleting && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
Yes, delete organization
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+4 -13
View File
@@ -12,7 +12,7 @@ import { Button } from "@/components/ui/button";
import { api, OrgPolicyResponse, UpdateOrgPolicyDto, create403Handler } from "@/lib/api";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useToast } from "@/hooks/use-toast";
import { useOrganizations } from "@/hooks/useOrganizations";
import { useOrg } from "@/contexts/OrgContext";
const MFA_MODE_LABELS: Record<string, { label: string; description: string }> = {
disabled: {
@@ -41,8 +41,8 @@ export default function PoliciesPage() {
const navigate = useNavigate();
const { toast } = useToast();
const queryClient = useQueryClient();
const [currentOrgId, setCurrentOrgId] = useState<string | null>(null);
const { selectedOrgId: currentOrgId } = useOrg();
// Local form state for unsaved changes
const [formData, setFormData] = useState({
mfa_policy_mode: '',
@@ -51,15 +51,6 @@ export default function PoliciesPage() {
});
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
// Fetch organizations to get current org
const { data: organizations, isLoading: orgsLoading } = useOrganizations();
useEffect(() => {
if (organizations && organizations.length > 0) {
setCurrentOrgId(organizations[0].id);
}
}, [organizations]);
// Fetch org policy
const { data: policy, isLoading: policyLoading } = useQuery({
queryKey: ['org-policy', currentOrgId],
@@ -180,7 +171,7 @@ export default function PoliciesPage() {
}
};
if (orgsLoading || policyLoading) {
if (policyLoading) {
return (
<div className="page-container">
<div className="flex items-center justify-center py-12">