Initial Commit
This commit is contained in:
parent
d7eccee7b8
commit
5b2eafcbee
9 changed files with 1431 additions and 135 deletions
80
server.ts
80
server.ts
|
|
@ -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',
|
||||
|
|
|
|||
16
src/App.tsx
16
src/App.tsx
|
|
@ -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
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 =>
|
||||
|
|
|
|||
|
|
@ -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,71 +115,174 @@ 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
|
||||
};
|
||||
|
||||
const handleQuickSelect = (user: UserType) => {
|
||||
const defaultPwd = user.role === 'admin' ? 'Admin' : user.role === 'editor' ? 'Editor' : 'Viewer';
|
||||
setUsername(user.username);
|
||||
setPassword(defaultPwd);
|
||||
performLogin(user.username, defaultPwd);
|
||||
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);
|
||||
};
|
||||
|
||||
// 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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative my-4">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-slate-800" />
|
||||
</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">
|
||||
{/* 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" />
|
||||
|
|
@ -126,27 +290,51 @@ export const LoginModal: React.FC<LoginModalProps> = ({ users, onLoginSuccess })
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3 text-xs">
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* 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 font-medium flex items-center space-x-1.5">
|
||||
<User className="w-3.5 h-3.5 text-slate-400" />
|
||||
<span>Username</span>
|
||||
<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="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="e.g. admin, pm, observer"
|
||||
placeholder="Enter username (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"
|
||||
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"
|
||||
/>
|
||||
</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" />
|
||||
<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'}
|
||||
|
|
@ -154,7 +342,7 @@ export const LoginModal: React.FC<LoginModalProps> = ({ users, onLoginSuccess })
|
|||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter password"
|
||||
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"
|
||||
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"
|
||||
|
|
@ -170,18 +358,185 @@ export const LoginModal: React.FC<LoginModalProps> = ({ users, onLoginSuccess })
|
|||
<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"
|
||||
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 JWT...</span>
|
||||
<span>Authenticating Credentials...</span>
|
||||
) : (
|
||||
<>
|
||||
<span>Sign In to Portal</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>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
726
src/components/TimelineView.tsx
Normal file
726
src/components/TimelineView.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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,7 +276,8 @@ export const UsersView: React.FC<UsersViewProps> = ({
|
|||
</span>
|
||||
</td>
|
||||
<td className="p-4 text-slate-300">{u.dept}</td>
|
||||
<td className="p-4">
|
||||
<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" />
|
||||
|
|
@ -286,6 +289,15 @@ export const UsersView: React.FC<UsersViewProps> = ({
|
|||
<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 && (
|
||||
<td className="p-4 text-right space-x-1.5">
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
20
src/types.ts
20
src/types.ts
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue