diff --git a/client/App.tsx b/client/App.tsx index e77ad1d1..bc208839 100644 --- a/client/App.tsx +++ b/client/App.tsx @@ -1,8 +1,6 @@ import "./global.css"; import { Toaster } from "@/components/ui/toaster"; -import { createRoot } from "react-dom/client"; - import { TooltipProvider } from "@/components/ui/tooltip"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { BrowserRouter, Routes, Route } from "react-router-dom"; @@ -15,7 +13,6 @@ import { MaintenanceProvider } from "./contexts/MaintenanceContext"; import MaintenanceGuard from "./components/MaintenanceGuard"; import PageTransition from "./components/PageTransition"; import SkipAgentController from "./components/SkipAgentController"; -import Index from "./pages/Index"; import Onboarding from "./pages/Onboarding"; import Dashboard from "./pages/Dashboard"; import Login from "./pages/Login"; @@ -26,14 +23,9 @@ import ResearchLabs from "./pages/ResearchLabs"; import Labs from "./pages/Labs"; import GameForge from "./pages/GameForge"; import Foundation from "./pages/Foundation"; -import Corp from "./pages/Corp"; -import Staff from "./pages/Staff"; import Nexus from "./pages/Nexus"; import Arms from "./pages/Arms"; import ExternalRedirect from "./components/ExternalRedirect"; -import CorpScheduleConsultation from "./pages/corp/CorpScheduleConsultation"; -import CorpViewCaseStudies from "./pages/corp/CorpViewCaseStudies"; -import CorpContactUs from "./pages/corp/CorpContactUs"; import RequireAccess from "@/components/RequireAccess"; import Engage from "./pages/Pricing"; import DocsLayout from "@/components/docs/DocsLayout"; @@ -56,7 +48,6 @@ import GameJoltIntegration from "./pages/docs/integrations/GameJolt"; import ItchIoIntegration from "./pages/docs/integrations/ItchIo"; import DocsCurriculum from "./pages/docs/DocsCurriculum"; import DocsCurriculumEthos from "./pages/docs/DocsCurriculumEthos"; -import EthosGuild from "./pages/community/EthosGuild"; import TrackLibrary from "./pages/ethos/TrackLibrary"; import ArtistProfile from "./pages/ethos/ArtistProfile"; import ArtistSettings from "./pages/ethos/ArtistSettings"; @@ -72,7 +63,6 @@ import DevelopersDirectory from "./pages/DevelopersDirectory"; import ProfilePassport from "./pages/ProfilePassport"; import SubdomainPassport from "./pages/SubdomainPassport"; import Profile from "./pages/Profile"; -import LegacyPassportRedirect from "./pages/LegacyPassportRedirect"; import { SubdomainPassportProvider } from "./contexts/SubdomainPassportContext"; import About from "./pages/About"; import Contact from "./pages/Contact"; @@ -81,28 +71,24 @@ import Careers from "./pages/Careers"; import Privacy from "./pages/Privacy"; import Terms from "./pages/Terms"; import Admin from "./pages/Admin"; -import Feed from "./pages/Feed"; +import AdminModeration from "./pages/admin/AdminModeration"; +import AdminAnalytics from "./pages/admin/AdminAnalytics"; import AdminFeed from "./pages/AdminFeed"; import ProjectsNew from "./pages/ProjectsNew"; -import Opportunities from "./pages/Opportunities"; import Explore from "./pages/Explore"; import ResetPassword from "./pages/ResetPassword"; import Teams from "./pages/Teams"; import Squads from "./pages/Squads"; import MenteeHub from "./pages/MenteeHub"; import ProjectBoard from "./pages/ProjectBoard"; +import ProjectDetail from "./pages/ProjectDetail"; import { Navigate } from "react-router-dom"; import FourOhFourPage from "./pages/404"; import SignupRedirect from "./pages/SignupRedirect"; -import MentorshipRequest from "./pages/community/MentorshipRequest"; -import MentorApply from "./pages/community/MentorApply"; -import MentorProfile from "./pages/community/MentorProfile"; import Realms from "./pages/Realms"; import Investors from "./pages/Investors"; import NexusDashboard from "./pages/dashboards/NexusDashboard"; -import LabsDashboard from "./pages/dashboards/LabsDashboard"; import GameForgeDashboard from "./pages/dashboards/GameForgeDashboard"; -import StaffDashboard from "./pages/dashboards/StaffDashboard"; import Roadmap from "./pages/Roadmap"; import Trust from "./pages/Trust"; import PressKit from "./pages/PressKit"; @@ -130,13 +116,7 @@ import OpportunitiesHub from "./pages/opportunities/OpportunitiesHub"; import OpportunityDetail from "./pages/opportunities/OpportunityDetail"; import OpportunityPostForm from "./pages/opportunities/OpportunityPostForm"; import MyApplications from "./pages/profile/MyApplications"; -import ClientHub from "./pages/hub/ClientHub"; -import ClientProjects from "./pages/hub/ClientProjects"; -import ClientDashboard from "./pages/hub/ClientDashboard"; -import ClientInvoices from "./pages/hub/ClientInvoices"; -import ClientContracts from "./pages/hub/ClientContracts"; -import ClientReports from "./pages/hub/ClientReports"; -import ClientSettings from "./pages/hub/ClientSettings"; +// Hub pages moved to aethex.co (aethex-corp app) import Space1Welcome from "./pages/internal-docs/Space1Welcome"; import Space1AxiomModel from "./pages/internal-docs/Space1AxiomModel"; import Space1FindYourRole from "./pages/internal-docs/Space1FindYourRole"; @@ -155,20 +135,7 @@ import Space4ClientOps from "./pages/internal-docs/Space4ClientOps"; import Space4PlatformStrategy from "./pages/internal-docs/Space4PlatformStrategy"; import Space5Onboarding from "./pages/internal-docs/Space5Onboarding"; import Space5Finance from "./pages/internal-docs/Space5Finance"; -import StaffLogin from "./pages/StaffLogin"; -import StaffDirectory from "./pages/StaffDirectory"; -import StaffAdmin from "./pages/StaffAdmin"; -import StaffChat from "./pages/StaffChat"; -import StaffDocs from "./pages/StaffDocs"; -import StaffAchievements from "./pages/StaffAchievements"; -import StaffAnnouncements from "./pages/staff/StaffAnnouncements"; -import StaffExpenseReports from "./pages/staff/StaffExpenseReports"; -import StaffInternalMarketplace from "./pages/staff/StaffInternalMarketplace"; -import StaffKnowledgeBase from "./pages/staff/StaffKnowledgeBase"; -import StaffLearningPortal from "./pages/staff/StaffLearningPortal"; -import StaffPerformanceReviews from "./pages/staff/StaffPerformanceReviews"; -import StaffProjectTracking from "./pages/staff/StaffProjectTracking"; -import StaffTeamHandbook from "./pages/staff/StaffTeamHandbook"; +// Staff/Candidate pages moved to staff.aethex.tech (aethex-staff app) import DeveloperDashboard from "./pages/dev-platform/DeveloperDashboard"; import ApiReference from "./pages/dev-platform/ApiReference"; import QuickStart from "./pages/dev-platform/QuickStart"; @@ -237,14 +204,8 @@ const App = () => ( path="/dashboard/dev-link" element={} /> - - - - } - /> + {/* Hub routes → aethex.co */} + } /> } /> } /> } /> @@ -286,6 +247,10 @@ const App = () => ( path="/projects/:projectId/board" element={} /> + } + /> } /> } /> ( {/* Foundation page with auto-redirect to aethex.foundation (Non-Profit Guardian - Axiom Model) */} } /> - } /> - } - /> - } - /> - } - /> + {/* Corp routes → aethex.co */} + } /> + } /> - {/* Staff Arm Routes */} - } /> - } /> - - {/* Staff Dashboard Routes */} - - - - } - /> - - {/* Staff Onboarding Routes */} - - - - } - /> - - - - } - /> - - {/* Staff Management Routes */} - - - - } - /> - - - - } - /> - - {/* Staff Tools & Resources */} - - - - } - /> - - - - } - /> - - - - } - /> - - {/* Staff Admin Pages */} - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - {/* Candidate Portal Routes */} - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> + {/* Staff + Candidate routes → staff.aethex.tech */} + } /> + } /> + } /> + } /> {/* Dev-Link routes - now redirect to Nexus Opportunities with ecosystem filter */} } /> @@ -631,55 +400,8 @@ const App = () => ( element={} /> - {/* Client Hub routes */} - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> + {/* Client Hub routes → aethex.co */} + } /> {/* Nexus routes */} } /> @@ -699,6 +421,10 @@ const App = () => ( path="curriculum" element={} /> + } + /> } @@ -801,88 +527,6 @@ const App = () => ( {/* Discord Activity route */} } /> - {/* Docs routes */} - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - {/* Internal Docs Hub Routes */} (null); + const [status, setStatus] = useState("unknown"); + const [node, setNode] = useState("vps.aethex.tech"); + + // Live clock + useEffect(() => { + const tick = () => setTime(new Date().toLocaleTimeString("en-US", { hour12: false })); + tick(); + const id = setInterval(tick, 1000); + return () => clearInterval(id); + }, []); + + // Ping /api/health every 30s — measures real round-trip latency + useEffect(() => { + const ping = async () => { + const t0 = performance.now(); + try { + const res = await fetch("/api/health", { cache: "no-store" }); + const ms = Math.round(performance.now() - t0); + setLatency(ms); + if (!res.ok) { + setStatus("outage"); + } else { + const body = await res.json().catch(() => ({})); + const host: string = body?.host ?? ""; + if (host) setNode(new URL(host).hostname.replace("www.", "")); + setStatus(ms > 800 ? "degraded" : "ok"); + } + } catch { + setLatency(null); + setStatus("outage"); + } + }; + ping(); + const id = setInterval(ping, 30_000); + return () => clearInterval(id); + }, []); + + const dotColor = + status === "ok" ? "#00ff41" : + status === "degraded" ? "#facc15" : + status === "outage" ? "#f87171" : + "rgba(0,255,255,0.3)"; + + return ( +
+ + AeThex.OS v{APP_VERSION} // Forge Terminal + + node: {node} + + + {latency !== null ? `${latency}ms` : "…"} + {time} + +
+ ); +} + +// ─── HexLogo ────────────────────────────────────────────────────────────────── +function HexLogo({ size = 30 }: { size?: number }) { + return ( + + + + Æ + + ); +} + +// ─── NavLink helper ─────────────────────────────────────────────────────────── +function NavLink({ to, children }: { to: string; children: React.ReactNode }) { + return ( + (e.currentTarget.style.color = "#00ffff")} + onMouseLeave={e => (e.currentTarget.style.color = "rgba(0,255,255,0.45)")} + > + {children} + + ); +} + +// ─── Layout ─────────────────────────────────────────────────────────────────── interface LayoutProps { children: React.ReactNode; hideFooter?: boolean; } -const ARMS = [ - { id: "staff", label: "Staff", color: "#7c3aed", href: "/staff" }, - { id: "labs", label: "Labs", color: "#FBBF24", href: "/labs" }, - { id: "gameforge", label: "GameForge", color: "#22C55E", href: "/gameforge" }, - { id: "corp", label: "Corp", color: "#3B82F6", href: "/corp" }, - { - id: "foundation", - label: "Foundation", - color: "#EF4444", - href: "https://aethex.foundation", - external: true, - }, - { id: "nexus", label: "Nexus", color: "#A855F7", href: "/nexus" }, -]; - -const ARM_LOGOS: Record = { - staff: - "https://cdn.builder.io/api/v1/image/assets%2Ffc53d607e21d497595ac97e0637001a1%2Fc0414efd7af54ef4b821a05d469150d0?format=webp&width=800", - labs: "https://cdn.builder.io/api/v1/image/assets%2Ffc53d607e21d497595ac97e0637001a1%2Fd93f7113d34347469e74421c3a3412e5?format=webp&width=800", - gameforge: - "https://cdn.builder.io/api/v1/image/assets%2Ffc53d607e21d497595ac97e0637001a1%2Fcd3534c1caa0497abfd44224040c6059?format=webp&width=800", - corp: "https://cdn.builder.io/api/v1/image/assets%2Ffc53d607e21d497595ac97e0637001a1%2F3772073d5b4b49e688ed02480f4cae43?format=webp&width=800", - foundation: - "https://cdn.builder.io/api/v1/image/assets%2Ffc53d607e21d497595ac97e0637001a1%2Fc02cb1bf5056479bbb3ea4bd91f0d472?format=webp&width=800", - nexus: - "https://cdn.builder.io/api/v1/image/assets%2Ffc53d607e21d497595ac97e0637001a1%2F6df123b87a144b1fb99894d94198d97b?format=webp&width=800", +const FOOTER_LINKS = { + Platform: [ + { name: "Home", href: "/" }, + { name: "Realms", href: "/realms" }, + { name: "Developer Platform", href: "/dev-platform" }, + { name: "Dashboard", href: "/dashboard" }, + ], + Community: [ + { name: "Explore", href: "/engage" }, + { name: "Teams", href: "/teams" }, + { name: "Squads", href: "/squads" }, + { name: "Opportunities", href: "/opportunities" }, + ], + Resources: [ + { name: "Docs", href: "/docs" }, + { name: "API Reference", href: "/dev-platform/api-reference" }, + { name: "Quick Start", href: "/dev-platform/quick-start" }, + { name: "Roadmap", href: "/roadmap" }, + ], + Legal: [ + { name: "About", href: "/about" }, + { name: "Contact", href: "/contact" }, + { name: "Blog", href: "/blog" }, + ], }; export default function CodeLayout({ children, hideFooter }: LayoutProps) { const location = useLocation(); - const navigate = useNavigate(); - const { user, profile, signOut, loading, profileComplete } = useAuth(); - const { theme } = useArmTheme(); + const { user, profile, roles, signOut, loading } = useAuth(); + const isPrivileged = Array.isArray(roles) && roles.some(r => ["owner", "admin", "founder"].includes(r.toLowerCase())); + const isInternal = Array.isArray(roles) && roles.some(r => ["owner", "admin", "founder", "staff"].includes(r.toLowerCase())); + useArmTheme(); // keep context alive for downstream consumers + const [mobileOpen, setMobileOpen] = useState(false); - // Detect if we're in developer platform section - const isDevMode = location.pathname.startsWith('/dev-platform'); - - // Developer Platform Navigation - const devNavigation = [ - { name: "Home", href: "/dev-platform" }, - { name: "Dashboard", href: "/dev-platform/dashboard" }, - { name: "API Reference", href: "/dev-platform/api-reference" }, - { name: "Quick Start", href: "/dev-platform/quick-start" }, - { name: "Templates", href: "/dev-platform/templates" }, - { name: "Marketplace", href: "/dev-platform/marketplace" }, - { name: "Examples", href: "/dev-platform/examples" }, - { name: "divider", href: "#" }, - { name: "Main Dashboard", href: "/dashboard" }, - { name: "Exit Dev Mode", href: "/" }, - ]; - - const navigation = [ - { name: "Home", href: "/" }, + const publicNavLinks = [ { name: "Realms", href: "/realms" }, - { name: "Get Started", href: "/onboarding" }, - { name: "Engage", href: "/engage" }, - { name: "Roadmap", href: "/roadmap" }, - { name: "Projects", href: "/projects" }, - { name: "Teams", href: "/teams" }, - { name: "Squads", href: "/squads" }, - { name: "Mentee Hub", href: "/mentee-hub" }, - { name: "Directory", href: "/directory" }, - { name: "Developer Platform", href: "/dev-platform" }, - { name: "Creators", href: "/creators" }, - { name: "Opportunities", href: "/opportunities" }, + { name: "Dev Platform", href: "/dev-platform" }, + { name: "Docs", href: "/docs" }, { name: "About", href: "/about" }, - { name: "Contact", href: "/contact" }, ]; - const publicNavigation = [ - { name: "Home", href: "/" }, - { name: "About", href: "/about" }, - { name: "Blog", href: "/blog" }, - { name: "Community", href: "/community" }, - { name: "Contact", href: "/contact" }, - { name: "Documentation", href: "/docs" }, - ]; - - const userNavigation = isDevMode ? devNavigation : [ + const userNavLinks = [ { name: "Dashboard", href: "/dashboard" }, { name: "Realms", href: "/realms" }, { name: "Teams", href: "/teams" }, - { name: "Squads", href: "/squads" }, - { name: "Mentee Hub", href: "/mentee-hub" }, - { name: "Feed", href: "/feed" }, + { name: "Dev Platform", href: "/dev-platform" }, { name: "Engage", href: "/engage" }, - { name: "Roadmap", href: "/roadmap" }, - { name: "Projects", href: "/projects" }, - { name: "Developer Platform", href: "/dev-platform" }, - { name: "Creators", href: "/creators" }, - { name: "Opportunities", href: "/opportunities" }, - { name: "My Applications", href: "/profile/applications" }, - { name: "Home", href: "/" }, - { name: "About", href: "/about" }, - { name: "Contact", href: "/contact" }, ]; - const isSubdomainHost = (() => { - try { - const hostname = - typeof window !== "undefined" ? window.location.hostname : ""; - if (!hostname) return false; - if (hostname.includes("aethex.me") || hostname.includes("aethex.space")) { - const parts = hostname.split("."); - return parts.length > 2; - } - return false; - } catch (e) { - return false; + const navLinks = user ? userNavLinks : publicNavLinks; + + // Inject global brand styles once + useEffect(() => { + const existing = document.getElementById("ax-forge-styles"); + if (!existing) { + const el = document.createElement("style"); + el.id = "ax-forge-styles"; + el.textContent = AX_STYLES; + document.head.appendChild(el); } - })(); + }, []); - const passportHref = isSubdomainHost - ? "/" - : profile?.username - ? `/passport/${profile.username}` - : "/passport/me"; - - const navItems: { name: string; href: string }[] = []; - - const scrollToTop = () => { - window.scrollTo({ top: 0, behavior: "smooth" }); - }; + const passportHref = profile?.username ? `/passport/${profile.username}` : "/passport/me"; + const userInitials = (profile?.full_name || profile?.username || "U") + .split(" ").map((n: string) => n[0]).join("").toUpperCase(); return ( -
-
-
+
+ + + {/* ── Navigation ──────────────────────────────────────────────────────── */} +
+ {/* TrinityBar */} +
+
+
+
+
+ +
{/* Logo */} -
- {/* Desktop - Regular Link */} - + + - - - - - - - - - - - - - - + AETHEX + + - {/* OS Window Frame */} - + {/* Desktop nav links */} + - {/* Title Bar */} - - - - {/* System Dots (Traffic Light Style) */} - - - - - {/* Central OS Symbol - Abstract "A" */} - - {/* Left diagonal */} - - {/* Right diagonal */} - - {/* Crossbar */} - - {/* Bottom connecting */} - - - - - - - {/* Mobile - Spinning Logo Button */} - -
- - {/* Navigation */} -
-
{children}
+ {/* ── Content ─────────────────────────────────────────────────────────── */} +
+ {children} +
+ {/* ── Footer ──────────────────────────────────────────────────────────── */} {!hideFooter && ( -
-
-
- {/* Company Info */} -
-
- - - - - - - - - - - - - - +
+ {/* TrinityBar */} +
+
+
+
+
- {/* OS Window Frame */} - - - {/* Title Bar */} - - - - {/* System Dots */} - - - - - {/* Central OS Symbol */} - - - - - - - - - - AeThex +
+
+ {/* Brand column */} +
+
+ + + AETHEX
-

- Pushing the boundaries of technology through cutting-edge - research and breakthrough discoveries. +

+ The integration layer connecting all metaverse platforms. Six specialized realms. One ecosystem.

- + + {/* Link columns */} + {Object.entries(FOOTER_LINKS).map(([section, links]) => ( +
+

+ {section}

+
+ {links.map((link) => ( + (e.currentTarget.style.color = "rgba(0,255,255,0.7)")} + onMouseLeave={e => (e.currentTarget.style.color = "rgba(0,255,255,0.3)")} + > + {link.name} + + ))} +
-
- - {/* Services */} -
-

- Services -

-
    -
  • - - (e.currentTarget.style.color = theme.accentHex) - } - onMouseLeave={(e) => - (e.currentTarget.style.color = "inherit") - } - > - Game Development - -
  • -
  • - - (e.currentTarget.style.color = theme.accentHex) - } - onMouseLeave={(e) => - (e.currentTarget.style.color = "inherit") - } - > - Development Consulting - -
  • -
  • - - (e.currentTarget.style.color = theme.accentHex) - } - onMouseLeave={(e) => - (e.currentTarget.style.color = "inherit") - } - > - Mentorship Programs - -
  • -
  • - - (e.currentTarget.style.color = theme.accentHex) - } - onMouseLeave={(e) => - (e.currentTarget.style.color = "inherit") - } - > - Research & Labs - -
  • -
