diff --git a/backend/igny8_core/auth/urls.py b/backend/igny8_core/auth/urls.py index 4ce2c2b6..2b3fb7bd 100644 --- a/backend/igny8_core/auth/urls.py +++ b/backend/igny8_core/auth/urls.py @@ -9,6 +9,7 @@ from rest_framework.response import Response from rest_framework import status, permissions from drf_spectacular.utils import extend_schema from igny8_core.api.response import success_response, error_response +from django.conf import settings from .views import ( GroupsViewSet, UsersViewSet, AccountsViewSet, SubscriptionsViewSet, SiteUserAccessViewSet, PlanViewSet, SiteViewSet, SectorViewSet, @@ -285,6 +286,44 @@ class LoginView(APIView): ) +@extend_schema( + tags=['Authentication'], + summary='User Logout', + description='Clear session and logout user' +) +class LogoutView(APIView): + """Logout endpoint.""" + permission_classes = [permissions.AllowAny] + + def post(self, request): + from django.contrib.auth import logout as django_logout + + # Clear Django auth session + django_logout(request) + try: + request.session.flush() + except Exception: + # If session is unavailable or already cleared, ignore + pass + + response = success_response( + message='Logged out successfully', + request=request + ) + + # Explicitly expire session cookie across domain/path + try: + response.delete_cookie( + settings.SESSION_COOKIE_NAME, + path=getattr(settings, 'SESSION_COOKIE_PATH', '/'), + domain=getattr(settings, 'SESSION_COOKIE_DOMAIN', None) + ) + except Exception: + # If settings are misconfigured, still return success + pass + + return response + @extend_schema( tags=['Authentication'], summary='Request Password Reset', @@ -684,6 +723,7 @@ urlpatterns = [ path('', include(router.urls)), path('register/', csrf_exempt(RegisterView.as_view()), name='auth-register'), path('login/', csrf_exempt(LoginView.as_view()), name='auth-login'), + path('logout/', csrf_exempt(LogoutView.as_view()), name='auth-logout'), path('refresh/', csrf_exempt(RefreshTokenView.as_view()), name='auth-refresh'), path('change-password/', ChangePasswordView.as_view(), name='auth-change-password'), path('password-reset/', csrf_exempt(PasswordResetRequestView.as_view()), name='auth-password-reset-request'), diff --git a/frontend/src/components/auth/SignInForm.tsx b/frontend/src/components/auth/SignInForm.tsx index 9495f125..4da1bd89 100644 --- a/frontend/src/components/auth/SignInForm.tsx +++ b/frontend/src/components/auth/SignInForm.tsx @@ -116,64 +116,6 @@ export default function SignInForm() {
- {/* Logout Reason Display */} - {logoutReason && ( -
-
-
-
- - - -

- Session Ended -

-
-

- {logoutReason.message} -

- {logoutReason.path && logoutReason.path !== '/signin' && ( -

- Original page: {logoutReason.path} -

- )} -
- -
- - {/* Expandable Technical Details */} - {showLogoutDetails && ( -
-

- Technical Details: -

-
-
Code: {logoutReason.code}
-
Source: {logoutReason.source}
-
Time: {formatDateTime(logoutReason.timestamp)}
- {logoutReason.context && Object.keys(logoutReason.context).length > 0 && ( -
- Context: -
-                                {JSON.stringify(logoutReason.context, null, 2)}
-                              
-
- )} -
-
- )} -
- )} - {/* Session Conflict Alert */} {sessionConflict && (
diff --git a/frontend/src/components/common/ThemeToggleButton.tsx b/frontend/src/components/common/ThemeToggleButton.tsx index 937cadf6..c90baa7f 100644 --- a/frontend/src/components/common/ThemeToggleButton.tsx +++ b/frontend/src/components/common/ThemeToggleButton.tsx @@ -1,41 +1,50 @@ import { useTheme } from "../../context/ThemeContext"; +import IconButton from "../ui/button/IconButton"; export const ThemeToggleButton: React.FC = () => { - const { toggleTheme } = useTheme(); + const { theme, toggleTheme } = useTheme(); + const isDark = theme === "dark"; return ( - + variant="outline" + tone="neutral" + size="md" + shape="circle" + aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"} + title={isDark ? "Light mode" : "Dark mode"} + icon={ + isDark ? ( + + + + ) : ( + + + + ) + } + /> ); }; diff --git a/frontend/src/components/header/NotificationDropdown.tsx b/frontend/src/components/header/NotificationDropdown.tsx index 599fcb0a..ed861223 100644 --- a/frontend/src/components/header/NotificationDropdown.tsx +++ b/frontend/src/components/header/NotificationDropdown.tsx @@ -134,6 +134,7 @@ export default function NotificationDropdown() { variant="outline" tone="neutral" size="md" + shape="circle" icon={ {/* Notification badge */} {unreadCount > 0 && ( - + {unreadCount > 9 ? '9+' : unreadCount} - + )}
diff --git a/frontend/src/components/header/UserDropdown.tsx b/frontend/src/components/header/UserDropdown.tsx index 3cbb0129..34ce9ed6 100644 --- a/frontend/src/components/header/UserDropdown.tsx +++ b/frontend/src/components/header/UserDropdown.tsx @@ -4,6 +4,7 @@ import { DropdownItem } from "../ui/dropdown/DropdownItem"; import { Dropdown } from "../ui/dropdown/Dropdown"; import { useAuthStore } from "../../store/authStore"; import Button from "../ui/button/Button"; +import { ArrowRightIcon, DollarLineIcon, HelpCircleIcon, PieChartIcon, UserCircleIcon } from "../../icons"; export default function UserDropdown() { const [isOpen, setIsOpen] = useState(false); @@ -11,6 +12,14 @@ export default function UserDropdown() { const { user, logout } = useAuthStore(); const buttonRef = useRef(null); + const firstName = user?.first_name || (user as any)?.firstName || ""; + const lastName = user?.last_name || (user as any)?.lastName || ""; + const displayName = firstName || user?.username || user?.email?.split("@")[0] || "User"; + const displayFullName = `${firstName} ${lastName}`.trim() || displayName; + const firstInitial = firstName?.trim()?.[0] || ""; + const lastInitial = lastName?.trim()?.[0] || ""; + const initials = (firstInitial + lastInitial).trim() || user?.email?.trim()?.[0] || "U"; + function toggleDropdown() { setIsOpen(!isOpen); } @@ -34,14 +43,14 @@ export default function UserDropdown() { {user?.email ? ( - {user.email.charAt(0).toUpperCase()} + {initials.toUpperCase()} ) : ( User )} - {user?.username || user?.email?.split("@")[0] || "User"} + {displayName}
- {user?.username || "User"} + {displayFullName} {user?.email || "No email"} @@ -90,47 +99,30 @@ export default function UserDropdown() { to="/account/settings" className="flex items-center gap-3 px-3 py-2 font-medium text-gray-700 rounded-lg group text-theme-sm hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300" > - - - - Edit profile + + Account Settings
  • - - - - Account settings + + Billing + +
  • +
  • + + + Usage
  • @@ -140,46 +132,18 @@ export default function UserDropdown() { to="/help" className="flex items-center gap-3 px-3 py-2 font-medium text-gray-700 rounded-lg group text-theme-sm hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300" > - - - + Support
  • diff --git a/frontend/src/layout/AppHeader.tsx b/frontend/src/layout/AppHeader.tsx index 4dae5d61..a2cb4b33 100644 --- a/frontend/src/layout/AppHeader.tsx +++ b/frontend/src/layout/AppHeader.tsx @@ -126,15 +126,16 @@ const AppHeader: React.FC = () => {
    {/* Sidebar Toggle Button - Always visible on desktop */} { {/* Search Icon */} setIsSearchOpen(true)} title="Search (⌘K)" aria-label="Search" diff --git a/frontend/src/layout/AppSidebar.tsx b/frontend/src/layout/AppSidebar.tsx index 901e46c5..62b2136c 100644 --- a/frontend/src/layout/AppSidebar.tsx +++ b/frontend/src/layout/AppSidebar.tsx @@ -1,6 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link, useLocation } from "react-router-dom"; -import { Bell } from "lucide-react"; // Assume these icons are imported from an icon library import { @@ -8,18 +7,12 @@ import { GridIcon, HorizontaLDots, ListIcon, - PieChartIcon, - PlugInIcon, TaskIcon, BoltIcon, DocsIcon, - PageIcon, - DollarLineIcon, - FileIcon, - UserIcon, - UserCircleIcon, ShootingStarIcon, CalendarIcon, + TagIcon, } from "../icons"; import { useSidebar } from "../context/SidebarContext"; import { useAuthStore } from "../store/authStore"; @@ -45,14 +38,12 @@ const AppSidebar: React.FC = () => { const { user, isAuthenticated } = useAuthStore(); const { isModuleEnabled, settings: moduleSettings } = useModuleStore(); - const [openSubmenu, setOpenSubmenu] = useState<{ - sectionIndex: number; - itemIndex: number; - } | null>(null); + const [openSubmenus, setOpenSubmenus] = useState>(new Set()); const [subMenuHeight, setSubMenuHeight] = useState>( {} ); const subMenuRefs = useRef>({}); + const submenusInitialized = useRef(false); // Check if a path is active - exact match only for menu items // Prefix matching is only used for parent menus to determine if submenu should be open @@ -89,7 +80,7 @@ const AppSidebar: React.FC = () => { // Setup Wizard at top - guides users through site setup setupItems.push({ - icon: , + icon: , name: "Setup Wizard", path: "/setup/wizard", }); @@ -103,7 +94,7 @@ const AppSidebar: React.FC = () => { // Keywords Library - Browse and add curated keywords setupItems.push({ - icon: , + icon: , name: "Keywords Library", path: "/keywords-library", }); @@ -166,7 +157,7 @@ const AppSidebar: React.FC = () => { // Add Automation if enabled (with dropdown) if (isModuleEnabled('automation')) { workflowItems.push({ - icon: , + icon: , name: "Automation", subItems: [ { name: "Overview", path: "/automation/overview" }, @@ -197,47 +188,6 @@ const AppSidebar: React.FC = () => { label: "WORKFLOW", items: workflowItems, }, - { - label: "ACCOUNT", - items: [ - { - icon: , - name: "Account Settings", - path: "/account/settings", // Single page, no sub-items - }, - { - icon: , - name: "Plans & Billing", - path: "/account/plans", - }, - { - icon: , - name: "Usage", - path: "/account/usage", - }, - { - icon: , - name: "AI Models", - path: "/settings/integration", - adminOnly: true, // Only visible to admin/staff users - }, - ], - }, - { - label: "HELP", - items: [ - { - icon: , - name: "Notifications", - path: "/account/notifications", - }, - { - icon: , - name: "Help & Docs", - path: "/help", - }, - ], - }, ]; }, [isModuleEnabled, moduleSettings]); // Re-run when settings change @@ -247,50 +197,46 @@ const AppSidebar: React.FC = () => { }, [menuSections]); useEffect(() => { - const currentPath = location.pathname; - let foundMatch = false; - - // Find the matching submenu for the current path - use exact match only for subitems + if (submenusInitialized.current) return; + + const initialKeys = new Set(); allSections.forEach((section, sectionIndex) => { section.items.forEach((nav, itemIndex) => { - if (nav.subItems && !foundMatch) { - // Only use exact match for submenu items to prevent multiple active states + if (nav.subItems) { + initialKeys.add(`${sectionIndex}-${itemIndex}`); + } + }); + }); + + setOpenSubmenus(initialKeys); + submenusInitialized.current = true; + }, [allSections]); + + useEffect(() => { + const currentPath = location.pathname; + + allSections.forEach((section, sectionIndex) => { + section.items.forEach((nav, itemIndex) => { + if (nav.subItems) { const shouldOpen = nav.subItems.some((subItem) => currentPath === subItem.path); - if (shouldOpen) { - setOpenSubmenu((prev) => { - // Only update if different to prevent infinite loops - if (prev?.sectionIndex === sectionIndex && prev?.itemIndex === itemIndex) { - return prev; - } - return { - sectionIndex, - itemIndex, - }; - }); - foundMatch = true; + const key = `${sectionIndex}-${itemIndex}`; + setOpenSubmenus((prev) => new Set([...prev, key])); } } }); }); - - // If no match found and we're not on a submenu path, don't change the state - // This allows manual toggles to persist }, [location.pathname, allSections]); useEffect(() => { - if (openSubmenu !== null) { - const key = `${openSubmenu.sectionIndex}-${openSubmenu.itemIndex}`; - // Use requestAnimationFrame and setTimeout to ensure DOM is ready - const frameId = requestAnimationFrame(() => { - setTimeout(() => { + const frameId = requestAnimationFrame(() => { + setTimeout(() => { + openSubmenus.forEach((key) => { const element = subMenuRefs.current[key]; if (element) { - // scrollHeight should work even when height is 0px due to overflow-hidden const scrollHeight = element.scrollHeight; if (scrollHeight > 0) { setSubMenuHeight((prevHeights) => { - // Only update if height changed to prevent infinite loops if (prevHeights[key] === scrollHeight) { return prevHeights; } @@ -301,22 +247,22 @@ const AppSidebar: React.FC = () => { }); } } - }, 50); - }); - return () => cancelAnimationFrame(frameId); - } - }, [openSubmenu]); + }); + }, 50); + }); + return () => cancelAnimationFrame(frameId); + }, [openSubmenus]); const handleSubmenuToggle = (sectionIndex: number, itemIndex: number) => { - setOpenSubmenu((prevOpenSubmenu) => { - if ( - prevOpenSubmenu && - prevOpenSubmenu.sectionIndex === sectionIndex && - prevOpenSubmenu.itemIndex === itemIndex - ) { - return null; + const key = `${sectionIndex}-${itemIndex}`; + setOpenSubmenus((prev) => { + const next = new Set(prev); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); } - return { sectionIndex, itemIndex }; + return next; }); }; @@ -338,7 +284,7 @@ const AppSidebar: React.FC = () => { .map((nav, itemIndex) => { // Check if any subitem is active to determine parent active state (uses exact match for subitems) const hasActiveSubItem = nav.subItems?.some(subItem => isSubItemActive(subItem.path)) ?? false; - const isSubmenuOpen = openSubmenu?.sectionIndex === sectionIndex && openSubmenu?.itemIndex === itemIndex; + const isSubmenuOpen = openSubmenus.has(`${sectionIndex}-${itemIndex}`); return (
  • @@ -476,7 +422,7 @@ const AppSidebar: React.FC = () => { onMouseLeave={() => setIsHovered(false)} >
    - + {isExpanded || isHovered || isMobileOpen ? ( <> { width={113} height={30} /> +
    ) : ( { )}
    -
    +