feat: add member portal and auth hardening

This commit is contained in:
defiQUG
2026-04-18 12:05:17 -07:00
parent c80b2a543a
commit 468bc05b78
59 changed files with 4066 additions and 604 deletions

View File

@@ -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.

View File

@@ -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>

View File

@@ -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;

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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;
}
}

View File

@@ -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>
);
}

View File

@@ -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',
});

View 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;

View File

@@ -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: {

View File

@@ -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>
);
}

View File

@@ -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"

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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);

View File

@@ -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();

View File

@@ -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' }
)
);

View File

@@ -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;
}