-
- - {/* Company */} -
-

- Company -

-
    -
  • - - (e.currentTarget.style.color = theme.accentHex) - } - onMouseLeave={(e) => - (e.currentTarget.style.color = "inherit") - } - > - About AeThex - -
  • -
  • - - (e.currentTarget.style.color = theme.accentHex) - } - onMouseLeave={(e) => - (e.currentTarget.style.color = "inherit") - } - > - Opportunities - -
  • -
  • - - (e.currentTarget.style.color = theme.accentHex) - } - onMouseLeave={(e) => - (e.currentTarget.style.color = "inherit") - } - > - Community Hub - -
  • -
  • - - (e.currentTarget.style.color = theme.accentHex) - } - onMouseLeave={(e) => - (e.currentTarget.style.color = "inherit") - } - > - Changelog - -
  • -
  • - - (e.currentTarget.style.color = theme.accentHex) - } - onMouseLeave={(e) => - (e.currentTarget.style.color = "inherit") - } - > - System Status - -
  • -
  • - - (e.currentTarget.style.color = theme.accentHex) - } - onMouseLeave={(e) => - (e.currentTarget.style.color = "inherit") - } - > - Investors - -
  • -
-
- - {/* Resources */} -
-

- Resources -

-
    -
  • - - (e.currentTarget.style.color = theme.accentHex) - } - onMouseLeave={(e) => - (e.currentTarget.style.color = "inherit") - } - > - Documentation - -
  • -
  • - - (e.currentTarget.style.color = theme.accentHex) - } - onMouseLeave={(e) => - (e.currentTarget.style.color = "inherit") - } - > - Tutorials - -
  • -
  • - - (e.currentTarget.style.color = theme.accentHex) - } - onMouseLeave={(e) => - (e.currentTarget.style.color = "inherit") - } - > - Blog - -
  • -
  • - - (e.currentTarget.style.color = theme.accentHex) - } - onMouseLeave={(e) => - (e.currentTarget.style.color = "inherit") - } - > - Support Center - -
  • -
  • - - (e.currentTarget.style.color = theme.accentHex) - } - onMouseLeave={(e) => - (e.currentTarget.style.color = "inherit") - } - > - Transparency - -
  • -
  • - - (e.currentTarget.style.color = theme.accentHex) - } - onMouseLeave={(e) => - (e.currentTarget.style.color = "inherit") - } - > - Press Kit - -
  • -
