Initial Commit

This commit is contained in:
Huzaifa Inam 2026-08-04 01:24:04 +05:00
parent d7eccee7b8
commit 5b2eafcbee
9 changed files with 1431 additions and 135 deletions

View file

@ -229,10 +229,90 @@ app.post("/api/auth/change-password", authenticateToken, (req: any, res: any) =>
}
user.password = hashPassword(newPassword);
user.mustChangePassword = false;
res.json({ success: true, message: "Password updated successfully." });
});
// User Authentication: First Login Password Change
app.post("/api/auth/first-login-change-password", (req: any, res: any) => {
const { userId, username, newPassword } = req.body;
if (!newPassword || newPassword.length < 6) {
return res.status(400).json({ error: "New password must be at least 6 characters long." });
}
let users = memoryStore?.users || [];
let user = users.find((u: any) => u.id === userId || u.username.toLowerCase() === String(username).toLowerCase());
if (user) {
user.password = hashPassword(newPassword);
user.mustChangePassword = false;
}
res.json({ success: true, message: "Password changed successfully for first login." });
});
// User Authentication: Request Forgot Password
app.post("/api/auth/forgot-password", async (req: any, res: any) => {
const { identifier, resetCode } = req.body;
if (!identifier) {
return res.status(400).json({ error: "Identifier (username or email) is required." });
}
let users = memoryStore?.users || [];
let user = users.find((u: any) => u.username.toLowerCase() === identifier.toLowerCase() || u.email.toLowerCase() === identifier.toLowerCase());
if (!user) {
return res.status(404).json({ error: "User account not found." });
}
// Send email if SMTP is configured
const transporter = createSmtpTransporter();
if (transporter) {
try {
await transporter.sendMail({
from: `"${backendSmtpConfig.fromName}" <${backendSmtpConfig.fromEmail}>`,
to: user.email,
subject: "Password Reset Request - Pinnacle Portal",
html: `
<div style="font-family: sans-serif; padding: 20px; color: #333;">
<h2>Password Reset Code</h2>
<p>Dear ${user.fullName},</p>
<p>You requested a password reset for your Pinnacle Executive Portal account.</p>
<p>Your 6-digit verification code is: <strong style="font-size: 20px; color: #f59e0b;">${resetCode}</strong></p>
<p>If you did not request this, please contact your administrator immediately.</p>
</div>
`
});
} catch (err) {
console.warn("SMTP send failed during forgot password request:", err);
}
}
res.json({ success: true, message: `Reset PIN generated and sent to ${user.email}` });
});
// User Authentication: Reset Password with Code
app.post("/api/auth/reset-password", (req: any, res: any) => {
const { username, newPassword } = req.body;
if (!newPassword || newPassword.length < 6) {
return res.status(400).json({ error: "New password must be at least 6 characters long." });
}
let users = memoryStore?.users || [];
let user = users.find((u: any) => u.username.toLowerCase() === String(username).toLowerCase());
if (user) {
user.password = hashPassword(newPassword);
user.mustChangePassword = false;
}
res.json({ success: true, message: "Password reset successfully." });
});
// Backend Secure SMTP Config Store
let backendSmtpConfig = {
host: process.env.SMTP_HOST || 'smtp.mailtrap.io',

View file

@ -11,6 +11,7 @@ import { RisksView } from './components/RisksView';
import { UsersView } from './components/UsersView';
import { SmtpSettingsView } from './components/SmtpSettingsView';
import { LogsView } from './components/LogsView';
import { TimelineView } from './components/TimelineView';
import { DeploymentView } from './components/DeploymentView';
import { LoginModal } from './components/LoginModal';
import { ProfileModal } from './components/ProfileModal';
@ -308,6 +309,15 @@ export default function App() {
/>
)}
{activeTab === 'timeline' && (
<TimelineView
store={store}
onUpdateStore={handleUpdateStore}
userRole={currentUser?.role || 'viewer'}
currentUserName={currentUser?.fullName || 'System User'}
/>
)}
{activeTab === 'users' && (
<UsersView
store={store}
@ -362,6 +372,12 @@ export default function App() {
setAuthToken(token);
setIsLoginModalOpen(false);
}}
onUpdateUsers={(updatedUsers) => {
handleUpdateStore({
...store,
users: updatedUsers
});
}}
/>
)}

View file

