Add base project
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
# AuthSec Backend Configuration
|
||||
# Points to Spring Boot backend service
|
||||
VITE_API_URL=http://localhost:9292
|
||||
@@ -0,0 +1,2 @@
|
||||
# AuthSec Backend Configuration
|
||||
VITE_API_URL=http://localhost:9292
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "oxc"],
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
# React + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the Oxlint configuration
|
||||
|
||||
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and Oxlint's TypeScript related rules in your project.
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AuthSec - Identity & Access Security Platform</title>
|
||||
<meta name="description" content="AuthSec modern authentication and security dashboard base platform" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Generated
+1336
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "authsec_react",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "oxlint",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^1.52.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-router-dom": "^7.18.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.7",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"oxlint": "^1.81.0",
|
||||
"vite": "^8.3.0"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1 @@
|
||||
/* App styles managed by global and module css */
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { ThemeProvider } from './context/ThemeContext';
|
||||
import { AuthProvider, useAuth } from './context/AuthContext';
|
||||
import { Login } from './pages/Login';
|
||||
import { Register } from './pages/Register';
|
||||
import { Dashboard } from './pages/Dashboard';
|
||||
import { NotFound } from './pages/NotFound';
|
||||
import { ProtectedRoute } from './components/auth/ProtectedRoute';
|
||||
import './styles/global.css';
|
||||
|
||||
// Root redirector based on authentication state
|
||||
function HomeRedirect() {
|
||||
const { isAuthenticated, loading } = useAuth();
|
||||
if (loading) return null;
|
||||
return isAuthenticated ? <Navigate to="/dashboard" replace /> : <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
// Redirect away from login/register if already logged in
|
||||
function PublicOnlyRoute({ children }) {
|
||||
const { isAuthenticated, loading } = useAuth();
|
||||
if (loading) return null;
|
||||
return isAuthenticated ? <Navigate to="/dashboard" replace /> : children;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomeRedirect />} />
|
||||
|
||||
<Route
|
||||
path="/login"
|
||||
element={
|
||||
<PublicOnlyRoute>
|
||||
<Login />
|
||||
</PublicOnlyRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/register"
|
||||
element={
|
||||
<PublicOnlyRoute>
|
||||
<Register />
|
||||
</PublicOnlyRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Dashboard />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* === BUILDER:ROUTES (do not remove) === */}
|
||||
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
|
||||
export function ProtectedRoute({ children }) {
|
||||
const { isAuthenticated, loading } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '16px',
|
||||
background: 'var(--bg-app)',
|
||||
}}
|
||||
>
|
||||
<div className="btn-spinner" style={{ width: 36, height: 36, borderWidth: 3 }} />
|
||||
<span style={{ fontSize: '0.9rem', color: 'var(--text-muted)' }}>
|
||||
Validating Security Session...
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
import './common.css';
|
||||
|
||||
export function Badge({ children, variant = 'primary', size = 'sm', className = '' }) {
|
||||
return (
|
||||
<span className={`badge badge-${variant} badge-${size} ${className}`}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function Card({ children, className = '', hoverable = false, ...props }) {
|
||||
return (
|
||||
<div className={`custom-card ${hoverable ? 'card-hoverable' : ''} ${className}`} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import './common.css';
|
||||
|
||||
export function Button({
|
||||
children,
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
isLoading = false,
|
||||
icon: Icon,
|
||||
fullWidth = false,
|
||||
className = '',
|
||||
disabled,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className={`btn btn-${variant} btn-${size} ${fullWidth ? 'btn-block' : ''} ${className}`}
|
||||
disabled={disabled || isLoading}
|
||||
{...props}
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="btn-spinner" />
|
||||
) : (
|
||||
<>
|
||||
{Icon && <Icon className="btn-icon" size={size === 'sm' ? 16 : size === 'lg' ? 20 : 18} />}
|
||||
<span>{children}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import './common.css';
|
||||
|
||||
export function Card({ children, className = '', hoverable = false, ...props }) {
|
||||
return (
|
||||
<div className={`custom-card ${hoverable ? 'card-hoverable' : ''} ${className}`} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
import './common.css';
|
||||
|
||||
export function Input({
|
||||
label,
|
||||
type = 'text',
|
||||
icon: Icon,
|
||||
error,
|
||||
helperText,
|
||||
id,
|
||||
className = '',
|
||||
required = false,
|
||||
...props
|
||||
}) {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const isPasswordType = type === 'password';
|
||||
const effectiveType = isPasswordType ? (showPassword ? 'text' : 'password') : type;
|
||||
|
||||
const inputId = id || (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined);
|
||||
|
||||
return (
|
||||
<div className={`input-group ${error ? 'has-error' : ''} ${className}`}>
|
||||
{label && (
|
||||
<label htmlFor={inputId} className="input-label">
|
||||
{label}
|
||||
{required && <span className="input-required">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="input-wrapper">
|
||||
{Icon && (
|
||||
<span className="input-prefix-icon">
|
||||
<Icon size={18} />
|
||||
</span>
|
||||
)}
|
||||
|
||||
<input
|
||||
id={inputId}
|
||||
type={effectiveType}
|
||||
className={`input-field ${Icon ? 'with-prefix' : ''} ${isPasswordType ? 'with-suffix' : ''}`}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
{isPasswordType && (
|
||||
<button
|
||||
type="button"
|
||||
className="input-suffix-btn"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
tabIndex={-1}
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className="input-error-text">{error}</p>
|
||||
) : helperText ? (
|
||||
<p className="input-helper-text">{helperText}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import { AlertCircle, CheckCircle2, Info, AlertTriangle, X } from 'lucide-react';
|
||||
import './common.css';
|
||||
|
||||
export function Toast({ type = 'info', message, onClose, className = '' }) {
|
||||
if (!message) return null;
|
||||
|
||||
const icons = {
|
||||
error: AlertCircle,
|
||||
success: CheckCircle2,
|
||||
warning: AlertTriangle,
|
||||
info: Info,
|
||||
};
|
||||
|
||||
const IconComponent = icons[type] || Info;
|
||||
|
||||
return (
|
||||
<div className={`toast-notification toast-${type} animate-fade-in ${className}`}>
|
||||
<div className="toast-icon">
|
||||
<IconComponent size={20} />
|
||||
</div>
|
||||
<div className="toast-message">{message}</div>
|
||||
{onClose && (
|
||||
<button className="toast-close" onClick={onClose} aria-label="Close notification">
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
/* Common Components CSS */
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
border-radius: var(--radius-md);
|
||||
transition: all var(--transition-normal);
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btn:active:not(:disabled) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.btn-block {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 8px 14px;
|
||||
font-size: 0.825rem;
|
||||
}
|
||||
|
||||
.btn-md {
|
||||
padding: 11px 20px;
|
||||
font-size: 0.925rem;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
padding: 14px 28px;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--gradient-primary);
|
||||
color: #ffffff;
|
||||
box-shadow: 0 4px 14px rgba(99, 102, 241, 0.35);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
box-shadow: 0 6px 20px rgba(99, 102, 241, 0.5);
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-surface-elevated);
|
||||
color: var(--text-main);
|
||||
border: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--bg-surface);
|
||||
border-color: var(--border-glow);
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
color: var(--text-main);
|
||||
border: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.btn-outline:hover:not(:disabled) {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary-light);
|
||||
background: rgba(99, 102, 241, 0.08);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
filter: grayscale(0.4);
|
||||
}
|
||||
|
||||
.btn-spinner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: #ffffff;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
/* Inputs */
|
||||
.input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.input-label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.input-required {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
font-size: 0.95rem;
|
||||
background: var(--bg-input);
|
||||
color: var(--text-main);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
outline: none;
|
||||
transition: all var(--transition-normal);
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.input-field::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.input-field:focus {
|
||||
border-color: var(--border-focus);
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
|
||||
}
|
||||
|
||||
.input-field.with-prefix {
|
||||
padding-left: 44px;
|
||||
}
|
||||
|
||||
.input-field.with-suffix {
|
||||
padding-right: 44px;
|
||||
}
|
||||
|
||||
.input-prefix-icon {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
pointer-events: none;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.input-wrapper:focus-within .input-prefix-icon {
|
||||
color: var(--color-primary-light);
|
||||
}
|
||||
|
||||
.input-suffix-btn {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.input-suffix-btn:hover {
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.input-group.has-error .input-field {
|
||||
border-color: var(--color-danger);
|
||||
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.15);
|
||||
}
|
||||
|
||||
.input-error-text {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-danger);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.input-helper-text {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-weight: 600;
|
||||
border-radius: var(--radius-full);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.badge-sm {
|
||||
padding: 3px 8px;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.badge-md {
|
||||
padding: 5px 12px;
|
||||
font-size: 0.775rem;
|
||||
}
|
||||
|
||||
.badge-primary {
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
color: var(--color-primary-light);
|
||||
border: 1px solid rgba(99, 102, 241, 0.3);
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: var(--color-success-bg);
|
||||
color: var(--color-success);
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background: var(--color-warning-bg);
|
||||
color: var(--color-warning);
|
||||
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
background: var(--color-danger-bg);
|
||||
color: var(--color-danger);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
background: var(--color-info-bg);
|
||||
color: var(--color-info);
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
/* Custom Card */
|
||||
.custom-card {
|
||||
background: var(--bg-glass-card);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 24px;
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
.custom-card.card-hoverable:hover {
|
||||
transform: translateY(-3px);
|
||||
border-color: rgba(99, 102, 241, 0.3);
|
||||
box-shadow: var(--shadow-lg), var(--shadow-glow);
|
||||
}
|
||||
|
||||
/* Toast Notification */
|
||||
.toast-notification {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 14px 18px;
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 18px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.toast-icon {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toast-message {
|
||||
flex: 1;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.toast-close {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.7;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.toast-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
background: var(--color-danger-bg);
|
||||
color: #fca5a5;
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
[data-theme='light'] .toast-error {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
background: var(--color-success-bg);
|
||||
color: #6ee7b7;
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
[data-theme='light'] .toast-success {
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.toast-warning {
|
||||
background: var(--color-warning-bg);
|
||||
color: #fcd34d;
|
||||
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
|
||||
[data-theme='light'] .toast-warning {
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.toast-info {
|
||||
background: var(--color-info-bg);
|
||||
color: #93c5fd;
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
[data-theme='light'] .toast-info {
|
||||
color: #1d4ed8;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
.product-page {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.product-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.product-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.product-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.product-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.product-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.product-error {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.product-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.product-list li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.product-row-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { getProducts, createProduct, updateProduct, deleteProduct } from './ProductService';
|
||||
import { Card } from '../../common/Card';
|
||||
import { Input } from '../../common/Input';
|
||||
import { Button } from '../../common/Button';
|
||||
import './Product.css';
|
||||
|
||||
const EMPTY_FORM = {
|
||||
name: '',
|
||||
description: '',
|
||||
email: '',
|
||||
price: '',
|
||||
quantity: '',
|
||||
dob: '',
|
||||
createdAt: '',
|
||||
isActive: true,
|
||||
status: '',
|
||||
tags: [],
|
||||
photo: null,
|
||||
categoryId: '',
|
||||
};
|
||||
|
||||
const STATUS_OPTIONS = ['New', 'In Stock', 'Discontinued'];
|
||||
const TAG_OPTIONS = ['Electronics', 'Food', 'Clothing', 'Books'];
|
||||
const CATEGORY_OPTIONS = [
|
||||
{ id: 'cat-1', label: 'Category 1' },
|
||||
{ id: 'cat-2', label: 'Category 2' },
|
||||
];
|
||||
|
||||
export function Product() {
|
||||
const [items, setItems] = useState([]);
|
||||
const [formData, setFormData] = useState(EMPTY_FORM);
|
||||
const [selectedId, setSelectedId] = useState(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const d = await getProducts();
|
||||
const list = Array.isArray(d) ? d : d?.data ?? d?.items ?? [];
|
||||
setItems(list);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, type, checked, files, value } = e.target;
|
||||
if (type === 'file') {
|
||||
setFormData((p) => ({ ...p, [name]: files && files[0] ? files[0].name : null }));
|
||||
return;
|
||||
}
|
||||
setFormData((p) => ({ ...p, [name]: type === 'checkbox' ? checked : value }));
|
||||
};
|
||||
|
||||
const handleMultiChange = (e) => {
|
||||
const { name, options } = e.target;
|
||||
const vals = Array.from(options)
|
||||
.filter((o) => o.selected)
|
||||
.map((o) => o.value);
|
||||
setFormData((p) => ({ ...p, [name]: vals }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
if (selectedId) {
|
||||
await updateProduct(selectedId, formData);
|
||||
} else {
|
||||
await createProduct(formData);
|
||||
}
|
||||
setFormData(EMPTY_FORM);
|
||||
setSelectedId(null);
|
||||
await fetchData();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (item) => {
|
||||
setSelectedId(item.id);
|
||||
setFormData({ ...EMPTY_FORM, ...item });
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
await deleteProduct(id);
|
||||
await fetchData();
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setFormData(EMPTY_FORM);
|
||||
setSelectedId(null);
|
||||
setError('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="product-page">
|
||||
<Card>
|
||||
<h2>{selectedId ? 'Update Product' : 'Add Product'}</h2>
|
||||
{error && <p className="product-error">{error}</p>}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="product-grid">
|
||||
<Input label="Name" name="name" value={formData.name || ''} onChange={handleChange} required />
|
||||
<Input label="Email" type="email" name="email" value={formData.email || ''} onChange={handleChange} />
|
||||
<Input label="Price" type="number" step="0.01" name="price" value={formData.price || ''} onChange={handleChange} />
|
||||
<Input label="Quantity" type="number" name="quantity" value={formData.quantity || ''} onChange={handleChange} />
|
||||
<Input label="Date of Birth" type="date" name="dob" value={formData.dob || ''} onChange={handleChange} />
|
||||
<Input label="Created At" type="datetime-local" name="createdAt" value={formData.createdAt || ''} onChange={handleChange} />
|
||||
<label className="product-check">
|
||||
<input type="checkbox" name="isActive" checked={!!formData.isActive} onChange={handleChange} /> Active
|
||||
</label>
|
||||
<label className="product-field">
|
||||
Status
|
||||
<select name="status" value={formData.status || ''} onChange={handleChange}>
|
||||
<option value="">Select status</option>
|
||||
{STATUS_OPTIONS.map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="product-field">
|
||||
Tags
|
||||
<select multiple name="tags" value={formData.tags || []} onChange={handleMultiChange}>
|
||||
{TAG_OPTIONS.map((t) => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="product-field">
|
||||
Photo
|
||||
<input type="file" name="photo" onChange={handleChange} />
|
||||
</label>
|
||||
<label className="product-field">
|
||||
Category
|
||||
<select name="categoryId" value={formData.categoryId || ''} onChange={handleChange}>
|
||||
<option value="">Select category</option>
|
||||
{CATEGORY_OPTIONS.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="product-field product-wide">
|
||||
Description
|
||||
<textarea name="description" value={formData.description || ''} onChange={handleChange} rows={3} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="product-actions">
|
||||
<Button type="submit" isLoading={saving}>{selectedId ? 'Update' : 'Add'}</Button>
|
||||
<Button type="button" variant="outline" onClick={reset}>Reset</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h2>Products</h2>
|
||||
<ul className="product-list">
|
||||
{items.map((item) => (
|
||||
<li key={item.id}>
|
||||
<span>{item.name} — {item.email} — {item.price} — {item.status}</span>
|
||||
<span className="product-row-actions">
|
||||
<Button size="sm" variant="secondary" onClick={() => handleEdit(item)}>Edit</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => handleDelete(item.id)}>Delete</Button>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { request } from '../../../../services/api';
|
||||
|
||||
const ENDPOINT = '/Product/Product';
|
||||
|
||||
export const getProducts = () => request(ENDPOINT);
|
||||
|
||||
export const getProductById = (id) => request(`${ENDPOINT}/${id}`);
|
||||
|
||||
export const createProduct = (data) =>
|
||||
request(ENDPOINT, { method: 'POST', body: JSON.stringify(data) });
|
||||
|
||||
export const updateProduct = (id, data) =>
|
||||
request(`${ENDPOINT}/${id}`, { method: 'PUT', body: JSON.stringify(data) });
|
||||
|
||||
export const deleteProduct = (id) =>
|
||||
request(`${ENDPOINT}/${id}`, { method: 'DELETE' });
|
||||
@@ -0,0 +1,26 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { Header } from './Header';
|
||||
import './layout.css';
|
||||
|
||||
export function DashboardLayout({ children, activeTab, setActiveTab }) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="dashboard-layout">
|
||||
<Sidebar
|
||||
collapsed={collapsed}
|
||||
setCollapsed={setCollapsed}
|
||||
mobileOpen={mobileOpen}
|
||||
setMobileOpen={setMobileOpen}
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
/>
|
||||
<div className="dashboard-main">
|
||||
<Header setMobileOpen={setMobileOpen} />
|
||||
<main className="dashboard-content-area">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Menu, Search, Bell, User as UserIcon, LogOut, ShieldCheck, ChevronDown } from 'lucide-react';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { ThemeToggle } from './ThemeToggle';
|
||||
import './layout.css';
|
||||
|
||||
export function Header({ setMobileOpen }) {
|
||||
const { user, logout, isDemoMode } = useAuth();
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const dropdownRef = useRef(null);
|
||||
|
||||
// Close dropdown on click outside
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
|
||||
setDropdownOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<header className="dashboard-header">
|
||||
{/* Left: Mobile hamburger & Search */}
|
||||
<div className="header-left">
|
||||
<button
|
||||
className="mobile-menu-btn"
|
||||
onClick={() => setMobileOpen(true)}
|
||||
aria-label="Open navigation menu"
|
||||
>
|
||||
<Menu size={22} />
|
||||
</button>
|
||||
|
||||
<div className="header-search">
|
||||
<Search size={18} className="header-search-icon" />
|
||||
<input
|
||||
type="text"
|
||||
className="header-search-input"
|
||||
placeholder="Search workspaces, logs, users..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Actions, Theme, User */}
|
||||
<div className="header-right">
|
||||
{/* Backend Connectivity Status Badge */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
fontSize: '0.8rem',
|
||||
padding: '4px 10px',
|
||||
borderRadius: 'var(--radius-full)',
|
||||
background: isDemoMode ? 'var(--color-warning-bg)' : 'var(--color-success-bg)',
|
||||
color: isDemoMode ? 'var(--color-warning)' : 'var(--color-success)',
|
||||
border: `1px solid ${isDemoMode ? 'rgba(245, 158, 11, 0.3)' : 'rgba(16, 185, 129, 0.3)'}`,
|
||||
}}
|
||||
title={isDemoMode ? 'Running in Standalone Demo Mode' : 'Connected to Spring Boot API'}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: isDemoMode ? 'var(--color-warning)' : 'var(--color-success)',
|
||||
}}
|
||||
/>
|
||||
<span>{isDemoMode ? 'Demo Sandbox' : 'Backend Live'}</span>
|
||||
</div>
|
||||
|
||||
{/* Theme Switcher */}
|
||||
<ThemeToggle />
|
||||
|
||||
{/* Notifications */}
|
||||
<button className="header-btn" title="Recent security alerts">
|
||||
<Bell size={18} />
|
||||
<span className="header-badge-dot" />
|
||||
</button>
|
||||
|
||||
{/* User Dropdown */}
|
||||
<div className="profile-dropdown-wrapper" ref={dropdownRef}>
|
||||
<button
|
||||
className="profile-trigger-btn"
|
||||
onClick={() => setDropdownOpen(!dropdownOpen)}
|
||||
aria-expanded={dropdownOpen}
|
||||
>
|
||||
<div className="sidebar-user-avatar" style={{ width: 30, height: 30, fontSize: '0.75rem' }}>
|
||||
{(user?.firstName?.[0] || 'A')}
|
||||
</div>
|
||||
<span style={{ fontSize: '0.85rem', fontWeight: 600 }}>
|
||||
{user?.firstName || 'User'}
|
||||
</span>
|
||||
<ChevronDown size={14} color="var(--text-muted)" />
|
||||
</button>
|
||||
|
||||
{dropdownOpen && (
|
||||
<div className="profile-dropdown-menu animate-fade-in">
|
||||
<div style={{ padding: '8px 12px', borderBottom: '1px solid var(--border-subtle)' }}>
|
||||
<p style={{ fontSize: '0.825rem', fontWeight: 700 }}>{user?.fullname || user?.firstName}</p>
|
||||
<p style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>{user?.email}</p>
|
||||
</div>
|
||||
|
||||
<div className="dropdown-item" onClick={() => setDropdownOpen(false)}>
|
||||
<UserIcon size={16} />
|
||||
<span>Profile Settings</span>
|
||||
</div>
|
||||
<div className="dropdown-item" onClick={() => setDropdownOpen(false)}>
|
||||
<ShieldCheck size={16} />
|
||||
<span>Security Token Info</span>
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item danger"
|
||||
onClick={() => {
|
||||
setDropdownOpen(false);
|
||||
logout();
|
||||
}}
|
||||
>
|
||||
<LogOut size={16} />
|
||||
<span>Sign Out</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Shield,
|
||||
LayoutDashboard,
|
||||
Layers,
|
||||
ShieldAlert,
|
||||
Sliders,
|
||||
Users,
|
||||
Settings,
|
||||
FileText,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
LogOut,
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import './layout.css';
|
||||
|
||||
export function Sidebar({ collapsed, setCollapsed, mobileOpen, setMobileOpen, activeTab, setActiveTab }) {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const navItems = [
|
||||
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ id: 'workspaces', label: 'Workspaces', icon: Layers },
|
||||
{ id: 'security', label: 'Security Audit', icon: ShieldAlert },
|
||||
{ id: 'system', label: 'System Parameters', icon: Sliders },
|
||||
{ id: 'users', label: 'User Directory', icon: Users },
|
||||
{ id: 'settings', label: 'Settings', icon: Settings },
|
||||
// === BUILDER:NAV-ITEMS (do not remove) ===
|
||||
];
|
||||
|
||||
const handleItemClick = (id) => {
|
||||
const target = navItems.find((n) => n.id === id);
|
||||
if (target && target.path) {
|
||||
navigate(target.path);
|
||||
if (mobileOpen) {
|
||||
setMobileOpen(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setActiveTab(id);
|
||||
if (mobileOpen) {
|
||||
setMobileOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getInitials = (name) => {
|
||||
if (!name) return 'AU';
|
||||
return name
|
||||
.split(' ')
|
||||
.map((n) => n[0])
|
||||
.join('')
|
||||
.slice(0, 2)
|
||||
.toUpperCase();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile Backdrop */}
|
||||
{mobileOpen && (
|
||||
<div className="sidebar-overlay" onClick={() => setMobileOpen(false)} />
|
||||
)}
|
||||
|
||||
<aside className={`dashboard-sidebar ${collapsed ? 'collapsed' : ''} ${mobileOpen ? 'mobile-open' : ''}`}>
|
||||
{/* Brand Header */}
|
||||
<div className="sidebar-header">
|
||||
<div className="sidebar-brand">
|
||||
<div className="sidebar-brand-icon">
|
||||
<Shield size={22} />
|
||||
</div>
|
||||
{!collapsed && <span className="sidebar-brand-title">AuthSec</span>}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="sidebar-collapse-btn"
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
>
|
||||
{collapsed ? <ChevronRight size={18} /> : <ChevronLeft size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigation Items */}
|
||||
<div className="sidebar-nav">
|
||||
{!collapsed && <span className="sidebar-section-title">Navigation</span>}
|
||||
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = activeTab === item.id;
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`sidebar-item ${isActive ? 'active' : ''}`}
|
||||
onClick={() => handleItemClick(item.id)}
|
||||
title={collapsed ? item.label : undefined}
|
||||
>
|
||||
<Icon size={20} className="sidebar-item-icon" />
|
||||
{!collapsed && <span>{item.label}</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* User Card in Footer */}
|
||||
<div className="sidebar-footer">
|
||||
<div
|
||||
className="sidebar-user-pill"
|
||||
onClick={logout}
|
||||
title={collapsed ? `${user?.firstName || 'User'} (Click to Logout)` : undefined}
|
||||
>
|
||||
<div className="sidebar-user-avatar">
|
||||
{getInitials(user?.fullname || user?.firstName || user?.email)}
|
||||
</div>
|
||||
|
||||
{!collapsed && (
|
||||
<>
|
||||
<div className="sidebar-user-details">
|
||||
<span className="sidebar-user-name">
|
||||
{user?.fullname || user?.firstName || 'Secured User'}
|
||||
</span>
|
||||
<span className="sidebar-user-role">
|
||||
{user?.roles?.[0] || 'ROLE_ADMIN'}
|
||||
</span>
|
||||
</div>
|
||||
<LogOut size={16} style={{ marginLeft: 'auto', color: 'var(--text-muted)' }} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import { Sun, Moon } from 'lucide-react';
|
||||
import { useTheme } from '../../context/ThemeContext';
|
||||
import './layout.css';
|
||||
|
||||
export function ThemeToggle({ className = '' }) {
|
||||
const { theme, toggleTheme, isDark } = useTheme();
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`theme-toggle-btn ${className}`}
|
||||
onClick={toggleTheme}
|
||||
title={`Switch to ${isDark ? 'light' : 'dark'} mode`}
|
||||
aria-label="Toggle visual theme"
|
||||
>
|
||||
<div className={`theme-toggle-inner ${isDark ? 'is-dark' : 'is-light'}`}>
|
||||
{isDark ? <Moon size={18} className="theme-icon" /> : <Sun size={18} className="theme-icon" />}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
/* Layout CSS (Header, Sidebar, Navigation) */
|
||||
|
||||
/* Theme Toggle Button */
|
||||
.theme-toggle-btn {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-surface-elevated);
|
||||
border: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
.theme-toggle-btn:hover {
|
||||
background: var(--bg-surface);
|
||||
color: var(--color-primary-light);
|
||||
border-color: var(--border-glow);
|
||||
}
|
||||
|
||||
.theme-toggle-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform var(--transition-smooth);
|
||||
}
|
||||
|
||||
/* Dashboard Layout Structure */
|
||||
.dashboard-layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
background-color: var(--bg-app);
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.dashboard-sidebar {
|
||||
width: 260px;
|
||||
background: var(--bg-surface);
|
||||
border-right: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
z-index: 50;
|
||||
transition: width var(--transition-smooth), transform var(--transition-smooth);
|
||||
}
|
||||
|
||||
.dashboard-sidebar.collapsed {
|
||||
width: 78px;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
height: 72px;
|
||||
padding: 0 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-brand-icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--gradient-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #ffffff;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);
|
||||
}
|
||||
|
||||
.sidebar-brand-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 800;
|
||||
color: var(--text-main);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sidebar-collapse-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.sidebar-collapse-btn:hover {
|
||||
background: var(--bg-surface-elevated);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
/* Sidebar Nav */
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
padding: 20px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar-section-title {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
padding: 8px 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.sidebar-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
font-size: 0.925rem;
|
||||
transition: all var(--transition-fast);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar-item:hover {
|
||||
background: var(--bg-surface-elevated);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.sidebar-item.active {
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
color: var(--color-primary-light);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sidebar-item.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 15%;
|
||||
bottom: 15%;
|
||||
width: 3px;
|
||||
background: var(--color-primary);
|
||||
border-radius: 0 var(--radius-xs) var(--radius-xs) 0;
|
||||
}
|
||||
|
||||
.sidebar-item-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Sidebar Footer User Card */
|
||||
.sidebar-footer {
|
||||
padding: 16px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.sidebar-user-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-surface-elevated);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-user-pill:hover {
|
||||
background: rgba(99, 102, 241, 0.08);
|
||||
}
|
||||
|
||||
.sidebar-user-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: var(--gradient-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-user-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-user-name {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-main);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sidebar-user-role {
|
||||
font-size: 0.725rem;
|
||||
color: var(--color-primary-light);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Main Content Area */
|
||||
.dashboard-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.dashboard-header {
|
||||
height: 72px;
|
||||
padding: 0 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: var(--bg-glass);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.mobile-menu-btn {
|
||||
display: none;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.header-search {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.header-search-input {
|
||||
width: 100%;
|
||||
padding: 8px 14px 8px 38px;
|
||||
background: var(--bg-surface-elevated);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-full);
|
||||
color: var(--text-main);
|
||||
font-size: 0.875rem;
|
||||
outline: none;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.header-search-input:focus {
|
||||
border-color: var(--border-focus);
|
||||
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
|
||||
}
|
||||
|
||||
.header-search-icon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
color: var(--text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.header-btn {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-surface-elevated);
|
||||
border: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
transition: all var(--transition-fast);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.header-btn:hover {
|
||||
color: var(--text-main);
|
||||
border-color: var(--border-glow);
|
||||
}
|
||||
|
||||
.header-badge-dot {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-secondary);
|
||||
}
|
||||
|
||||
/* Profile Menu Dropdown */
|
||||
.profile-dropdown-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.profile-trigger-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 4px 10px 4px 4px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--bg-surface-elevated);
|
||||
border: 1px solid var(--border-subtle);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.profile-trigger-btn:hover {
|
||||
border-color: var(--border-glow);
|
||||
}
|
||||
|
||||
.profile-dropdown-menu {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 8px);
|
||||
width: 220px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 8px;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background: var(--bg-surface-elevated);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.dropdown-item.danger:hover {
|
||||
background: var(--color-danger-bg);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
/* Responsive Mobile Sidebar overlay */
|
||||
.sidebar-overlay {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.dashboard-header {
|
||||
padding: 0 16px;
|
||||
}
|
||||
.header-search {
|
||||
display: none;
|
||||
}
|
||||
.mobile-menu-btn {
|
||||
display: flex;
|
||||
}
|
||||
.dashboard-sidebar {
|
||||
position: fixed;
|
||||
left: -280px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 280px !important;
|
||||
transition: left var(--transition-smooth);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
.dashboard-sidebar.mobile-open {
|
||||
left: 0;
|
||||
}
|
||||
.sidebar-overlay {
|
||||
display: block;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 45;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { authService } from '../services/authService';
|
||||
import { DEMO_USER } from '../services/mockData';
|
||||
|
||||
const AuthContext = createContext();
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null);
|
||||
const [token, setToken] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isDemoMode, setIsDemoMode] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Restore session on mount
|
||||
try {
|
||||
const storedToken = localStorage.getItem('authsec_token');
|
||||
const storedUser = localStorage.getItem('authsec_user');
|
||||
if (storedToken && storedUser) {
|
||||
setToken(storedToken);
|
||||
setUser(JSON.parse(storedUser));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse stored session:', e);
|
||||
localStorage.removeItem('authsec_token');
|
||||
localStorage.removeItem('authsec_user');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const login = async (email, password, forceMock = false) => {
|
||||
const res = await authService.login(email, password, forceMock);
|
||||
const item = res.item || res;
|
||||
const authToken = item.token || 'demo-token-' + Date.now();
|
||||
|
||||
// Store in localStorage
|
||||
localStorage.setItem('authsec_token', authToken);
|
||||
localStorage.setItem('authsec_user', JSON.stringify(item));
|
||||
|
||||
setToken(authToken);
|
||||
setUser(item);
|
||||
if (forceMock || res.operationMessage?.includes('Demo')) {
|
||||
setIsDemoMode(true);
|
||||
}
|
||||
return item;
|
||||
};
|
||||
|
||||
const loginAsDemo = async () => {
|
||||
return login(DEMO_USER.email, 'admin123', true);
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
await authService.logout();
|
||||
setUser(null);
|
||||
setToken(null);
|
||||
setIsDemoMode(false);
|
||||
};
|
||||
|
||||
const register = async (userData) => {
|
||||
return authService.registerUser(userData);
|
||||
};
|
||||
|
||||
const value = {
|
||||
user,
|
||||
token,
|
||||
loading,
|
||||
isAuthenticated: !!token && !!user,
|
||||
isDemoMode,
|
||||
login,
|
||||
loginAsDemo,
|
||||
logout,
|
||||
register,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||
|
||||
const ThemeContext = createContext();
|
||||
|
||||
export function ThemeProvider({ children }) {
|
||||
const [theme, setTheme] = useState(() => {
|
||||
const saved = localStorage.getItem('authsec_theme');
|
||||
if (saved) return saved;
|
||||
return window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches
|
||||
? 'light'
|
||||
: 'dark';
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem('authsec_theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme((prev) => (prev === 'dark' ? 'light' : 'dark'));
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, toggleTheme, isDark: theme === 'dark' }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext);
|
||||
if (!context) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
@import './styles/global.css';
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.jsx'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,331 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
ShieldCheck,
|
||||
Lock,
|
||||
LayoutGrid,
|
||||
Activity,
|
||||
Key,
|
||||
ShieldAlert,
|
||||
CheckCircle,
|
||||
MailCheck,
|
||||
User,
|
||||
Sliders,
|
||||
FileText,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
Info,
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { DashboardLayout } from '../components/layout/DashboardLayout';
|
||||
import { Badge } from '../components/common/Badge';
|
||||
import { Button } from '../components/common/Button';
|
||||
import { Card } from '../components/common/Card';
|
||||
import { MOCK_STATS, MOCK_ACTIVITIES } from '../services/mockData';
|
||||
import '../styles/dashboard.css';
|
||||
|
||||
export function Dashboard() {
|
||||
const { user, token, isDemoMode, logout } = useAuth();
|
||||
const [activeTab, setActiveTab] = useState('dashboard');
|
||||
const [showTokenModal, setShowTokenModal] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Icon mapping for stats
|
||||
const statIcons = {
|
||||
ShieldCheck,
|
||||
Lock,
|
||||
LayoutGrid,
|
||||
Activity,
|
||||
};
|
||||
|
||||
// Icon mapping for activities
|
||||
const activityIcons = {
|
||||
Key,
|
||||
ShieldAlert,
|
||||
CheckCircle,
|
||||
MailCheck,
|
||||
};
|
||||
|
||||
const handleCopyToken = () => {
|
||||
if (token) {
|
||||
navigator.clipboard.writeText(token);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout activeTab={activeTab} setActiveTab={setActiveTab}>
|
||||
{/* Hero Welcome Banner */}
|
||||
<section className="dashboard-hero animate-fade-in">
|
||||
<div className="hero-left">
|
||||
<div className="hero-tag">
|
||||
<ShieldCheck size={16} />
|
||||
<span>Identity & Access Controller</span>
|
||||
</div>
|
||||
<h1 className="hero-title">
|
||||
Welcome back, <span className="gradient-text">{user?.firstName || 'Alex'}</span>
|
||||
</h1>
|
||||
<p className="hero-description">
|
||||
AuthSec base project is operational. Your session is protected with Spring Boot JWT
|
||||
authentication and granular role-based security boundaries.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="hero-right">
|
||||
<div className="hero-token-pill">
|
||||
<Key size={14} />
|
||||
<span>JWT: {token ? `${token.slice(0, 14)}...` : 'demo-token'}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={Key}
|
||||
onClick={() => setShowTokenModal(true)}
|
||||
>
|
||||
Inspect Token
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={logout}
|
||||
>
|
||||
Sign Out
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Metric Stats Cards */}
|
||||
<section className="stats-grid">
|
||||
{MOCK_STATS.map((stat) => {
|
||||
const Icon = statIcons[stat.icon] || Activity;
|
||||
return (
|
||||
<div key={stat.id} className="stat-card">
|
||||
<div className="stat-card-header">
|
||||
<span className="stat-card-title">{stat.title}</span>
|
||||
<div className="stat-card-icon-box">
|
||||
<Icon size={20} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card-value">{stat.value}</div>
|
||||
|
||||
<div className="stat-card-footer">
|
||||
<span>{stat.subtitle}</span>
|
||||
<span className={`stat-trend ${stat.status}`}>{stat.trend}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
|
||||
{/* Quick Action Buttons */}
|
||||
<section>
|
||||
<div className="section-header">
|
||||
<h2 className="section-title">Quick Actions</h2>
|
||||
</div>
|
||||
|
||||
<div className="quick-actions-grid">
|
||||
<div className="quick-action-card" onClick={() => setShowTokenModal(true)}>
|
||||
<div className="action-icon-circle">
|
||||
<Key size={20} />
|
||||
</div>
|
||||
<div className="action-details">
|
||||
<span className="action-name">Security Token</span>
|
||||
<span className="action-desc">View JWT payload & auth headers</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="quick-action-card"
|
||||
onClick={() => alert(`User Profile:\nName: ${user?.fullname || user?.firstName}\nEmail: ${user?.email}\nRoles: ${user?.roles?.join(', ')}`)}
|
||||
>
|
||||
<div className="action-icon-circle" style={{ background: 'linear-gradient(135deg, #06b6d4, #3b82f6)' }}>
|
||||
<User size={20} />
|
||||
</div>
|
||||
<div className="action-details">
|
||||
<span className="action-name">User Profile</span>
|
||||
<span className="action-desc">Account ID #{user?.userId || '101'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="quick-action-card"
|
||||
onClick={() => alert('System Parameters: Spring Boot Service Port 9292\nCORS: Enabled\nToken Lifetime: 24h')}
|
||||
>
|
||||
<div className="action-icon-circle" style={{ background: 'linear-gradient(135deg, #8b5cf6, #ec4899)' }}>
|
||||
<Sliders size={20} />
|
||||
</div>
|
||||
<div className="action-details">
|
||||
<span className="action-name">System Parameters</span>
|
||||
<span className="action-desc">Endpoints & security policies</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="quick-action-card"
|
||||
onClick={() => setActiveTab('security')}
|
||||
>
|
||||
<div className="action-icon-circle" style={{ background: 'linear-gradient(135deg, #10b981, #06b6d4)' }}>
|
||||
<FileText size={20} />
|
||||
</div>
|
||||
<div className="action-details">
|
||||
<span className="action-name">Audit Reports</span>
|
||||
<span className="action-desc">Inspect security logs</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Live Security Activity Stream */}
|
||||
<section className="activity-card">
|
||||
<div className="section-header">
|
||||
<div>
|
||||
<h2 className="section-title">Recent Security Events</h2>
|
||||
<p style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>
|
||||
Audited session entries recorded for this workspace
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="primary" size="md">
|
||||
Live Stream
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="activity-list">
|
||||
{MOCK_ACTIVITIES.map((act) => {
|
||||
const Icon = activityIcons[act.icon] || Key;
|
||||
return (
|
||||
<div key={act.id} className="activity-item">
|
||||
<div className="activity-left">
|
||||
<div className="activity-icon">
|
||||
<Icon size={18} />
|
||||
</div>
|
||||
<div className="activity-info">
|
||||
<span className="activity-title">{act.title}</span>
|
||||
<span className="activity-desc">
|
||||
{act.description} • <strong style={{ color: 'var(--text-secondary)' }}>IP: {act.ip}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="activity-right">
|
||||
<Badge
|
||||
variant={
|
||||
act.badge === 'SUCCESS' ? 'success' : act.badge === 'SECURITY' ? 'warning' : 'info'
|
||||
}
|
||||
>
|
||||
{act.badge}
|
||||
</Badge>
|
||||
<span className="activity-time">{act.timestamp}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* === BUILDER:TABS (do not remove) === */}
|
||||
{/* Token Inspector Modal */}
|
||||
{showTokenModal && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0, 0, 0, 0.7)',
|
||||
backdropFilter: 'blur(6px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
padding: '20px',
|
||||
}}
|
||||
onClick={() => setShowTokenModal(false)}
|
||||
>
|
||||
<div
|
||||
className="custom-card animate-fade-in"
|
||||
style={{ maxWidth: '580px', width: '100%', maxHeight: '90vh', overflowY: 'auto' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Key size={20} color="var(--color-primary-light)" />
|
||||
<h3 style={{ fontSize: '1.25rem' }}>Session Token Inspector</h3>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => setShowTokenModal(false)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: '0.85rem', color: 'var(--text-secondary)', marginBottom: '16px' }}>
|
||||
This Bearer token is automatically attached to API requests in the <code>Authorization</code> header.
|
||||
</p>
|
||||
|
||||
<div className="session-info-card">
|
||||
<div className="session-info-row">
|
||||
<span>Account Email:</span>
|
||||
<strong>{user?.email || 'N/A'}</strong>
|
||||
</div>
|
||||
<div className="session-info-row">
|
||||
<span>User ID:</span>
|
||||
<strong>{user?.userId || '101'}</strong>
|
||||
</div>
|
||||
<div className="session-info-row">
|
||||
<span>Assigned Roles:</span>
|
||||
<strong>{Array.isArray(user?.roles) ? user.roles.join(', ') : 'ROLE_ADMIN'}</strong>
|
||||
</div>
|
||||
<div className="session-info-row">
|
||||
<span>Mode:</span>
|
||||
<strong>{isDemoMode ? 'Sandbox Demo Mode' : 'Live Spring Boot'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '16px' }}>
|
||||
<label style={{ fontSize: '0.8rem', fontWeight: 600, color: 'var(--text-secondary)' }}>
|
||||
Raw JWT Token:
|
||||
</label>
|
||||
<textarea
|
||||
readOnly
|
||||
value={token || ''}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '80px',
|
||||
background: 'var(--bg-input)',
|
||||
color: 'var(--text-main)',
|
||||
border: '1px solid var(--border-subtle)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
padding: '10px',
|
||||
fontSize: '0.75rem',
|
||||
fontFamily: 'monospace',
|
||||
resize: 'none',
|
||||
marginTop: '6px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px', marginTop: '20px' }}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={Copy}
|
||||
onClick={handleCopyToken}
|
||||
>
|
||||
{copied ? 'Copied to Clipboard!' : 'Copy Token'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => setShowTokenModal(false)}
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { Shield, Mail, Lock, Sparkles, ArrowRight, Server } from 'lucide-react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { Button } from '../components/common/Button';
|
||||
import { Input } from '../components/common/Input';
|
||||
import { Toast } from '../components/common/Toast';
|
||||
import { ThemeToggle } from '../components/layout/ThemeToggle';
|
||||
import { DEMO_USER } from '../services/mockData';
|
||||
import '../styles/auth.css';
|
||||
|
||||
export function Login() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { login } = useAuth();
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [rememberMe, setRememberMe] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// If redirected with registered email
|
||||
React.useEffect(() => {
|
||||
if (location.state?.registeredEmail) {
|
||||
setEmail(location.state.registeredEmail);
|
||||
}
|
||||
}, [location.state]);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!email.trim() || !password.trim()) {
|
||||
setError('Please provide both email and password.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
await login(email, password);
|
||||
navigate('/dashboard');
|
||||
} catch (err) {
|
||||
console.error('Login error:', err);
|
||||
setError(err.message || 'Failed to authenticate. Please check your credentials.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuickDemo = async () => {
|
||||
setEmail(DEMO_USER.email);
|
||||
setPassword('admin123');
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
await login(DEMO_USER.email, 'admin123', true);
|
||||
navigate('/dashboard');
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="auth-container">
|
||||
{/* Background Orbs */}
|
||||
<div className="auth-bg-decorations">
|
||||
<div className="auth-orb auth-orb-1" />
|
||||
<div className="auth-orb auth-orb-2" />
|
||||
<div className="auth-orb auth-orb-3" />
|
||||
</div>
|
||||
|
||||
<div className="auth-card animate-fade-in">
|
||||
{/* Top bar with Brand and Theme Toggle */}
|
||||
<div className="auth-top-bar">
|
||||
<div className="auth-brand">
|
||||
<div className="auth-brand-icon">
|
||||
<Shield size={22} />
|
||||
</div>
|
||||
<span className="auth-brand-name">AuthSec</span>
|
||||
</div>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="auth-header">
|
||||
<h1 className="auth-title">Welcome back</h1>
|
||||
<p className="auth-subtitle">Sign in to your enterprise security console</p>
|
||||
</div>
|
||||
|
||||
{/* Demo Credentials Quick-Fill Banner */}
|
||||
<div className="demo-quick-bar">
|
||||
<div className="demo-quick-info">
|
||||
<Sparkles size={16} />
|
||||
<span>Demo: <strong>{DEMO_USER.email}</strong></span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="demo-quick-btn"
|
||||
onClick={handleQuickDemo}
|
||||
disabled={loading}
|
||||
>
|
||||
One-Click Login
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error Toast */}
|
||||
{error && <Toast type="error" message={error} onClose={() => setError(null)} />}
|
||||
|
||||
{/* Login Form */}
|
||||
<form onSubmit={handleSubmit} className="auth-form" noValidate>
|
||||
<Input
|
||||
label="Email Address"
|
||||
type="email"
|
||||
placeholder="admin@authsec.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
icon={Mail}
|
||||
required
|
||||
autoComplete="email"
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
icon={Lock}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
|
||||
<div className="auth-options">
|
||||
<label className="auth-remember">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="auth-checkbox"
|
||||
checked={rememberMe}
|
||||
onChange={(e) => setRememberMe(e.target.checked)}
|
||||
/>
|
||||
<span>Remember this session</span>
|
||||
</label>
|
||||
<a href="#trouble" className="auth-link" onClick={(e) => { e.preventDefault(); setError('Contact security administrator to reset credential tokens.'); }}>
|
||||
Forgot password?
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
fullWidth
|
||||
isLoading={loading}
|
||||
icon={ArrowRight}
|
||||
>
|
||||
Sign In to AuthSec
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* Bottom Switch to Register */}
|
||||
<div className="auth-footer">
|
||||
Don't have an enterprise account?{' '}
|
||||
<Link to="/register" className="auth-link">
|
||||
Register here
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ShieldAlert, Home } from 'lucide-react';
|
||||
import { Button } from '../components/common/Button';
|
||||
|
||||
export function NotFound() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '24px',
|
||||
textAlign: 'center',
|
||||
background: 'var(--bg-app)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
background: 'var(--color-danger-bg)',
|
||||
color: 'var(--color-danger)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: '20px',
|
||||
}}
|
||||
>
|
||||
<ShieldAlert size={36} />
|
||||
</div>
|
||||
|
||||
<h1 style={{ fontSize: '2.5rem', marginBottom: '8px' }}>404 - Not Found</h1>
|
||||
<p style={{ color: 'var(--text-secondary)', maxWidth: '420px', marginBottom: '24px' }}>
|
||||
The security boundary or requested resource does not exist or has been relocated.
|
||||
</p>
|
||||
|
||||
<Link to="/dashboard">
|
||||
<Button variant="primary" icon={Home}>
|
||||
Return to Dashboard
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { Shield, Mail, Lock, User, Phone, ArrowRight, ArrowLeft, CheckCircle2, RefreshCw } from 'lucide-react';
|
||||
import { authService } from '../services/authService';
|
||||
import { Button } from '../components/common/Button';
|
||||
import { Input } from '../components/common/Input';
|
||||
import { Toast } from '../components/common/Toast';
|
||||
import { ThemeToggle } from '../components/layout/ThemeToggle';
|
||||
import '../styles/auth.css';
|
||||
|
||||
export function Register() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Wizard Steps: 1 = Email OTP, 2 = Verify OTP, 3 = Profile Details
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [successMsg, setSuccessMsg] = useState(null);
|
||||
|
||||
// Form State
|
||||
const [email, setEmail] = useState('');
|
||||
const [otp, setOtp] = useState(['', '', '', '', '', '']);
|
||||
const [resendTimer, setResendTimer] = useState(60);
|
||||
const [canResend, setCanResend] = useState(false);
|
||||
|
||||
// Profile Details State
|
||||
const [formData, setFormData] = useState({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
phone: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
});
|
||||
|
||||
const otpInputRefs = useRef([]);
|
||||
|
||||
// Timer countdown for OTP resend
|
||||
React.useEffect(() => {
|
||||
let interval = null;
|
||||
if (currentStep === 2 && resendTimer > 0) {
|
||||
interval = setInterval(() => {
|
||||
setResendTimer((prev) => {
|
||||
if (prev <= 1) {
|
||||
setCanResend(true);
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
return () => clearInterval(interval);
|
||||
}, [currentStep, resendTimer]);
|
||||
|
||||
// Step 1: Send OTP
|
||||
const handleSendOtp = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!email.trim() || !email.includes('@')) {
|
||||
setError('Please provide a valid email address.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await authService.sendOtp(email);
|
||||
setSuccessMsg(res.mockOtp ? `Verification code sent! (Demo OTP: ${res.mockOtp})` : 'Verification code sent to your email.');
|
||||
setCurrentStep(2);
|
||||
setResendTimer(60);
|
||||
setCanResend(false);
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to send verification OTP.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Step 2: Handle OTP box input & verification
|
||||
const handleOtpChange = (index, value) => {
|
||||
if (value.length > 1) {
|
||||
// Handle paste
|
||||
const pastedDigits = value.replace(/\D/g, '').slice(0, 6).split('');
|
||||
const newOtp = [...otp];
|
||||
pastedDigits.forEach((digit, i) => {
|
||||
newOtp[i] = digit;
|
||||
});
|
||||
setOtp(newOtp);
|
||||
const nextIndex = Math.min(pastedDigits.length, 5);
|
||||
otpInputRefs.current[nextIndex]?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const newOtp = [...otp];
|
||||
newOtp[index] = value;
|
||||
setOtp(newOtp);
|
||||
|
||||
// Auto-focus next input
|
||||
if (value && index < 5) {
|
||||
otpInputRefs.current[index + 1]?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleOtpKeyDown = (index, e) => {
|
||||
if (e.key === 'Backspace' && !otp[index] && index > 0) {
|
||||
otpInputRefs.current[index - 1]?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerifyOtp = async (e) => {
|
||||
e.preventDefault();
|
||||
const code = otp.join('');
|
||||
if (code.length < 6) {
|
||||
setError('Please enter the complete 6-digit verification code.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
await authService.verifyOtp(email, code);
|
||||
setSuccessMsg('Email verified successfully! Complete your account details.');
|
||||
setCurrentStep(3);
|
||||
} catch (err) {
|
||||
setError(err.message || 'Invalid or expired OTP. (Demo code: 123456)');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResend = async () => {
|
||||
if (!canResend) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await authService.resendOtp(email);
|
||||
setSuccessMsg(res.mockOtp ? `New code sent! (Demo OTP: ${res.mockOtp})` : 'New verification code sent.');
|
||||
setResendTimer(60);
|
||||
setCanResend(false);
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to resend OTP.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Step 3: Complete Account Registration
|
||||
const handleCompleteRegistration = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!formData.firstName.trim() || !formData.lastName.trim()) {
|
||||
setError('Please provide your first and last name.');
|
||||
return;
|
||||
}
|
||||
if (formData.password.length < 6) {
|
||||
setError('Password must be at least 6 characters.');
|
||||
return;
|
||||
}
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
setError('Passwords do not match.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
await authService.registerUser({
|
||||
email,
|
||||
firstName: formData.firstName,
|
||||
lastName: formData.lastName,
|
||||
phone: formData.phone,
|
||||
password: formData.password,
|
||||
});
|
||||
|
||||
// Navigate to login with registered email
|
||||
navigate('/login', {
|
||||
state: { registeredEmail: email },
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to create account.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="auth-container">
|
||||
{/* Background Orbs */}
|
||||
<div className="auth-bg-decorations">
|
||||
<div className="auth-orb auth-orb-1" />
|
||||
<div className="auth-orb auth-orb-2" />
|
||||
<div className="auth-orb auth-orb-3" />
|
||||
</div>
|
||||
|
||||
<div className="auth-card animate-fade-in">
|
||||
{/* Top bar with Brand and Theme Toggle */}
|
||||
<div className="auth-top-bar">
|
||||
<div className="auth-brand">
|
||||
<div className="auth-brand-icon">
|
||||
<Shield size={22} />
|
||||
</div>
|
||||
<span className="auth-brand-name">AuthSec</span>
|
||||
</div>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
{/* Wizard Stepper */}
|
||||
<div className="register-stepper">
|
||||
<div className={`stepper-step ${currentStep === 1 ? 'active' : currentStep > 1 ? 'completed' : ''}`}>
|
||||
<div className="stepper-badge">{currentStep > 1 ? '✓' : '1'}</div>
|
||||
<span className="stepper-title">Email</span>
|
||||
</div>
|
||||
<div className={`stepper-step ${currentStep === 2 ? 'active' : currentStep > 2 ? 'completed' : ''}`}>
|
||||
<div className="stepper-badge">{currentStep > 2 ? '✓' : '2'}</div>
|
||||
<span className="stepper-title">Verify OTP</span>
|
||||
</div>
|
||||
<div className={`stepper-step ${currentStep === 3 ? 'active' : ''}`}>
|
||||
<div className="stepper-badge">3</div>
|
||||
<span className="stepper-title">Profile</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="auth-header">
|
||||
<h1 className="auth-title">
|
||||
{currentStep === 1 && 'Create an account'}
|
||||
{currentStep === 2 && 'Verify your email'}
|
||||
{currentStep === 3 && 'Account Details'}
|
||||
</h1>
|
||||
<p className="auth-subtitle">
|
||||
{currentStep === 1 && 'Register for enterprise security workspace'}
|
||||
{currentStep === 2 && `Enter the 6-digit code sent to ${email}`}
|
||||
{currentStep === 3 && 'Set up your credentials and personal profile'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Notifications */}
|
||||
{error && <Toast type="error" message={error} onClose={() => setError(null)} />}
|
||||
{successMsg && <Toast type="success" message={successMsg} onClose={() => setSuccessMsg(null)} />}
|
||||
|
||||
{/* STEP 1: Email Input */}
|
||||
{currentStep === 1 && (
|
||||
<form onSubmit={handleSendOtp} className="auth-form" noValidate>
|
||||
<Input
|
||||
label="Work Email"
|
||||
type="email"
|
||||
placeholder="user@enterprise.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
icon={Mail}
|
||||
required
|
||||
helperText="We will send a one-time verification code to this address."
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
fullWidth
|
||||
isLoading={loading}
|
||||
icon={ArrowRight}
|
||||
>
|
||||
Send Verification Code
|
||||
</Button>
|
||||
|
||||
{/* Direct Skip option for quick testing */}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
style={{ marginTop: '-6px' }}
|
||||
onClick={() => {
|
||||
if (!email) setEmail('test@authsec.com');
|
||||
setCurrentStep(3);
|
||||
}}
|
||||
>
|
||||
Skip OTP (Direct Registration) →
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* STEP 2: OTP Verification */}
|
||||
{currentStep === 2 && (
|
||||
<form onSubmit={handleVerifyOtp} className="auth-form" noValidate>
|
||||
<div className="otp-inputs">
|
||||
{otp.map((digit, idx) => (
|
||||
<input
|
||||
key={idx}
|
||||
ref={(el) => (otpInputRefs.current[idx] = el)}
|
||||
type="text"
|
||||
maxLength={1}
|
||||
className="otp-box"
|
||||
value={digit}
|
||||
onChange={(e) => handleOtpChange(idx, e.target.value)}
|
||||
onKeyDown={(e) => handleOtpKeyDown(idx, e)}
|
||||
autoFocus={idx === 0}
|
||||
inputMode="numeric"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="otp-resend-row">
|
||||
<button
|
||||
type="button"
|
||||
className="otp-resend-btn"
|
||||
onClick={handleResend}
|
||||
disabled={!canResend || loading}
|
||||
>
|
||||
{canResend ? 'Resend verification code' : `Resend code in ${resendTimer}s`}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => setCurrentStep(1)}
|
||||
>
|
||||
Change Email
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
fullWidth
|
||||
isLoading={loading}
|
||||
icon={CheckCircle2}
|
||||
>
|
||||
Verify & Proceed
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* STEP 3: Profile Details */}
|
||||
{currentStep === 3 && (
|
||||
<form onSubmit={handleCompleteRegistration} className="auth-form" noValidate>
|
||||
<div className="form-row">
|
||||
<Input
|
||||
label="First Name"
|
||||
placeholder="Jane"
|
||||
value={formData.firstName}
|
||||
onChange={(e) => setFormData({ ...formData, firstName: e.target.value })}
|
||||
icon={User}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Last Name"
|
||||
placeholder="Doe"
|
||||
value={formData.lastName}
|
||||
onChange={(e) => setFormData({ ...formData, lastName: e.target.value })}
|
||||
icon={User}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Phone Number"
|
||||
type="tel"
|
||||
placeholder="+1 (555) 000-0000"
|
||||
value={formData.phone}
|
||||
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
|
||||
icon={Phone}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
icon={Lock}
|
||||
required
|
||||
helperText="Minimum 6 characters"
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Confirm Password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={formData.confirmPassword}
|
||||
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
|
||||
icon={Lock}
|
||||
required
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', gap: '10px' }}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
icon={ArrowLeft}
|
||||
onClick={() => setCurrentStep(2)}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
fullWidth
|
||||
isLoading={loading}
|
||||
icon={CheckCircle2}
|
||||
>
|
||||
Complete Registration
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="auth-footer">
|
||||
Already have an account?{' '}
|
||||
<Link to="/login" className="auth-link">
|
||||
Sign In
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
const BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:9292';
|
||||
|
||||
/**
|
||||
* Make an HTTP request with automatic token header injection and error handling.
|
||||
*/
|
||||
export async function request(endpoint, options = {}) {
|
||||
const token = localStorage.getItem('authsec_token');
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
const url = endpoint.startsWith('http') ? endpoint : `${BASE_URL}${endpoint}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
const contentType = response.headers.get('content-type');
|
||||
let data;
|
||||
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
data = await response.json();
|
||||
} else {
|
||||
data = await response.text();
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMessage =
|
||||
(typeof data === 'object' && (data.operationMessage || data.message || data.error)) ||
|
||||
(typeof data === 'string' && data) ||
|
||||
`HTTP Error: ${response.status}`;
|
||||
const err = new Error(errorMessage);
|
||||
err.status = response.status;
|
||||
err.data = data;
|
||||
throw err;
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
// If network fetch fails completely (e.g. backend server is not running)
|
||||
if (error.name === 'TypeError' && error.message.includes('fetch')) {
|
||||
const netErr = new Error('Cannot reach Spring Boot server at ' + BASE_URL + '. Is backend running?');
|
||||
netErr.isNetworkError = true;
|
||||
throw netErr;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import { request } from './api';
|
||||
import { DEMO_USER } from './mockData';
|
||||
|
||||
export const authService = {
|
||||
/**
|
||||
* Log into Spring Boot backend (/token/session)
|
||||
*/
|
||||
async login(email, password, useMockFallback = false) {
|
||||
if (useMockFallback) {
|
||||
// Simulate slight network delay for realistic UX
|
||||
await new Promise((r) => setTimeout(r, 600));
|
||||
return {
|
||||
operationStatus: 'SUCCESS',
|
||||
operationMessage: 'Demo session authenticated',
|
||||
item: {
|
||||
...DEMO_USER,
|
||||
email: email || DEMO_USER.email,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await request('/token/session', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
if (response.operationStatus === 'ERROR') {
|
||||
throw new Error(response.operationMessage || 'Login failed');
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (err) {
|
||||
if (err.isNetworkError) {
|
||||
// If server is not running and user enters demo account, offer seamless demo fallback
|
||||
if (email.toLowerCase().includes('admin') || email.toLowerCase().includes('demo')) {
|
||||
console.warn('Backend server offline. Falling back to Demo mode for admin account.');
|
||||
return {
|
||||
operationStatus: 'SUCCESS',
|
||||
operationMessage: 'Connected in Demo Mode (Backend Offline)',
|
||||
item: {
|
||||
...DEMO_USER,
|
||||
email,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Send OTP for Email Verification (/token/user/send_email)
|
||||
*/
|
||||
async sendOtp(email) {
|
||||
try {
|
||||
const res = await request('/token/user/send_email', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
return res;
|
||||
} catch (err) {
|
||||
if (err.isNetworkError) {
|
||||
// Mock fallback for testing OTP flow without active SMTP
|
||||
console.warn('Backend offline - Mock OTP 123456 generated');
|
||||
return { message: 'Demo OTP sent: 123456', mockOtp: '123456' };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Resend OTP (/token/user/resend_otp?email=...)
|
||||
*/
|
||||
async resendOtp(email) {
|
||||
try {
|
||||
const res = await request(`/token/user/resend_otp?email=${encodeURIComponent(email)}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
return res;
|
||||
} catch (err) {
|
||||
if (err.isNetworkError) {
|
||||
return { message: 'Demo OTP resent: 123456', mockOtp: '123456' };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Verify OTP (/token/user/otp_verification?email=...&otp=...)
|
||||
*/
|
||||
async verifyOtp(email, otp) {
|
||||
// If testing mock OTP
|
||||
if (otp === '123456') {
|
||||
return { status: 'OK', message: 'OTP Verified' };
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await request(
|
||||
`/token/user/otp_verification?email=${encodeURIComponent(email)}&otp=${encodeURIComponent(otp)}`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
return res;
|
||||
} catch (err) {
|
||||
if (err.isNetworkError) {
|
||||
if (otp.length === 6) {
|
||||
return { status: 'OK', message: 'Demo OTP Verified' };
|
||||
}
|
||||
throw new Error('Invalid OTP code. Try 123456 for demo.');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Create sysaccount (/token/users/sysaccount/savesysaccount)
|
||||
*/
|
||||
async createAccount(email) {
|
||||
try {
|
||||
const res = await request('/token/users/sysaccount/savesysaccount', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
return res;
|
||||
} catch (e) {
|
||||
console.warn('Account creation step note:', e);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Register user (/token/addOneAppUser)
|
||||
* Strictly formats payload to the 12 fields expected by com.realnet.users.entity1.Registration
|
||||
*/
|
||||
async registerUser(userData) {
|
||||
// 1. Resolve or create account_id
|
||||
let accountId = userData.account_id;
|
||||
if (!accountId) {
|
||||
try {
|
||||
const accountRes = await this.createAccount(userData.email);
|
||||
accountId = accountRes?.account_id || accountRes?.id || 1;
|
||||
} catch (err) {
|
||||
accountId = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Parse phone/mobile to Long number
|
||||
const mobDigits = String(userData.phone || userData.mob_no || '9876543210').replace(/\D/g, '');
|
||||
const mob_no = mobDigits ? parseInt(mobDigits.slice(-10), 10) : 9876543210;
|
||||
|
||||
// 3. Exact 12 fields for Spring Boot Registration entity
|
||||
const registrationPayload = {
|
||||
first_name: userData.first_name || userData.firstName || '',
|
||||
last_name: userData.last_name || userData.lastName || '',
|
||||
email: userData.email,
|
||||
mob_no: mob_no,
|
||||
account_id: Number(accountId) || 1,
|
||||
usrGrpId: Number(userData.usrGrpId) || 1,
|
||||
new_password: userData.new_password || userData.password || '',
|
||||
confirm_password: userData.confirm_password || userData.confirmPassword || userData.password || '',
|
||||
accesstype: userData.accesstype || 'Regular',
|
||||
gender: userData.gender || 'Not Specified',
|
||||
date_of_birth: userData.date_of_birth || userData.dob || '2000-01-01',
|
||||
how_you_know_aboutus: userData.how_you_know_aboutus || 'Direct',
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await request('/token/addOneAppUser', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(registrationPayload),
|
||||
});
|
||||
return res;
|
||||
} catch (err) {
|
||||
if (err.isNetworkError) {
|
||||
// Mock fallback
|
||||
return {
|
||||
userId: Date.now(),
|
||||
...registrationPayload,
|
||||
message: 'Account created successfully (Demo Mode)',
|
||||
};
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Logout (/token/logout)
|
||||
*/
|
||||
async logout() {
|
||||
try {
|
||||
await request('/token/logout', { method: 'GET' });
|
||||
} catch (e) {
|
||||
// Ignore logout backend error and continue local cleanup
|
||||
console.log('Backend logout response or offline:', e);
|
||||
} finally {
|
||||
localStorage.removeItem('authsec_token');
|
||||
localStorage.removeItem('authsec_user');
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
// Pre-configured mock data matching authsec_springboot schema
|
||||
|
||||
export const DEMO_USER = {
|
||||
userId: 101,
|
||||
firstName: "Alex",
|
||||
lastName: "Vance",
|
||||
fullname: "Alex Vance",
|
||||
username: "alex.admin",
|
||||
email: "admin@authsec.com",
|
||||
phone: "+1 (555) 234-5678",
|
||||
roles: ["ROLE_ADMIN", "SECURITY_OFFICER", "WORKSPACE_MANAGER"],
|
||||
token: "eyJhbGciOiJIUzUxMiJ9.authsec_demo_token_authenticated_user_session_9942",
|
||||
avatar: "https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&w=256&q=80",
|
||||
lastLogin: new Date().toISOString(),
|
||||
account: {
|
||||
accountId: 4001,
|
||||
name: "Enterprise Security Core",
|
||||
plan: "Enterprise Shield",
|
||||
status: "ACTIVE"
|
||||
}
|
||||
};
|
||||
|
||||
export const MOCK_STATS = [
|
||||
{
|
||||
id: "sessions",
|
||||
title: "Active Sessions",
|
||||
value: "1",
|
||||
subtitle: "Current device IP: 127.0.0.1",
|
||||
trend: "+0 this week",
|
||||
status: "positive",
|
||||
icon: "ShieldCheck"
|
||||
},
|
||||
{
|
||||
id: "securityScore",
|
||||
title: "Security Posture",
|
||||
value: "98.4%",
|
||||
subtitle: "JWT 512-bit & Role Guard enabled",
|
||||
trend: "+2.1% optimal",
|
||||
status: "positive",
|
||||
icon: "Lock"
|
||||
},
|
||||
{
|
||||
id: "workspaces",
|
||||
title: "Managed Workspaces",
|
||||
value: "4",
|
||||
subtitle: "Production, Staging, QA, Dev",
|
||||
trend: "All synced",
|
||||
status: "neutral",
|
||||
icon: "LayoutGrid"
|
||||
},
|
||||
{
|
||||
id: "apiGateway",
|
||||
title: "API Gateway",
|
||||
value: "Healthy",
|
||||
subtitle: "Spring Boot Port :9292",
|
||||
trend: "24ms response",
|
||||
status: "positive",
|
||||
icon: "Activity"
|
||||
}
|
||||
];
|
||||
|
||||
export const MOCK_ACTIVITIES = [
|
||||
{
|
||||
id: "act-1",
|
||||
type: "LOGIN_SUCCESS",
|
||||
title: "Successful Session Authentication",
|
||||
description: "Authenticated via Spring Boot /token/session endpoint",
|
||||
timestamp: "Just now",
|
||||
ip: "127.0.0.1",
|
||||
badge: "SUCCESS",
|
||||
icon: "Key"
|
||||
},
|
||||
{
|
||||
id: "act-2",
|
||||
type: "TOKEN_GENERATED",
|
||||
title: "JWT Access Token Issued",
|
||||
description: "Session authorization header applied with HMAC-SHA512",
|
||||
timestamp: "2 mins ago",
|
||||
ip: "127.0.0.1",
|
||||
badge: "SECURITY",
|
||||
icon: "ShieldAlert"
|
||||
},
|
||||
{
|
||||
id: "act-3",
|
||||
type: "WORKSPACE_SYNC",
|
||||
title: "Security Workspace Handshake",
|
||||
description: "Role mappings verified: ROLE_ADMIN, WORKSPACE_MANAGER",
|
||||
timestamp: "12 mins ago",
|
||||
ip: "10.0.2.15",
|
||||
badge: "INFO",
|
||||
icon: "CheckCircle"
|
||||
},
|
||||
{
|
||||
id: "act-4",
|
||||
type: "OTP_VERIFIED",
|
||||
title: "Email Verification Completed",
|
||||
description: "Two-step OTP challenge validated successfully",
|
||||
timestamp: "1 hour ago",
|
||||
ip: "192.168.1.104",
|
||||
badge: "VERIFIED",
|
||||
icon: "MailCheck"
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,350 @@
|
||||
/* Auth Pages CSS */
|
||||
|
||||
.auth-container {
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* Background animated radiant orbs */
|
||||
.auth-bg-decorations {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.auth-orb {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(80px);
|
||||
opacity: 0.45;
|
||||
animation: floatOrbs 18s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.auth-orb-1 {
|
||||
width: 480px;
|
||||
height: 480px;
|
||||
background: radial-gradient(circle, rgba(99, 102, 241, 0.6), transparent 70%);
|
||||
top: -100px;
|
||||
left: -100px;
|
||||
}
|
||||
|
||||
.auth-orb-2 {
|
||||
width: 420px;
|
||||
height: 420px;
|
||||
background: radial-gradient(circle, rgba(6, 182, 212, 0.5), transparent 70%);
|
||||
bottom: -80px;
|
||||
right: -80px;
|
||||
animation-delay: -5s;
|
||||
}
|
||||
|
||||
.auth-orb-3 {
|
||||
width: 320px;
|
||||
height: 320px;
|
||||
background: radial-gradient(circle, rgba(139, 92, 246, 0.4), transparent 70%);
|
||||
top: 40%;
|
||||
right: 25%;
|
||||
animation-delay: -9s;
|
||||
}
|
||||
|
||||
/* Auth Card */
|
||||
.auth-card {
|
||||
width: 100%;
|
||||
max-width: 460px;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
background: var(--bg-glass-card);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 40px 36px;
|
||||
box-shadow: var(--shadow-lg), 0 0 50px rgba(99, 102, 241, 0.12);
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
.auth-card:hover {
|
||||
border-color: rgba(99, 102, 241, 0.25);
|
||||
}
|
||||
|
||||
/* Auth Top Bar */
|
||||
.auth-top-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.auth-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.auth-brand-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--gradient-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #ffffff;
|
||||
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);
|
||||
}
|
||||
|
||||
.auth-brand-name {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
/* Auth Header */
|
||||
.auth-header {
|
||||
margin-bottom: 28px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.auth-title {
|
||||
font-size: 1.85rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 6px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.auth-subtitle {
|
||||
font-size: 0.925rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Quick Demo Bar */
|
||||
.demo-quick-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: rgba(99, 102, 241, 0.08);
|
||||
border: 1px dashed rgba(99, 102, 241, 0.35);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.demo-quick-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-primary-light);
|
||||
}
|
||||
|
||||
.demo-quick-btn {
|
||||
font-size: 0.775rem;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
background: var(--color-primary);
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.demo-quick-btn:hover {
|
||||
background: var(--color-primary-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Auth Form */
|
||||
.auth-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.auth-options {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
.auth-remember {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.auth-checkbox {
|
||||
accent-color: var(--color-primary);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.auth-link {
|
||||
color: var(--color-primary-light);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.auth-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Footer Switch */
|
||||
.auth-footer {
|
||||
margin-top: 28px;
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
/* Step Wizard for Registration */
|
||||
.register-stepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.register-stepper::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 15%;
|
||||
right: 15%;
|
||||
height: 2px;
|
||||
background: var(--border-subtle);
|
||||
z-index: 1;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.stepper-step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.stepper-badge {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
background: var(--bg-surface-elevated);
|
||||
border: 2px solid var(--border-subtle);
|
||||
color: var(--text-muted);
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
.stepper-step.active .stepper-badge {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: #ffffff;
|
||||
box-shadow: 0 0 16px rgba(99, 102, 241, 0.45);
|
||||
}
|
||||
|
||||
.stepper-step.completed .stepper-badge {
|
||||
background: var(--color-success);
|
||||
border-color: var(--color-success);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.stepper-title {
|
||||
font-size: 0.725rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.stepper-step.active .stepper-title {
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
/* OTP Code Input Grid */
|
||||
.otp-inputs {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.otp-box {
|
||||
width: 48px;
|
||||
height: 54px;
|
||||
text-align: center;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
font-family: var(--font-heading);
|
||||
background: var(--bg-input);
|
||||
color: var(--text-main);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
outline: none;
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
.otp-box:focus {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.25);
|
||||
}
|
||||
|
||||
.otp-resend-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 0.825rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.otp-resend-btn {
|
||||
color: var(--color-primary-light);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.otp-resend-btn:disabled {
|
||||
color: var(--text-muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Grid form row */
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.auth-card {
|
||||
padding: 28px 20px;
|
||||
}
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.otp-box {
|
||||
width: 40px;
|
||||
height: 48px;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
/* Dashboard Styles */
|
||||
|
||||
.dashboard-content-area {
|
||||
padding: 32px;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 28px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Welcome Hero Banner */
|
||||
.dashboard-hero {
|
||||
background: linear-gradient(135deg, rgba(99, 102, 241, 0.15) 0%, rgba(6, 182, 212, 0.08) 50%, rgba(14, 20, 36, 0.4) 100%);
|
||||
border: 1px solid rgba(99, 102, 241, 0.25);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 32px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
[data-theme='light'] .dashboard-hero {
|
||||
background: linear-gradient(135deg, rgba(99, 102, 241, 0.12) 0%, rgba(6, 182, 212, 0.06) 100%);
|
||||
}
|
||||
|
||||
.hero-left {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.hero-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.775rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-primary-light);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.hero-description {
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-secondary);
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.hero-right {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.hero-token-pill {
|
||||
background: var(--bg-surface-elevated);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-full);
|
||||
padding: 6px 14px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* Stats Cards Grid */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-glass-card);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 22px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
transition: all var(--transition-normal);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-3px);
|
||||
border-color: rgba(99, 102, 241, 0.35);
|
||||
box-shadow: var(--shadow-lg), var(--shadow-glow);
|
||||
}
|
||||
|
||||
.stat-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.stat-card-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.stat-card-icon-box {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
color: var(--color-primary-light);
|
||||
}
|
||||
|
||||
.stat-card-value {
|
||||
font-size: 1.85rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
font-family: var(--font-heading);
|
||||
}
|
||||
|
||||
.stat-card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 0.775rem;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
padding-top: 10px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.stat-trend {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stat-trend.positive {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
/* Quick Actions Section */
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.quick-actions-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.quick-action-card {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
.quick-action-card:hover {
|
||||
background: var(--bg-surface-elevated);
|
||||
border-color: var(--border-glow);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.action-icon-circle {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--gradient-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #ffffff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.action-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.action-name {
|
||||
font-weight: 700;
|
||||
font-size: 0.925rem;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.action-desc {
|
||||
font-size: 0.775rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Activity Table / Stream */
|
||||
.activity-card {
|
||||
background: var(--bg-glass-card);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 24px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.activity-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.activity-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.activity-item:hover {
|
||||
border-color: rgba(99, 102, 241, 0.25);
|
||||
background: var(--bg-surface-elevated);
|
||||
}
|
||||
|
||||
.activity-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.activity-icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
color: var(--color-primary-light);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.activity-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.activity-title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.activity-desc {
|
||||
font-size: 0.775rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.activity-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.activity-time {
|
||||
font-size: 0.775rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Security Session Drawer/Modal Preview */
|
||||
.session-info-card {
|
||||
background: var(--bg-surface-elevated);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
font-size: 0.825rem;
|
||||
}
|
||||
|
||||
.session-info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.session-info-row strong {
|
||||
color: var(--text-main);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.dashboard-content-area {
|
||||
padding: 18px;
|
||||
gap: 20px;
|
||||
}
|
||||
.dashboard-hero {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 22px;
|
||||
}
|
||||
.hero-right {
|
||||
align-items: flex-start;
|
||||
}
|
||||
.activity-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
.activity-right {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
@import './variables.css';
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
font-family: var(--font-body);
|
||||
background-color: var(--bg-app);
|
||||
color: var(--text-main);
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
transition: background-color var(--transition-normal), color var(--transition-normal);
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 700;
|
||||
color: var(--text-main);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-primary-light);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: var(--font-heading);
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input, select, textarea {
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
/* Scrollbars */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border-subtle);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulseSlow {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
opacity: 0.8;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.08);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes floatOrbs {
|
||||
0% {
|
||||
transform: translate(0, 0) rotate(0deg);
|
||||
}
|
||||
50% {
|
||||
transform: translate(25px, 20px) rotate(180deg);
|
||||
}
|
||||
100% {
|
||||
transform: translate(0, 0) rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.4s var(--transition-smooth) forwards;
|
||||
}
|
||||
|
||||
/* Glassmorphism helpers */
|
||||
.glass-panel {
|
||||
background: var(--bg-glass-card);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.glass-panel:hover {
|
||||
border-color: rgba(99, 102, 241, 0.25);
|
||||
}
|
||||
|
||||
/* Text Gradients */
|
||||
.gradient-text {
|
||||
background: var(--gradient-primary);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
display: inline-block;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
:root {
|
||||
/* Fonts */
|
||||
--font-heading: 'Outfit', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
--font-body: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
|
||||
/* Brand Accents */
|
||||
--color-primary: #6366f1;
|
||||
--color-primary-rgb: 99, 102, 241;
|
||||
--color-primary-hover: #4f46e5;
|
||||
--color-primary-light: #818cf8;
|
||||
--color-secondary: #06b6d4;
|
||||
--color-secondary-rgb: 6, 182, 212;
|
||||
--color-accent-purple: #8b5cf6;
|
||||
--color-accent-pink: #ec4899;
|
||||
|
||||
/* Status Colors */
|
||||
--color-success: #10b981;
|
||||
--color-success-bg: rgba(16, 185, 129, 0.12);
|
||||
--color-warning: #f59e0b;
|
||||
--color-warning-bg: rgba(245, 158, 11, 0.12);
|
||||
--color-danger: #ef4444;
|
||||
--color-danger-bg: rgba(239, 68, 68, 0.12);
|
||||
--color-info: #3b82f6;
|
||||
--color-info-bg: rgba(59, 130, 246, 0.12);
|
||||
|
||||
/* Gradients */
|
||||
--gradient-primary: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #06b6d4 100%);
|
||||
--gradient-glow: radial-gradient(circle at 50% 50%, rgba(99, 102, 241, 0.25) 0%, rgba(6, 182, 212, 0.05) 50%, transparent 80%);
|
||||
--gradient-surface: linear-gradient(180deg, rgba(255, 255, 255, 0.04) 0%, rgba(255, 255, 255, 0.01) 100%);
|
||||
|
||||
/* Transitions & Radii */
|
||||
--radius-xs: 4px;
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 16px;
|
||||
--radius-xl: 24px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-normal: 250ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-smooth: 350ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
/* Dark Theme (Default) */
|
||||
[data-theme='dark'],
|
||||
:root {
|
||||
--bg-app: #080c14;
|
||||
--bg-surface: #0e1424;
|
||||
--bg-surface-elevated: #141c30;
|
||||
--bg-glass: rgba(14, 20, 36, 0.72);
|
||||
--bg-glass-card: rgba(18, 25, 44, 0.65);
|
||||
--bg-input: rgba(14, 20, 36, 0.85);
|
||||
|
||||
--border-subtle: rgba(255, 255, 255, 0.07);
|
||||
--border-glow: rgba(99, 102, 241, 0.35);
|
||||
--border-focus: rgba(99, 102, 241, 0.8);
|
||||
|
||||
--text-main: #f1f5f9;
|
||||
--text-secondary: #94a3b8;
|
||||
--text-muted: #64748b;
|
||||
--text-inverse: #090d16;
|
||||
|
||||
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.35);
|
||||
--shadow-md: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||
--shadow-lg: 0 16px 40px rgba(0, 0, 0, 0.55);
|
||||
--shadow-glow: 0 0 35px rgba(99, 102, 241, 0.2);
|
||||
}
|
||||
|
||||
/* Light Theme */
|
||||
[data-theme='light'] {
|
||||
--bg-app: #f4f6fa;
|
||||
--bg-surface: #ffffff;
|
||||
--bg-surface-elevated: #f8fafc;
|
||||
--bg-glass: rgba(255, 255, 255, 0.85);
|
||||
--bg-glass-card: rgba(255, 255, 255, 0.9);
|
||||
--bg-input: #ffffff;
|
||||
|
||||
--border-subtle: rgba(0, 0, 0, 0.09);
|
||||
--border-glow: rgba(99, 102, 241, 0.3);
|
||||
--border-focus: #6366f1;
|
||||
|
||||
--text-main: #0f172a;
|
||||
--text-secondary: #475569;
|
||||
--text-muted: #94a3b8;
|
||||
--text-inverse: #ffffff;
|
||||
|
||||
--shadow-sm: 0 2px 6px rgba(0, 0, 0, 0.04);
|
||||
--shadow-md: 0 8px 20px rgba(15, 23, 42, 0.08);
|
||||
--shadow-lg: 0 20px 40px rgba(15, 23, 42, 0.12);
|
||||
--shadow-glow: 0 0 30px rgba(99, 102, 241, 0.15);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/token': {
|
||||
target: 'http://localhost:9292',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
'/api': {
|
||||
target: 'http://localhost:9292',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user