Added LDAP config and auth

This commit is contained in:
2026-07-08 15:05:50 -07:00
parent 95dbd26ca8
commit 5447479d1b
13 changed files with 736 additions and 214 deletions
+39 -24
View File
@@ -1,7 +1,13 @@
import 'leaflet/dist/leaflet.css';
import { useEffect } from 'react';
import { Routes, Route, Navigate, Outlet } from 'react-router-dom';
import { useRecipe, Grid, GridItem, useDialog, Flex, Heading } from "@chakra-ui/react";
import { createContext } from "react";
// --- NEW AUTH IMPORTS ---
import useAuthStore from './store/authStore';
import ProtectedRoute from './components/layout/ProtectedRoute.jsx';
// Pages & Components
import Login from "@/pages/auth/Login.jsx";
import Sidebar from "./components/layout/Sidebar.jsx";
import DialogPopup from "@/components/dashboard/DialogPopup.jsx";
@@ -41,43 +47,52 @@ function AppFoundation() {
)
}
// function ProtectedRoute({ user, children }) {
// if (user) return children;
// else return <Navigate to="/login" replace />;
// }
const Placeholder = ({ name }) => (
<Flex width="100%" height="100%" align="center" justify="center" bg="gray.50"_dark={{ bg: "gray.900" }}>
<Flex width="100%" height="100%" align="center" justify="center" bg="gray.50" _dark={{ bg: "gray.900" }}>
<Heading color="gray.400" _dark={{ bg: "gray.600" }} size="md">{name} Dashboard - Coming Soon</Heading>
</Flex>
);
function App() {
// 1. Pull the initialization function from Zustand
const checkAuth = useAuthStore((state) => state.checkAuth);
// 2. Ping Django to verify the HttpOnly cookie the moment the app boots
useEffect(() => {
checkAuth();
}, [checkAuth]);
return (
<Routes>
{/*Public Routes*/}
{/* --- Public Routes --- */}
<Route path="/login" element={<Login />} />
{/*Non-Public Routes*/}
<Route element={<AppFoundation />}>
{/* --- SECURE ROUTES --- */}
<Route element={<ProtectedRoute />}>
<Route path="/home" element={<Home />}>
{/* We use our "pass" component to fill the Outlet until the real pages are built */}
<Route index element={<MyDashboard />} />
<Route path="gas" element={<MyDashboard />} />
<Route path="seismic" element={<Placeholder name="Seismic" />} />
<Route path="remote" element={<Placeholder name="Remote Sensing" />} />
<Route path="daily" element={<Placeholder name="Daily Activity" />} />
{/* --- Non-Public Routes (Now completely locked down) --- */}
<Route element={<AppFoundation />}>
<Route path="/home" element={<Home />}>
<Route index element={<MyDashboard />} />
<Route path="gas" element={<MyDashboard />} />
<Route path="seismic" element={<Placeholder name="Seismic" />} />
<Route path="remote" element={<Placeholder name="Remote Sensing" />} />
<Route path="daily" element={<Placeholder name="Daily Activity" />} />
</Route>
<Route path="/global-map" element={<GlobalMap />} />
<Route path="/regional-map" element={<RegionalMap />} />
<Route path="/volcano" element={<Volcano />} />
<Route path="/admin" element={<Admin />} />
</Route>
<Route path="/global-map" element={<GlobalMap />} />
<Route path="/regional-map" element={<RegionalMap />} />
<Route path="/volcano" element={<Volcano />} />
<Route path="/admin" element={<Admin />} />
</Route>
{/*Redirect Routes*/}
{/* --- Redirect Routes --- */}
<Route path="/" element={<Navigate to="/home" replace />} />
</Routes>
);
</Routes>
);
}
export default App
export default App;
+17
View File
@@ -0,0 +1,17 @@
// src/api/axiosConfig.js
import axios from 'axios';
const api = axios.create({
// Vite will catch this relative path and proxy it to Django
baseURL: '/api/v3/',
withCredentials: true,
xsrfCookieName: 'csrftoken',
xsrfHeaderName: 'X-CSRFToken',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
}
});
export default api;
@@ -59,12 +59,12 @@ export default function VaaActivity7Day() {
paper_bgcolor: 'transparent', // Let Chakra UI background show through
plot_bgcolor: 'transparent',
font: { color: '#d1d5db' }, // gray.300
// margin: { t: 40, r: 20, l: 30, b: 60 }, // Extra bottom margin for legend
title: {
text: '7 Day Consolidated Flight Levels',
font: { size: 16, color: 'white' },
x: 0.05
},
margin: { t: 20, r: 20, l: 20, b: 20 },
// title: {
// text: '7 Day Consolidated Flight Levels',
// font: { size: 16, color: 'white' },
// x: 0.05
// },
xaxis: {
title: {
text: 'Date (UTC)',
+12 -12
View File
@@ -1,22 +1,22 @@
import { useRecipe, Button, Stack, Text, Flex } from "@chakra-ui/react"
import UserAvatarMenu from "./UserAvatarMenu.jsx";
import { NavLink } from "react-router-dom";
import useAuthStore from "@/store/authStore";
// Import the asset properly so Vite can bundle it for any server
// import defaultAvatar from "@/assets/user_profile.svg";
// TODO: Pull this dynamically from an Auth Context later!
const placeholderUser = {
name: "Princess Zelda",
email: "zelda.royal@hyrule.gov",
// avatar: defaultAvatar
}
// Accept a 'user' prop now, fallback to placeholder if empty
export default function Header({ user = placeholderUser }) {
export default function Header() {
const recipe = useRecipe({ key: "header" });
const styles = recipe();
const { user } = useAuthStore();
console.log(user);
const displayName = user ? (user.fullName || user.username) : "Loading...";
const displayEmail = user?.email || "LDAP User";
return (
<Flex css={styles} justify="space-between" align="center" width="100%">
{/* Home Link Logo */}
@@ -27,12 +27,12 @@ export default function Header({ user = placeholderUser }) {
{/* User Profile Section */}
<Flex align="center" gap={3} pr={6}>
<Stack gap="0" textAlign="right" cursor="default">
<Text color="color" fontWeight="bold" textStyle="lg">{user.name}</Text>
<Text color="fg.muted" textStyle="sm">{user.email}</Text>
<Text color="color" fontWeight="bold" textStyle="lg">{displayName}</Text>
<Text color="fg.muted" textStyle="sm">{displayEmail}</Text>
</Stack>
<UserAvatarMenu
name={user.name}
avatar={user.avatar}
name={displayName}
// avatar={user.avatar}
/>
</Flex>
</Flex>
@@ -0,0 +1,27 @@
import React from 'react';
import { Navigate, Outlet } from 'react-router-dom';
import useAuthStore from '../../store/authStore';
const ProtectedRoute = () => {
// Grab the state directly from your Zustand store
const { isAuthenticated, isLoading } = useAuthStore();
// 1. App is currently pinging Django to verify the HttpOnly cookie
if (isLoading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', marginTop: '20vh' }}>
<h2>Loading Secure Environment...</h2>
</div>
);
}
// 2. The check failed (no cookie or expired cookie)
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
// 3. The check passed! Render the layout shell and the protected children
return <Outlet />;
};
export default ProtectedRoute;
@@ -1,16 +1,18 @@
import { Avatar, Menu, Portal, Box } from "@chakra-ui/react";
import { NavLink } from "react-router-dom";
import useAuthStore from "../../store/authStore";
export default function UserAvatarMenu({ name, avatar }) {
const { logout } = useAuthStore();
return (
<Menu.Root>
<Menu.Trigger focusRing="none">
<Box
rounded="full"
tabIndex={0}
_hover={{ boxShadow: "0 0 0 2px rgb(102, 122, 182)" }}
cursor="pointer"
rounded="full"
tabIndex={0}
_hover={{ boxShadow: "0 0 0 2px rgb(102, 122, 182)" }}
cursor="pointer"
>
<Avatar.Root shape="full">
<Avatar.Fallback name={name} />
@@ -20,17 +22,24 @@ export default function UserAvatarMenu({ name, avatar }) {
</Menu.Trigger>
<Portal>
{/* zIndex: overlay = 1300 */}
<Menu.Positioner zIndex="overlay">
<Menu.Positioner zIndex="overlay">
<Menu.Content>
<Menu.Item value="settings" as={NavLink} to={"/settings"}>
Settings
</Menu.Item>
<Menu.Item value="logout" onClick={() => console.log("Log out triggered")}>
{/* Replaced console.log with the Zustand logout action */}
<Menu.Item
value="logout"
onClick={logout}
color="red.600"
_dark={{ color: "red.400" }}
>
Logout
</Menu.Item>
</Menu.Content>
</Menu.Positioner>
</Portal>
</Menu.Root>
);
);
}
+2
View File
@@ -1,4 +1,5 @@
// config/widgetRegistry.js
import { lazy } from 'react';
// We assume a 12-column grid.
// w: 12 = full screen width
@@ -11,6 +12,7 @@ export const WIDGET_REGISTRY = {
name: "VAA 7 Day Activity",
category: "Aviation",
description: "Displays 7-day flight level timelines across multiple volcanoes.",
component: lazy(() => import('../components/widgets/VaaActivity7Day')),
allowedSizes: {
xlarge: { w: 12, h: 12 },
}
+123 -80
View File
@@ -1,91 +1,134 @@
import { useContext } from 'react';
import { useForm } from 'react-hook-form';
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Button,
Field,
Input,
Stack,
useRecipe,
Center,
Text,
Flex,
Heading,
Highlight,
GridItem,
Grid
} from "@chakra-ui/react"
import { PasswordInput } from "@/components/ui/password-input.jsx";
Box, Heading, Stack, Input, Button, Text, Separator, Field, Link, Flex
} from '@chakra-ui/react';
import useAuthStore from '../../store/authStore';
import { toaster } from "../../components/ui/toaster.jsx"; // Ensure this path matches your project
export default function Login( signedIn, setSignedIn) {
const recipe = useRecipe({ key: "dashboard" }); // styling recipes option
const styles = recipe(); // use style
const Login = () => {
const navigate = useNavigate();
const {
register,
handleSubmit,
formState: { errors, isSubmitSuccessful },
} = useForm({
defaultValues: {
username: "",
password: "",
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const { login, isAuthenticated, isLoading, error } = useAuthStore();
// --- VDAP COLOR STYLES ---
const fieldStyles = {
borderRadius: "3xl",
bg: "gray.100",
borderColor: "gray.100",
px: 4,
_focus: {
bg: "white",
borderColor: "blue.400",
boxShadow: "0 0 0 1px #4299E1",
outline: "none"
}
});
};
const onSubmit = (data) => {
console.log(data);
// setUser(data.username);
const buttonStyles = {
borderRadius: "3xl",
bg: "blue.600",
color: "white",
_hover: { bg: 'blue.700', transform: 'translateY(-1px)' },
_active: { bg: 'blue.800', transform: 'translateY(0)' },
transition: 'all 0.2s'
};
// The Redirect Watcher
useEffect(() => {
if (isAuthenticated) {
navigate('/home', { replace: true });
}
}, [isAuthenticated, navigate]);
// The Error Watcher
useEffect(() => {
if (error) {
toaster.create({
title: "Login Failed",
description: error,
type: "error",
duration: 5000,
});
}
}, [error]);
const handleSubmit = async (e) => {
e.preventDefault();
if (username && password) {
await login(username, password);
}
};
return (
<Flex css={styles} width="100%" height="100%">
<Grid
templateRows="1fr 11fr"
height="100vh"
width="100vw"
>
<GridItem rowSpan={1} >
<Flex width="100%" height="100%" align="center" >
<Heading size="4xl" fontWeight="bold" pl="1rem" cursor="default" >
<Highlight query="A" styles={{ color: 'red.600'}}>
VDAP
</Highlight>
</Heading>
</Flex>
</GridItem>
<GridItem rowSpan={11} >
<Flex width="100%" height="100%" justify="center" align="center" direction="column" >
<Center background="white" width="40%" height="50%" borderRadius="1rem">
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="4" align="center" maxW="sm">
<Text textStyle="2xl" fontWeight="bold" cursor="default">Sign In</Text>
<Field.Root invalid={!!errors.username}>
<Input
placeholder="username"
{...register("username", {required: "Required"})}
/>
<Field.ErrorText>{errors.username?.message}</Field.ErrorText>
</Field.Root>
<Flex p={8} bg="gray.50" _dark={{ bg: "gray.900" }} flex="1" minH="100vh" align="center" justify="center">
<Box w="full" maxW="450px" bg="white" _dark={{ bg: "gray.800", borderColor: "gray.700" }} p={8} borderRadius="2xl" boxShadow="sm" borderWidth="1px" borderColor="gray.200">
<Stack gap={6} align="stretch" as="form" onSubmit={handleSubmit}>
<Field.Root invalid={!!errors.password}>
<PasswordInput
placeholder="password"
{...register("password", {required: "Required"})}
/>
<Field.ErrorText>{errors.password?.message}</Field.ErrorText>
</Field.Root>
<Stack gap={2} textAlign="center">
<Heading as="h1" size="xl" color="gray.800" _dark={{ color: "white" }}>
VDAP System Login
</Heading>
<Text color="gray.500">Sign in with your LDAP credentials.</Text>
</Stack>
<Button
type="submit"
disabled={isSubmitSuccessful}
>
Submit
</Button>
</Stack>
</form>
</Center>
</Flex>
</GridItem>
</Grid>
</Flex>
<Separator />
<Field.Root required>
<Field.Label ml={1} fontSize="sm" fontWeight="medium" color="gray.700" _dark={{ color: "gray.300" }}>
Username
</Field.Label>
<Input
name="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="jdoe"
{...fieldStyles}
/>
</Field.Root>
<Field.Root required>
<Box display="flex" justifyContent="space-between" alignItems="center" w="full" mb={1} ml={1}>
<Field.Label mb={0} fontSize="sm" fontWeight="medium" color="gray.700" _dark={{ color: "gray.300" }}>
Password
</Field.Label>
</Box>
<Input
name="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
{...fieldStyles}
/>
</Field.Root>
<Button
type="submit"
loading={isLoading}
loadingText="Authenticating..."
size="lg"
mt={2}
w="full"
{...buttonStyles}
>
Sign In
</Button>
<Text textAlign="center" fontSize="sm" color="gray.500">
Need access?{' '}
<Link href="mailto:it-chight@usgs.gov" color="blue.600" _dark={{ color: "blue.400" }} fontWeight="medium">
Contact IT Administration
</Link>
</Text>
</Stack>
</Box>
</Flex>
);
}
};
export default Login;
+132 -80
View File
@@ -1,94 +1,146 @@
import { Box, Text, Flex, IconButton } from "@chakra-ui/react";
import { Box, Text, Flex, IconButton, Spinner } from "@chakra-ui/react";
import { X } from "lucide-react";
import { Responsive, WidthProvider } from "react-grid-layout";
import { useDashboardStore } from "@/store/useDashboardStore.jsx";
import { Suspense } from 'react';
// 2. Import registry!
import { WIDGET_REGISTRY } from '@/constants/widgetRegistry.jsx';
// React-Grid-Layout requires this wrapper to automatically calculate screen width
const ResponsiveGridLayout = WidthProvider(Responsive);
export default function MyDashboard() {
// 1. ☁️ Connect to the Cloud (Zustand)
const widgets = useDashboardStore(state => state.widgets);
const isEditing = useDashboardStore(state => state.isEditing);
const updateLayoutPositions = useDashboardStore(state => state.updateLayoutPositions);
const removeWidget = useDashboardStore(state => state.removeWidget);
// 1. ☁️ Connect to the Cloud (Zustand)
const widgets = useDashboardStore(state => state.widgets);
const isEditing = useDashboardStore(state => state.isEditing);
const updateLayoutPositions = useDashboardStore(state => state.updateLayoutPositions);
const removeWidget = useDashboardStore(state => state.removeWidget);
// 2. 🛠️ Format the data for RGL
// RGL expects a very specific array format for its layout prop
const rglLayout = widgets.map(w => ({
i: w.id,
x: w.x,
y: w.y,
w: w.w,
h: w.h,
isResizable: false // 🚫 The magic lock! No resizing allowed globally.
}));
// 2. 🛠️ Format the data for RGL
// RGL expects a very specific array format for its layout prop
const rglLayout = widgets.map(w => ({
i: w.id,
x: w.x,
y: w.y,
w: w.w,
h: w.h,
isResizable: false // 🚫 The magic lock! No resizing allowed globally.
}));
// 3. THE COMPONENT MAPPER
const renderWidget = (widget) => {
const registryEntry = WIDGET_REGISTRY[widget.type];
if (registryEntry && registryEntry.component) {
const Component = registryEntry.component;
return (
// The "Frame" that holds both the Title and the Chart
<Flex direction="column" height="100%" width="100%" bg="gray.800">
{/* 1. THE TITLE BAR (Dynamically pulled from registry!) */}
<Flex
justify="space-between"
align="center"
px={3}
py={2}
// borderBottom="1px solid"
// borderColor="gray.700"
>
<Text color="gray.100" fontWeight="semibold" fontSize="md" isTruncated>
{registryEntry.name}
</Text>
{/* (Optional) This is exactly where the ellipsis/settings icon will go later! */}
<Box w="20px" h="20px" />
</Flex>
{/* 2. THE CHART CANVAS */}
<Box flex="1" overflow="hidden" position="relative" p={1}>
<Component />
</Box>
return (
<Box width="100%" height="100%">
<ResponsiveGridLayout
className="layout"
layouts={{ lg: rglLayout }} // We feed it the formatted layout map
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
cols={{ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }} // Our 12-column master grid
rowHeight={30}
isDraggable={isEditing} // Only allows dragging when your Settings tools toggle this
isResizable={false} // Double lock-down
onLayoutChange={(newLayout) => updateLayoutPositions(newLayout)} // Fires when user drops a widget
compactType="vertical" // Auto-packs widgets to the top
>
{/* 3. 🎨 Paint the Widgets */}
{widgets.map(widget => (
<Box
key={widget.id}
bg="white"
borderRadius="md"
boxShadow={isEditing ? "outline" : "sm"} // Visual cue when editing
border={isEditing ? "2px dashed gray" : "1px solid"}
borderColor={isEditing ? "gray.400" : "gray.200"}
overflow="hidden"
cursor={isEditing ? "grab" : "default"}
position="relative" // 3. REQUIRED so the absolute button stays inside this box
>
{/* 4. THE DELETE BUTTON */}
{isEditing && (
<IconButton
aria-label="Remove widget"
position="absolute"
top={2}
right={2}
size="xs"
bg="white"
color="gray.400"
border="1px solid"
borderColor="gray.200"
_hover={{ bg: "red.50", color: "red.500", borderColor: "red.200" }}
zIndex={2}
onClick={() => removeWidget(widget.id)}
onMouseDown={(e) => e.stopPropagation()} // 🚫 Stops RGL from grabbing the widget
onTouchStart={(e) => e.stopPropagation()} // 🚫 Same protection for mobile touches
>
<X size={14} />
</IconButton>
)}
{/* 🚧 Temporary Placeholder!
Eventually, we will swap this block out for a dynamic component loader
that looks at widget.type and loads the correct graph or table.
*/}
<Flex width="100%" height="100%" align="center" justify="center" direction="column">
<Text fontWeight="bold" color="gray.600">{widget.type}</Text>
<Text fontSize="sm" color="gray.400">Size: {widget.size}</Text>
</Flex>
</Box>
))}
</ResponsiveGridLayout>
);
}
{/* Empty State Helper */}
{widgets.length === 0 && (
<Flex width="100%" height="200px" align="center" justify="center">
<Text color="gray.400">Your dashboard is empty. Click Settings to add widgets.</Text>
// Fallback if someone asks for a widget that doesn't exist
return (
<Flex width="100%" height="100%" align="center" justify="center" direction="column" bg="gray.800">
<Text fontWeight="bold" color="red.400">Error</Text>
<Text fontSize="sm" color="gray.400">Widget '{widget.type}' not found.</Text>
</Flex>
)}
</Box>
);
);
};
return (
<Box width="100%" height="100%">
<ResponsiveGridLayout
className="layout"
layouts={{ lg: rglLayout }} // We feed it the formatted layout map
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
cols={{ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }} // Our 12-column master grid
rowHeight={30}
isDraggable={isEditing} // Only allows dragging when your Settings tools toggle this
isResizable={false} // Double lock-down
onLayoutChange={(newLayout) => updateLayoutPositions(newLayout)} // Fires when user drops a widget
compactType="vertical" // Auto-packs widgets to the top
>
{/* 3. 🎨 Paint the Widgets */}
{widgets.map(widget => (
<Box
key={widget.id}
bg="white"
borderRadius="md"
boxShadow={isEditing ? "outline" : "sm"} // Visual cue when editing
border={isEditing ? "2px dashed gray" : "1px solid"}
borderColor={isEditing ? "gray.400" : "gray.200"}
overflow="hidden"
cursor={isEditing ? "grab" : "default"}
position="relative" // 3. REQUIRED so the absolute button stays inside this box
>
{/* 4. THE DELETE BUTTON */}
{isEditing && (
<IconButton
aria-label="Remove widget"
position="absolute"
top={2}
right={2}
size="xs"
bg="white"
color="gray.400"
border="1px solid"
borderColor="gray.200"
_hover={{ bg: "red.50", color: "red.500", borderColor: "red.200" }}
zIndex={2}
onClick={() => removeWidget(widget.id)}
onMouseDown={(e) => e.stopPropagation()} // 🚫 Stops RGL from grabbing the widget
onTouchStart={(e) => e.stopPropagation()} // 🚫 Same protection for mobile touches
>
<X size={14} />
</IconButton>
)}
{/* 4. SUSPENSE BOUNDARY wrapping the render function */}
<Suspense fallback={
<Flex w="100%" h="100%" align="center" justify="center" bg="gray.800">
<Spinner size="md" color="blue.400" thickness="3px" />
</Flex>
}>
{renderWidget(widget)}
</Suspense>
</Box>
))}
</ResponsiveGridLayout>
{/* Empty State Helper */}
{widgets.length === 0 && (
<Flex width="100%" height="200px" align="center" justify="center">
<Text color="gray.400">Your dashboard is empty. Click Settings to add widgets.</Text>
</Flex>
)}
</Box>
);
}
+66
View File
@@ -0,0 +1,66 @@
import { create } from 'zustand';
import api from '../api/axiosConfig';
const useAuthStore = create((set) => ({
isAuthenticated: false,
isLoading: true,
error: null,
user: null,
login: async (username, password) => {
set({ isLoading: true, error: null });
try {
const res = await api.post('login/', { username, password });
set({
isAuthenticated: true,
isLoading: false,
user: {
username: res.data.username,
firstName: res.data.first_name,
lastName: res.data.last_name,
email: res.data.email,
fullName: [res.data.first_name, res.data.last_name].filter(Boolean).join(' ')
}
});
} catch (err) {
// PROPER USAGE: Check if Django sent a specific error string, otherwise default
const backendError = err.response?.data?.error || 'Invalid network credentials';
set({ error: backendError, isLoading: false, isAuthenticated: false });
}
},
logout: async () => {
try {
await api.post('logout/');
} catch (err) {
console.error("Logout error:", err);
} finally {
set({ isAuthenticated: false, user: null, error: null });
}
},
checkAuth: async () => {
try {
// Hit the new lightweight endpoint
const res = await api.get('session');
// Rehydrate the store with the user's info!
set({
isAuthenticated: true,
isLoading: false,
user: {
username: res.data.username,
firstName: res.data.first_name,
lastName: res.data.last_name,
email: res.data.email,
fullName: [res.data.first_name, res.data.last_name].filter(Boolean).join(' ')
}
});
} catch {
// If it fails, ensure the store is completely wiped
set({ isAuthenticated: false, isLoading: false, user: null });
}
}
}));
export default useAuthStore;