-
+ ))}
-
-

- © 2024 AeThex Corporation. All rights reserved. -

-
- - (e.currentTarget.style.color = theme.accentHex) - } - onMouseLeave={(e) => - (e.currentTarget.style.color = "inherit") - } - > - Privacy Policy - - - (e.currentTarget.style.color = theme.accentHex) - } - onMouseLeave={(e) => - (e.currentTarget.style.color = "inherit") - } - > - Terms of Service - -
+ {/* Bottom bar */} +
+ + © 2026 AeThex Corporation. All rights reserved. + + + AeThex.OS v3.7.1 // aethex.dev +
)} - {/* Supabase Configuration Status */} - - - {/* AI Chat Assistant */} - - - +
); } diff --git a/client/components/dev-platform/DevPlatformNav.tsx b/client/components/dev-platform/DevPlatformNav.tsx index e270cd9b..efb0867c 100644 --- a/client/components/dev-platform/DevPlatformNav.tsx +++ b/client/components/dev-platform/DevPlatformNav.tsx @@ -21,18 +21,122 @@ import { User, Menu, X, + Zap, + FlaskConical, + LayoutDashboard, + ChevronRight, } from "lucide-react"; export interface DevPlatformNavProps { className?: string; } +interface NavEntry { + name: string; + href: string; + icon: React.ElementType; + description: string; + comingSoon?: boolean; +} + +interface NavGroup { + label: string; + items: NavEntry[]; +} + +// ── Grouped nav structure ────────────────────────────────────────────────────── +const NAV_GROUPS: NavGroup[] = [ + { + label: "Learn", + items: [ + { + name: "Quick Start", + href: "/dev-platform/quick-start", + icon: Zap, + description: "Up and running in under 5 minutes", + }, + { + name: "Documentation", + href: "/docs", + icon: BookOpen, + description: "Guides, concepts, and deep dives", + }, + { + name: "Code Examples", + href: "/dev-platform/examples", + icon: FlaskConical, + description: "Copy-paste snippets for common patterns", + }, + ], + }, + { + label: "Build", + items: [ + { + name: "API Reference", + href: "/dev-platform/api-reference", + icon: Code2, + description: "Full endpoint docs with live samples", + }, + { + name: "SDK", + href: "/sdk", + icon: Package, + description: "Client libraries for JS, Python, Go and more", + comingSoon: true, + }, + { + name: "Templates", + href: "/dev-platform/templates", + icon: LayoutTemplate, + description: "Project starters and boilerplates", + }, + { + name: "Marketplace", + href: "/dev-platform/marketplace", + icon: Store, + description: "Plugins, integrations, and extensions", + comingSoon: true, + }, + ], + }, +]; + +// ── Shared dropdown item component ──────────────────────────────────────────── +function DropdownItem({ item, onClick }: { item: NavEntry; onClick?: () => void }) { + return ( + + +
+ +
+
+
+ {item.name} + {item.comingSoon && ( + + Soon + + )} +
+

