diff --git a/src/components/navigation/AppSidebar.tsx b/src/components/navigation/AppSidebar.tsx index ed9a78d..b0406b8 100644 --- a/src/components/navigation/AppSidebar.tsx +++ b/src/components/navigation/AppSidebar.tsx @@ -22,6 +22,7 @@ import { import { SecuirdLogo } from "@/components/branding/SecuirdLogo"; import { NavLink } from "@/components/NavLink"; import { useAuth } from "@/contexts/AuthContext"; +import { useOrg } from "@/contexts/OrgContext"; import { Sidebar, SidebarContent, @@ -78,7 +79,11 @@ export function AppSidebar() { const { state } = useSidebar(); const collapsed = state === "collapsed"; const location = useLocation(); - const { isOrgAdmin, isOrgMember, canViewSystemLogs } = useAuth(); + const { isOrgMember, canViewSystemLogs } = useAuth(); + const { selectedOrg } = useOrg(); + + // Check if user is admin/owner of the CURRENTLY SELECTED org (not just any org) + const isCurrentOrgAdmin = selectedOrg?.role === "owner" || selectedOrg?.role === "admin"; const isActive = (path: string) => location.pathname === path; const isOrgActive = orgAdminNavItems.some((item) => isActive(item.url)) || adminNavItems.some((item) => isActive(item.url)); @@ -149,7 +154,7 @@ export function AppSidebar() { )} - {(isOrgAdmin ? orgAdminNavItems : orgMemberNavItems).map((item) => ( + {(isCurrentOrgAdmin ? orgAdminNavItems : orgMemberNavItems).map((item) => ( )} - {/* Admin Section — only visible to org admins/owners */} - {isOrgAdmin && ( + {/* Admin Section — only visible to org admins/owners of the CURRENT org */} + {isCurrentOrgAdmin && ( {!collapsed && ( diff --git a/src/components/navigation/TopBar.tsx b/src/components/navigation/TopBar.tsx index a92393c..cdaec37 100644 --- a/src/components/navigation/TopBar.tsx +++ b/src/components/navigation/TopBar.tsx @@ -1,5 +1,7 @@ +import { useState } from "react"; import { useNavigate } from "react-router-dom"; -import { Menu, ChevronDown, LogOut, User, Shield, Building2, Loader2 } from "lucide-react"; +import { useQueryClient } from "@tanstack/react-query"; +import { Menu, ChevronDown, LogOut, User, Shield, Building2, Loader2, Plus } from "lucide-react"; import { Button } from "@/components/ui/button"; import { SidebarTrigger } from "@/components/ui/sidebar"; import { @@ -15,15 +17,20 @@ import { useAuth } from "@/contexts/AuthContext"; import { useOrg } from "@/contexts/OrgContext"; import { useOrganizations } from "@/hooks/useOrganizations"; import { ComplianceBanner } from "@/components/auth/ComplianceBanner"; +import { CreateOrgDialog } from "@/components/org/CreateOrgDialog"; export function TopBar() { const navigate = useNavigate(); const { user, mfaCompliance, logout } = useAuth(); const { selectedOrg, selectOrg } = useOrg(); + const queryClient = useQueryClient(); // Use React Query hook for organizations with automatic caching and deduplication const { data: organizations = [], isLoading: orgsLoading } = useOrganizations(); + // New org dialog state + const [createOrgOpen, setCreateOrgOpen] = useState(false); + // Ensure organizations is always an array (defensive check) const organizationsArray = Array.isArray(organizations) ? organizations : []; @@ -93,6 +100,19 @@ export function TopBar() { )) )} + {/* New Organisation button - only show when under 10 orgs */} + {organizationsArray.length < 10 && ( + <> + + setCreateOrgOpen(true)} + className="flex items-center gap-2 text-primary cursor-pointer" + > + + New Organisation + + + )} @@ -136,6 +156,18 @@ export function TopBar() { + + {/* Create Organisation Dialog - only render when user can create orgs */} + {organizationsArray.length < 10 && ( + { + queryClient.invalidateQueries({ queryKey: ['organizations'] }); + selectOrg(org); + }} + /> + )} ); } \ No newline at end of file diff --git a/src/components/org/CreateOrgDialog.tsx b/src/components/org/CreateOrgDialog.tsx new file mode 100644 index 0000000..18461c4 --- /dev/null +++ b/src/components/org/CreateOrgDialog.tsx @@ -0,0 +1,169 @@ +import { useState, useEffect } from "react"; +import { Loader2 } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { useToast } from "@/hooks/use-toast"; +import { api, ApiError, Organization } from "@/lib/api"; + +interface CreateOrgDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSuccess?: (org: Organization) => void; +} + +function toSlug(name: string): string { + const slug = name + .toLowerCase() + .trim() + .replace(/[^\w\s-]/g, "") + .replace(/[\s_]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-+|-+$/g, ""); + return slug || "untitled-org"; +} + +export function CreateOrgDialog({ + open, + onOpenChange, + onSuccess, +}: CreateOrgDialogProps) { + const [orgName, setOrgName] = useState(""); + const [orgSlug, setOrgSlug] = useState(""); + const [slugTouched, setSlugTouched] = useState(false); + const [isCreating, setIsCreating] = useState(false); + const [createError, setCreateError] = useState(null); + const { toast } = useToast(); + + useEffect(() => { + if (open) { + setOrgName(""); + setOrgSlug(""); + setSlugTouched(false); + setCreateError(null); + setIsCreating(false); + } + }, [open]); + + useEffect(() => { + if (!slugTouched && orgName) { + setOrgSlug(toSlug(orgName)); + } + }, [orgName, slugTouched]); + + const handleClose = (isOpen: boolean) => { + if (!isOpen) { + setOrgName(""); + setOrgSlug(""); + setSlugTouched(false); + setCreateError(null); + setIsCreating(false); + } + onOpenChange(isOpen); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + const trimmedName = orgName.trim(); + const trimmedSlug = orgSlug.trim(); + + if (!trimmedName || !trimmedSlug) { + return; + } + + setIsCreating(true); + setCreateError(null); + + try { + const result = await api.organizations.create(trimmedName, trimmedSlug); + toast({ + title: "Organisation created", + description: `${result.organization.name} has been created successfully.`, + }); + onSuccess?.(result.organization); + handleClose(false); + } catch (err) { + console.error("Failed to create organisation:", err); + if (err instanceof ApiError) { + setCreateError(err.message); + } else { + setCreateError("An error occurred. Please try again."); + } + } finally { + setIsCreating(false); + } + }; + + const isValid = orgName.trim() && orgSlug.trim(); + + return ( + + + + Create Organisation + + +
+
+ + { + setOrgName(e.target.value); + setCreateError(null); + }} + disabled={isCreating} + autoFocus + /> +
+ +
+ + { + setOrgSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, "")); + setSlugTouched(true); + setCreateError(null); + }} + disabled={isCreating} + /> +

+ Used in URLs. Lowercase letters, numbers, and hyphens only. +

+
+ + {createError && ( +

{createError}

+ )} + +
+ + +
+
+
+
+ ); +}