feat: add member portal and auth hardening
This commit is contained in:
@@ -1,48 +1,53 @@
|
||||
# DBIS Admin Console - Login Credentials & Endpoints
|
||||
# DBIS Portal Login Credentials & Endpoints
|
||||
|
||||
**Last Updated:** 2025-01-22
|
||||
**Last Updated:** 2026-04-15
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Login Credentials
|
||||
## Login Credentials
|
||||
|
||||
### Current Authentication Status
|
||||
|
||||
**⚠️ Mock Authentication Active**
|
||||
The portals now use the backend `/api/auth/*` endpoints. The old mock-auth behavior is no longer active.
|
||||
|
||||
The frontend is currently using **mock authentication** for development. This means:
|
||||
|
||||
- **Any username and password will work**
|
||||
- The system accepts any credentials and creates a mock admin user
|
||||
- No actual backend authentication is performed yet
|
||||
|
||||
### Mock User Details
|
||||
|
||||
When you log in with any credentials, you'll receive:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "1",
|
||||
"employeeId": "emp-001",
|
||||
"name": "Admin User",
|
||||
"email": "<your-username>",
|
||||
"role": "DBIS_Super_Admin",
|
||||
"permissions": ["all"]
|
||||
}
|
||||
```
|
||||
There are now two supported login patterns:
|
||||
- `core.d-bis.org` and `admin.d-bis.org` use employee-backed portal auth.
|
||||
- `secure.d-bis.org` uses member-backed portal auth.
|
||||
|
||||
### Login Instructions
|
||||
|
||||
1. **Go to:** http://192.168.11.130/login
|
||||
2. **Enter any username** (e.g., `admin`, `test`, `user`)
|
||||
3. **Enter any password** (e.g., `password`, `123456`, `admin`)
|
||||
4. **Click "Sign In"**
|
||||
1. Go to the portal surface you need:
|
||||
- `https://core.d-bis.org/login`
|
||||
- `https://admin.d-bis.org/login`
|
||||
- `https://secure.d-bis.org/login`
|
||||
2. Enter the username for that surface.
|
||||
3. Enter the matching secret or credential.
|
||||
4. Click `Sign In`.
|
||||
5. For `core` and `admin`, enter the 6-digit authenticator code when prompted if MFA is enabled on the employee account.
|
||||
|
||||
**Note:** The login form requires both fields to be filled, but the values don't matter - any combination will work.
|
||||
### Credential Rules
|
||||
|
||||
#### Employee-backed surfaces: `core` and `admin`
|
||||
|
||||
The username must match an active `employee_credentials` record by employee ID or email.
|
||||
|
||||
The password must match the employee's stored `portalPasswordHash` credential.
|
||||
|
||||
If MFA is enabled, the login flow requires a valid TOTP code after the password step.
|
||||
|
||||
#### Member surface: `secure`
|
||||
|
||||
The username must match an active `portal_member_accounts` record by member ID or email.
|
||||
|
||||
The password must match the member account's stored `portalPasswordHash` credential.
|
||||
|
||||
The member account must also be `approved` and linked to either:
|
||||
- a live participant record with GLEIF-backed LEI validation, or
|
||||
- a stored institution snapshot containing a registry-validated LEI, institution name, and country.
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Frontend Routes (Client-Side)
|
||||
## Frontend Routes (Client-Side)
|
||||
|
||||
### Public Routes
|
||||
|
||||
@@ -78,7 +83,7 @@ When you log in with any credentials, you'll receive:
|
||||
|
||||
---
|
||||
|
||||
## 🔌 Backend API Endpoints
|
||||
## Backend API Endpoints
|
||||
|
||||
### Base URL
|
||||
|
||||
@@ -90,9 +95,23 @@ When you log in with any credentials, you'll receive:
|
||||
|
||||
| Method | Endpoint | Description | Status |
|
||||
|--------|----------|-------------|--------|
|
||||
| `POST` | `/api/auth/login` | User login | ⚠️ Not implemented (using mock) |
|
||||
| `POST` | `/api/auth/logout` | User logout | ⚠️ Not implemented (using mock) |
|
||||
| `POST` | `/api/auth/refresh` | Refresh token | ⚠️ Not implemented |
|
||||
| `POST` | `/api/auth/login` | Portal login | Live |
|
||||
| `POST` | `/api/auth/logout` | Portal logout | Live |
|
||||
| `GET` | `/api/auth/me` | Resolve current portal user from token | Live |
|
||||
| `POST` | `/api/auth/password/change` | Authenticated password rotation | Live |
|
||||
| `POST` | `/api/auth/password/reset/request` | Record password reset request | Live |
|
||||
| `POST` | `/api/auth/password/reset/complete` | Complete reset with one-time token | Live |
|
||||
| `GET` | `/api/auth/mfa/status` | Employee MFA status | Live |
|
||||
| `POST` | `/api/auth/mfa/setup` | Generate employee MFA enrollment secret | Live |
|
||||
| `POST` | `/api/auth/mfa/enable` | Enable employee MFA | Live |
|
||||
| `POST` | `/api/auth/mfa/disable` | Disable employee MFA | Live |
|
||||
| `POST` | `/api/auth/admin/accounts/employee` | Issue or update employee portal account | Live |
|
||||
| `GET` | `/api/auth/admin/accounts/member` | List member portal accounts | Live |
|
||||
| `POST` | `/api/auth/admin/accounts/member` | Issue member portal account | Live |
|
||||
| `POST` | `/api/auth/admin/accounts/member/:memberId/approve` | Approve member portal account | Live |
|
||||
| `POST` | `/api/auth/admin/password-reset/issue` | Issue one-time reset token | Live |
|
||||
| `POST` | `/api/auth/admin/accounts/deactivate` | Deactivate employee or member account | Live |
|
||||
| `POST` | `/api/auth/refresh` | Refresh token | Not implemented |
|
||||
|
||||
### DBIS Admin API Endpoints
|
||||
|
||||
@@ -203,10 +222,13 @@ When you log in with any credentials, you'll receive:
|
||||
|
||||
### Current Implementation
|
||||
|
||||
- **Type:** Mock authentication (development mode)
|
||||
- **Type:** Live backend-backed portal authentication
|
||||
- **Token Storage:** `sessionStorage` (cleared on tab close)
|
||||
- **Token Format:** `SOV-TOKEN <token>`
|
||||
- **Token Header:** `Authorization: SOV-TOKEN <token>`
|
||||
- **Employee MFA:** TOTP for `core` and `admin` when enabled on the employee record
|
||||
- **Lockout Policy:** Failed login attempts trigger temporary account lockout
|
||||
- **Password Lifecycle:** Change-password, admin-issued reset token, and reset completion flows are available
|
||||
|
||||
### Request Headers
|
||||
|
||||
@@ -230,16 +252,18 @@ Content-Type: application/json
|
||||
|
||||
---
|
||||
|
||||
## 📍 Quick Reference
|
||||
## Quick Reference
|
||||
|
||||
### Login
|
||||
- **URL:** http://192.168.11.130/login
|
||||
- **Credentials:** Any username/password combination
|
||||
- **After Login:** Redirects to `/dbis/overview`
|
||||
- **Core:** `https://core.d-bis.org/login`
|
||||
- **Admin:** `https://admin.d-bis.org/login`
|
||||
- **Member:** `https://secure.d-bis.org/login`
|
||||
- **After Login:** Redirects to the runtime portal home route
|
||||
|
||||
### Main Dashboards
|
||||
- **DBIS Overview:** http://192.168.11.130/dbis/overview
|
||||
- **SCB Overview:** http://192.168.11.130/scb/overview
|
||||
- **Core Overview:** `https://core.d-bis.org/`
|
||||
- **Admin Overview:** `https://admin.d-bis.org/`
|
||||
- **Member Overview:** `https://secure.d-bis.org/`
|
||||
|
||||
### API Base URL
|
||||
- **Default:** `http://192.168.11.150:3000`
|
||||
@@ -247,26 +271,18 @@ Content-Type: application/json
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Important Notes
|
||||
## Important Notes
|
||||
|
||||
1. **Mock Authentication:** Currently using mock auth - any credentials work
|
||||
2. **Backend Required:** Most API endpoints require a running backend
|
||||
3. **Token Format:** Uses `SOV-TOKEN` prefix (not standard `Bearer`)
|
||||
4. **Session Storage:** Tokens stored in `sessionStorage` (not `localStorage`)
|
||||
5. **Auto-Logout:** Session clears when browser tab closes
|
||||
1. **Real portal auth:** The frontend calls the backend auth routes and no longer accepts arbitrary credentials.
|
||||
2. **Backend required:** Portal login depends on the live DBIS API.
|
||||
3. **Token format:** Portal sessions use JWT bearer tokens.
|
||||
4. **Session storage:** Tokens and user state are kept in `sessionStorage`.
|
||||
5. **Member surface:** `secure.d-bis.org` uses the member shared-secret login path.
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Next Steps
|
||||
## Next Steps
|
||||
|
||||
To enable real authentication:
|
||||
|
||||
1. Implement backend `/api/auth/login` endpoint
|
||||
2. Update `authService.ts` to call real API
|
||||
3. Configure JWT token validation
|
||||
4. Set up proper user roles and permissions
|
||||
5. Remove mock authentication code
|
||||
|
||||
---
|
||||
|
||||
**For development/testing:** Use any username and password to log in.
|
||||
1. Replace shared-secret employee bootstrap access with individually managed credentials only.
|
||||
2. Add token refresh or httpOnly cookie sessions.
|
||||
3. Add role-specific operator runbooks for issuing portal accounts.
|
||||
|
||||
@@ -4,11 +4,10 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>DBIS Admin Console</title>
|
||||
<title>DBIS Institutional Portal</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { lazy, Suspense, useEffect } from 'react';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { useAuthStore } from './stores/authStore';
|
||||
import ProtectedRoute from './components/auth/ProtectedRoute';
|
||||
@@ -6,10 +6,12 @@ import ErrorBoundary from './components/shared/ErrorBoundary';
|
||||
import PageError from './components/shared/PageError';
|
||||
import LoadingSpinner from './components/shared/LoadingSpinner';
|
||||
import SkipLink from './components/shared/SkipLink';
|
||||
import { runtimePortal } from './config/runtimePortal';
|
||||
|
||||
// Layout components (loaded immediately as they're always needed)
|
||||
import DBISLayout from './components/layout/DBISLayout';
|
||||
import SCBLayout from './components/layout/SCBLayout';
|
||||
import MemberPortalLayout from './components/layout/MemberPortalLayout';
|
||||
|
||||
// Auth page (loaded immediately for faster login)
|
||||
import LoginPage from './pages/auth/LoginPage';
|
||||
@@ -28,6 +30,11 @@ const DBISRiskCompliancePage = lazy(() => import('./pages/dbis/RiskCompliancePag
|
||||
const SCBOverviewPage = lazy(() => import('./pages/scb/OverviewPage'));
|
||||
const SCBFIManagementPage = lazy(() => import('./pages/scb/FIManagementPage'));
|
||||
const SCBCorridorPolicyPage = lazy(() => import('./pages/scb/CorridorPolicyPage'));
|
||||
const MemberOverviewPage = lazy(() => import('./pages/portal/MemberOverviewPage'));
|
||||
const MemberAccessPage = lazy(() => import('./pages/portal/MemberAccessPage'));
|
||||
const MemberDocumentsPage = lazy(() => import('./pages/portal/MemberDocumentsPage'));
|
||||
const MemberStatusPage = lazy(() => import('./pages/portal/MemberStatusPage'));
|
||||
const MemberSupportPage = lazy(() => import('./pages/portal/MemberSupportPage'));
|
||||
|
||||
/**
|
||||
* Lazy-loaded route wrapper with Suspense fallback
|
||||
@@ -39,102 +46,158 @@ const LazyRoute = ({ children }: { children: React.ReactNode }) => (
|
||||
function App() {
|
||||
const { isAuthenticated } = useAuthStore();
|
||||
|
||||
useEffect(() => {
|
||||
document.title = runtimePortal.pageTitle;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<SkipLink />
|
||||
<Routes>
|
||||
<Route path="/login" element={!isAuthenticated ? <LoginPage /> : <Navigate to="/dbis/overview" replace />} />
|
||||
<Route path="/login" element={!isAuthenticated ? <LoginPage /> : <Navigate to={runtimePortal.homeRoute} replace />} />
|
||||
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route path="/dbis/*" element={<DBISLayout />}>
|
||||
<Route
|
||||
path="overview"
|
||||
element={
|
||||
<LazyRoute>
|
||||
<DBISOverviewPage />
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="participants"
|
||||
element={
|
||||
<LazyRoute>
|
||||
<DBISParticipantsPage />
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="gru"
|
||||
element={
|
||||
<LazyRoute>
|
||||
<DBISGRUPage />
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="gas-qps"
|
||||
element={
|
||||
<LazyRoute>
|
||||
<DBISGASQPSPage />
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="cbdc-fx"
|
||||
element={
|
||||
<LazyRoute>
|
||||
<DBISCBDCFXPage />
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="metaverse-edge"
|
||||
element={
|
||||
<LazyRoute>
|
||||
<DBISMetaverseEdgePage />
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="risk-compliance"
|
||||
element={
|
||||
<LazyRoute>
|
||||
<DBISRiskCompliancePage />
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
<Route index element={<Navigate to="overview" replace />} />
|
||||
</Route>
|
||||
|
||||
<Route path="/scb/*" element={<SCBLayout />}>
|
||||
<Route
|
||||
path="overview"
|
||||
element={
|
||||
<LazyRoute>
|
||||
<SCBOverviewPage />
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="fi-management"
|
||||
element={
|
||||
<LazyRoute>
|
||||
<SCBFIManagementPage />
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="corridors"
|
||||
element={
|
||||
<LazyRoute>
|
||||
<SCBCorridorPolicyPage />
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
<Route index element={<Navigate to="overview" replace />} />
|
||||
</Route>
|
||||
|
||||
<Route path="/" element={<Navigate to="/dbis/overview" replace />} />
|
||||
{runtimePortal.isMember ? (
|
||||
<>
|
||||
<Route path="/portal/*" element={<MemberPortalLayout />}>
|
||||
<Route
|
||||
path="overview"
|
||||
element={
|
||||
<LazyRoute>
|
||||
<MemberOverviewPage />
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="access"
|
||||
element={
|
||||
<LazyRoute>
|
||||
<MemberAccessPage />
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="documents"
|
||||
element={
|
||||
<LazyRoute>
|
||||
<MemberDocumentsPage />
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="status"
|
||||
element={
|
||||
<LazyRoute>
|
||||
<MemberStatusPage />
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="support"
|
||||
element={
|
||||
<LazyRoute>
|
||||
<MemberSupportPage />
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
<Route index element={<Navigate to="overview" replace />} />
|
||||
</Route>
|
||||
|
||||
<Route path="/" element={<Navigate to="/portal/overview" replace />} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Route path="/dbis/*" element={<DBISLayout />}>
|
||||
<Route
|
||||
path="overview"
|
||||
element={
|
||||
<LazyRoute>
|
||||
<DBISOverviewPage />
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="participants"
|
||||
element={
|
||||
<LazyRoute>
|
||||
<DBISParticipantsPage />
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="gru"
|
||||
element={
|
||||
<LazyRoute>
|
||||
<DBISGRUPage />
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="gas-qps"
|
||||
element={
|
||||
<LazyRoute>
|
||||
<DBISGASQPSPage />
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="cbdc-fx"
|
||||
element={
|
||||
<LazyRoute>
|
||||
<DBISCBDCFXPage />
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="metaverse-edge"
|
||||
element={
|
||||
<LazyRoute>
|
||||
<DBISMetaverseEdgePage />
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="risk-compliance"
|
||||
element={
|
||||
<LazyRoute>
|
||||
<DBISRiskCompliancePage />
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
<Route index element={<Navigate to="overview" replace />} />
|
||||
</Route>
|
||||
|
||||
<Route path="/scb/*" element={<SCBLayout />}>
|
||||
<Route
|
||||
path="overview"
|
||||
element={
|
||||
<LazyRoute>
|
||||
<SCBOverviewPage />
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="fi-management"
|
||||
element={
|
||||
<LazyRoute>
|
||||
<SCBFIManagementPage />
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="corridors"
|
||||
element={
|
||||
<LazyRoute>
|
||||
<SCBCorridorPolicyPage />
|
||||
</LazyRoute>
|
||||
}
|
||||
/>
|
||||
<Route index element={<Navigate to="overview" replace />} />
|
||||
</Route>
|
||||
|
||||
<Route path="/" element={<Navigate to="/dbis/overview" replace />} />
|
||||
</>
|
||||
)}
|
||||
</Route>
|
||||
|
||||
<Route path="/404" element={<PageError code={404} />} />
|
||||
@@ -147,4 +210,3 @@ function App() {
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
|
||||
@@ -33,16 +33,17 @@ const dbisNavItems = [
|
||||
|
||||
export default function DBISLayout() {
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const handleToggleSidebar = () => setSidebarCollapsed((current) => !current);
|
||||
|
||||
return (
|
||||
<div className="layout">
|
||||
<SidebarNavigation
|
||||
items={dbisNavItems}
|
||||
collapsed={sidebarCollapsed}
|
||||
onToggle={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
onToggle={handleToggleSidebar}
|
||||
/>
|
||||
<div className="layout__main">
|
||||
<TopBar />
|
||||
<TopBar sidebarCollapsed={sidebarCollapsed} onToggleSidebar={handleToggleSidebar} />
|
||||
<main id="main-content" className="layout__content" role="main">
|
||||
<Outlet />
|
||||
</main>
|
||||
@@ -50,4 +51,3 @@ export default function DBISLayout() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
34
frontend/src/components/layout/MemberPortalLayout.tsx
Normal file
34
frontend/src/components/layout/MemberPortalLayout.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { useState } from 'react';
|
||||
import { MdAssignmentTurnedIn, MdDashboard, MdFolderShared, MdHealthAndSafety, MdSupportAgent } from 'react-icons/md';
|
||||
import SidebarNavigation from './SidebarNavigation';
|
||||
import TopBar from './TopBar';
|
||||
|
||||
const memberNavItems = [
|
||||
{ path: '/portal/overview', label: 'Overview', icon: <MdDashboard /> },
|
||||
{ path: '/portal/access', label: 'Access & Requests', icon: <MdAssignmentTurnedIn /> },
|
||||
{ path: '/portal/documents', label: 'Documents & Reporting', icon: <MdFolderShared /> },
|
||||
{ path: '/portal/status', label: 'Status & Support', icon: <MdHealthAndSafety /> },
|
||||
{ path: '/portal/support', label: 'Support Channels', icon: <MdSupportAgent /> },
|
||||
];
|
||||
|
||||
export default function MemberPortalLayout() {
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const handleToggleSidebar = () => setSidebarCollapsed((current) => !current);
|
||||
|
||||
return (
|
||||
<div className="layout">
|
||||
<SidebarNavigation
|
||||
items={memberNavItems}
|
||||
collapsed={sidebarCollapsed}
|
||||
onToggle={handleToggleSidebar}
|
||||
/>
|
||||
<div className="layout__main">
|
||||
<TopBar sidebarCollapsed={sidebarCollapsed} onToggleSidebar={handleToggleSidebar} />
|
||||
<main id="main-content" className="layout__content" role="main">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -27,16 +27,17 @@ const scbNavItems = [
|
||||
|
||||
export default function SCBLayout() {
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const handleToggleSidebar = () => setSidebarCollapsed((current) => !current);
|
||||
|
||||
return (
|
||||
<div className="layout">
|
||||
<SidebarNavigation
|
||||
items={scbNavItems}
|
||||
collapsed={sidebarCollapsed}
|
||||
onToggle={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
onToggle={handleToggleSidebar}
|
||||
/>
|
||||
<div className="layout__main">
|
||||
<TopBar />
|
||||
<TopBar sidebarCollapsed={sidebarCollapsed} onToggleSidebar={handleToggleSidebar} />
|
||||
<main id="main-content" className="layout__content" role="main">
|
||||
<Outlet />
|
||||
</main>
|
||||
@@ -44,4 +45,3 @@ export default function SCBLayout() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { NavLink } from 'react-router-dom';
|
||||
import { ReactNode } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import { runtimePortal } from '@/config/runtimePortal';
|
||||
import './SidebarNavigation.css';
|
||||
|
||||
interface NavItem {
|
||||
@@ -27,9 +28,15 @@ export default function SidebarNavigation({ items, collapsed = false, onToggle }
|
||||
return (
|
||||
<nav className={clsx('sidebar', { 'sidebar--collapsed': collapsed })}>
|
||||
<div className="sidebar__header">
|
||||
<h2 className="sidebar__title">DBIS Admin</h2>
|
||||
{onToggle && (
|
||||
<button className="sidebar__toggle" onClick={onToggle} aria-label="Toggle sidebar">
|
||||
<h2 className="sidebar__title">{runtimePortal.sidebarTitle}</h2>
|
||||
{onToggle && !collapsed && (
|
||||
<button
|
||||
type="button"
|
||||
className="sidebar__toggle"
|
||||
onClick={onToggle}
|
||||
aria-label="Close navigation panel"
|
||||
aria-expanded={!collapsed}
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
)}
|
||||
@@ -57,4 +64,3 @@ export default function SidebarNavigation({ items, collapsed = false, onToggle }
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,31 @@
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.topbar__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.topbar__menu-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: 1.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.topbar__menu-toggle:hover {
|
||||
background: var(--color-bg-tertiary, var(--color-bg-secondary));
|
||||
}
|
||||
|
||||
.topbar__title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
@@ -42,3 +67,12 @@
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.topbar {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.topbar__user-role {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,15 @@
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Button from '@/components/shared/Button';
|
||||
import { runtimePortal } from '@/config/runtimePortal';
|
||||
import './TopBar.css';
|
||||
|
||||
export default function TopBar() {
|
||||
interface TopBarProps {
|
||||
sidebarCollapsed?: boolean;
|
||||
onToggleSidebar?: () => void;
|
||||
}
|
||||
|
||||
export default function TopBar({ sidebarCollapsed = false, onToggleSidebar }: TopBarProps) {
|
||||
const { user, logout } = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -16,7 +22,18 @@ export default function TopBar() {
|
||||
return (
|
||||
<header className="topbar">
|
||||
<div className="topbar__left">
|
||||
<h1 className="topbar__title">DBIS Admin Console</h1>
|
||||
{onToggleSidebar && sidebarCollapsed && (
|
||||
<button
|
||||
type="button"
|
||||
className="topbar__menu-toggle"
|
||||
onClick={onToggleSidebar}
|
||||
aria-label="Open navigation panel"
|
||||
aria-expanded={!sidebarCollapsed}
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
)}
|
||||
<h1 className="topbar__title">{runtimePortal.topbarTitle}</h1>
|
||||
</div>
|
||||
<div className="topbar__right">
|
||||
<div className="topbar__user">
|
||||
@@ -30,4 +47,3 @@ export default function TopBar() {
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,19 @@
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
|
||||
function resolveApiBaseUrl(): string {
|
||||
const configured = import.meta.env.VITE_API_BASE_URL?.trim();
|
||||
if (configured) {
|
||||
return configured;
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && window.location?.origin) {
|
||||
return window.location.origin;
|
||||
}
|
||||
|
||||
return 'http://localhost:3000';
|
||||
}
|
||||
|
||||
const envSchema = z.object({
|
||||
VITE_API_BASE_URL: z.string().url('VITE_API_BASE_URL must be a valid URL'),
|
||||
VITE_APP_NAME: z.string().min(1, 'VITE_APP_NAME is required'),
|
||||
@@ -21,7 +34,7 @@ let env: Env;
|
||||
|
||||
try {
|
||||
env = envSchema.parse({
|
||||
VITE_API_BASE_URL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000',
|
||||
VITE_API_BASE_URL: resolveApiBaseUrl(),
|
||||
VITE_APP_NAME: import.meta.env.VITE_APP_NAME || 'DBIS Admin Console',
|
||||
VITE_REAL_TIME_UPDATE_INTERVAL: import.meta.env.VITE_REAL_TIME_UPDATE_INTERVAL || '5000',
|
||||
});
|
||||
|
||||
70
frontend/src/config/runtimePortal.ts
Normal file
70
frontend/src/config/runtimePortal.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
export type PortalSurface = 'admin' | 'member' | 'core';
|
||||
|
||||
const MEMBER_HOSTS = new Set(['secure.d-bis.org', 'members.d-bis.org']);
|
||||
const CORE_HOSTS = new Set(['core.d-bis.org']);
|
||||
|
||||
function getSurfaceCopy(surface: PortalSurface) {
|
||||
switch (surface) {
|
||||
case 'member':
|
||||
return {
|
||||
pageTitle: 'DBIS Member Portal',
|
||||
sidebarTitle: 'DBIS Member',
|
||||
topbarTitle: 'DBIS Member Portal',
|
||||
loginTitle: 'DBIS Member Portal',
|
||||
loginSubtitle: 'Secure institution access for submissions, requests, reporting, and member services',
|
||||
homeRoute: '/portal/overview',
|
||||
};
|
||||
case 'core':
|
||||
return {
|
||||
pageTitle: 'DBIS Core Banking Application',
|
||||
sidebarTitle: 'DBIS Core',
|
||||
topbarTitle: 'DBIS Core Banking',
|
||||
loginTitle: 'DBIS Core Banking Application',
|
||||
loginSubtitle: 'Operational access to the DBIS Core banking application and institution workflows',
|
||||
homeRoute: '/dbis/overview',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
pageTitle: 'DBIS Admin Console',
|
||||
sidebarTitle: 'DBIS Admin',
|
||||
topbarTitle: 'DBIS Admin Console',
|
||||
loginTitle: 'DBIS Admin Console',
|
||||
loginSubtitle: 'Operations access for DBIS and sovereign central bank administration',
|
||||
homeRoute: '/dbis/overview',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function detectSurface(): PortalSurface {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'admin';
|
||||
}
|
||||
|
||||
const host = window.location.hostname.toLowerCase();
|
||||
if (MEMBER_HOSTS.has(host)) {
|
||||
return 'member';
|
||||
}
|
||||
|
||||
if (CORE_HOSTS.has(host)) {
|
||||
return 'core';
|
||||
}
|
||||
|
||||
return 'admin';
|
||||
}
|
||||
|
||||
const surface = detectSurface();
|
||||
const surfaceCopy = getSurfaceCopy(surface);
|
||||
|
||||
export const runtimePortal = {
|
||||
surface,
|
||||
isAdmin: surface === 'admin',
|
||||
isMember: surface === 'member',
|
||||
isCore: surface === 'core',
|
||||
host: typeof window === 'undefined' ? '' : window.location.hostname.toLowerCase(),
|
||||
pageTitle: surfaceCopy.pageTitle,
|
||||
sidebarTitle: surfaceCopy.sidebarTitle,
|
||||
topbarTitle: surfaceCopy.topbarTitle,
|
||||
loginTitle: surfaceCopy.loginTitle,
|
||||
loginSubtitle: surfaceCopy.loginSubtitle,
|
||||
homeRoute: surfaceCopy.homeRoute,
|
||||
} as const;
|
||||
@@ -6,6 +6,7 @@ import { Toaster } from 'react-hot-toast';
|
||||
import App from './App';
|
||||
import { useAuthStore } from './stores/authStore';
|
||||
import { env } from './config/env';
|
||||
import { runtimePortal } from './config/runtimePortal';
|
||||
import { logger } from './utils/logger';
|
||||
import { errorTracker } from './utils/errorTracking';
|
||||
import './index.css';
|
||||
@@ -13,7 +14,7 @@ import './index.css';
|
||||
// Initialize error tracking
|
||||
errorTracker.init();
|
||||
|
||||
logger.info('DBIS Admin Console starting', { version: env.VITE_APP_NAME });
|
||||
logger.info(`${runtimePortal.pageTitle} starting`, { version: env.VITE_APP_NAME, surface: runtimePortal.surface });
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
|
||||
@@ -4,13 +4,16 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import Button from '@/components/shared/Button';
|
||||
import toast from 'react-hot-toast';
|
||||
import { runtimePortal } from '@/config/runtimePortal';
|
||||
import './LoginPage.css';
|
||||
|
||||
export default function LoginPage() {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [otp, setOtp] = useState('');
|
||||
const [rememberMe, setRememberMe] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [mfaRequired, setMfaRequired] = useState(false);
|
||||
const { login } = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -19,9 +22,18 @@ export default function LoginPage() {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await login({ username, password, rememberMe });
|
||||
toast.success('Login successful');
|
||||
navigate('/dbis/overview');
|
||||
const result = await login({ username, password, otp, rememberMe });
|
||||
if (result.mfaRequired) {
|
||||
setMfaRequired(true);
|
||||
toast.success('Authenticator code required');
|
||||
} else {
|
||||
if (result.passwordRotationRequired) {
|
||||
toast.success('Login successful. Password rotation is recommended.');
|
||||
} else {
|
||||
toast.success('Login successful');
|
||||
}
|
||||
navigate(runtimePortal.homeRoute);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Login failed');
|
||||
} finally {
|
||||
@@ -33,8 +45,8 @@ export default function LoginPage() {
|
||||
<div className="login-page">
|
||||
<div className="login-page__container">
|
||||
<div className="login-page__header">
|
||||
<h1>DBIS Admin Console</h1>
|
||||
<p>Sign in to your account</p>
|
||||
<h1>{runtimePortal.loginTitle}</h1>
|
||||
<p>{runtimePortal.loginSubtitle}</p>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="login-page__form">
|
||||
<div className="login-page__field">
|
||||
@@ -59,6 +71,22 @@ export default function LoginPage() {
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
{mfaRequired ? (
|
||||
<div className="login-page__field">
|
||||
<label htmlFor="otp">Authenticator Code</label>
|
||||
<input
|
||||
id="otp"
|
||||
type="text"
|
||||
value={otp}
|
||||
onChange={(e) => setOtp(e.target.value)}
|
||||
required
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
maxLength={6}
|
||||
placeholder="123456"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="login-page__field">
|
||||
<label className="login-page__checkbox">
|
||||
<input
|
||||
@@ -70,11 +98,10 @@ export default function LoginPage() {
|
||||
</label>
|
||||
</div>
|
||||
<Button type="submit" loading={loading} fullWidth>
|
||||
Sign In
|
||||
{mfaRequired ? 'Verify Code' : 'Sign In'}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import DataTable, { Column } from '@/components/shared/DataTable';
|
||||
import Button from '@/components/shared/Button';
|
||||
import LoadingSpinner from '@/components/shared/LoadingSpinner';
|
||||
import type { SCBStatus } from '@/types';
|
||||
import { env } from '@/config/env';
|
||||
import './OverviewPage.css';
|
||||
|
||||
export default function OverviewPage() {
|
||||
@@ -38,7 +39,7 @@ export default function OverviewPage() {
|
||||
{isNetworkError ? (
|
||||
<div>
|
||||
<h2>API Connection Error</h2>
|
||||
<p>The backend API is not available at {import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'}</p>
|
||||
<p>The backend API is not available at {env.VITE_API_BASE_URL}</p>
|
||||
<p>Please ensure the API server is running.</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
|
||||
29
frontend/src/pages/portal/MemberAccessPage.tsx
Normal file
29
frontend/src/pages/portal/MemberAccessPage.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
const accessItems = [
|
||||
'Submission intake for dossiers, supporting instruments, and controlled correspondence',
|
||||
'Request management for service actions, publication support, and authenticated document retrieval',
|
||||
'Institution-level access review, delegated users, and operating contact confirmation',
|
||||
'Workflow status visibility for accepted, pending, returned, and closed items',
|
||||
];
|
||||
|
||||
export default function MemberAccessPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<h1 className="text-3xl font-semibold text-slate-900">Access & Requests</h1>
|
||||
<p className="mt-3 max-w-3xl text-sm leading-6 text-slate-600">
|
||||
Use the secure member portal for operational requests and protected submission workflows. This surface is intended for institutional users rather
|
||||
than public browsing.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<h2 className="text-xl font-semibold text-slate-900">Available workflow classes</h2>
|
||||
<ul className="mt-4 space-y-3 text-sm leading-6 text-slate-600">
|
||||
{accessItems.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
frontend/src/pages/portal/MemberDocumentsPage.tsx
Normal file
36
frontend/src/pages/portal/MemberDocumentsPage.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
const documentGroups = [
|
||||
{
|
||||
title: 'Governance materials',
|
||||
body: 'Approved circulars, directives, standards, and member notices that require authenticated distribution or acknowledgement.',
|
||||
},
|
||||
{
|
||||
title: 'Reporting packs',
|
||||
body: 'Structured reporting templates, filing instructions, submission windows, and evidence requirements for controlled workflows.',
|
||||
},
|
||||
{
|
||||
title: 'Operational records',
|
||||
body: 'Access-controlled service correspondence, request responses, and downloadable artifacts associated with member actions.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function MemberDocumentsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<h1 className="text-3xl font-semibold text-slate-900">Documents & Reporting</h1>
|
||||
<p className="mt-3 max-w-3xl text-sm leading-6 text-slate-600">
|
||||
Authenticated document delivery belongs in the secure portal when distribution, acknowledgement, or participant-level access control is required.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-3">
|
||||
{documentGroups.map((group) => (
|
||||
<article key={group.title} className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<h2 className="text-lg font-semibold text-slate-900">{group.title}</h2>
|
||||
<p className="mt-3 text-sm leading-6 text-slate-600">{group.body}</p>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
frontend/src/pages/portal/MemberOverviewPage.tsx
Normal file
58
frontend/src/pages/portal/MemberOverviewPage.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
const cards = [
|
||||
{
|
||||
title: 'Submissions',
|
||||
body: 'Prepare institutional filings, controlled disclosures, corridor requests, and supporting documents in one secure workspace.',
|
||||
},
|
||||
{
|
||||
title: 'Requests',
|
||||
body: 'Track service requests, operational actions, and document retrieval across DBIS coordination teams and member support functions.',
|
||||
},
|
||||
{
|
||||
title: 'Reporting',
|
||||
body: 'Use the secure portal for incident, ethics, sanctions, audit, and data-protection reporting workflows where disclosure channels are required.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function MemberOverviewPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="rounded-2xl bg-slate-900 px-8 py-10 text-white shadow-sm">
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-slate-300">Secure surface</p>
|
||||
<h1 className="mt-3 text-4xl font-semibold tracking-tight">DBIS Member Portal</h1>
|
||||
<p className="mt-4 max-w-3xl text-base leading-7 text-slate-200">
|
||||
Authenticated workspace for participating institutions, accredited counterparties, and approved operations teams. Use this portal for secure
|
||||
submissions, controlled requests, reporting workflows, and member service coordination.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-3">
|
||||
{cards.map((card) => (
|
||||
<article key={card.title} className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<h2 className="text-lg font-semibold text-slate-900">{card.title}</h2>
|
||||
<p className="mt-3 text-sm leading-6 text-slate-600">{card.body}</p>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 lg:grid-cols-[1.4fr_1fr]">
|
||||
<article className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<h2 className="text-xl font-semibold text-slate-900">What belongs here</h2>
|
||||
<ul className="mt-4 space-y-3 text-sm leading-6 text-slate-600">
|
||||
<li>Institution onboarding packets and accredited user access management.</li>
|
||||
<li>Secure document exchange for governance, compliance, and operational workflows.</li>
|
||||
<li>Submission and request tracking with role-based access and auditable status history.</li>
|
||||
<li>Member-facing notices, service health, and controlled release materials.</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article className="rounded-xl border border-slate-200 bg-slate-50 p-6 shadow-sm">
|
||||
<h2 className="text-xl font-semibold text-slate-900">Current scope</h2>
|
||||
<p className="mt-4 text-sm leading-6 text-slate-600">
|
||||
This portal is the secure companion to the public `d-bis.org` site. Public governance and institutional reference material stay on the public
|
||||
web surface; authenticated workflow activity belongs here.
|
||||
</p>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
frontend/src/pages/portal/MemberStatusPage.tsx
Normal file
30
frontend/src/pages/portal/MemberStatusPage.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
export default function MemberStatusPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<h1 className="text-3xl font-semibold text-slate-900">Status & Support</h1>
|
||||
<p className="mt-3 max-w-3xl text-sm leading-6 text-slate-600">
|
||||
Service-health notices, scheduled maintenance windows, support escalations, and secure coordination updates for member institutions.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-2">
|
||||
<article className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<h2 className="text-lg font-semibold text-slate-900">Service posture</h2>
|
||||
<p className="mt-3 text-sm leading-6 text-slate-600">
|
||||
Operational notices should summarize current availability for secure workflows, submission handling, and member support channels without exposing
|
||||
internal administrative controls.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<h2 className="text-lg font-semibold text-slate-900">Escalation path</h2>
|
||||
<p className="mt-3 text-sm leading-6 text-slate-600">
|
||||
Use this portal surface for authenticated support coordination, incident follow-up, and member communications that should not be handled on the
|
||||
public website.
|
||||
</p>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
frontend/src/pages/portal/MemberSupportPage.tsx
Normal file
22
frontend/src/pages/portal/MemberSupportPage.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
export default function MemberSupportPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<h1 className="text-3xl font-semibold text-slate-900">Support Channels</h1>
|
||||
<p className="mt-3 max-w-3xl text-sm leading-6 text-slate-600">
|
||||
Member support is routed through authenticated channels so institution identity, request history, and access controls can be preserved.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<h2 className="text-xl font-semibold text-slate-900">Use secure support for</h2>
|
||||
<ul className="mt-4 space-y-3 text-sm leading-6 text-slate-600">
|
||||
<li>Portal access and delegated user issues</li>
|
||||
<li>Submission and request follow-up</li>
|
||||
<li>Controlled document delivery and acknowledgement questions</li>
|
||||
<li>Member service coordination that should not traverse the public site</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -82,6 +82,10 @@ class ApiClient {
|
||||
},
|
||||
async (error: AxiosError) => {
|
||||
const url = error.config?.url || '';
|
||||
const isInteractiveAuthFlow =
|
||||
url.startsWith('/api/auth/login') ||
|
||||
url.startsWith('/api/auth/password/reset') ||
|
||||
url.startsWith('/api/auth/mfa/');
|
||||
this.cancelTokenSources.delete(url);
|
||||
|
||||
if (axios.isCancel(error)) {
|
||||
@@ -101,10 +105,13 @@ class ApiClient {
|
||||
|
||||
switch (status) {
|
||||
case 401:
|
||||
sessionStorage.removeItem('auth_token');
|
||||
sessionStorage.removeItem('user');
|
||||
window.location.href = '/login';
|
||||
toast.error(ERROR_MESSAGES.UNAUTHORIZED);
|
||||
if (!isInteractiveAuthFlow) {
|
||||
sessionStorage.removeItem('auth_token');
|
||||
sessionStorage.removeItem('user');
|
||||
sessionStorage.removeItem('auth-storage');
|
||||
window.location.href = '/login';
|
||||
toast.error(ERROR_MESSAGES.UNAUTHORIZED);
|
||||
}
|
||||
break;
|
||||
case 403:
|
||||
toast.error(ERROR_MESSAGES.FORBIDDEN);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Authentication Service
|
||||
import { apiClient } from '../api/client';
|
||||
import type { LoginCredentials, User } from '@/types';
|
||||
import type { LoginCredentials, LoginResponse, User } from '@/types';
|
||||
import { runtimePortal } from '@/config/runtimePortal';
|
||||
|
||||
/**
|
||||
* Authentication Service
|
||||
@@ -19,33 +20,39 @@ class AuthService {
|
||||
// Tokens are cleared when the browser tab/window is closed
|
||||
private readonly storage = sessionStorage;
|
||||
|
||||
async login(credentials: LoginCredentials): Promise<{ user: User; token: string }> {
|
||||
// TODO: Replace with actual login endpoint when available
|
||||
// For now, this is a placeholder that would call the backend
|
||||
// const response = await apiClient.post('/api/auth/login', credentials);
|
||||
|
||||
// Mock response for development
|
||||
const mockUser: User = {
|
||||
id: '1',
|
||||
employeeId: 'emp-001',
|
||||
name: 'Admin User',
|
||||
email: credentials.username,
|
||||
role: 'DBIS_Super_Admin',
|
||||
permissions: ['all'],
|
||||
};
|
||||
private decodeTokenPayload(token: string): { exp?: number } | null {
|
||||
try {
|
||||
return JSON.parse(atob(token.split('.')[1]));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const mockToken = 'mock-jwt-token';
|
||||
async login(credentials: LoginCredentials): Promise<LoginResponse> {
|
||||
try {
|
||||
const response = await apiClient.post<LoginResponse>('/api/auth/login', {
|
||||
username: credentials.username,
|
||||
password: credentials.password,
|
||||
...(credentials.otp?.trim() ? { otp: credentials.otp.trim() } : {}),
|
||||
surface: runtimePortal.surface,
|
||||
rememberMe: credentials.rememberMe,
|
||||
});
|
||||
|
||||
this.setToken(mockToken);
|
||||
this.setUser(mockUser);
|
||||
if (!response.mfaRequired) {
|
||||
this.setToken(response.token);
|
||||
this.setUser(response.user);
|
||||
}
|
||||
|
||||
return { user: mockUser, token: mockToken };
|
||||
return response;
|
||||
} catch (error: any) {
|
||||
const message = error?.response?.data?.error?.message || error?.message || 'Login failed';
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
async logout(): Promise<void> {
|
||||
try {
|
||||
// Call logout endpoint if available
|
||||
// await apiClient.post('/api/auth/logout');
|
||||
await apiClient.post('/api/auth/logout');
|
||||
} catch (error) {
|
||||
// Ignore errors on logout
|
||||
} finally {
|
||||
@@ -53,6 +60,17 @@ class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
async getCurrentUser(): Promise<User | null> {
|
||||
const token = this.getToken();
|
||||
if (!token || !this.isAuthenticated()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await apiClient.get<{ user: User }>('/api/auth/me');
|
||||
this.setUser(response.user);
|
||||
return response.user;
|
||||
}
|
||||
|
||||
getToken(): string | null {
|
||||
try {
|
||||
return this.storage.getItem(this.TOKEN_KEY);
|
||||
@@ -105,13 +123,12 @@ class AuthService {
|
||||
if (!token) return false;
|
||||
|
||||
// Check if token is expired (basic check)
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
const exp = payload.exp * 1000; // Convert to milliseconds
|
||||
return Date.now() < exp;
|
||||
} catch {
|
||||
const payload = this.decodeTokenPayload(token);
|
||||
if (!payload?.exp) {
|
||||
return false;
|
||||
}
|
||||
const exp = payload.exp * 1000; // Convert to milliseconds
|
||||
return Date.now() < exp;
|
||||
}
|
||||
|
||||
async refreshToken(): Promise<string | null> {
|
||||
@@ -129,4 +146,3 @@ class AuthService {
|
||||
}
|
||||
|
||||
export const authService = new AuthService();
|
||||
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
// Auth Store (Zustand)
|
||||
import { create } from 'zustand';
|
||||
import { devtools, persist } from 'zustand/middleware';
|
||||
import { createJSONStorage, devtools, persist } from 'zustand/middleware';
|
||||
import { authService } from '@/services/auth/authService';
|
||||
import type { User, LoginCredentials } from '@/types';
|
||||
import type { User, LoginCredentials, LoginResponse } from '@/types';
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
login: (credentials: LoginCredentials) => Promise<void>;
|
||||
login: (credentials: LoginCredentials) => Promise<LoginResponse>;
|
||||
logout: () => Promise<void>;
|
||||
initialize: () => void;
|
||||
initialize: () => Promise<void>;
|
||||
checkPermission: (permission: string) => boolean;
|
||||
isDBISLevel: () => boolean;
|
||||
}
|
||||
@@ -25,18 +25,29 @@ export const useAuthStore = create<AuthState>()(
|
||||
isAuthenticated: false,
|
||||
isLoading: true,
|
||||
|
||||
initialize: () => {
|
||||
initialize: async () => {
|
||||
const token = authService.getToken();
|
||||
const user = authService.getUser();
|
||||
|
||||
if (token && user && authService.isAuthenticated()) {
|
||||
if (!token || !authService.isAuthenticated()) {
|
||||
authService.clearAuth();
|
||||
set({
|
||||
token: null,
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await authService.getCurrentUser();
|
||||
set({
|
||||
token,
|
||||
user,
|
||||
isAuthenticated: true,
|
||||
isAuthenticated: Boolean(user),
|
||||
isLoading: false,
|
||||
});
|
||||
} else {
|
||||
} catch (error) {
|
||||
authService.clearAuth();
|
||||
set({
|
||||
token: null,
|
||||
@@ -50,13 +61,19 @@ export const useAuthStore = create<AuthState>()(
|
||||
login: async (credentials: LoginCredentials) => {
|
||||
try {
|
||||
set({ isLoading: true });
|
||||
const { user, token } = await authService.login(credentials);
|
||||
set({
|
||||
user,
|
||||
token,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
});
|
||||
const response = await authService.login(credentials);
|
||||
if (!response.mfaRequired) {
|
||||
const { user, token } = response;
|
||||
set({
|
||||
user,
|
||||
token,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
});
|
||||
} else {
|
||||
set({ isLoading: false });
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
set({ isLoading: false });
|
||||
throw error;
|
||||
@@ -87,6 +104,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
}),
|
||||
{
|
||||
name: 'auth-storage',
|
||||
storage: createJSONStorage(() => sessionStorage),
|
||||
// Only persist user data, not token (token is in sessionStorage for security)
|
||||
partialize: (state) => ({
|
||||
user: state.user,
|
||||
@@ -97,4 +115,3 @@ export const useAuthStore = create<AuthState>()(
|
||||
{ name: 'AuthStore' }
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -20,9 +20,32 @@ export interface AuthState {
|
||||
export interface LoginCredentials {
|
||||
username: string;
|
||||
password: string;
|
||||
otp?: string;
|
||||
rememberMe?: boolean;
|
||||
}
|
||||
|
||||
export interface LoginChallengeUser {
|
||||
id: string;
|
||||
employeeId: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export type LoginResponse =
|
||||
| {
|
||||
user: User;
|
||||
token: string;
|
||||
mfaRequired?: false;
|
||||
passwordRotationRequired?: boolean;
|
||||
}
|
||||
| {
|
||||
mfaRequired: true;
|
||||
method: 'totp';
|
||||
passwordRotationRequired?: boolean;
|
||||
user: LoginChallengeUser;
|
||||
};
|
||||
|
||||
export interface ApiError {
|
||||
code: string;
|
||||
message: string;
|
||||
@@ -114,4 +137,3 @@ export interface ParticipantInfo {
|
||||
latency?: number;
|
||||
errorRate?: number;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user