@ -75,7 +75,7 @@ export const Header: React.FC<HeaderProps> = ({
d.title.toLowerCase().includes(q) ||
d.description?.toLowerCase().includes(q) ||
d.decisionBy?.toLowerCase().includes(q) ||
d.id.toLowerCase().includes(q)
String(d.id).toLowerCase().includes(q)
) : [];
const matchedRisks = store && q ? store.risks.filter(r =>
@ -83,7 +83,7 @@ export const Header: React.FC<HeaderProps> = ({
r.mitigation?.toLowerCase().includes(q) ||
r.category?.toLowerCase().includes(q) ||
r.owner?.toLowerCase().includes(q) ||
r.id.toLowerCase().includes(q)
String(r.id).toLowerCase().includes(q)
) : [];
const matchedMeetings = store && q ? store.meetings.filter(m =>

View file

@ -1,51 +1,112 @@
import React, { useState } from 'react';
import { Crown, Lock, User, ArrowRight, ShieldCheck, Eye, EyeOff, KeyRound, AlertCircle } from 'lucide-react';
import { Crown, Lock, User, ArrowRight, ShieldCheck, Eye, EyeOff, KeyRound, AlertCircle, ArrowLeft, CheckCircle2, RefreshCw, Mail } from 'lucide-react';
import { User as UserType } from '../types';
interface LoginModalProps {
users: UserType[];
onLoginSuccess: (user: UserType, token: string) => void;
onUpdateUsers?: (updatedUsers: UserType[]) => void;
}
export const LoginModal: React.FC<LoginModalProps> = ({ users, onLoginSuccess }) => {
const [username, setUsername] = useState('admin');
const [password, setPassword] = useState('Admin');
type Mode = 'login' | 'forgot_password' | 'first_login_change_password';
export const LoginModal: React.FC<LoginModalProps> = ({ users, onLoginSuccess, onUpdateUsers }) => {
const [mode, setMode] = useState<Mode>('login');
// Login form state
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
// General feedback
const [errorMsg, setErrorMsg] = useState('');
const [successMsg, setSuccessMsg] = useState('');
const [isLoading, setIsLoading] = useState(false);
const performLogin = async (un: string, pw: string) => {
setIsLoading(true);
// Pending target user for first login password change
const [targetUser, setTargetUser] = useState<UserType | null>(null);
const [targetToken, setTargetToken] = useState<string>('');
// First Login Change Password state
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showNewPassword, setShowNewPassword] = useState(false);
// Forgot Password state
const [forgotIdentifier, setForgotIdentifier] = useState('');
const [resetStep, setResetStep] = useState<1 | 2>(1);
const [generatedCode, setGeneratedCode] = useState('');
const [inputCode, setInputCode] = useState('');
const [resetNewPass, setResetNewPass] = useState('');
const [resetConfirmPass, setResetConfirmPass] = useState('');
const clearMessages = () => {
setErrorMsg('');
setSuccessMsg('');
};
// Perform Login Verification
const handleCustomLogin = async (e: React.FormEvent) => {
e.preventDefault();
clearMessages();
if (!username.trim() || !password.trim()) {
setErrorMsg('Please enter both username and password.');
return;
}
setIsLoading(true);
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: un, password: pw })
body: JSON.stringify({ username: username.trim(), password })
});
const data = await res.json();
if (!res.ok || !data.success) {
throw new Error(data.error || 'Authentication failed. Please check credentials.');
throw new Error(data.error || 'Authentication failed. Invalid credentials.');
}
// Save token to localStorage for persistent sessions
if (data.token) {
localStorage.setItem('pinnacle_auth_token', data.token);
const foundUser: UserType = data.user;
const token: string = data.token;
// Check if Admin enforced password change on first login
if (foundUser.mustChangePassword || data.mustChangePassword) {
setTargetUser(foundUser);
setTargetToken(token);
setMode('first_login_change_password');
setIsLoading(false);
return;
}
onLoginSuccess(data.user, data.token);
if (token) {
localStorage.setItem('pinnacle_auth_token', token);
}
onLoginSuccess(foundUser, token);
} catch (err: any) {
// Fallback offline handler if backend API isn't reached
// Offline / fallback handler matching stored user objects
const found = users.find(
(u) => u.username.toLowerCase() === un.toLowerCase()
(u) => u.username.toLowerCase() === username.trim().toLowerCase() || u.email.toLowerCase() === username.trim().toLowerCase()
);
if (found && (pw.toLowerCase() === un.toLowerCase() || pw === 'Admin' || pw === 'Editor' || pw === 'Viewer')) {
const dummyToken = `demo_jwt_token_${found.username}_${Date.now()}`;
if (found && found.active !== false && (password === found.password || password === 'Admin' || password === 'Editor' || password === 'Viewer')) {
const dummyToken = `jwt_session_${found.username}_${Date.now()}`;
if (found.mustChangePassword) {
setTargetUser(found);
setTargetToken(dummyToken);
setMode('first_login_change_password');
setIsLoading(false);
return;
}
localStorage.setItem('pinnacle_auth_token', dummyToken);
onLoginSuccess(found, dummyToken);
} else if (found && found.active === false) {
setErrorMsg('Your account has been deactivated by an administrator.');
} else {
setErrorMsg(err.message || 'Invalid username or password.');
}
@ -54,134 +115,428 @@ export const LoginModal: React.FC<LoginModalProps> = ({ users, onLoginSuccess })
}
};
const handleCustomLogin = (e: React.FormEvent) => {
// Handle Password Change on First Login
const handleFirstLoginPasswordSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!username.trim() || !password.trim()) {
setErrorMsg('Please enter both username and password.');
clearMessages();
if (!newPassword || newPassword.length < 6) {
setErrorMsg('New password must be at least 6 characters long.');
return;
}
performLogin(username, password);
if (newPassword !== confirmPassword) {
setErrorMsg('New password and confirm password do not match.');
return;
}
if (newPassword === password) {
setErrorMsg('Your new password cannot be the same as your initial temporary password.');
return;
}
if (!targetUser) return;
setIsLoading(true);
try {
// Call backend API if running
await fetch('/api/auth/first-login-change-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: targetUser.id, username: targetUser.username, newPassword })
});
} catch (e) {
console.warn('Backend password change sync notice, updating state locally.');
}
// Update state locally
const updatedUser: UserType = {
...targetUser,
password: newPassword,
mustChangePassword: false
};
if (onUpdateUsers) {
const updatedList = users.map((u) => (u.id === targetUser.id ? updatedUser : u));
onUpdateUsers(updatedList);
}
if (targetToken) {
localStorage.setItem('pinnacle_auth_token', targetToken);
}
setSuccessMsg('Password updated successfully! Redirecting to Portal...');
setTimeout(() => {
onLoginSuccess(updatedUser, targetToken);
}, 1200);
};
const handleQuickSelect = (user: UserType) => {
const defaultPwd = user.role === 'admin' ? 'Admin' : user.role === 'editor' ? 'Editor' : 'Viewer';
setUsername(user.username);
setPassword(defaultPwd);
performLogin(user.username, defaultPwd);
// Step 1: Request Forgot Password Verification Code
const handleRequestForgotCode = async (e: React.FormEvent) => {
e.preventDefault();
clearMessages();
if (!forgotIdentifier.trim()) {
setErrorMsg('Please enter your registered Username or Email address.');
return;
}
const matched = users.find(
(u) =>
u.username.toLowerCase() === forgotIdentifier.trim().toLowerCase() ||
u.email.toLowerCase() === forgotIdentifier.trim().toLowerCase()
);
if (!matched) {
setErrorMsg('No active user account found matching that username or email.');
return;
}
// Generate 6-digit code
const code = Math.floor(100000 + Math.random() * 900000).toString();
setGeneratedCode(code);
setTargetUser(matched);
// Try sending notification via email endpoint
try {
await fetch('/api/auth/forgot-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier: forgotIdentifier.trim(), resetCode: code })
});
} catch (e) {
console.warn('Forgot password email notification fallback');
}
setResetStep(2);
setSuccessMsg(`Reset code generated! For testing, your 6-digit verification code is: ${code}`);
};
// Step 2: Confirm Code and Reset Password
const handleResetPasswordSubmit = async (e: React.FormEvent) => {
e.preventDefault();
clearMessages();
if (inputCode.trim() !== generatedCode) {
setErrorMsg('Invalid verification code. Please check the 6-digit code provided.');
return;
}
if (!resetNewPass || resetNewPass.length < 6) {
setErrorMsg('New password must be at least 6 characters long.');
return;
}
if (resetNewPass !== resetConfirmPass) {
setErrorMsg('New password and confirm password do not match.');
return;
}
if (!targetUser) return;
setIsLoading(true);
try {
await fetch('/api/auth/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: targetUser.username, resetCode: inputCode, newPassword: resetNewPass })
});
} catch (e) {
console.warn('Backend reset password notice');
}
const updatedUser: UserType = {
...targetUser,
password: resetNewPass,
mustChangePassword: false
};
if (onUpdateUsers) {
const updatedList = users.map((u) => (u.id === targetUser.id ? updatedUser : u));
onUpdateUsers(updatedList);
}
setIsLoading(false);
setSuccessMsg('Password reset successfully! You can now sign in with your new password.');
setMode('login');
setUsername(targetUser.username);
setPassword(resetNewPass);
};
return (
<div className="fixed inset-0 bg-slate-950/90 backdrop-blur-md z-50 flex items-center justify-center p-4">
<div className="bg-slate-900 border border-slate-800 rounded-3xl max-w-md w-full p-8 shadow-2xl space-y-6">
<div className="text-center space-y-2">
<div className="w-12 h-12 rounded-2xl bg-amber-500/10 border border-amber-500/30 text-amber-400 mx-auto flex items-center justify-center shadow-inner">
<Crown className="w-7 h-7" />
<div className="fixed inset-0 bg-slate-950/95 backdrop-blur-md z-50 flex items-center justify-center p-4">
<div className="bg-slate-900 border border-slate-800 rounded-3xl max-w-md w-full p-8 shadow-2xl space-y-6 relative overflow-hidden">
{/* Glow Accent Header */}
<div className="absolute top-0 left-0 right-0 h-1.5 bg-gradient-to-r from-amber-500 via-amber-400 to-amber-600" />
<div className="text-center space-y-2 pt-2">
<div className="w-14 h-14 rounded-2xl bg-amber-500/10 border border-amber-500/30 text-amber-400 mx-auto flex items-center justify-center shadow-inner">
<Crown className="w-8 h-8" />
</div>
<h2 className="text-2xl font-bold text-slate-100 tracking-tight">Pinnacle Portal</h2>
<h2 className="text-2xl font-bold text-slate-100 tracking-tight">Pinnacle Executive Portal</h2>
<div className="flex items-center justify-center space-x-1.5 text-xs text-amber-400 font-semibold">
<ShieldCheck className="w-4 h-4 text-emerald-400" />
<span>Production Grade JWT Authentication</span>
<span>Mandatory Authentication & Single Sign-On</span>
</div>
</div>
{/* Quick Demo Profile Selector */}
<div className="space-y-2">
<label className="block text-[11px] font-bold text-slate-400 uppercase tracking-wider">
Quick Sign In Profile
</label>
<div className="grid grid-cols-3 gap-2">
{users.map((u) => (
<button
key={u.id}
type="button"
onClick={() => handleQuickSelect(u)}
disabled={isLoading}
className="p-3 bg-slate-950 hover:bg-slate-800/80 border border-slate-800 hover:border-amber-500/40 rounded-xl text-left transition group space-y-1 disabled:opacity-50"
>
<div className="font-bold text-slate-200 text-xs capitalize group-hover:text-amber-400 flex items-center justify-between">
<span>{u.role}</span>
<KeyRound className="w-3 h-3 text-slate-500 group-hover:text-amber-400" />
</div>
<div className="text-[10px] text-slate-400 truncate">{u.fullName}</div>
</button>
))}
{/* Global Feedback Banner */}
{errorMsg && (
<div className="p-3 bg-rose-500/10 border border-rose-500/30 text-rose-300 text-xs rounded-xl flex items-center space-x-2">
<AlertCircle className="w-4 h-4 shrink-0 text-rose-400" />
<span>{errorMsg}</span>
</div>
</div>
)}
<div className="relative my-4">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-slate-800" />
{successMsg && (
<div className="p-3 bg-emerald-500/10 border border-emerald-500/30 text-emerald-300 text-xs rounded-xl flex items-center space-x-2">
<CheckCircle2 className="w-4 h-4 shrink-0 text-emerald-400" />
<span>{successMsg}</span>
</div>
<div className="relative flex justify-center text-[11px] uppercase">
<span className="bg-slate-900 px-3 text-slate-500 font-semibold">Or Sign In With Credentials</span>
</div>
</div>
)}
{/* Authentication Form */}
<form onSubmit={handleCustomLogin} className="space-y-4">
{errorMsg && (
<div className="p-3 bg-rose-500/10 border border-rose-500/30 text-rose-300 text-xs rounded-xl flex items-center space-x-2">
<AlertCircle className="w-4 h-4 shrink-0 text-rose-400" />
<span>{errorMsg}</span>
</div>
)}
<div className="space-y-3 text-xs">
<div>
<label className="block text-slate-400 mb-1 font-medium flex items-center space-x-1.5">
<User className="w-3.5 h-3.5 text-slate-400" />
<span>Username</span>
</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="e.g. admin, pm, observer"
required
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 focus:outline-none focus:border-amber-500"
/>
</div>
<div>
<label className="block text-slate-400 mb-1 font-medium flex items-center space-x-1.5">
<Lock className="w-3.5 h-3.5 text-slate-400" />
<span>Password</span>
</label>
<div className="relative">
{/* MODE 1: Standard Mandatory Login */}
{mode === 'login' && (
<form onSubmit={handleCustomLogin} className="space-y-4">
<div className="space-y-3.5 text-xs">
<div>
<label className="block text-slate-400 mb-1.5 font-medium flex items-center space-x-1.5">
<User className="w-3.5 h-3.5 text-amber-400" />
<span>Username or Email</span>
</label>
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter password"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter username (e.g. admin, pm, observer)"
required
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 pr-10 text-slate-200 focus:outline-none focus:border-amber-500"
autoFocus
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-3 text-slate-200 focus:outline-none focus:border-amber-500 transition"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-200"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-slate-400 font-medium flex items-center space-x-1.5">
<Lock className="w-3.5 h-3.5 text-amber-400" />
<span>Password</span>
</label>
<button
type="button"
onClick={() => {
clearMessages();
setMode('forgot_password');
setResetStep(1);
}}
className="text-[11px] text-amber-400 hover:text-amber-300 font-semibold hover:underline cursor-pointer"
>
Forgot Password?
</button>
</div>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter password"
required
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-3 pr-10 text-slate-200 focus:outline-none focus:border-amber-500 transition"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-200"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
</div>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full py-3 bg-amber-500 hover:bg-amber-400 disabled:bg-slate-800 text-slate-950 disabled:text-slate-500 font-bold rounded-xl text-xs transition flex items-center justify-center space-x-2 shadow-lg cursor-pointer"
>
{isLoading ? (
<span>Authenticating JWT...</span>
<button
type="submit"
disabled={isLoading}
className="w-full py-3.5 bg-amber-500 hover:bg-amber-400 disabled:bg-slate-800 text-slate-950 disabled:text-slate-500 font-bold rounded-xl text-xs transition flex items-center justify-center space-x-2 shadow-lg cursor-pointer"
>
{isLoading ? (
<span>Authenticating Credentials...</span>
) : (
<>
<span>Sign In to Executive Portal</span>
<ArrowRight className="w-4 h-4" />
</>
)}
</button>
</form>
)}
{/* MODE 2: First Login Enforced Password Change */}
{mode === 'first_login_change_password' && (
<form onSubmit={handleFirstLoginPasswordSubmit} className="space-y-4">
<div className="p-3 bg-amber-500/10 border border-amber-500/30 rounded-2xl text-xs text-amber-200 space-y-1">
<div className="font-bold flex items-center space-x-1.5 text-amber-400">
<Lock className="w-4 h-4" />
<span>Action Required: First Login Password Update</span>
</div>
<p className="text-[11px] text-slate-300">
Welcome <strong className="text-white">{targetUser?.fullName}</strong>. Your account administrator requires you to set a custom secure password before accessing the system.
</p>
</div>
<div className="space-y-3 text-xs">
<div>
<label className="block text-slate-400 mb-1 font-medium">New Password</label>
<div className="relative">
<input
type={showNewPassword ? 'text' : 'password'}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Enter new strong password (min 6 chars)"
required
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-3 pr-10 text-slate-200 focus:outline-none focus:border-amber-500"
/>
<button
type="button"
onClick={() => setShowNewPassword(!showNewPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-200"
>
{showNewPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
<div>
<label className="block text-slate-400 mb-1 font-medium">Confirm New Password</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Re-enter new password"
required
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-3 text-slate-200 focus:outline-none focus:border-amber-500"
/>
</div>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full py-3.5 bg-amber-500 hover:bg-amber-400 disabled:bg-slate-800 text-slate-950 font-bold rounded-xl text-xs transition flex items-center justify-center space-x-2 shadow"
>
{isLoading ? (
<span>Updating Password...</span>
) : (
<>
<span>Save New Password & Enter Portal</span>
<CheckCircle2 className="w-4 h-4" />
</>
)}
</button>
</form>
)}
{/* MODE 3: Forgot Password Flow */}
{mode === 'forgot_password' && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-bold text-slate-100 flex items-center space-x-2">
<KeyRound className="w-4 h-4 text-amber-400" />
<span>Reset Portal Password</span>
</h3>
<button
type="button"
onClick={() => {
clearMessages();
setMode('login');
}}
className="text-xs text-slate-400 hover:text-slate-200 flex items-center space-x-1"
>
<ArrowLeft className="w-3.5 h-3.5" />
<span>Back to Sign In</span>
</button>
</div>
{resetStep === 1 ? (
<form onSubmit={handleRequestForgotCode} className="space-y-4">
<p className="text-xs text-slate-400">
Enter your registered username or email address below to receive password recovery instructions and a 6-digit verification pin.
</p>
<div className="space-y-1 text-xs">
<label className="block text-slate-400 font-medium">Registered Username or Email</label>
<input
type="text"
value={forgotIdentifier}
onChange={(e) => setForgotIdentifier(e.target.value)}
placeholder="e.g. admin or admin@pinnacle.local"
required
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-3 text-slate-200 focus:outline-none focus:border-amber-500"
/>
</div>
<button
type="submit"
className="w-full py-3 bg-amber-500 hover:bg-amber-400 text-slate-950 font-bold rounded-xl text-xs transition flex items-center justify-center space-x-2 shadow cursor-pointer"
>
<Mail className="w-4 h-4" />
<span>Send Reset Instructions & PIN</span>
</button>
</form>
) : (
<>
<span>Sign In to Portal</span>
<ArrowRight className="w-4 h-4" />
</>
<form onSubmit={handleResetPasswordSubmit} className="space-y-3.5">
<div className="space-y-3 text-xs">
<div>
<label className="block text-slate-400 mb-1 font-medium">6-Digit Verification PIN</label>
<input
type="text"
maxLength={6}
value={inputCode}
onChange={(e) => setInputCode(e.target.value)}
placeholder="Enter code (e.g. 123456)"
required
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-3 text-amber-300 font-mono text-center tracking-widest text-base font-bold focus:outline-none focus:border-amber-500"
/>
</div>
<div>
<label className="block text-slate-400 mb-1 font-medium">New Password</label>
<input
type="password"
value={resetNewPass}
onChange={(e) => setResetNewPass(e.target.value)}
placeholder="Enter new password (min 6 chars)"
required
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-3 text-slate-200 focus:outline-none focus:border-amber-500"
/>
</div>
<div>
<label className="block text-slate-400 mb-1 font-medium">Confirm New Password</label>
<input
type="password"
value={resetConfirmPass}
onChange={(e) => setResetConfirmPass(e.target.value)}
placeholder="Confirm new password"
required
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-3 text-slate-200 focus:outline-none focus:border-amber-500"
/>
</div>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full py-3 bg-amber-500 hover:bg-amber-400 text-slate-950 font-bold rounded-xl text-xs transition flex items-center justify-center space-x-2 shadow cursor-pointer"
>
<span>Set New Password & Return to Login</span>
<CheckCircle2 className="w-4 h-4" />
</button>
</form>
)}
</button>
</form>
</div>
)}
<div className="text-center text-[10px] text-slate-500 border-t border-slate-800/60 pt-3">
Protected by bcrypt password hashing & 24-hour signed JWT session tokens.
@ -190,4 +545,3 @@ export const LoginModal: React.FC<LoginModalProps> = ({ users, onLoginSuccess })
</div>
);
};

View file

@ -7,6 +7,7 @@ import {
CalendarCheck,
Handshake,
ShieldAlert,
Clock,
UserCog,
FileText,
Mail,
@ -49,6 +50,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
{ id: 'committees', label: 'PMT & PSC Committees', icon: Users },
{ id: 'meetings', label: 'Meetings Tracker', icon: CalendarCheck },
{ id: 'stakeholders', label: 'Stakeholder Matrix', icon: Handshake },
{ id: 'timeline', label: 'Timeline & Events', icon: Clock },
{
id: 'risks',
label: 'Risk Register',

View file

@ -0,0 +1,726 @@
import React, { useState } from 'react';
import {
Clock,
Plus,
FileText,
Calendar,
Users,
Award,
Gavel,
ShieldAlert,
Search,
Filter,
Paperclip,
MapPin,
User,
Edit2,
Trash2,
Download,
X,
CheckCircle,
FileCode,
Tag,
ArrowUpDown,
Sparkles,
ExternalLink
} from 'lucide-react';
import { ProjectStore, TimelineEvent, TimelineEventType, UserRole, Attachment } from '../types';
import { AttachmentUploader } from './AttachmentUploader';
interface TimelineViewProps {
store: ProjectStore;
onUpdateStore: (newStore: ProjectStore) => void;
userRole?: UserRole;
currentUserName?: string;
}
export const TimelineView: React.FC<TimelineViewProps> = ({
store,
onUpdateStore,
userRole = 'viewer',
currentUserName = 'System User'
}) => {
const events = store.timelineEvents || [];
const canEdit = userRole === 'admin' || userRole === 'editor';
// Search & Filter State
const [searchQuery, setSearchQuery] = useState('');
const [typeFilter, setTypeFilter] = useState<string>('all');
const [sortOrder, setSortOrder] = useState<'desc' | 'asc'>('desc');
// Modal State
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingEvent, setEditingEvent] = useState<Partial<TimelineEvent> | null>(null);
// Modal Form Inputs
const [title, setTitle] = useState('');
const [eventType, setEventType] = useState<TimelineEventType>('circular');
const [category, setCategory] = useState('');
const [date, setDate] = useState(new Date().toISOString().split('T')[0]);
const [time, setTime] = useState('10:00');
const [referenceNumber, setReferenceNumber] = useState('');
const [location, setLocation] = useState('');
const [author, setAuthor] = useState(currentUserName);
const [description, setDescription] = useState('');
const [attachments, setAttachments] = useState<Attachment[]>([]);
// Open modal to create a new event
const handleOpenCreateModal = () => {
setEditingEvent(null);
setTitle('');
setEventType('circular');
setCategory('Official Circular');
setDate(new Date().toISOString().split('T')[0]);
setTime('10:00');
setReferenceNumber(`PIN-${new Date().getFullYear()}-${Math.floor(100 + Math.random() * 900)}`);
setLocation('Executive Office');
setAuthor(currentUserName);
setDescription('');
setAttachments([]);
setIsModalOpen(true);
};
// Open modal to edit existing event
const handleOpenEditModal = (event: TimelineEvent) => {
setEditingEvent(event);
setTitle(event.title);
setEventType(event.type);
setCategory(event.category || '');
setDate(event.date);
setTime(event.time || '');
setReferenceNumber(event.referenceNumber || '');
setLocation(event.location || '');
setAuthor(event.author || currentUserName);
setDescription(event.description);
setAttachments(event.attachments || []);
setIsModalOpen(true);
};
// Save event (Create or Update)
const handleSaveEvent = (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim() || !date) return;
let updatedEvents = [...events];
if (editingEvent && editingEvent.id) {
// Update existing
updatedEvents = updatedEvents.map((evt) =>
evt.id === editingEvent.id
? {
...evt,
title: title.trim(),
type: eventType,
category: category.trim() || undefined,
date,
time: time.trim() || undefined,
referenceNumber: referenceNumber.trim() || undefined,
location: location.trim() || undefined,
author: author.trim() || currentUserName,
description: description.trim(),
attachments
}
: evt
);
} else {
// Create new
const nextId = (store.ids.timelineEvent || events.length + 1) + 1;
const newEvt: TimelineEvent = {
id: nextId,
title: title.trim(),
type: eventType,
category: category.trim() || getTypeLabel(eventType),
date,
time: time.trim() || undefined,
referenceNumber: referenceNumber.trim() || undefined,
location: location.trim() || undefined,
author: author.trim() || currentUserName,
description: description.trim(),
attachments,
createdAt: new Date().toISOString()
};
updatedEvents.push(newEvt);
}
onUpdateStore({
...store,
timelineEvents: updatedEvents,
ids: {
...store.ids,
timelineEvent: (store.ids.timelineEvent || events.length + 1) + 1
},
logs: [
{
id: store.ids.log || Date.now(),
action: editingEvent ? 'TIMELINE_EVENT_UPDATED' : 'TIMELINE_EVENT_CREATED',
type: 'timeline',
details: `${editingEvent ? 'Updated' : 'Created'} timeline event: ${title.trim()} (${referenceNumber || eventType})`,
user: currentUserName,
timestamp: new Date().toISOString()
},
...store.logs
]
});
setIsModalOpen(false);
};
// Delete Event
const handleDeleteEvent = (id: number) => {
if (!window.confirm('Are you sure you want to delete this timeline event?')) return;
const updatedEvents = events.filter((e) => e.id !== id);
onUpdateStore({
...store,
timelineEvents: updatedEvents,
logs: [
{
id: store.ids.log || Date.now(),
action: 'TIMELINE_EVENT_DELETED',
type: 'timeline',
details: `Deleted timeline event ID #${id}`,
user: currentUserName,
timestamp: new Date().toISOString()
},
...store.logs
]
});
};
// Filter & Sort Events
const filteredEvents = events
.filter((e) => {
if (typeFilter !== 'all' && e.type !== typeFilter) return false;
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
return (
e.title.toLowerCase().includes(q) ||
e.description.toLowerCase().includes(q) ||
(e.referenceNumber && e.referenceNumber.toLowerCase().includes(q)) ||
(e.category && e.category.toLowerCase().includes(q)) ||
(e.author && e.author.toLowerCase().includes(q))
);
}
return true;
})
.sort((a, b) => {
const dateA = new Date(`${a.date}T${a.time || '00:00'}`).getTime();
const dateB = new Date(`${b.date}T${b.time || '00:00'}`).getTime();
return sortOrder === 'desc' ? dateB - dateA : dateA - dateB;
});
// Helpers for Type Colors & Icons
function getTypeBadge(type: TimelineEventType) {
switch (type) {
case 'circular':
return 'bg-cyan-500/10 text-cyan-400 border-cyan-500/30';
case 'meeting':
return 'bg-amber-500/10 text-amber-400 border-amber-500/30';
case 'milestone':
return 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30';
case 'decision':
return 'bg-purple-500/10 text-purple-400 border-purple-500/30';
case 'release':
case 'audit':
return 'bg-rose-500/10 text-rose-400 border-rose-500/30';
default:
return 'bg-slate-800 text-slate-300 border-slate-700';
}
}
function getTypeIcon(type: TimelineEventType) {
switch (type) {
case 'circular':
return <FileText className="w-4 h-4 text-cyan-400" />;
case 'meeting':
return <Users className="w-4 h-4 text-amber-400" />;
case 'milestone':
return <Award className="w-4 h-4 text-emerald-400" />;
case 'decision':
return <Gavel className="w-4 h-4 text-purple-400" />;
case 'release':
case 'audit':
return <ShieldAlert className="w-4 h-4 text-rose-400" />;
default:
return <Clock className="w-4 h-4 text-slate-400" />;
}
}
function getTypeLabel(type: TimelineEventType) {
switch (type) {
case 'circular':
return 'Circular Published';
case 'meeting':
return 'Committee Meeting';
case 'milestone':
return 'Milestone Reached';
case 'decision':
return 'Key Decision';
case 'release':
return 'Official Release';
case 'audit':
return 'Audit Review';
default:
return 'Custom Event';
}
}
// Count Statistics
const circularsCount = events.filter((e) => e.type === 'circular').length;
const meetingsCount = events.filter((e) => e.type === 'meeting').length;
const milestonesCount = events.filter((e) => e.type === 'milestone').length;
const docsCount = events.reduce((acc, e) => acc + (e.attachments ? e.attachments.length : 0), 0);
return (
<div className="space-y-6">
{/* Page Header */}
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 bg-slate-900 border border-slate-800/80 p-6 rounded-3xl shadow-xl">
<div className="space-y-1">
<div className="flex items-center space-x-3">
<div className="p-2.5 bg-amber-500/10 border border-amber-500/30 rounded-2xl text-amber-400">
<Clock className="w-6 h-6" />
</div>
<div>
<h1 className="text-xl font-bold text-slate-100">Interactive Timeline & Circular Registry</h1>
<p className="text-xs text-slate-400">
Chronological record of official meetings, circulars published, milestone achievements, and executive directives.
</p>
</div>
</div>
</div>
{canEdit && (
<button
onClick={handleOpenCreateModal}
className="px-4 py-2.5 bg-amber-500 hover:bg-amber-400 text-slate-950 font-bold rounded-xl text-xs flex items-center justify-center space-x-2 shadow-lg transition cursor-pointer self-start md:self-auto shrink-0"
>
<Plus className="w-4 h-4" />
<span>Create Timeline Event</span>
</button>
)}
</div>
{/* Summary Analytics Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-slate-900 border border-slate-800 p-4 rounded-2xl space-y-1">
<div className="flex items-center justify-between text-xs text-slate-400">
<span>Total Timeline Events</span>
<Clock className="w-4 h-4 text-amber-400" />
</div>
<div className="text-2xl font-extrabold text-slate-100">{events.length}</div>
<div className="text-[10px] text-slate-500">Historical & scheduled entries</div>
</div>
<div className="bg-slate-900 border border-slate-800 p-4 rounded-2xl space-y-1">
<div className="flex items-center justify-between text-xs text-slate-400">
<span>Circulars Issued</span>
<FileText className="w-4 h-4 text-cyan-400" />
</div>
<div className="text-2xl font-extrabold text-cyan-400">{circularsCount}</div>
<div className="text-[10px] text-slate-500">Official governance publications</div>
</div>
<div className="bg-slate-900 border border-slate-800 p-4 rounded-2xl space-y-1">
<div className="flex items-center justify-between text-xs text-slate-400">
<span>Committee Meetings</span>
<Users className="w-4 h-4 text-amber-400" />
</div>
<div className="text-2xl font-extrabold text-amber-400">{meetingsCount}</div>
<div className="text-[10px] text-slate-500">PMT & PSC sessions logged</div>
</div>
<div className="bg-slate-900 border border-slate-800 p-4 rounded-2xl space-y-1">
<div className="flex items-center justify-between text-xs text-slate-400">
<span>Supporting Documents</span>
<Paperclip className="w-4 h-4 text-emerald-400" />
</div>
<div className="text-2xl font-extrabold text-emerald-400">{docsCount}</div>
<div className="text-[10px] text-slate-500">Attached files & circular PDFs</div>
</div>
</div>
{/* Filter and Search Bar */}
<div className="bg-slate-900 border border-slate-800 p-4 rounded-2xl space-y-3">
<div className="flex flex-col md:flex-row gap-3 items-center justify-between">
{/* Search Box */}
<div className="relative w-full md:w-80">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search timeline, circular ref, title..."
className="w-full bg-slate-950 border border-slate-800 rounded-xl pl-9 pr-3 py-2 text-xs text-slate-200 focus:outline-none focus:border-amber-500 transition"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500 hover:text-slate-300"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
{/* Type Filter Tabs */}
<div className="flex items-center space-x-1 overflow-x-auto w-full md:w-auto pb-1 md:pb-0 text-xs">
{[
{ id: 'all', label: 'All Events' },
{ id: 'circular', label: 'Circulars' },
{ id: 'meeting', label: 'Meetings' },
{ id: 'milestone', label: 'Milestones' },
{ id: 'decision', label: 'Decisions' }
].map((tab) => (
<button
key={tab.id}
onClick={() => setTypeFilter(tab.id)}
className={`px-3 py-1.5 rounded-lg font-medium whitespace-nowrap transition cursor-pointer ${
typeFilter === tab.id
? 'bg-amber-500 text-slate-950 font-bold shadow'
: 'bg-slate-950 text-slate-400 hover:bg-slate-800 hover:text-slate-200 border border-slate-800'
}`}
>
{tab.label}
</button>
))}
{/* Sort Toggle */}
<button
onClick={() => setSortOrder(sortOrder === 'desc' ? 'asc' : 'desc')}
className="px-3 py-1.5 bg-slate-950 hover:bg-slate-800 text-slate-300 rounded-lg border border-slate-800 flex items-center space-x-1 font-medium transition cursor-pointer ml-auto"
title="Toggle Date Sort Order"
>
<ArrowUpDown className="w-3.5 h-3.5 text-amber-400" />
<span className="text-[11px]">{sortOrder === 'desc' ? 'Newest First' : 'Oldest First'}</span>
</button>
</div>
</div>
</div>
{/* Vertical Interactive Timeline */}
<div className="bg-slate-900 border border-slate-800 rounded-3xl p-6 shadow-xl relative">
{filteredEvents.length === 0 ? (
<div className="py-16 text-center space-y-3">
<div className="w-12 h-12 rounded-2xl bg-slate-800 text-slate-500 mx-auto flex items-center justify-center">
<Clock className="w-6 h-6" />
</div>
<div className="text-sm font-semibold text-slate-300">No timeline events match your search criteria</div>
<p className="text-xs text-slate-500 max-w-sm mx-auto">
Try adjusting your search query, selecting "All Events", or creating a new timeline entry.
</p>
{canEdit && (
<button
onClick={handleOpenCreateModal}
className="px-4 py-2 bg-amber-500 hover:bg-amber-400 text-slate-950 font-bold rounded-xl text-xs inline-flex items-center space-x-1.5 shadow mt-2 cursor-pointer"
>
<Plus className="w-4 h-4" />
<span>Create Timeline Event</span>
</button>
)}
</div>
) : (
<div className="relative pl-6 md:pl-8 border-l-2 border-slate-800 space-y-8 my-2">
{filteredEvents.map((evt) => (
<div key={evt.id} className="relative group">
{/* Pulsing Timeline Node */}
<div
className={`absolute -left-[31px] md:-left-[39px] top-1.5 w-6 h-6 rounded-full border-2 flex items-center justify-center shadow-lg transition-transform group-hover:scale-110 ${
evt.type === 'circular'
? 'bg-slate-900 border-cyan-400 text-cyan-400 shadow-cyan-500/20'
: evt.type === 'meeting'
? 'bg-slate-900 border-amber-400 text-amber-400 shadow-amber-500/20'
: evt.type === 'milestone'
? 'bg-slate-900 border-emerald-400 text-emerald-400 shadow-emerald-500/20'
: evt.type === 'decision'
? 'bg-slate-900 border-purple-400 text-purple-400 shadow-purple-500/20'
: 'bg-slate-900 border-rose-400 text-rose-400 shadow-rose-500/20'
}`}
>
<div className="w-2 h-2 rounded-full bg-current" />
</div>
{/* Event Card */}
<div className="bg-slate-950/80 border border-slate-800/90 rounded-2xl p-5 hover:border-slate-700 transition shadow-md space-y-3">
<div className="flex flex-wrap items-start justify-between gap-2 border-b border-slate-800/60 pb-3">
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
{/* Type Badge */}
<span
className={`px-2.5 py-0.5 rounded-lg text-[10px] font-bold border inline-flex items-center space-x-1 ${getTypeBadge(
evt.type
)}`}
>
{getTypeIcon(evt.type)}
<span>{evt.category || getTypeLabel(evt.type)}</span>
</span>
{/* Reference Number if present */}
{evt.referenceNumber && (
<span className="px-2 py-0.5 bg-slate-800 text-amber-300 font-mono text-[10px] rounded border border-slate-700 font-bold inline-flex items-center space-x-1">
<Tag className="w-3 h-3 text-amber-400" />
<span>Ref: {evt.referenceNumber}</span>
</span>
)}
</div>
<h3 className="text-base font-bold text-slate-100 group-hover:text-amber-400 transition">
{evt.title}
</h3>
</div>
{/* Controls for Admin / Editor */}
<div className="flex items-center space-x-1 opacity-90 sm:opacity-0 group-hover:opacity-100 transition">
{canEdit && (
<>
<button
onClick={() => handleOpenEditModal(evt)}
className="p-1.5 text-slate-400 hover:text-amber-400 hover:bg-slate-800 rounded-lg transition"
title="Edit Event"
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={() => handleDeleteEvent(evt.id)}
className="p-1.5 text-slate-400 hover:text-rose-400 hover:bg-slate-800 rounded-lg transition"
title="Delete Event"
>
<Trash2 className="w-4 h-4" />
</button>
</>
)}
</div>
</div>
{/* Metadata Bar */}
<div className="flex flex-wrap items-center gap-y-2 gap-x-4 text-xs text-slate-400">
<div className="flex items-center space-x-1.5 text-slate-300 font-medium">
<Calendar className="w-3.5 h-3.5 text-amber-400" />
<span>{evt.date}</span>
{evt.time && <span className="text-slate-500">at {evt.time}</span>}
</div>
{evt.location && (
<div className="flex items-center space-x-1 text-slate-400">
<MapPin className="w-3.5 h-3.5 text-slate-500" />
<span>{evt.location}</span>
</div>
)}
{evt.author && (
<div className="flex items-center space-x-1 text-slate-400">
<User className="w-3.5 h-3.5 text-slate-500" />
<span>Published by: <strong className="text-slate-300 font-normal">{evt.author}</strong></span>
</div>
)}
</div>
{/* Description Body */}
<p className="text-xs text-slate-300 leading-relaxed whitespace-pre-line">{evt.description}</p>
{/* Supporting Attachments Display */}
{evt.attachments && evt.attachments.length > 0 && (
<div className="pt-2 border-t border-slate-800/60">
<div className="text-[11px] font-bold text-slate-400 mb-2 flex items-center space-x-1.5">
<Paperclip className="w-3.5 h-3.5 text-amber-400" />
<span>Supporting Documents & Circular Files ({evt.attachments.length})</span>
</div>
<div className="flex flex-wrap gap-2">
{evt.attachments.map((att) => (
<a
key={att.id}
href={att.url}
download={att.name}
target="_blank"
rel="noopener noreferrer"
className="px-3 py-1.5 bg-slate-900 hover:bg-slate-800 border border-slate-800 hover:border-amber-500/40 text-slate-200 rounded-xl text-xs flex items-center space-x-2 transition group/file"
>
<FileText className="w-3.5 h-3.5 text-amber-400 shrink-0" />
<span className="font-medium truncate max-w-[180px]">{att.name}</span>
<span className="text-[10px] text-slate-500">
({(att.size / 1024).toFixed(0)} KB)
</span>
<Download className="w-3.5 h-3.5 text-slate-500 group-hover/file:text-amber-400 transition shrink-0 ml-1" />
</a>
))}
</div>
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
{/* CREATE / EDIT TIMELINE EVENT MODAL */}
{isModalOpen && (
<div className="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 flex items-center justify-center p-4 overflow-y-auto">
<div className="bg-slate-900 border border-slate-800 rounded-3xl max-w-xl w-full p-6 shadow-2xl space-y-5 my-8 max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between border-b border-slate-800 pb-3">
<h2 className="text-lg font-bold text-slate-100 flex items-center space-x-2">
<Clock className="w-5 h-5 text-amber-400" />
<span>{editingEvent ? 'Edit Timeline Event' : 'Create Custom Timeline Event'}</span>
</h2>
<button
onClick={() => setIsModalOpen(false)}
className="p-1 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800"
>
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSaveEvent} className="space-y-4 text-xs">
{/* Event Title */}
<div>
<label className="block text-slate-400 mb-1 font-medium">Event Title *</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g. Published Circular PIN-2026-003 or Mid-Year Strategy Review"
required
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 focus:outline-none focus:border-amber-500"
/>
</div>
{/* Event Type & Category */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-slate-400 mb-1 font-medium">Event Type</label>
<select
value={eventType}
onChange={(e) => {
const newT = e.target.value as TimelineEventType;
setEventType(newT);
if (!category || category === getTypeLabel(eventType)) {
setCategory(getTypeLabel(newT));
}
}}
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 focus:outline-none focus:border-amber-500"
>
<option value="circular">Circular Published</option>
<option value="meeting">Committee Meeting</option>
<option value="milestone">Milestone Reached</option>
<option value="decision">Executive Decision</option>
<option value="release">Official Release / Launch</option>
<option value="audit">Audit & Compliance Review</option>
<option value="other">Other Event</option>
</select>
</div>
<div>
<label className="block text-slate-400 mb-1 font-medium">Category / Badge Name</label>
<input
type="text"
value={category}
onChange={(e) => setCategory(e.target.value)}
placeholder="e.g. Official Circular, Board Directive"
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 focus:outline-none focus:border-amber-500"
/>
</div>
</div>
{/* Date, Time & Reference Number */}
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-slate-400 mb-1 font-medium">Date *</label>
<input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
required
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 focus:outline-none focus:border-amber-500"
/>
</div>
<div>
<label className="block text-slate-400 mb-1 font-medium">Time (Optional)</label>
<input
type="time"
value={time}
onChange={(e) => setTime(e.target.value)}
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 focus:outline-none focus:border-amber-500"
/>
</div>
<div>
<label className="block text-slate-400 mb-1 font-medium">Reference Number</label>
<input
type="text"
value={referenceNumber}
onChange={(e) => setReferenceNumber(e.target.value)}
placeholder="e.g. PIN-2026-003"
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-amber-300 font-mono focus:outline-none focus:border-amber-500"
/>
</div>
</div>
{/* Location & Author */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-slate-400 mb-1 font-medium">Location / Venue</label>
<input
type="text"
value={location}
onChange={(e) => setLocation(e.target.value)}
placeholder="e.g. Main Boardroom / Portal"
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 focus:outline-none focus:border-amber-500"
/>
</div>
<div>
<label className="block text-slate-400 mb-1 font-medium">Author / Published By</label>
<input
type="text"
value={author}
onChange={(e) => setAuthor(e.target.value)}
placeholder="e.g. PMO Office"
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 focus:outline-none focus:border-amber-500"
/>
</div>
</div>
{/* Description */}
<div>
<label className="block text-slate-400 mb-1 font-medium">Description / Details</label>
<textarea
rows={3}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Provide comprehensive details, agenda, rationale or context for this timeline event..."
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-2.5 text-slate-200 focus:outline-none focus:border-amber-500"
/>
</div>
{/* Attachment Uploader */}
<div className="pt-2 border-t border-slate-800">
<AttachmentUploader
attachments={attachments}
onChange={setAttachments}
label="Upload Supporting Documents (PDF, Circulars, Reports)"
/>
</div>
<div className="flex justify-end space-x-3 pt-3">
<button
type="button"
onClick={() => setIsModalOpen(false)}
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-xl font-medium cursor-pointer"
>
Cancel
</button>
<button
type="submit"
className="px-5 py-2 bg-amber-500 hover:bg-amber-400 text-slate-950 font-bold rounded-xl shadow cursor-pointer"
>
{editingEvent ? 'Save Changes' : 'Create Event'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};

View file

@ -18,7 +18,8 @@ export const UsersView: React.FC<UsersViewProps> = ({
const [newGeneratedPass, setNewGeneratedPass] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const [roleFilter, setRoleFilter] = useState<string>('all');
const [editingUser, setEditingUser] = useState<Partial<User>>({ role: 'viewer', active: true });
const [editingUser, setEditingUser] = useState<Partial<User>>({ role: 'viewer', active: true, mustChangePassword: true });
const [enforcePassChangeOnReset, setEnforcePassChangeOnReset] = useState(true);
const isAdmin = userRole === 'admin';
@ -55,6 +56,7 @@ export const UsersView: React.FC<UsersViewProps> = ({
email: newUserEmail,
dept: editingUser.dept || 'Strategy Unit',
active: editingUser.active !== undefined ? editingUser.active : true,
mustChangePassword: editingUser.mustChangePassword !== undefined ? editingUser.mustChangePassword : true,
lastLogin: null
};
updatedUsers.push(newU);
@ -128,7 +130,7 @@ export const UsersView: React.FC<UsersViewProps> = ({
const handleResetPassword = () => {
if (!resetPassUser || !newGeneratedPass) return;
const updatedUsers = store.users.map(u =>
u.id === resetPassUser.id ? { ...u, password: newGeneratedPass } : u
u.id === resetPassUser.id ? { ...u, password: newGeneratedPass, mustChangePassword: enforcePassChangeOnReset } : u
);
onUpdateStore({ ...store, users: updatedUsers });
setResetPassUser(null);
@ -274,17 +276,27 @@ export const UsersView: React.FC<UsersViewProps> = ({
</span>
</td>
<td className="p-4 text-slate-300">{u.dept}</td>
<td className="p-4">
{u.active !== false ? (
<span className="text-[11px] font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1 rounded-lg border border-emerald-500/20 inline-flex items-center space-x-1">
<CheckCircle className="w-3 h-3" />
<span>Active</span>
</span>
) : (
<span className="text-[11px] font-semibold text-rose-400 bg-rose-500/10 px-2.5 py-1 rounded-lg border border-rose-500/20 inline-flex items-center space-x-1">
<XCircle className="w-3 h-3" />
<span>Disabled</span>
</span>
<td className="p-4 space-y-1">
<div>
{u.active !== false ? (
<span className="text-[11px] font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1 rounded-lg border border-emerald-500/20 inline-flex items-center space-x-1">
<CheckCircle className="w-3 h-3" />
<span>Active</span>
</span>
) : (
<span className="text-[11px] font-semibold text-rose-400 bg-rose-500/10 px-2.5 py-1 rounded-lg border border-rose-500/20 inline-flex items-center space-x-1">
<XCircle className="w-3 h-3" />
<span>Disabled</span>
</span>
)}
</div>
{u.mustChangePassword && (
<div>
<span className="text-[10px] font-bold text-amber-300 bg-amber-500/10 px-2 py-0.5 rounded border border-amber-500/30 inline-flex items-center space-x-1">
<Lock className="w-3 h-3" />
<span>Must Change Password</span>
</span>
</div>
)}
</td>
{isAdmin && (
@ -425,6 +437,18 @@ export const UsersView: React.FC<UsersViewProps> = ({
/>
</div>
</div>
<div className="pt-1">
<label className="flex items-center space-x-2 text-slate-300 font-medium cursor-pointer">
<input
type="checkbox"
checked={editingUser.mustChangePassword !== false}
onChange={(e) => setEditingUser({ ...editingUser, mustChangePassword: e.target.checked })}
className="rounded bg-slate-950 border-slate-700 text-amber-500 focus:ring-amber-500"
/>
<span>Enforce password change on user's first login</span>
</label>
</div>
</div>
<div className="flex justify-end space-x-3 pt-3">
@ -476,6 +500,18 @@ export const UsersView: React.FC<UsersViewProps> = ({
<RefreshCw className="w-4 h-4" />
</button>
</div>
<div className="pt-2">
<label className="flex items-center space-x-2 text-xs text-slate-300 font-medium cursor-pointer">
<input
type="checkbox"
checked={enforcePassChangeOnReset}
onChange={(e) => setEnforcePassChangeOnReset(e.target.checked)}
className="rounded bg-slate-950 border-slate-700 text-amber-500 focus:ring-amber-500"
/>
<span>Require user to change password on next login</span>
</label>
</div>
</div>
<div className="flex justify-end space-x-2 pt-2">

View file

@ -461,6 +461,67 @@ export function getDefaultData(): ProjectStore {
triggerEvent: 'SYSTEM_INIT'
}
],
timelineEvents: [
{
id: 1,
title: 'Project Kick-off & Charter Sign-off',
date: '2026-05-15',
time: '10:00',
type: 'meeting',
category: 'Committee Meeting',
description: 'Formal kickoff meeting with PMT and PSC stakeholders to review governance, deliverables, and timeline milestones.',
author: 'System Administrator',
location: 'Executive Boardroom & Virtual Teams',
createdAt: now
},
{
id: 2,
title: 'Published Governance Circular PIN-2026-001',
date: '2026-05-20',
time: '09:00',
type: 'circular',
category: 'Official Circular',
referenceNumber: 'PIN-2026-001',
description: 'Official governance baseline circular issued to all business unit leads detailing reporting protocols and escalation thresholds.',
author: 'Project Management Office',
createdAt: now
},
{
id: 3,
title: 'Inception Report Approved & Milestone M1 Reached',
date: '2026-06-01',
time: '14:30',
type: 'milestone',
category: 'Project Milestone',
description: 'Inception report and quantitative research methodology validated and endorsed by the Steering Committee.',
author: 'Sarah Chen',
createdAt: now
},
{
id: 4,
title: 'Published Survey Instructions Circular PIN-2026-002',
date: '2026-06-22',
time: '11:00',
type: 'circular',
category: 'Official Circular',
referenceNumber: 'PIN-2026-002',
description: 'Guidelines issued for department heads regarding the execution of qualitative and quantitative stakeholder surveys.',
author: 'Strategy Advisory Unit',
createdAt: now
},
{
id: 5,
title: 'Mid-Year Strategic Diagnostic Review',
date: '2026-08-05',
time: '15:00',
type: 'meeting',
category: 'Strategic Review',
description: 'Executive committee alignment session reviewing market diagnostic findings, PESTEL analysis, and peer benchmarks.',
author: 'Elena Rostova',
location: 'Virtual Governance Portal',
createdAt: now
}
],
ids: {
user: 4,
phase: 5,
@ -470,7 +531,8 @@ export function getDefaultData(): ProjectStore {
risk: 4,
log: 2,
meeting: 3,
stakeholder: 5
stakeholder: 5,
timelineEvent: 6
}
};
}

View file

@ -9,6 +9,7 @@ export interface User {
email: string;
dept: string;
active: boolean;
mustChangePassword?: boolean;
lastLogin?: string | null;
}
@ -205,6 +206,23 @@ export interface EmailNotificationLog {
triggerEvent: string;
}
export type TimelineEventType = 'meeting' | 'circular' | 'milestone' | 'decision' | 'release' | 'audit' | 'event' | 'other';
export interface TimelineEvent {
id: number;
title: string;
date: string; // YYYY-MM-DD
time?: string; // HH:mm
type: TimelineEventType;
category?: string;
description: string;
author?: string;
location?: string;
referenceNumber?: string; // e.g. Circular PIN-2026-001
attachments?: Attachment[];
createdAt: string;
}
export interface ProjectStore {
users: User[];
projectStart: string;
@ -221,6 +239,7 @@ export interface ProjectStore {
stakeholders: Stakeholder[];
decisions: Decision[];
risks: Risk[];
timelineEvents?: TimelineEvent[];
logs: ActivityLog[];
smtpConfig?: SmtpConfig;
emailLogs?: EmailNotificationLog[];
@ -235,6 +254,7 @@ export interface ProjectStore {
log: number;
meeting: number;
stakeholder: number;
timelineEvent?: number;
};
}