+ {item.description} +

+
+ +
+ ); +} + export function DevPlatformNav({ className }: DevPlatformNavProps) { const [mobileMenuOpen, setMobileMenuOpen] = React.useState(false); const [searchOpen, setSearchOpen] = React.useState(false); const location = useLocation(); - // Command palette keyboard shortcut React.useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === "k") { @@ -40,46 +144,12 @@ export function DevPlatformNav({ className }: DevPlatformNavProps) { setSearchOpen(true); } }; - document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); }, []); - const navLinks = [ - { - name: "Docs", - href: "/docs", - icon: BookOpen, - description: "Guides, tutorials, and API concepts", - }, - { - name: "API Reference", - href: "/api-reference", - icon: Code2, - description: "Complete API documentation", - }, - { - name: "SDK", - href: "/sdk", - icon: Package, - description: "Download SDKs for all platforms", - }, - { - name: "Templates", - href: "/templates", - icon: LayoutTemplate, - description: "Project starters and boilerplates", - }, - { - name: "Marketplace", - href: "/marketplace", - icon: Store, - description: "Plugins and extensions (coming soon)", - comingSoon: true, - }, - ]; - - const isActive = (path: string) => location.pathname.startsWith(path); + const isGroupActive = (group: NavGroup) => + group.items.some((item) => location.pathname.startsWith(item.href)); return (
+
); diff --git a/client/pages/hub/ClientSettings.tsx b/client/pages/hub/ClientSettings.tsx index a0bc25fa..9ec8e55e 100644 --- a/client/pages/hub/ClientSettings.tsx +++ b/client/pages/hub/ClientSettings.tsx @@ -566,7 +566,7 @@ export default function ClientSettings() { - + ); diff --git a/client/pages/staff/StaffAnnouncements.tsx b/client/pages/staff/StaffAnnouncements.tsx index 77710aa7..3a39ca0a 100644 --- a/client/pages/staff/StaffAnnouncements.tsx +++ b/client/pages/staff/StaffAnnouncements.tsx @@ -12,7 +12,7 @@ import { import { Badge } from "@/components/ui/badge"; import { Bell, Pin, Loader2, Eye, EyeOff } from "lucide-react"; import { useAuth } from "@/contexts/AuthContext"; -import { aethexToast } from "@/components/ui/aethex-toast"; +import { aethexToast } from "@/lib/aethex-toast"; interface Announcement { id: string; diff --git a/client/pages/staff/StaffExpenseReports.tsx b/client/pages/staff/StaffExpenseReports.tsx index 54d377dc..c29b404c 100644 --- a/client/pages/staff/StaffExpenseReports.tsx +++ b/client/pages/staff/StaffExpenseReports.tsx @@ -11,7 +11,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from " import { Textarea } from "@/components/ui/textarea"; import { DollarSign, FileText, Calendar, CheckCircle, AlertCircle, Plus, Loader2 } from "lucide-react"; import { useAuth } from "@/contexts/AuthContext"; -import { aethexToast } from "@/components/ui/aethex-toast"; +import { aethexToast } from "@/lib/aethex-toast"; interface Expense { id: string; diff --git a/client/pages/staff/StaffInternalMarketplace.tsx b/client/pages/staff/StaffInternalMarketplace.tsx index 5abf4edf..ff0009bc 100644 --- a/client/pages/staff/StaffInternalMarketplace.tsx +++ b/client/pages/staff/StaffInternalMarketplace.tsx @@ -22,7 +22,7 @@ import { } from "lucide-react"; import { Input } from "@/components/ui/input"; import { useAuth } from "@/contexts/AuthContext"; -import { aethexToast } from "@/components/ui/aethex-toast"; +import { aethexToast } from "@/lib/aethex-toast"; import { Dialog, DialogContent, diff --git a/client/pages/staff/StaffKnowledgeBase.tsx b/client/pages/staff/StaffKnowledgeBase.tsx index f6ffbfa0..63e9d392 100644 --- a/client/pages/staff/StaffKnowledgeBase.tsx +++ b/client/pages/staff/StaffKnowledgeBase.tsx @@ -25,7 +25,7 @@ import { Eye, } from "lucide-react"; import { useAuth } from "@/contexts/AuthContext"; -import { aethexToast } from "@/components/ui/aethex-toast"; +import { aethexToast } from "@/lib/aethex-toast"; interface KnowledgeArticle { id: string; diff --git a/client/pages/staff/StaffLearningPortal.tsx b/client/pages/staff/StaffLearningPortal.tsx index 259ff2ae..45233010 100644 --- a/client/pages/staff/StaffLearningPortal.tsx +++ b/client/pages/staff/StaffLearningPortal.tsx @@ -22,7 +22,7 @@ import { Loader2, } from "lucide-react"; import { useAuth } from "@/contexts/AuthContext"; -import { aethexToast } from "@/components/ui/aethex-toast"; +import { aethexToast } from "@/lib/aethex-toast"; interface Course { id: string; diff --git a/client/pages/staff/StaffOKRs.tsx b/client/pages/staff/StaffOKRs.tsx index 647c20f0..0f357b47 100644 --- a/client/pages/staff/StaffOKRs.tsx +++ b/client/pages/staff/StaffOKRs.tsx @@ -38,7 +38,7 @@ import { Trash2, } from "lucide-react"; import { useAuth } from "@/contexts/AuthContext"; -import { aethexToast } from "@/components/ui/aethex-toast"; +import { aethexToast } from "@/lib/aethex-toast"; interface KeyResult { id: string; diff --git a/client/pages/staff/StaffOnboarding.tsx b/client/pages/staff/StaffOnboarding.tsx index e226f4ee..797cc85f 100644 --- a/client/pages/staff/StaffOnboarding.tsx +++ b/client/pages/staff/StaffOnboarding.tsx @@ -28,7 +28,7 @@ import { Loader2, } from "lucide-react"; import { useAuth } from "@/contexts/AuthContext"; -import { aethexToast } from "@/components/ui/aethex-toast"; +import { aethexToast } from "@/lib/aethex-toast"; interface OnboardingData { progress: { diff --git a/client/pages/staff/StaffOnboardingChecklist.tsx b/client/pages/staff/StaffOnboardingChecklist.tsx index e9c47a32..ad9f5b69 100644 --- a/client/pages/staff/StaffOnboardingChecklist.tsx +++ b/client/pages/staff/StaffOnboardingChecklist.tsx @@ -28,7 +28,7 @@ import { Target, } from "lucide-react"; import { useAuth } from "@/contexts/AuthContext"; -import { aethexToast } from "@/components/ui/aethex-toast"; +import { aethexToast } from "@/lib/aethex-toast"; interface ChecklistItem { id: string; diff --git a/client/pages/staff/StaffPerformanceReviews.tsx b/client/pages/staff/StaffPerformanceReviews.tsx index 385e2877..f419d063 100644 --- a/client/pages/staff/StaffPerformanceReviews.tsx +++ b/client/pages/staff/StaffPerformanceReviews.tsx @@ -21,7 +21,7 @@ import { Loader2, } from "lucide-react"; import { useAuth } from "@/contexts/AuthContext"; -import { aethexToast } from "@/components/ui/aethex-toast"; +import { aethexToast } from "@/lib/aethex-toast"; import { Dialog, DialogContent, diff --git a/client/pages/staff/StaffProjectTracking.tsx b/client/pages/staff/StaffProjectTracking.tsx index 50181445..ded702da 100644 --- a/client/pages/staff/StaffProjectTracking.tsx +++ b/client/pages/staff/StaffProjectTracking.tsx @@ -20,7 +20,7 @@ import { Calendar, } from "lucide-react"; import { useAuth } from "@/contexts/AuthContext"; -import { aethexToast } from "@/components/ui/aethex-toast"; +import { aethexToast } from "@/lib/aethex-toast"; import { Dialog, DialogContent, diff --git a/client/pages/staff/StaffTeamHandbook.tsx b/client/pages/staff/StaffTeamHandbook.tsx index e92e7cb2..9b433933 100644 --- a/client/pages/staff/StaffTeamHandbook.tsx +++ b/client/pages/staff/StaffTeamHandbook.tsx @@ -23,7 +23,7 @@ import { ChevronUp, } from "lucide-react"; import { useAuth } from "@/contexts/AuthContext"; -import { aethexToast } from "@/components/ui/aethex-toast"; +import { aethexToast } from "@/lib/aethex-toast"; interface HandbookSection { id: string; diff --git a/client/pages/staff/StaffTimeTracking.tsx b/client/pages/staff/StaffTimeTracking.tsx index 7088690b..62716940 100644 --- a/client/pages/staff/StaffTimeTracking.tsx +++ b/client/pages/staff/StaffTimeTracking.tsx @@ -38,7 +38,7 @@ import { Edit, } from "lucide-react"; import { useAuth } from "@/contexts/AuthContext"; -import { aethexToast } from "@/components/ui/aethex-toast"; +import { aethexToast } from "@/lib/aethex-toast"; interface Project { id: string; diff --git a/package-lock.json b/package-lock.json index 00c536af..ae183737 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "png-to-ico": "^3.0.1", "sharp": "^0.34.5", "stripe": "^15.12.0", + "wouter": "^3.9.0", "zod": "^3.23.8" }, "devDependencies": { @@ -13120,6 +13121,11 @@ "node": ">=8" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==" + }, "node_modules/mkdirp": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", @@ -14684,6 +14690,14 @@ "dev": true, "license": "MIT" }, + "node_modules/regexparam": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/regexparam/-/regexparam-3.0.0.tgz", + "integrity": "sha512-RSYAtP31mvYLkAHrOlh25pCNQ5hWnT106VukGaaFfuJrZFkGRX5GhUAdPqpSDXxOhA2c4akmRuplv1mRqnBn6Q==", + "engines": { + "node": ">=8" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -16671,7 +16685,6 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", - "dev": true, "license": "MIT", "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -17569,6 +17582,19 @@ "string-width": "^1.0.2 || 2 || 3 || 4" } }, + "node_modules/wouter": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/wouter/-/wouter-3.9.0.tgz", + "integrity": "sha512-sF/od/PIgqEQBQcrN7a2x3MX6MQE6nW0ygCfy9hQuUkuB28wEZuu/6M5GyqkrrEu9M6jxdkgE12yDFsQMKos4Q==", + "dependencies": { + "mitt": "^3.0.1", + "regexparam": "^3.0.0", + "use-sync-external-store": "^1.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/package.json b/package.json index 20f71d99..57cf1f63 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "png-to-ico": "^3.0.1", "sharp": "^0.34.5", "stripe": "^15.12.0", + "wouter": "^3.9.0", "zod": "^3.23.8" }, "devDependencies": { diff --git a/server/index.ts b/server/index.ts index 078fa527..a6b22b59 100644 --- a/server/index.ts +++ b/server/index.ts @@ -4,7 +4,39 @@ import express from "express"; import cors from "cors"; import { adminSupabase } from "./supabase"; import { emailService } from "./email"; -import { randomUUID, createHash, createVerify, randomBytes } from "crypto"; +import { randomUUID, createHash, createVerify, randomBytes, createHmac } from "crypto"; +import * as https from "https"; +import * as http from "http"; + +// httpsPost / httpsGet — use Node's https module which respects /etc/hosts (unlike fetch/undici) +function httpsRequest(url: string, options: { method?: string; headers?: Record; body?: string }): Promise<{ status: number; text: () => string; json: () => any }> { + return new Promise((resolve, reject) => { + const parsed = new URL(url); + const lib = parsed.protocol === "https:" ? https : http; + const reqOpts = { + hostname: parsed.hostname, + port: parsed.port || (parsed.protocol === "https:" ? 443 : 80), + path: parsed.pathname + parsed.search, + method: options.method || "GET", + headers: options.headers || {}, + }; + const req = lib.request(reqOpts, (res) => { + const chunks: Buffer[] = []; + res.on("data", (c) => chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c))); + res.on("end", () => { + const raw = Buffer.concat(chunks).toString("utf8"); + resolve({ + status: res.statusCode || 0, + text: () => raw, + json: () => JSON.parse(raw), + }); + }); + }); + req.on("error", reject); + if (options.body) req.write(options.body); + req.end(); + }); +} import blogIndexHandler from "../api/blog/index"; import blogSlugHandler from "../api/blog/[slug]"; import aiChatHandler from "../api/ai/chat"; @@ -384,7 +416,7 @@ export function createServer() { const { data: user, error } = await adminSupabase .from("user_profiles") .select( - "id, username, full_name, bio, avatar_url, banner_url, location, website_url, github_url, linkedin_url, twitter_url, role, level, total_xp, user_type, experience_level, current_streak, longest_streak, created_at, updated_at", + "id, username, full_name, bio, avatar_url, location, website_url, github_url, linkedin_url, twitter_url, roles, level, total_xp, user_type, experience_level, current_streak, longest_streak, created_at, updated_at", ) .eq("username", username) .single(); @@ -402,11 +434,10 @@ export function createServer() { achievement_id, achievements( id, - name, + title, description, icon, - category, - badge_color + category ) `, ) @@ -645,25 +676,19 @@ export function createServer() { } // First try exact match by name - let query = adminSupabase + let { data, error } = await (adminSupabase as any) .from("projects") - .select( - "id, title, slug, description, user_id, created_at, updated_at, status, image_url, website", - ) - .eq("slug", projectname); - - let { data, error } = await query.single(); + .select("id, title, slug, description, user_id, created_at, updated_at, status, image_url, website") + .eq("slug", projectname) + .single(); // If not found by slug, try by title (case-insensitive) if (error && error.code === "PGRST116") { - query = adminSupabase + const response = await (adminSupabase as any) .from("projects") - .select( - "id, title, slug, description, user_id, created_at, updated_at, status, image_url, website", - ) + .select("id, title, slug, description, user_id, created_at, updated_at, status, image_url, website") .ilike("title", projectname); - const response = await query; if (response.data && response.data.length > 0) { data = response.data[0]; error = null; @@ -698,6 +723,35 @@ export function createServer() { } }); + // Public project detail by ID + app.get("/api/projects/:projectId", async (req, res) => { + try { + const { projectId } = req.params; + const { data: project, error } = await adminSupabase + .from("projects") + .select("id, title, description, status, technologies, github_url, live_url, image_url, engine, priority, progress, created_at, updated_at, user_id, owner_user_id") + .eq("id", projectId) + .single(); + + if (error || !project) { + return res.status(404).json({ error: "Project not found" }); + } + + const ownerId = (project as any).owner_user_id || (project as any).user_id; + const { data: owner } = ownerId + ? await adminSupabase + .from("user_profiles") + .select("id, username, full_name, avatar_url") + .eq("id", ownerId) + .maybeSingle() + : { data: null }; + + return res.json({ project, owner: owner ?? null }); + } catch (e: any) { + return res.status(500).json({ error: e?.message || "Failed to fetch project" }); + } + }); + // DevConnect REST proxy (GET only) app.get("/api/devconnect/rest/:table", async (req, res) => { try { @@ -3212,6 +3266,16 @@ export function createServer() { .upsert(rows, { onConflict: "user_id,achievement_id" as any }); if (iErr && iErr.code !== "23505") return res.status(500).json({ error: iErr.message }); + // Notify user of each achievement awarded + if (rows.length) { + const awardedNames = (achievements || []).map((a: any) => a.name).join(", "); + await adminSupabase.from("notifications").insert({ + user_id, + type: "success", + title: `🏆 Achievement${rows.length > 1 ? "s" : ""} unlocked!`, + message: awardedNames, + }); + } return res.json({ ok: true, awarded: rows.length }); } catch (e: any) { console.error("[API] achievements/award exception", e); @@ -3257,42 +3321,42 @@ export function createServer() { const CORE_ACHIEVEMENTS = [ { id: "welcome-to-aethex", - name: "Welcome to AeThex", + slug: "welcome-to-aethex", + title: "Welcome to AeThex", description: "Completed onboarding and joined the AeThex network.", icon: "🎉", - badge_color: "#7C3AED", xp_reward: 250, }, { id: "aethex-explorer", - name: "AeThex Explorer", + slug: "aethex-explorer", + title: "AeThex Explorer", description: "Engaged with community initiatives and posted first update.", icon: "🧭", - badge_color: "#0EA5E9", xp_reward: 400, }, { id: "community-champion", - name: "Community Champion", + slug: "community-champion", + title: "Community Champion", description: "Contributed feedback, resolved bugs, and mentored squads.", icon: "🏆", - badge_color: "#22C55E", xp_reward: 750, }, { id: "workshop-architect", - name: "Workshop Architect", + slug: "workshop-architect", + title: "Workshop Architect", description: "Published a high-impact mod or toolkit adopted by teams.", icon: "🛠️", - badge_color: "#F97316", xp_reward: 1200, }, { id: "god-mode", - name: "GOD Mode", + slug: "god-mode", + title: "GOD Mode", description: "Legendary status awarded by AeThex studio leadership.", icon: "⚡", - badge_color: "#FACC15", xp_reward: 5000, }, ]; @@ -3317,10 +3381,10 @@ export function createServer() { const { error } = await adminSupabase.from("achievements").upsert( { id: uuidId, - name: achievement.name, + slug: achievement.slug, + title: achievement.title, description: achievement.description, icon: achievement.icon, - badge_color: achievement.badge_color, xp_reward: achievement.xp_reward, }, { onConflict: "id", ignoreDuplicates: true }, @@ -3329,7 +3393,7 @@ export function createServer() { if (error && error.code !== "23505") { console.error(`Failed to upsert achievement ${achievement.id}:`, error); } else { - seededAchievements[achievement.name] = uuidId; + seededAchievements[achievement.title] = uuidId; } } @@ -3341,20 +3405,33 @@ export function createServer() { const awardedAchievementIds: string[] = []; if (canAwardToTarget && (targetEmail || targetUsername)) { - let query = adminSupabase.from("user_profiles").select("id, email, username"); + let targetProfile: { id: string; username: string | null } | null = null; if (targetEmail) { - query = query.eq("email", targetEmail); + // Look up by email via auth.admin, then fetch profile by user id + const { data: authList } = await adminSupabase.auth.admin.listUsers(); + const authUser = authList?.users?.find((u) => u.email === targetEmail); + if (authUser) { + const { data: prof } = await adminSupabase + .from("user_profiles") + .select("id, username") + .eq("id", authUser.id) + .single(); + targetProfile = prof ?? null; + } } else if (targetUsername) { - query = query.eq("username", targetUsername); + const { data: prof } = await adminSupabase + .from("user_profiles") + .select("id, username") + .eq("username", targetUsername) + .single(); + targetProfile = prof ?? null; } - const { data: userProfile } = await query.single(); - - if (userProfile?.id) { - targetUserId = userProfile.id; + if (targetProfile?.id) { + targetUserId = targetProfile.id; // Check if target user is an admin (for GOD Mode) - const isTargetAdmin = userProfile.email === "mrpiglr@gmail.com" || userProfile.username === "mrpiglr"; + const isTargetAdmin = targetEmail === "mrpiglr@gmail.com" || targetProfile.username === "mrpiglr"; // Award Welcome achievement to the user const welcomeId = seededAchievements["Welcome to AeThex"]; @@ -3633,6 +3710,26 @@ export function createServer() { .select() .single(); if (error) return res.status(500).json({ error: error.message }); + // Notify team owner + const { data: team } = await adminSupabase + .from("activity_teams") + .select("owner_id, name") + .eq("id", teamId) + .single(); + const { data: applicant } = await adminSupabase + .from("user_profiles") + .select("username, full_name") + .eq("id", user_id) + .single(); + if (team?.owner_id && team.owner_id !== user_id) { + const applicantName = (applicant as any)?.full_name || (applicant as any)?.username || "Someone"; + await adminSupabase.from("notifications").insert({ + user_id: team.owner_id, + type: "info", + title: `📋 New team application`, + message: `${applicantName} applied to join ${(team as any).name}${role_applied ? ` as ${role_applied}` : ""}`, + }); + } res.json(data); } catch (e: any) { res.status(500).json({ error: e?.message || String(e) }); @@ -3692,6 +3789,23 @@ export function createServer() { adminSupabase.from("activity_projects").update({ upvotes: adminSupabase.rpc("get_upvote_count", { pid: projectId }) }).eq("id", projectId); }); + // Notify project owner (not self-upvotes, and throttle: only on milestone counts) + const { data: project } = await adminSupabase + .from("activity_projects") + .select("owner_id, title, upvotes") + .eq("id", projectId) + .single(); + const upvotes = (project as any)?.upvotes || 0; + const milestones = [5, 10, 25, 50, 100]; + if (project && (project as any).owner_id !== user_id && milestones.includes(upvotes)) { + await adminSupabase.from("notifications").insert({ + user_id: (project as any).owner_id, + type: "success", + title: `🚀 ${upvotes} upvotes on your project`, + message: `"${(project as any).title}" just hit ${upvotes} upvotes!`, + }); + } + res.json({ ok: true }); } catch (e: any) { res.status(500).json({ error: e?.message || String(e) }); @@ -6527,7 +6641,7 @@ export function createServer() { verified, total_downloads, created_at, - user_profiles(id, full_name, avatar_url, email) + user_profiles(id, full_name, avatar_url) `, ) .eq("user_id", id) @@ -7666,7 +7780,7 @@ export function createServer() { .from("nexus_applications") .select(` *, - creator:user_profiles(id, full_name, avatar_url, email), + creator:user_profiles(id, full_name, avatar_url), creator_profile:nexus_creator_profiles(skills, experience_level, hourly_rate, rating, review_count), opportunity:nexus_opportunities(id, title) `) @@ -7710,7 +7824,7 @@ export function createServer() { .from("nexus_applications") .select(` *, - creator:user_profiles(id, full_name, avatar_url, email), + creator:user_profiles(id, full_name, avatar_url), creator_profile:nexus_creator_profiles(skills, experience_level, hourly_rate, rating, review_count) `) .eq("opportunity_id", opportunityId) @@ -7787,6 +7901,262 @@ export function createServer() { } }); + // ─── Authentik SSO ──────────────────────────────────────────────────────── + // Server-side OIDC PKCE flow against auth.aethex.tech. + // Stateless signed state token — survives server restarts, no in-memory store needed. + // state = base64url(payload).hmac where payload = base64url(JSON({verifier,redirectTo,exp})) + + const AK_STATE_SECRET = process.env.AUTHENTIK_CLIENT_SECRET || process.env.SUPABASE_SERVICE_ROLE_KEY || "fallback-secret"; + + const signAkState = (verifier: string, redirectTo: string): string => { + const payload = Buffer.from(JSON.stringify({ v: verifier, r: redirectTo, exp: Date.now() + 10 * 60 * 1000 })).toString("base64url"); + const sig = createHmac("sha256", AK_STATE_SECRET).update(payload).digest("base64url"); + return `${payload}.${sig}`; + }; + + const verifyAkState = (state: string): { verifier: string; redirectTo: string } | null => { + try { + const dot = state.lastIndexOf("."); + if (dot === -1) return null; + const payload = state.slice(0, dot); + const sig = state.slice(dot + 1); + const expected = createHmac("sha256", AK_STATE_SECRET).update(payload).digest("base64url"); + if (sig !== expected) return null; + const data = JSON.parse(Buffer.from(payload, "base64url").toString()); + if (!data.exp || Date.now() > data.exp) return null; + return { verifier: data.v, redirectTo: data.r || "/dashboard" }; + } catch { + return null; + } + }; + + app.get("/api/auth/authentik/start", (req, res) => { + try { + const clientId = process.env.AUTHENTIK_CLIENT_ID; + const authentikBase = process.env.AUTHENTIK_BASE_URL || "https://auth.aethex.tech"; + if (!clientId || clientId === "REPLACE_WITH_YOUR_AUTHENTIK_CLIENT_ID") { + return res.status(500).send("Authentik SSO is not configured yet. Set AUTHENTIK_CLIENT_ID in .env"); + } + + const baseUrl = process.env.PUBLIC_BASE_URL || process.env.SITE_URL || "https://aethex.dev"; + const redirectUri = `${baseUrl}/api/auth/authentik/callback`; + + // PKCE + const codeVerifier = randomBytes(48).toString("base64url"); + const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url"); + const redirectTo = (req.query.redirectTo as string) || "/dashboard"; + const state = signAkState(codeVerifier, redirectTo); + + const params = new URLSearchParams({ + client_id: clientId, + response_type: "code", + redirect_uri: redirectUri, + scope: "openid email profile", + state, + code_challenge: codeChallenge, + code_challenge_method: "S256", + }); + + const authorizeUrl = `${authentikBase}/application/o/authorize/?${params.toString()}`; + console.log("[Authentik] Starting OIDC flow, redirectUri:", redirectUri); + return res.redirect(302, authorizeUrl); + } catch (e: any) { + console.error("[Authentik] start error:", e); + return res.status(500).json({ error: e?.message || String(e) }); + } + }); + + app.get("/api/auth/authentik/callback", async (req, res) => { + try { + const code = req.query.code as string; + const state = req.query.state as string; + const error = req.query.error as string; + const errorDesc = req.query.error_description as string; + + if (error) { + console.error("[Authentik] callback error from provider:", error, errorDesc); + return res.redirect(`/login?error=${encodeURIComponent(error)}&desc=${encodeURIComponent(errorDesc || "")}`); + } + + if (!code || !state) { + return res.redirect("/login?error=no_code"); + } + + // Verify signed state — CSRF-safe, restart-safe + const stateData = verifyAkState(state); + if (!stateData) { + console.error("[Authentik] invalid/expired state"); + return res.redirect("/login?error=state_mismatch"); + } + + const { verifier: codeVerifier, redirectTo } = stateData; + + const clientId = process.env.AUTHENTIK_CLIENT_ID!; + const clientSecret = process.env.AUTHENTIK_CLIENT_SECRET!; + const authentikBase = process.env.AUTHENTIK_BASE_URL || "https://auth.aethex.tech"; + const providerSlug = process.env.AUTHENTIK_PROVIDER_SLUG || "aethex-forge"; + + const baseUrl = process.env.PUBLIC_BASE_URL || process.env.SITE_URL || "https://aethex.dev"; + const redirectUri = `${baseUrl}/api/auth/authentik/callback`; + + // Exchange code for tokens — endpoint from discovery, no provider slug in path + const tokenUrl = `${authentikBase}/application/o/token/`; + const tokenBody = new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: redirectUri, + client_id: clientId, + client_secret: clientSecret, + code_verifier: codeVerifier, + }); + + const tokenResp = await httpsRequest(tokenUrl, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: tokenBody.toString(), + }); + + if (tokenResp.status < 200 || tokenResp.status >= 300) { + const tokenErr = tokenResp.text(); + console.error("[Authentik] token exchange failed — status:", tokenResp.status, "url:", tokenUrl, "body:", tokenErr, "redirect_uri:", redirectUri, "client_id:", clientId); + return res.redirect(`/login?error=token_exchange_failed`); + } + + const tokens = tokenResp.json() as { access_token: string; id_token?: string }; + + // Fetch user info + const userInfoUrl = `${authentikBase}/application/o/userinfo/`; + const userInfoResp = await httpsRequest(userInfoUrl, { + headers: { Authorization: `Bearer ${tokens.access_token}` }, + }); + + if (userInfoResp.status < 200 || userInfoResp.status >= 300) { + console.error("[Authentik] userinfo failed:", userInfoResp.text()); + return res.redirect("/login?error=userinfo_failed"); + } + + const userInfo = userInfoResp.json() as { + sub: string; + email: string; + email_verified?: boolean; + name?: string; + preferred_username?: string; + given_name?: string; + family_name?: string; + groups?: string[]; + }; + + if (!userInfo.email) { + console.error("[Authentik] no email in userinfo:", userInfo); + return res.redirect("/login?error=no_email"); + } + + console.log("[Authentik] userinfo:", { sub: userInfo.sub, email: userInfo.email }); + + // Find or create Supabase user + // Priority: 1) pre-linked authentik_sub in metadata, 2) email match, 3) create new + const { data: existingUsers, error: lookupError } = await adminSupabase.auth.admin.listUsers({ perPage: 1000 }); + if (lookupError) { + console.error("[Authentik] user lookup error:", lookupError); + return res.redirect("/login?error=user_lookup"); + } + + const allUsers = existingUsers?.users || []; + // Check for pre-linked sub first — this is how existing accounts get tied to Authentik + let supabaseUser = allUsers.find((u: any) => u.user_metadata?.authentik_sub === userInfo.sub) + // Fall back to email match + ?? allUsers.find((u: any) => u.email?.toLowerCase() === userInfo.email.toLowerCase()); + + if (!supabaseUser) { + // Create new user + const { data: newUser, error: createError } = await adminSupabase.auth.admin.createUser({ + email: userInfo.email, + email_confirm: true, + user_metadata: { + full_name: userInfo.name || userInfo.preferred_username || userInfo.email.split("@")[0], + authentik_sub: userInfo.sub, + provider: "authentik", + }, + }); + if (createError || !newUser?.user) { + console.error("[Authentik] user create error:", createError); + return res.redirect("/login?error=user_create_failed"); + } + supabaseUser = newUser.user; + console.log("[Authentik] Created new Supabase user:", supabaseUser.id); + } else { + // Update metadata to note Authentik link + await adminSupabase.auth.admin.updateUserById(supabaseUser.id, { + user_metadata: { + ...supabaseUser.user_metadata, + authentik_sub: userInfo.sub, + authentik_linked: true, + }, + }); + console.log("[Authentik] Linked existing Supabase user:", supabaseUser.id); + } + + // Generate a Supabase magic link using the MATCHED user's email, not the Authentik email. + // This ensures we sign into mrpiglr@gmail.com even when Authentik says mrpiglr@aethex.dev. + const { data: linkData, error: linkError } = await adminSupabase.auth.admin.generateLink({ + type: "magiclink", + email: supabaseUser.email!, + options: { + redirectTo: `${baseUrl}${redirectTo}`, + }, + }); + + if (linkError || !linkData?.properties?.action_link) { + console.error("[Authentik] generateLink error:", linkError); + return res.redirect("/login?error=link_gen_failed"); + } + + // Clear flow cookies + res.clearCookie("ak_state", { path: "/" }); + res.clearCookie("ak_verifier", { path: "/" }); + res.clearCookie("ak_redirect", { path: "/" }); + + console.log("[Authentik] Redirecting user to Supabase action link → dashboard"); + return res.redirect(302, linkData.properties.action_link); + } catch (e: any) { + console.error("[Authentik] callback error:", e); + return res.redirect(`/login?error=server_error`); + } + }); + // Unlink AeThex ID — removes authentik_sub from user metadata + app.post("/api/auth/authentik/unlink", async (req, res) => { + try { + const authHeader = req.headers.authorization || ""; + const token = authHeader.replace(/^Bearer\s+/i, ""); + if (!token) return res.status(401).json({ error: "Unauthorized" }); + + // Verify the session token and get user + const { createClient } = await import("@supabase/supabase-js"); + const userClient = createClient( + process.env.SUPABASE_URL || process.env.VITE_SUPABASE_URL || "", + process.env.SUPABASE_ANON_KEY || process.env.VITE_SUPABASE_ANON_KEY || "", + ); + const { data: { user }, error: authErr } = await userClient.auth.getUser(token); + if (authErr || !user) return res.status(401).json({ error: "Invalid session" }); + + // Strip authentik fields from metadata + const meta = { ...(user.user_metadata || {}) }; + delete meta.authentik_sub; + delete meta.authentik_linked; + + const { error: updateErr } = await adminSupabase.auth.admin.updateUserById(user.id, { + user_metadata: meta, + }); + if (updateErr) return res.status(500).json({ error: updateErr.message }); + + console.log("[Authentik] Unlinked user:", user.id); + return res.json({ ok: true }); + } catch (e: any) { + return res.status(500).json({ error: e?.message || String(e) }); + } + }); + // ── End Authentik SSO ────────────────────────────────────────────────────── + // Blog API routes app.get("/api/blog", blogIndexHandler); app.get("/api/blog/:slug", (req: express.Request, res: express.Response) => { diff --git a/server/node-build.ts b/server/node-build.ts index f1d900e9..5daa0865 100644 --- a/server/node-build.ts +++ b/server/node-build.ts @@ -1,4 +1,5 @@ import path from "path"; +import { fileURLToPath } from "url"; import { createServer } from "./index"; import * as express from "express"; @@ -7,7 +8,8 @@ const port = process.env.PORT || 5000; const host = "0.0.0.0"; // In production, serve the built SPA files -const __dirname = import.meta.dirname; +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); const distPath = path.join(__dirname, "../spa"); // Serve static files diff --git a/server/supabase.ts b/server/supabase.ts index b7f0ec87..43db7ac1 100644 --- a/server/supabase.ts +++ b/server/supabase.ts @@ -1,4 +1,5 @@ import { createClient } from "@supabase/supabase-js"; +import type { Database } from "../client/lib/database.types"; const SUPABASE_URL = process.env.SUPABASE_URL || process.env.VITE_SUPABASE_URL || ""; @@ -13,11 +14,11 @@ if (!SUPABASE_SERVICE_ROLE) { ); } -let admin: any = null; +let admin: ReturnType> | null = null; if (SUPABASE_URL && SUPABASE_SERVICE_ROLE) { - admin = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE, { + admin = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE, { auth: { autoRefreshToken: false, persistSession: false }, }); } -export const adminSupabase = admin as ReturnType; +export const adminSupabase = admin as ReturnType>; diff --git a/supabase/migrations/20260412_fix_notifications_schema.sql b/supabase/migrations/20260412_fix_notifications_schema.sql new file mode 100644 index 00000000..d0db217a --- /dev/null +++ b/supabase/migrations/20260412_fix_notifications_schema.sql @@ -0,0 +1,32 @@ +-- Fix notifications table: add title, message, read columns that server and client expect. +-- The original table only had: id, user_id, type, data (jsonb), is_read, created_at +-- Server inserts use: title, message (and implicitly read=false) +-- Client reads use: title, message, read + +ALTER TABLE public.notifications + ADD COLUMN IF NOT EXISTS title TEXT, + ADD COLUMN IF NOT EXISTS message TEXT, + ADD COLUMN IF NOT EXISTS read BOOLEAN DEFAULT false; + +-- Backfill read from is_read for any existing rows +UPDATE public.notifications SET read = is_read WHERE read IS NULL; + +-- Keep is_read and read in sync +CREATE OR REPLACE FUNCTION public.sync_notification_read() +RETURNS TRIGGER LANGUAGE plpgsql AS $$ +BEGIN + IF TG_OP = 'UPDATE' THEN + IF NEW.read IS DISTINCT FROM OLD.read THEN + NEW.is_read := NEW.read; + ELSIF NEW.is_read IS DISTINCT FROM OLD.is_read THEN + NEW.read := NEW.is_read; + END IF; + END IF; + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_sync_notification_read ON public.notifications; +CREATE TRIGGER trg_sync_notification_read + BEFORE UPDATE ON public.notifications + FOR EACH ROW EXECUTE FUNCTION public.sync_notification_read(); diff --git a/tailwind.config.ts b/tailwind.config.ts index ff488ae8..d8372aa4 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -72,11 +72,19 @@ export default { }, neon: { purple: "hsl(var(--neon-purple))", - blue: "hsl(var(--neon-blue))", + magenta: "hsl(var(--neon-magenta))", + cyan: "hsl(var(--neon-cyan))", + blue: "hsl(var(--neon-cyan))", // alias — neon-blue → cyan green: "hsl(var(--neon-green))", yellow: "hsl(var(--neon-yellow))", }, }, + fontFamily: { + sans: ["Electrolize", "Source Code Pro", "monospace"], + mono: ["Source Code Pro", "JetBrains Mono", "monospace"], + display: ["Electrolize", "monospace"], + orbitron: ["Orbitron", "monospace"], + }, borderRadius: { lg: "var(--radius)", md: "calc(var(--radius) - 2px)", diff --git a/vite.config.ts b/vite.config.ts index b8f0e83f..de793304 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,6 +1,9 @@ import { defineConfig, Plugin } from "vite"; import react from "@vitejs/plugin-react-swc"; import path from "path"; +import { readFileSync } from "fs"; + +const pkg = JSON.parse(readFileSync(path.resolve(__dirname, "package.json"), "utf-8")); // https://vitejs.dev/config/ export default defineConfig(({ mode }) => ({ @@ -20,6 +23,9 @@ export default defineConfig(({ mode }) => ({ build: { outDir: "dist/spa", }, + define: { + "import.meta.env.VITE_APP_VERSION": JSON.stringify(pkg.version), + }, plugins: [react(), expressPlugin()], resolve: { alias: {