This commit is contained in:
jpmvaz
2026-09-13 20:17:19 +01:00
commit cfd83aedc4
11 changed files with 1348 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
node_modules
npm-debug.log
.git
.gitignore
README.md
.env
.DS_Store
backup_*.json
Binary file not shown.
+227
View File
@@ -0,0 +1,227 @@
# Martinhal Contact Management System - Docker Setup
## 🐳 Docker Installation (Recommended)
### Prerequisites
- Docker installed: https://docs.docker.com/get-docker/
- Docker Compose installed (usually comes with Docker Desktop)
### Quick Start with Docker
1. **Make sure you have all files in the same directory:**
```
martinhal-contacts/
├── Dockerfile
├── docker-compose.yml
├── .dockerignore
├── index.html
├── server.js
├── package.json
├── contacts_data.json (created automatically)
└── users_data.json (created automatically on first run)
```
2. **Build and start the container:**
```bash
docker-compose up -d
```
3. **Access the application:**
Open your browser and go to: `http://localhost:8000`
4. **Stop the application:**
```bash
docker-compose down
```
5. **View logs:**
```bash
docker-compose logs -f
```
### Docker Commands Reference
**Start the application:**
```bash
docker-compose up -d
```
**Stop the application:**
```bash
docker-compose down
```
**Restart the application:**
```bash
docker-compose restart
```
**View logs:**
```bash
docker-compose logs -f martinhal-contacts
```
**Rebuild after code changes:**
```bash
docker-compose down
docker-compose build
docker-compose up -d
```
**Access container shell:**
```bash
docker exec -it martinhal-contacts sh
```
## 📦 Data Persistence
Docker volumes ensure your data persists:
- **contacts_data.json** - Your contact database (automatically backed up)
- **backups/** - Directory for backup files
Even if you delete and recreate containers, your data remains safe!
## 🔄 Updating the Application
1. Update your files (index.html, server.js, etc.)
2. Rebuild and restart:
```bash
docker-compose down
docker-compose build
docker-compose up -d
```
## 🔐 Authentication (v1.5)
There are **no hardcoded or default passwords** anywhere. Nothing on the site is visible until you sign in, and every account is created by an administrator.
### First time you open the site
On the very first visit **no account exists yet**, so the site shows a **"Create Admin Account"** screen. You choose the first administrator's username and password and you're signed in straight away. This one-time setup only appears when the site has no users — it is **not** shown when navigating to the admin section later.
### Signing in
After setup, opening the site shows a **login page**. By default sign-in is just **username + password** — there is no separate "view password" and no separate "admin password", the whole site is behind this single login.
### Two-factor authentication (optional)
**MFA is never mandatory.** Every account signs in with just a username and password unless that user chooses to turn on 2FA. Any user can enable it from **Profile → Two-factor authentication**: scan the QR code with an authenticator app (Google Authenticator, Authy, 1Password, Microsoft Authenticator…) and confirm a 6-digit code. Once enabled, that account's login also asks for the current code. The same panel lets the user **disable** it again at any time, returning to password-only sign-in.
### Users & roles
Signed-in administrators can open **Users** to add more accounts:
- **Administrator** accounts can view and edit the directory and manage users.
- **View-only** accounts can browse the directory but cannot edit it or manage users (the Admin button is hidden for them, and the server rejects any write attempts).
New users sign in with just their username and password; enabling 2FA is each user's own choice. Every user has a **profile with an uploadable avatar** and display name (the **Profile** button, available on every page after login).
### Changing your password
Any signed-in user (administrators included) can change their **own** password from **Profile → Change password**: enter the current password, then the new one twice. The new password must be at least 8 characters. After a successful change the account is signed out of any *other* active sessions, while the session you changed it from stays logged in.
### Footer
Every page shown **after login** displays the footer **"© 2026 Martinhal IT - Joao Vaz - Version 1.5"**. The login / create-admin screen intentionally has no footer.
### Resetting all accounts (start setup over)
Delete `users_data.json` and restart — the create-admin screen returns:
```bash
docker exec -it martinhal-contacts sh -c "rm -f users_data.json"
docker-compose restart
```
> ⚠️ `users_data.json` holds password hashes and MFA secrets. It is **never** served over HTTP and must be treated as sensitive. To keep accounts across container rebuilds, mount it as a volume (e.g. `- ./users_data.json:/app/users_data.json`).
## ⬆️ Updating from a previous version (no data loss)
This update ships **only code files** — it does **not** contain `contacts_data.json` or `users_data.json`, so unzipping it over your existing installation keeps all your contacts and accounts intact.
1. Unzip the update **into your existing project folder**, overwriting `index.html`, `server.js`, `package.json`, `Readme.md`, `dockerfile`, `docker-compose.yml` and `.dockerignore`. Your data files are left untouched.
2. Rebuild and restart the container (required because `server.js`/`package.json` changed):
```bash
docker-compose down
docker-compose build
docker-compose up -d
```
3. Existing accounts keep working. Any account that previously had MFA turned on will still be asked for its code (and can disable it from **Profile**); every other account now signs in with just username and password. If you're upgrading from the very first (pre-login) version and have no accounts yet, the site will show the create-admin screen on first load.
> `credentials.json` is no longer used and can be deleted; it is ignored by the app and the Docker build.
## 🛠 Troubleshooting
**Port 8000 already in use:**
Edit `docker-compose.yml` and change the port mapping:
```yaml
ports:
- "3000:8000" # Access via http://localhost:3000
```
**Container won't start:**
```bash
docker-compose logs martinhal-contacts
```
**Remove everything and start fresh:**
```bash
docker-compose down
docker system prune -a
docker-compose up -d
```
## 📋 Alternative: Manual Setup (No Docker)
If you don't want to use Docker:
### Step 1: Install Node.js
Download from: https://nodejs.org/
### Step 2: Install Dependencies
```bash
npm install
```
### Step 3: Start Server
```bash
npm start
```
### Step 4: Open Browser
```
http://localhost:8000
```
## 🎯 Production Deployment
For production deployment with Docker:
1. **Use environment variables for sensitive data**
2. **Set up HTTPS with reverse proxy (nginx)**
3. **Configure automated backups**
4. **Set resource limits in docker-compose.yml**
Example production docker-compose.yml:
```yaml
version: '3.8'
services:
martinhal-contacts:
build: .
container_name: martinhal-contacts
ports:
- "8000:8000"
volumes:
- ./contacts_data.json:/app/contacts_data.json
- ./backups:/app/backups
restart: always
environment:
- NODE_ENV=production
deploy:
resources:
limits:
cpus: '0.5'
memory: 512M
```
## ✅ Benefits of Docker
-**Consistent environment** - Works the same everywhere
-**Easy deployment** - One command to start
-**Isolated** - Doesn't interfere with other apps
-**Easy updates** - Rebuild and restart
-**Data persistence** - Your data is safe
-**Easy backups** - Just copy the volume
That's it! Your Martinhal Contact Management System is now running in Docker! 🎉
+16
View File
@@ -0,0 +1,16 @@
version: '3.8'
services:
martinhal-contacts:
build: .
container_name: martinhal-contacts
ports:
- "8000:8000"
volumes:
- contacts-data:/app/data
restart: unless-stopped
environment:
- NODE_ENV=production
volumes:
contacts-data:
+23
View File
@@ -0,0 +1,23 @@
FROM node:18-alpine
# Set working directory
WORKDIR /app
# Copy package files
COPY package.json ./
# Install dependencies
RUN npm install
# Copy application files
COPY index.html ./
COPY server.js ./
# Create backups directory
RUN mkdir -p /app/backups
# Expose port
EXPOSE 8000
# Start the server
CMD ["npm", "start"]
+668
View File
@@ -0,0 +1,668 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Martinhal Contacts</title>
<script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.5.31/jspdf.plugin.autotable.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const{useState,useEffect}=React;
const FOOTER_TEXT='\u00A9 2026 Martinhal IT - Joao Vaz - Version 1.5';
function App(){
const[view,setView]=useState('front');
const[dark,setDark]=useState(false);
const[contacts,setContacts]=useState([]);
const[depts,setDepts]=useState([]);
const[locs,setLocs]=useState([]);
const[search,setSearch]=useState('');
const[filterLoc,setFilterLoc]=useState('All');
const[filterDept,setFilterDept]=useState('All');
const[auth,setAuth]=useState(false);
const[user,setUser]=useState('');
const[pwd,setPwd]=useState('');
const[editing,setEditing]=useState(null);
const[editDept,setEditDept]=useState(null);
const[editLoc,setEditLoc]=useState(null);
const[newDept,setNewDept]=useState('');
const[newLoc,setNewLoc]=useState('');
const[form,setForm]=useState({name:'',unit:'',phone:'',mobile:'',email:'',department:'',locations:[],photo:''});
const[loaded,setLoaded]=useState(false);
// --- unified server-side auth (login / first-run admin setup / MFA / profiles) ---
const[token,setToken]=useState(localStorage.getItem('mh_token')||'');
const[profile,setProfile]=useState(null);
const[setupComplete,setSetupComplete]=useState(true);
const[authChecked,setAuthChecked]=useState(false);
const[displayName,setDisplayName]=useState('');
const[mfaCode,setMfaCode]=useState('');
const[stage,setStage]=useState('creds'); // creds | mfa
const[authErr,setAuthErr]=useState('');
// optional per-user MFA (enabled/disabled from the profile)
const[mfaSetup,setMfaSetup]=useState(null); // {qrDataUrl,secret} while enabling
const[mfaEnableCode,setMfaEnableCode]=useState('');
const[mfaBusy,setMfaBusy]=useState(false);
const[mfaErr,setMfaErr]=useState('');
// change own password
const[cpCurrent,setCpCurrent]=useState('');
const[cpNew,setCpNew]=useState('');
const[cpConfirm,setCpConfirm]=useState('');
const[cpBusy,setCpBusy]=useState(false);
const[cpErr,setCpErr]=useState('');
const[cpMsg,setCpMsg]=useState('');
const[authBusy,setAuthBusy]=useState(false);
const[showProfile,setShowProfile]=useState(false);
const[showUsers,setShowUsers]=useState(false);
const[usersList,setUsersList]=useState([]);
const[nuUser,setNuUser]=useState('');
const[nuPwd,setNuPwd]=useState('');
const[nuName,setNuName]=useState('');
const[nuAdmin,setNuAdmin]=useState(false);
// On load: check whether an admin exists yet, and restore any saved session
useEffect(()=>{
fetch('/api/auth/status').then(r=>r.json()).then(s=>setSetupComplete(!!s.setupComplete)).catch(()=>{});
const t=localStorage.getItem('mh_token');
if(t){
fetch('/api/auth/me',{headers:{Authorization:'Bearer '+t}})
.then(r=>r.ok?r.json():Promise.reject())
.then(d=>{setProfile(d.profile);setToken(t);setAuth(true);loadData(t);if(d.profile.isAdmin)loadUsersList(t);})
.catch(()=>{localStorage.removeItem('mh_token');})
.finally(()=>setAuthChecked(true));
}else setAuthChecked(true);
},[]);
const loadData=async(tk)=>{
try{
const r=await fetch('/api/data',{headers:{Authorization:'Bearer '+(tk||token)}});
if(r.ok){
const d=await r.json();
setContacts(d.contacts||[]);
setDepts(d.departments||['IT','Accounting','Board','Housekeeping','Maintenance']);
setLocs(d.locations||['Martinhal Oriente','Martinhal Lisbon','Martinhal Quinta','Martinhal Sagres']);
setLoaded(true);
}
}catch(e){console.error(e)}
};
const saveData=async()=>{
try{
await fetch('/api/data',{
method:'POST',
headers:{'Content-Type':'application/json',Authorization:'Bearer '+token},
body:JSON.stringify({contacts,departments:depts,locations:locs})
});
}catch(e){console.error(e)}
};
// Auto-save whenever data changes (admins only; view-only users can't write)
useEffect(()=>{
if(loaded&&auth&&profile&&profile.isAdmin){
saveData();
}
},[contacts,depts,locs]);
const resetAuthFlow=()=>{setStage('creds');setMfaCode('');setAuthErr('');setPwd('');};
const finishLogin=(d)=>{
localStorage.setItem('mh_token',d.token);
setToken(d.token);setProfile(d.profile);setAuth(true);
setUser('');setPwd('');setMfaCode('');setStage('creds');setAuthErr('');
setView('front');
loadData(d.token);
if(d.profile.isAdmin)loadUsersList(d.token);
};
const doSetup=async()=>{
setAuthErr('');
if(!user||!pwd){setAuthErr('Enter a username and password');return}
if(pwd.length<8){setAuthErr('Password must be at least 8 characters');return}
setAuthBusy(true);
try{
const r=await fetch('/api/auth/setup',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:user,password:pwd,displayName})});
const d=await r.json();
if(!r.ok){setAuthErr(d.error||'Setup failed');return}
finishLogin(d); // MFA is optional, so the first admin is signed in immediately
}catch(e){setAuthErr('Network error')}finally{setAuthBusy(false)}
};
const doLogin=async()=>{
setAuthErr('');setAuthBusy(true);
try{
const r=await fetch('/api/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:user,password:pwd,code:mfaCode||undefined})});
const d=await r.json();
if(d.status==='ok')finishLogin(d);
else if(d.status==='mfa'){setStage('mfa');setMfaCode('')} // only for accounts that enabled MFA
else setAuthErr(d.error||'Login failed');
}catch(e){setAuthErr('Network error')}finally{setAuthBusy(false)}
};
// --- optional MFA management from the profile ---
const startMfa=async()=>{
setMfaErr('');setMfaBusy(true);
try{
const r=await fetch('/api/auth/mfa/setup',{method:'POST',headers:{Authorization:'Bearer '+token}});
const d=await r.json();
if(r.ok)setMfaSetup({qrDataUrl:d.qrDataUrl,secret:d.secret});
else setMfaErr(d.error||'Failed to start MFA setup');
}catch(e){setMfaErr('Network error')}finally{setMfaBusy(false)}
};
const enableMfa=async()=>{
setMfaErr('');setMfaBusy(true);
try{
const r=await fetch('/api/auth/mfa/enable',{method:'POST',headers:{'Content-Type':'application/json',Authorization:'Bearer '+token},body:JSON.stringify({code:mfaEnableCode})});
const d=await r.json();
if(r.ok){setProfile(d.profile);setMfaSetup(null);setMfaEnableCode('')}
else setMfaErr(d.error||'Invalid code');
}catch(e){setMfaErr('Network error')}finally{setMfaBusy(false)}
};
const disableMfa=async()=>{
if(!confirm('Disable two-factor authentication for your account?'))return;
setMfaErr('');setMfaBusy(true);
try{
const r=await fetch('/api/auth/mfa/disable',{method:'POST',headers:{Authorization:'Bearer '+token}});
const d=await r.json();
if(r.ok)setProfile(d.profile);
else setMfaErr(d.error||'Failed');
}catch(e){setMfaErr('Network error')}finally{setMfaBusy(false)}
};
const changePassword=async()=>{
setCpErr('');setCpMsg('');
if(!cpCurrent||!cpNew){setCpErr('Enter your current and new password');return}
if(cpNew.length<8){setCpErr('New password must be at least 8 characters');return}
if(cpNew!==cpConfirm){setCpErr('New passwords do not match');return}
setCpBusy(true);
try{
const r=await fetch('/api/auth/password',{method:'POST',headers:{'Content-Type':'application/json',Authorization:'Bearer '+token},body:JSON.stringify({currentPassword:cpCurrent,newPassword:cpNew})});
const d=await r.json();
if(r.ok){setCpMsg('Password updated successfully.');setCpCurrent('');setCpNew('');setCpConfirm('')}
else setCpErr(d.error||'Failed to change password');
}catch(e){setCpErr('Network error')}finally{setCpBusy(false)}
};
const closeProfile=()=>{setShowProfile(false);setMfaSetup(null);setMfaEnableCode('');setMfaErr('');setCpCurrent('');setCpNew('');setCpConfirm('');setCpErr('');setCpMsg('')};
const doLogout=()=>{
fetch('/api/auth/logout',{method:'POST',headers:{Authorization:'Bearer '+token}}).catch(()=>{});
localStorage.removeItem('mh_token');
setToken('');setProfile(null);setAuth(false);setView('front');setLoaded(false);setContacts([]);
resetAuthFlow();
};
const saveProfile=async(av,dn)=>{
try{
const r=await fetch('/api/auth/profile',{method:'POST',headers:{'Content-Type':'application/json',Authorization:'Bearer '+token},body:JSON.stringify({avatar:av,displayName:dn})});
const d=await r.json();
if(r.ok)setProfile(d.profile);
}catch(e){console.error(e)}
};
const onProfileAvatar=e=>{
const f=e.target.files[0];
if(f){const r=new FileReader();r.onloadend=()=>{setProfile(p=>({...p,avatar:r.result}));saveProfile(r.result,profile.displayName)};r.readAsDataURL(f)}
};
const loadUsersList=async(tk)=>{
try{
const r=await fetch('/api/auth/users',{headers:{Authorization:'Bearer '+(tk||token)}});
if(r.ok){const d=await r.json();setUsersList(d.users)}
}catch(e){console.error(e)}
};
const addUser=async()=>{
if(!nuUser||!nuPwd){alert('Username and password required');return}
try{
const r=await fetch('/api/auth/users',{method:'POST',headers:{'Content-Type':'application/json',Authorization:'Bearer '+token},body:JSON.stringify({username:nuUser,password:nuPwd,displayName:nuName,isAdmin:nuAdmin})});
const d=await r.json();
if(r.ok){setNuUser('');setNuPwd('');setNuName('');setNuAdmin(false);loadUsersList()}
else alert(d.error||'Failed to add user');
}catch(e){alert('Network error')}
};
const delUser=async(un)=>{
if(!confirm('Delete user '+un+'?'))return;
try{
const r=await fetch('/api/auth/users/'+encodeURIComponent(un),{method:'DELETE',headers:{Authorization:'Bearer '+token}});
if(r.ok)loadUsersList();
else{const d=await r.json();alert(d.error||'Failed')}
}catch(e){alert('Network error')}
};
const uploadPhoto=(e,isEdit)=>{
const f=e.target.files[0];
if(f){
const r=new FileReader();
r.onloadend=()=>{
if(isEdit)setEditing({...editing,photo:r.result});
else setForm({...form,photo:r.result});
};
r.readAsDataURL(f);
}
};
const addContact=()=>{if(form.name&&form.email){setContacts([...contacts,{...form,id:Date.now()}]);setForm({name:'',unit:'',phone:'',mobile:'',email:'',department:'',locations:[],photo:''})}};
const updateContact=()=>{setContacts(contacts.map(c=>c.id===editing.id?editing:c));setEditing(null)};
const delContact=id=>{if(confirm('Delete contact?'))setContacts(contacts.filter(c=>c.id!==id))};
const addDept=()=>{if(newDept&&!depts.includes(newDept)){setDepts([...depts,newDept]);setNewDept('')}};
const addLoc=()=>{if(newLoc&&!locs.includes(newLoc)){setLocs([...locs,newLoc]);setNewLoc('')}};
const updateDept=old=>{if(editDept&&editDept!==old){setDepts(depts.map(d=>d===old?editDept:d));setContacts(contacts.map(c=>({...c,department:c.department===old?editDept:c.department})))}setEditDept(null)};
const updateLoc=old=>{if(editLoc&&editLoc!==old){setLocs(locs.map(l=>l===old?editLoc:l));setContacts(contacts.map(c=>({...c,locations:c.locations.map(l=>l===old?editLoc:l)})))}setEditLoc(null)};
const delDept=d=>{if(confirm(`Delete ${d}?`)){setDepts(depts.filter(x=>x!==d));setContacts(contacts.map(c=>({...c,department:c.department===d?'':c.department})))}};
const delLoc=l=>{if(confirm(`Delete ${l}?`)){setLocs(locs.filter(x=>x!==l));setContacts(contacts.map(c=>({...c,locations:c.locations.filter(x=>x!==l)})))}};
const exportXLS=()=>{
const d=[['Name','Unit','Phone','Mobile','Email','Department','Locations']];
contacts.forEach(c=>d.push([c.name,c.unit,c.phone,c.mobile,c.email,c.department,c.locations.join(', ')]));
const wb=XLSX.utils.book_new();
const ws=XLSX.utils.aoa_to_sheet(d);
ws['!cols']=[{wch:20},{wch:15},{wch:18},{wch:18},{wch:30},{wch:15},{wch:40}];
XLSX.utils.book_append_sheet(wb,ws,"Contacts");
XLSX.writeFile(wb,`Contacts_${new Date().toISOString().split('T')[0]}.xlsx`);
};
const exportPDF=()=>{
const{jsPDF}=window.jspdf;
const doc=new jsPDF('l','mm','a4');
doc.setFontSize(18);
doc.text('Martinhal Contacts',14,15);
doc.setFontSize(10);
doc.text(`Exported: ${new Date().toLocaleDateString()}`,14,22);
const d=contacts.map(c=>[c.name,c.unit,c.phone,c.mobile,c.email,c.department,c.locations.join(', ')]);
doc.autoTable({
head:[['Name','Unit','Phone','Mobile','Email','Department','Locations']],
body:d,
startY:28,
styles:{fontSize:8,cellPadding:2},
headStyles:{fillColor:[102,126,234]},
columnStyles:{0:{cellWidth:35},1:{cellWidth:25},2:{cellWidth:30},3:{cellWidth:30},4:{cellWidth:50},5:{cellWidth:25},6:{cellWidth:'auto'}}
});
doc.save(`Contacts_${new Date().toISOString().split('T')[0]}.pdf`);
};
const downloadTemplate=()=>{
const data=[
['Name','Unit','Phone','Mobile','Email','Department','Locations'],
['John Doe','Marketing','+351 123 456 789','+351 987 654 321','john.doe@example.com','IT','Martinhal Oriente'],
['Jane Smith','Finance','+351 123 456 790','+351 987 654 322','jane.smith@example.com','Accounting','Martinhal Lisbon, Martinhal Quinta']
];
const wb=XLSX.utils.book_new();
const ws=XLSX.utils.aoa_to_sheet(data);
ws['!cols']=[{wch:20},{wch:15},{wch:18},{wch:18},{wch:30},{wch:15},{wch:40}];
XLSX.utils.book_append_sheet(wb,ws,"Template");
XLSX.writeFile(wb,'Martinhal_Contacts_Template.xlsx');
};
const importFromExcel=e=>{
const file=e.target.files[0];
if(!file)return;
const reader=new FileReader();
reader.onload=evt=>{
try{
const data=new Uint8Array(evt.target.result);
const workbook=XLSX.read(data,{type:'array'});
const sheet=workbook.Sheets[workbook.SheetNames[0]];
const rows=XLSX.utils.sheet_to_json(sheet,{header:1});
let imported=0;
let errors=0;
for(let i=1;i<rows.length;i++){
const row=rows[i];
if(!row[0]||!row[4])continue;
const newContact={
id:Date.now()+i,
name:row[0]||'',
unit:row[1]||'',
phone:row[2]||'',
mobile:row[3]||'',
email:row[4]||'',
department:row[5]||'',
locations:row[6]?String(row[6]).split(',').map(l=>l.trim()).filter(l=>locs.includes(l)):[],
photo:''
};
const exists=contacts.find(c=>c.email.toLowerCase()===newContact.email.toLowerCase());
if(!exists){
setContacts(prev=>[...prev,newContact]);
imported++;
}else{
errors++;
}
}
alert(`Import complete!\nImported: ${imported} contacts\nSkipped (duplicates): ${errors}`);
}catch(err){
alert('Error reading file. Please use the template format.');
console.error(err);
}
};
reader.readAsArrayBuffer(file);
e.target.value='';
};
const filtered=contacts.filter(c=>{
const s=(c.name+c.email+c.unit).toLowerCase().includes(search.toLowerCase());
const l=filterLoc==='All'||c.locations.includes(filterLoc);
const d=filterDept==='All'||c.department===filterDept;
return s&&l&&d;
});
// ---------- Auth gate: nothing is visible until you log in ----------
if(!authChecked)return<div className="min-h-screen bg-purple-50 flex items-center justify-center"><div className="animate-spin rounded-full h-16 w-16 border-b-4 border-purple-600"/></div>;
if(!auth){
// First ever visit — no admin exists yet: create the first admin account here.
if(!setupComplete)return(
<div className="min-h-screen bg-purple-50 flex items-center justify-center p-4">
<div className="bg-white p-8 rounded-2xl shadow-xl max-w-md w-full">
<h2 className="text-2xl font-bold text-purple-600 mb-2 text-center">Welcome to Martinhal Contacts</h2>
<p className="text-gray-600 mb-6 text-sm text-center">No account exists yet. Create the first administrator account to set up the site.</p>
<input type="text" placeholder="Display name (optional)" value={displayName} onChange={e=>setDisplayName(e.target.value)} className="w-full px-4 py-3 border-2 rounded-xl mb-3"/>
<input type="text" placeholder="Admin username" value={user} onChange={e=>setUser(e.target.value)} className="w-full px-4 py-3 border-2 rounded-xl mb-3"/>
<input type="password" placeholder="Admin password (min 8 chars)" value={pwd} onChange={e=>setPwd(e.target.value)} onKeyPress={e=>e.key==='Enter'&&doSetup()} className="w-full px-4 py-3 border-2 rounded-xl mb-4"/>
{authErr&&<p className="text-red-600 text-sm mb-3">{authErr}</p>}
<button onClick={doSetup} disabled={authBusy} className="w-full bg-purple-600 text-white py-3 rounded-xl hover:bg-purple-700 font-semibold disabled:opacity-50">{authBusy?'Creating...':'Create Admin Account'}</button>
<p className="text-xs text-gray-400 mt-3 text-center">You can optionally enable two-factor authentication later from your profile.</p>
</div>
</div>
);
// Normal login (username + password + MFA). Accounts are created by an admin.
return(
<div className="min-h-screen bg-purple-50 flex items-center justify-center p-4">
<div className="bg-white p-8 rounded-2xl shadow-xl max-w-md w-full">
<h2 className="text-3xl font-bold text-purple-600 mb-1 text-center">Martinhal Contacts</h2>
<p className="text-gray-500 mb-6 text-center text-sm">Please sign in to continue</p>
{stage==='creds'&&<>
<input type="text" placeholder="Username" value={user} onChange={e=>setUser(e.target.value)} className="w-full px-4 py-3 border-2 rounded-xl mb-3" autoFocus/>
<input type="password" placeholder="Password" value={pwd} onChange={e=>setPwd(e.target.value)} onKeyPress={e=>e.key==='Enter'&&doLogin()} className="w-full px-4 py-3 border-2 rounded-xl mb-4"/>
</>}
{stage==='mfa'&&<>
<p className="text-gray-600 mb-4 text-sm">Enter the 6-digit code from your authenticator app.</p>
<input type="text" inputMode="numeric" maxLength="6" placeholder="123456" value={mfaCode} onChange={e=>setMfaCode(e.target.value.replace(/\D/g,''))} onKeyPress={e=>e.key==='Enter'&&doLogin()} className="w-full px-4 py-3 border-2 rounded-xl mb-4 text-center tracking-widest text-lg" autoFocus/>
</>}
{authErr&&<p className="text-red-600 text-sm mb-3">{authErr}</p>}
<button onClick={doLogin} disabled={authBusy} className="w-full bg-purple-600 text-white py-3 rounded-xl hover:bg-purple-700 font-semibold disabled:opacity-50">{authBusy?'Please wait...':(stage==='creds'?'Sign In':'Verify')}</button>
{stage!=='creds'&&<button onClick={resetAuthFlow} className="w-full mt-2 text-gray-500 text-sm hover:text-gray-700"> Back</button>}
</div>
</div>
);
}
// ---------- Authenticated ----------
if(!loaded)return<div className="min-h-screen bg-purple-50 flex items-center justify-center"><div className="animate-spin rounded-full h-16 w-16 border-b-4 border-purple-600"/></div>;
const isAdmin=!!(profile&&profile.isAdmin);
const footer=<footer className={dark?'text-center py-6 text-gray-400':'text-center py-6 text-gray-600'}>{FOOTER_TEXT}</footer>;
const profileModal=(showProfile&&profile)?(
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50" onClick={closeProfile}>
<div className={dark?'bg-gray-800 text-white p-6 rounded-2xl max-w-sm w-full max-h-[90vh] overflow-y-auto':'bg-white p-6 rounded-2xl max-w-sm w-full max-h-[90vh] overflow-y-auto'} onClick={e=>e.stopPropagation()}>
<div className="flex justify-between items-center mb-4"><h3 className="text-xl font-bold">My Profile</h3><button onClick={closeProfile} className="text-gray-400 hover:text-gray-600"></button></div>
<div className="flex flex-col items-center gap-3 mb-4">
{profile.avatar?<img src={profile.avatar} className="w-28 h-28 rounded-full object-cover border-4 border-purple-500"/>:<div className="w-28 h-28 rounded-full bg-gray-200 flex items-center justify-center text-5xl">👤</div>}
<label className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 cursor-pointer">Change Avatar<input type="file" accept="image/*" onChange={onProfileAvatar} className="hidden"/></label>
{profile.avatar&&<button onClick={()=>{setProfile({...profile,avatar:''});saveProfile('',profile.displayName)}} className="text-red-500 text-sm hover:text-red-700">Remove avatar</button>}
</div>
<label className="block text-sm text-gray-500 mb-1">Display name</label>
<input type="text" value={profile.displayName} onChange={e=>setProfile({...profile,displayName:e.target.value})} className={dark?'w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg mb-1 text-white':'w-full px-4 py-2 border rounded-lg mb-1'}/>
<p className="text-xs text-gray-400 mb-3">Username: {profile.username} · {profile.isAdmin?'Administrator':'View-only'}</p>
<div className="border-t pt-3 mb-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">Two-factor authentication</span>
<span className={profile.mfaEnabled?'text-green-600 text-sm font-medium':'text-gray-400 text-sm'}>{profile.mfaEnabled?'Enabled ✅':'Optional'}</span>
</div>
{!profile.mfaEnabled&&!mfaSetup&&<>
<p className="text-xs text-gray-400 mb-2">2FA is optional. Enable it to add a one-time code to your login.</p>
<button onClick={startMfa} disabled={mfaBusy} className="w-full bg-gray-700 text-white py-2 rounded-lg hover:bg-gray-800 disabled:opacity-50 text-sm">{mfaBusy?'Please wait...':'Enable 2FA'}</button>
</>}
{!profile.mfaEnabled&&mfaSetup&&<>
<p className="text-xs text-gray-500 mb-2">Scan with an authenticator app (Google Authenticator, Authy, 1Password), then enter the 6-digit code.</p>
{mfaSetup.qrDataUrl&&<div className="flex justify-center mb-2"><img src={mfaSetup.qrDataUrl} alt="MFA QR code" className="w-40 h-40 border rounded-lg"/></div>}
<p className="text-xs text-gray-500 mb-2 text-center break-all">Secret: <span className="font-mono">{mfaSetup.secret}</span></p>
<input type="text" inputMode="numeric" maxLength="6" placeholder="123456" value={mfaEnableCode} onChange={e=>setMfaEnableCode(e.target.value.replace(/\D/g,''))} onKeyPress={e=>e.key==='Enter'&&enableMfa()} className="w-full px-4 py-2 border-2 rounded-lg mb-2 text-center tracking-widest"/>
<div className="flex gap-2">
<button onClick={enableMfa} disabled={mfaBusy} className="flex-1 bg-green-600 text-white py-2 rounded-lg hover:bg-green-700 disabled:opacity-50 text-sm">{mfaBusy?'Verifying...':'Verify & Enable'}</button>
<button onClick={()=>{setMfaSetup(null);setMfaEnableCode('');setMfaErr('')}} className="px-4 py-2 text-gray-500 hover:text-gray-700 text-sm">Cancel</button>
</div>
</>}
{profile.mfaEnabled&&<button onClick={disableMfa} disabled={mfaBusy} className="w-full bg-red-600 text-white py-2 rounded-lg hover:bg-red-700 disabled:opacity-50 text-sm">{mfaBusy?'Please wait...':'Disable 2FA'}</button>}
{mfaErr&&<p className="text-red-600 text-xs mt-2">{mfaErr}</p>}
</div>
<div className="border-t pt-3 mb-4">
<span className="text-sm font-medium">Change password</span>
<input type="password" placeholder="Current password" value={cpCurrent} onChange={e=>setCpCurrent(e.target.value)} className={dark?'w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white mt-2':'w-full px-4 py-2 border rounded-lg mt-2'}/>
<input type="password" placeholder="New password (min 8 chars)" value={cpNew} onChange={e=>setCpNew(e.target.value)} className={dark?'w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white mt-2':'w-full px-4 py-2 border rounded-lg mt-2'}/>
<input type="password" placeholder="Confirm new password" value={cpConfirm} onChange={e=>setCpConfirm(e.target.value)} onKeyPress={e=>e.key==='Enter'&&changePassword()} className={dark?'w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white mt-2':'w-full px-4 py-2 border rounded-lg mt-2'}/>
{cpErr&&<p className="text-red-600 text-xs mt-2">{cpErr}</p>}
{cpMsg&&<p className="text-green-600 text-xs mt-2">{cpMsg}</p>}
<button onClick={changePassword} disabled={cpBusy} className="w-full bg-gray-700 text-white py-2 rounded-lg hover:bg-gray-800 disabled:opacity-50 text-sm mt-2">{cpBusy?'Updating...':'Update Password'}</button>
</div>
<button onClick={()=>{saveProfile(profile.avatar,profile.displayName);closeProfile()}} className="w-full bg-purple-600 text-white py-2 rounded-lg hover:bg-purple-700">Save & Close</button>
</div>
</div>
):null;
const usersModal=(showUsers&&isAdmin)?(
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50" onClick={()=>setShowUsers(false)}>
<div className={dark?'bg-gray-800 text-white p-6 rounded-2xl max-w-lg w-full':'bg-white p-6 rounded-2xl max-w-lg w-full'} onClick={e=>e.stopPropagation()}>
<div className="flex justify-between items-center mb-4"><h3 className="text-xl font-bold">Users</h3><button onClick={()=>setShowUsers(false)} className="text-gray-400 hover:text-gray-600"></button></div>
<div className="space-y-2 mb-4 max-h-60 overflow-y-auto">
{usersList.map(u=>(
<div key={u.username} className={dark?'flex items-center gap-3 bg-gray-700 p-2 rounded-lg':'flex items-center gap-3 bg-gray-100 p-2 rounded-lg'}>
{u.avatar?<img src={u.avatar} className="w-10 h-10 rounded-full object-cover"/>:<div className="w-10 h-10 rounded-full bg-gray-300 flex items-center justify-center">👤</div>}
<div className="flex-1"><div className="font-medium">{u.displayName}{profile&&u.username===profile.username&&<span className="text-xs text-purple-500 ml-1">(you)</span>}</div><div className="text-xs text-gray-500">@{u.username} · {u.isAdmin?'Admin':'View-only'} · MFA {u.mfaEnabled?'':''}</div></div>
{profile&&u.username!==profile.username&&<button onClick={()=>delUser(u.username)} className="text-red-600 hover:text-red-800">🗑</button>}
</div>
))}
</div>
<h4 className="font-semibold mb-2">Add user</h4>
<div className="grid grid-cols-1 gap-2">
<input type="text" placeholder="Username" value={nuUser} onChange={e=>setNuUser(e.target.value)} className={dark?'px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white':'px-3 py-2 border rounded-lg'}/>
<input type="text" placeholder="Display name (optional)" value={nuName} onChange={e=>setNuName(e.target.value)} className={dark?'px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white':'px-3 py-2 border rounded-lg'}/>
<input type="password" placeholder="Temp password (min 8 chars)" value={nuPwd} onChange={e=>setNuPwd(e.target.value)} className={dark?'px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white':'px-3 py-2 border rounded-lg'}/>
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={nuAdmin} onChange={e=>setNuAdmin(e.target.checked)}/> Grant administrator privileges</label>
<button onClick={addUser} className="bg-green-600 text-white py-2 rounded-lg hover:bg-green-700">Add User</button>
</div>
<p className="text-xs text-gray-400 mt-2">New users sign in with just their username and password. Two-factor authentication is optional each user can enable it from their own profile. View-only users can browse the directory but can't edit it.</p>
</div>
</div>
):null;
// ----- Admin panel (admins only) -----
if(view==='admin'&&isAdmin){
return(
<div className={dark?'min-h-screen bg-gray-900 text-white flex flex-col':'min-h-screen bg-gray-50 flex flex-col'}>
<div className="bg-green-600 text-white p-6 shadow-lg">
<div className="max-w-7xl mx-auto flex justify-between items-center">
<h1 className="text-3xl font-bold">Admin Panel</h1>
<div className="flex gap-3 flex-wrap">
<label className="px-4 py-2 bg-green-700 rounded-lg hover:bg-green-800 cursor-pointer">
📥 Import Excel
<input type="file" accept=".xlsx,.xls" onChange={importFromExcel} className="hidden"/>
</label>
<button onClick={downloadTemplate} className="px-4 py-2 bg-green-700 rounded-lg hover:bg-green-800">📄 Template</button>
<button onClick={exportXLS} className="px-4 py-2 bg-green-700 rounded-lg hover:bg-green-800">📊 Export Excel</button>
<button onClick={exportPDF} className="px-4 py-2 bg-green-700 rounded-lg hover:bg-green-800">📄 PDF</button>
<button onClick={()=>setDark(!dark)} className="px-4 py-2 bg-green-700 rounded-lg hover:bg-green-800">{dark?'':'🌙'}</button>
<button onClick={()=>setView('front')} className="px-4 py-2 bg-green-700 rounded-lg hover:bg-green-800">View</button>
<button onClick={()=>{setShowUsers(true);loadUsersList()}} className="px-4 py-2 bg-green-700 rounded-lg hover:bg-green-800">Users</button>
<button onClick={()=>setShowProfile(true)} className="flex items-center gap-2 px-3 py-2 bg-green-700 rounded-lg hover:bg-green-800">{profile&&profile.avatar?<img src={profile.avatar} className="w-7 h-7 rounded-full object-cover"/>:<span className="text-lg">👤</span>}<span className="hidden sm:inline">{profile?profile.displayName:'Profile'}</span></button>
<button onClick={doLogout} className="px-4 py-2 bg-green-700 rounded-lg hover:bg-green-800">Logout</button>
</div>
</div>
</div>
{profileModal}
{usersModal}
<div className="max-w-7xl mx-auto p-6 flex-1 w-full">
<div className={dark?'bg-gray-800 p-6 rounded-lg mb-6':'bg-white p-6 rounded-lg shadow-lg mb-6'}>
<h2 className="text-xl font-bold mb-4">Add Contact</h2>
<div className="grid md:grid-cols-2 gap-4">
<div className="col-span-2 flex items-center gap-4">
{form.photo?<img src={form.photo} className="w-20 h-20 rounded-full object-cover"/>:<div className="w-20 h-20 rounded-full bg-gray-200 flex items-center justify-center text-3xl">👤</div>}
<label className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 cursor-pointer">Upload Photo<input type="file" accept="image/*" onChange={e=>uploadPhoto(e,false)} className="hidden"/></label>
</div>
<input type="text" placeholder="Name *" value={form.name} onChange={e=>setForm({...form,name:e.target.value})} className={dark?'px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white':'px-4 py-2 border rounded-lg'}/>
<input type="text" placeholder="Unit" value={form.unit} onChange={e=>setForm({...form,unit:e.target.value})} className={dark?'px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white':'px-4 py-2 border rounded-lg'}/>
<input type="text" placeholder="Phone" value={form.phone} onChange={e=>setForm({...form,phone:e.target.value})} className={dark?'px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white':'px-4 py-2 border rounded-lg'}/>
<input type="text" placeholder="Mobile" value={form.mobile} onChange={e=>setForm({...form,mobile:e.target.value})} className={dark?'px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white':'px-4 py-2 border rounded-lg'}/>
<input type="email" placeholder="Email *" value={form.email} onChange={e=>setForm({...form,email:e.target.value})} className={dark?'px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white':'px-4 py-2 border rounded-lg'}/>
<select value={form.department} onChange={e=>setForm({...form,department:e.target.value})} className={dark?'px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white':'px-4 py-2 border rounded-lg'}>
<option value="">Select Department</option>
{depts.map(d=><option key={d} value={d}>{d}</option>)}
</select>
<div className="col-span-2 flex gap-2 flex-wrap">
{locs.map(l=><label key={l} className="flex items-center gap-1"><input type="checkbox" checked={form.locations.includes(l)} onChange={e=>setForm({...form,locations:e.target.checked?[...form.locations,l]:form.locations.filter(x=>x!==l)})}/>{l}</label>)}
</div>
</div>
<button onClick={addContact} className="mt-4 px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700">Add Contact</button>
</div>
<div className="grid md:grid-cols-2 gap-6 mb-6">
<div className={dark?'bg-gray-800 p-6 rounded-lg':'bg-white p-6 rounded-lg shadow-lg'}>
<h2 className="text-xl font-bold mb-4">Departments</h2>
<div className="flex gap-2 mb-4">
<input type="text" placeholder="New department" value={newDept} onChange={e=>setNewDept(e.target.value)} className={dark?'flex-1 px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white':'flex-1 px-4 py-2 border rounded-lg'}/>
<button onClick={addDept} className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700">Add</button>
</div>
<div className="flex flex-wrap gap-2">
{depts.map(d=><div key={d} className="flex items-center gap-2 bg-purple-100 px-3 py-2 rounded-lg">{editDept===d?<><input type="text" value={editDept} onChange={e=>setEditDept(e.target.value)} className="px-2 py-1 border rounded"/><button onClick={()=>updateDept(d)} className="text-green-600">✓</button><button onClick={()=>setEditDept(null)} className="text-gray-600">✕</button></>:<><span className="text-purple-800">{d}</span><button onClick={()=>setEditDept(d)} className="text-blue-600">✎</button><button onClick={()=>delDept(d)} className="text-red-600">🗑</button></>}</div>)}
</div>
</div>
<div className={dark?'bg-gray-800 p-6 rounded-lg':'bg-white p-6 rounded-lg shadow-lg'}>
<h2 className="text-xl font-bold mb-4">Locations</h2>
<div className="flex gap-2 mb-4">
<input type="text" placeholder="New location" value={newLoc} onChange={e=>setNewLoc(e.target.value)} className={dark?'flex-1 px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white':'flex-1 px-4 py-2 border rounded-lg'}/>
<button onClick={addLoc} className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700">Add</button>
</div>
<div className="flex flex-wrap gap-2">
{locs.map(l=><div key={l} className="flex items-center gap-2 bg-blue-100 px-3 py-2 rounded-lg">{editLoc===l?<><input type="text" value={editLoc} onChange={e=>setEditLoc(e.target.value)} className="px-2 py-1 border rounded"/><button onClick={()=>updateLoc(l)} className="text-green-600">✓</button><button onClick={()=>setEditLoc(null)} className="text-gray-600">✕</button></>:<><span className="text-blue-800">{l}</span><button onClick={()=>setEditLoc(l)} className="text-blue-600">✎</button><button onClick={()=>delLoc(l)} className="text-red-600">🗑</button></>}</div>)}
</div>
</div>
</div>
<div className={dark?'bg-gray-800 rounded-lg overflow-hidden':'bg-white rounded-lg shadow-lg overflow-hidden'}>
<h2 className="text-xl font-bold p-6 border-b">Contacts</h2>
<div className="overflow-x-auto">
<table className="w-full">
<thead className={dark?'bg-gray-700':'bg-gray-100'}>
<tr>
<th className="text-left p-4">Photo</th>
<th className="text-left p-4">Name</th>
<th className="text-left p-4">Unit</th>
<th className="text-left p-4">Phone</th>
<th className="text-left p-4">Mobile</th>
<th className="text-left p-4">Email</th>
<th className="text-left p-4">Dept</th>
<th className="text-left p-4">Locations</th>
<th className="text-left p-4">Actions</th>
</tr>
</thead>
<tbody>
{contacts.map(c=><tr key={c.id} className="border-b">{editing?.id===c.id?
<><td className="p-4"><div className="flex items-center gap-2">{editing.photo?<img src={editing.photo} className="w-12 h-12 rounded-full object-cover"/>:<div className="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center">👤</div>}<label className="text-blue-600 cursor-pointer">📤<input type="file" accept="image/*" onChange={e=>uploadPhoto(e,true)} className="hidden"/></label></div></td>
<td className="p-4"><input type="text" value={editing.name} onChange={e=>setEditing({...editing,name:e.target.value})} className={dark?'w-full px-2 py-1 bg-gray-700 border border-gray-600 rounded text-white':'w-full px-2 py-1 border rounded'}/></td>
<td className="p-4"><input type="text" value={editing.unit} onChange={e=>setEditing({...editing,unit:e.target.value})} className={dark?'w-full px-2 py-1 bg-gray-700 border border-gray-600 rounded text-white':'w-full px-2 py-1 border rounded'}/></td>
<td className="p-4"><input type="text" value={editing.phone} onChange={e=>setEditing({...editing,phone:e.target.value})} className={dark?'w-full px-2 py-1 bg-gray-700 border border-gray-600 rounded text-white':'w-full px-2 py-1 border rounded'}/></td>
<td className="p-4"><input type="text" value={editing.mobile} onChange={e=>setEditing({...editing,mobile:e.target.value})} className={dark?'w-full px-2 py-1 bg-gray-700 border border-gray-600 rounded text-white':'w-full px-2 py-1 border rounded'}/></td>
<td className="p-4"><input type="email" value={editing.email} onChange={e=>setEditing({...editing,email:e.target.value})} className={dark?'w-full px-2 py-1 bg-gray-700 border border-gray-600 rounded text-white':'w-full px-2 py-1 border rounded'}/></td>
<td className="p-4"><select value={editing.department} onChange={e=>setEditing({...editing,department:e.target.value})} className={dark?'w-full px-2 py-1 bg-gray-700 border border-gray-600 rounded text-white':'w-full px-2 py-1 border rounded'}><option value="">Select</option>{depts.map(d=><option key={d} value={d}>{d}</option>)}</select></td>
<td className="p-4"><div className="flex gap-1 flex-wrap">{locs.map(l=><label key={l} className="flex items-center gap-1 text-xs"><input type="checkbox" checked={editing.locations.includes(l)} onChange={e=>setEditing({...editing,locations:e.target.checked?[...editing.locations,l]:editing.locations.filter(x=>x!==l)})}/>{l}</label>)}</div></td>
<td className="p-4"><div className="flex gap-2"><button onClick={updateContact} className="text-green-600 text-xl">✓</button><button onClick={()=>setEditing(null)} className="text-gray-600 text-xl">✕</button></div></td></>
:
<><td className="p-4">{c.photo?<img src={c.photo} className="w-12 h-12 rounded-full object-cover"/>:<div className="w-12 h-12 rounded-full bg-gray-300 flex items-center justify-center">👤</div>}</td>
<td className="p-4 font-medium">{c.name}</td>
<td className="p-4">{c.unit}</td>
<td className="p-4">{c.phone}</td>
<td className="p-4">{c.mobile}</td>
<td className="p-4">{c.email}</td>
<td className="p-4"><span className="bg-purple-100 text-purple-800 px-2 py-1 rounded text-xs">{c.department}</span></td>
<td className="p-4"><div className="flex flex-wrap gap-1">{c.locations.map(l=><span key={l} className="bg-green-100 text-green-800 px-2 py-1 rounded text-xs">{l}</span>)}</div></td>
<td className="p-4"><div className="flex gap-2"><button onClick={()=>setEditing({...c})} className="text-blue-600 hover:text-blue-800 text-xl">✎</button><button onClick={()=>delContact(c.id)} className="text-red-600 hover:text-red-800 text-xl">🗑</button></div></td></>
}</tr>)}
</tbody>
</table>
</div>
</div>
</div>
{footer}
</div>
);
}
// ----- Directory (default landing after login; visible to all signed-in users) -----
return(
<div className={dark?'min-h-screen bg-gray-900 text-white flex flex-col':'min-h-screen bg-purple-50 flex flex-col'}>
<div className={dark?'bg-gray-800 p-6 shadow-lg':'bg-purple-600 text-white p-6 shadow-lg'}>
<div className="max-w-7xl mx-auto flex justify-between items-center">
<h1 className="text-3xl font-bold">Martinhal Contacts</h1>
<div className="flex gap-3 flex-wrap">
<button onClick={()=>setDark(!dark)} className="px-4 py-2 bg-white bg-opacity-20 rounded-lg hover:bg-opacity-30">{dark?'':'🌙'}</button>
{isAdmin&&<button onClick={()=>setView('admin')} className="px-4 py-2 bg-white bg-opacity-20 rounded-lg hover:bg-opacity-30">Admin</button>}
<button onClick={()=>setShowProfile(true)} className="flex items-center gap-2 px-3 py-2 bg-white bg-opacity-20 rounded-lg hover:bg-opacity-30">{profile&&profile.avatar?<img src={profile.avatar} className="w-7 h-7 rounded-full object-cover"/>:<span className="text-lg">👤</span>}<span className="hidden sm:inline">{profile?profile.displayName:'Profile'}</span></button>
<button onClick={doLogout} className="px-4 py-2 bg-white bg-opacity-20 rounded-lg hover:bg-opacity-30">Logout</button>
</div>
</div>
</div>
{profileModal}
<div className="max-w-7xl mx-auto p-6 flex-1 w-full">
<div className={dark?'bg-gray-800 p-6 rounded-xl mb-6':'bg-white p-6 rounded-xl shadow-lg mb-6'}>
<div className="flex flex-col gap-4">
<div className="flex gap-4 flex-wrap">
<input type="text" placeholder="Search..." value={search} onChange={e=>setSearch(e.target.value)} className={dark?'flex-1 min-w-[200px] px-4 py-3 bg-gray-700 border border-gray-600 rounded-lg text-white':'flex-1 min-w-[200px] px-4 py-3 border-2 rounded-lg'}/>
<select value={filterDept} onChange={e=>setFilterDept(e.target.value)} className={dark?'px-6 py-3 bg-gray-700 border border-gray-600 rounded-lg text-white font-medium':'px-6 py-3 border-2 rounded-lg font-medium'}>
<option value="All">All Departments</option>
{depts.map(d=><option key={d} value={d}>{d}</option>)}
</select>
<select value={filterLoc} onChange={e=>setFilterLoc(e.target.value)} className={dark?'px-6 py-3 bg-gray-700 border border-gray-600 rounded-lg text-white font-medium':'px-6 py-3 border-2 rounded-lg font-medium'}>
<option value="All">All Locations</option>
{locs.map(l=><option key={l} value={l}>{l}</option>)}
</select>
</div>
{(filterLoc!=='All'||filterDept!=='All')&&<div className="flex items-center gap-2 flex-wrap"><span className={dark?'text-gray-300':'text-gray-600'}>Active filters:</span>{filterDept!=='All'&&<span className="bg-purple-500 text-white px-3 py-1 rounded-full text-sm font-medium flex items-center gap-2">{filterDept}<button onClick={()=>setFilterDept('All')} className="hover:text-red-200">✕</button></span>}{filterLoc!=='All'&&<span className="bg-blue-500 text-white px-3 py-1 rounded-full text-sm font-medium flex items-center gap-2">{filterLoc}<button onClick={()=>setFilterLoc('All')} className="hover:text-red-200">✕</button></span>}<button onClick={()=>{setFilterLoc('All');setFilterDept('All')}} className="text-red-500 hover:text-red-700 font-bold ml-2">Clear All</button></div>}
</div>
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{filtered.map(c=>(
<div key={c.id} className={dark?'bg-gray-800 rounded-xl shadow-lg overflow-hidden':'bg-white rounded-xl shadow-lg overflow-hidden'}>
<div className="bg-purple-600 p-6 text-center text-white">
{c.photo?<img src={c.photo} alt={c.name} className="w-24 h-24 rounded-full mx-auto border-4 border-white object-cover"/>:<div className="w-24 h-24 rounded-full bg-white bg-opacity-20 mx-auto border-4 border-white flex items-center justify-center text-4xl">👤</div>}
<h3 className="text-xl font-bold mt-4">{c.name}</h3>
<p className="text-purple-100 text-sm">{c.unit}</p>
</div>
<div className="p-6 space-y-2">
<p className={dark?'text-gray-300':''}><strong className="text-purple-600">Phone:</strong> {c.phone}</p>
<p className={dark?'text-gray-300':''}><strong className="text-purple-600">Mobile:</strong> {c.mobile}</p>
<p className={dark?'text-gray-300':'break-all'}><strong className="text-purple-600">Email:</strong> {c.email}</p>
<div className="pt-2 border-t">
<span className="inline-block bg-purple-500 text-white px-3 py-1 rounded-full text-xs mb-2">{c.department}</span>
<div className="flex flex-wrap gap-1">
{c.locations.map(l=><span key={l} className="bg-blue-100 text-blue-800 px-2 py-1 rounded text-xs">{l}</span>)}
</div>
</div>
</div>
</div>
))}
</div>
{filtered.length===0&&<div className={dark?'bg-gray-800 p-12 rounded-xl text-center text-gray-400':'bg-white p-12 rounded-xl shadow-lg text-center text-gray-500'}>No contacts found</div>}
</div>
{footer}
</div>
);
}
ReactDOM.render(<App/>,document.getElementById('root'));
</script>
</body>
</html>
+14
View File
@@ -0,0 +1,14 @@
{
"name": "martinhal-contacts",
"version": "1.5.0",
"description": "Martinhal Contact Management System",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^4.18.2",
"cors": "^2.8.5",
"qrcode": "^1.5.3"
}
}
+392
View File
@@ -0,0 +1,392 @@
const express = require('express');
const fs = require('fs');
const path = require('path');
const cors = require('cors');
const crypto = require('crypto');
const QRCode = require('qrcode');
const app = express();
const PORT = 8000;
// Middleware
app.use(cors());
app.use(express.json({ limit: '50mb' })); // Increased limit for base64 images/avatars
// SECURITY: never let the static handler expose the auth store (contains
// password hashes and MFA secrets). Must come BEFORE express.static.
app.use((req, res, next) => {
if (/users_data\.json/i.test(req.path)) return res.status(404).end();
next();
});
app.use(express.static(__dirname)); // Serve static files from current directory
const DATA_FILE = path.join(__dirname, 'contacts_data.json');
const USERS_FILE = path.join(__dirname, 'users_data.json');
// Initialize contacts data file if it doesn't exist
if (!fs.existsSync(DATA_FILE)) {
const initialData = {
contacts: [
{ id: 1, name: 'John Smith', unit: 'Marketing', phone: '+351 123 456 789', mobile: '+351 987 654 321', email: 'john.smith@company.com', department: 'IT', locations: ['Martinhal Oriente'], photo: 'https://i.pravatar.cc/150?img=12' },
{ id: 2, name: 'Maria Santos', unit: 'Finance', phone: '+351 123 456 790', mobile: '+351 987 654 322', email: 'maria.santos@company.com', department: 'Accounting', locations: ['Martinhal Lisbon', 'Martinhal Quinta'], photo: 'https://i.pravatar.cc/150?img=5' },
{ id: 3, name: 'Pedro Costa', unit: 'Operations', phone: '+351 123 456 791', mobile: '+351 987 654 323', email: 'pedro.costa@company.com', department: 'Maintenance', locations: ['Martinhal Sagres'], photo: 'https://i.pravatar.cc/150?img=33' }
],
departments: ['IT', 'Accounting', 'Board', 'Housekeeping', 'Maintenance'],
locations: ['Martinhal Oriente', 'Martinhal Lisbon', 'Martinhal Quinta', 'Martinhal Sagres']
};
fs.writeFileSync(DATA_FILE, JSON.stringify(initialData, null, 2));
console.log('Created initial data file:', DATA_FILE);
}
// Initialize (empty) users store if it doesn't exist -> triggers first-run setup
if (!fs.existsSync(USERS_FILE)) {
fs.writeFileSync(USERS_FILE, JSON.stringify({ users: [] }, null, 2));
console.log('Created empty users store:', USERS_FILE);
}
// ---------------------------------------------------------------------------
// Auth helpers
// ---------------------------------------------------------------------------
function loadUsers() {
try { return JSON.parse(fs.readFileSync(USERS_FILE, 'utf8')); }
catch (e) { return { users: [] }; }
}
function saveUsers(d) {
fs.writeFileSync(USERS_FILE, JSON.stringify(d, null, 2));
}
// Password hashing (scrypt + random salt, constant-time compare)
function hashPassword(pw) {
const salt = crypto.randomBytes(16).toString('hex');
const hash = crypto.scryptSync(String(pw), salt, 64).toString('hex');
return { salt, hash };
}
function verifyPassword(pw, salt, hash) {
try {
const h = crypto.scryptSync(String(pw), salt, 64).toString('hex');
const a = Buffer.from(h, 'hex');
const b = Buffer.from(hash, 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
} catch (e) { return false; }
}
// --- TOTP (RFC 6238) implemented with built-in crypto, no extra deps ---
const B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
function base32Encode(buf) {
let bits = 0, value = 0, out = '';
for (let i = 0; i < buf.length; i++) {
value = (value << 8) | buf[i]; bits += 8;
while (bits >= 5) { out += B32[(value >>> (bits - 5)) & 31]; bits -= 5; }
}
if (bits > 0) out += B32[(value << (5 - bits)) & 31];
return out;
}
function base32Decode(str) {
str = String(str).replace(/=+$/, '').toUpperCase().replace(/\s/g, '');
let bits = 0, value = 0; const out = [];
for (const ch of str) {
const idx = B32.indexOf(ch);
if (idx < 0) continue;
value = (value << 5) | idx; bits += 5;
if (bits >= 8) { out.push((value >>> (bits - 8)) & 0xff); bits -= 8; }
}
return Buffer.from(out);
}
function genTotpSecret() { return base32Encode(crypto.randomBytes(20)); }
function hotp(secretB32, counter) {
const key = base32Decode(secretB32);
const buf = Buffer.alloc(8);
for (let i = 7; i >= 0; i--) { buf[i] = counter & 0xff; counter = Math.floor(counter / 256); }
const hmac = crypto.createHmac('sha1', key).update(buf).digest();
const offset = hmac[hmac.length - 1] & 0xf;
const code = ((hmac[offset] & 0x7f) << 24) | ((hmac[offset + 1] & 0xff) << 16) |
((hmac[offset + 2] & 0xff) << 8) | (hmac[offset + 3] & 0xff);
return (code % 1000000).toString().padStart(6, '0');
}
function verifyTotp(secretB32, token, window = 1) {
if (!token) return false;
token = String(token).replace(/\s/g, '');
const counter = Math.floor(Date.now() / 1000 / 30);
for (let e = -window; e <= window; e++) {
if (hotp(secretB32, counter + e) === token) return true;
}
return false;
}
function otpauthUrl(username, secret) {
const issuer = 'Martinhal Contacts';
return 'otpauth://totp/' + encodeURIComponent(issuer) + ':' + encodeURIComponent(username) +
'?secret=' + secret + '&issuer=' + encodeURIComponent(issuer) + '&algorithm=SHA1&digits=6&period=30';
}
// Sessions (in-memory; users re-login after a server restart)
const sessions = new Map(); // token -> username
function issueToken(username) {
const t = crypto.randomBytes(24).toString('hex');
sessions.set(t, username);
return t;
}
function currentUser(req) {
const h = req.headers.authorization || '';
const t = h.startsWith('Bearer ') ? h.slice(7) : null;
if (!t || !sessions.has(t)) return null;
const uname = sessions.get(t);
const d = loadUsers();
return d.users.find(u => u.username === uname) || null;
}
function requireAuth(req, res, next) {
const u = currentUser(req);
if (!u) return res.status(401).json({ error: 'Not authenticated' });
req.user = u;
next();
}
function requireAdmin(req, res, next) {
const u = currentUser(req);
if (!u) return res.status(401).json({ error: 'Not authenticated' });
if (!u.isAdmin) return res.status(403).json({ error: 'Administrator privileges required' });
req.user = u;
next();
}
function safeProfile(u) {
return {
username: u.username,
displayName: u.displayName || u.username,
avatar: u.avatar || '',
mfaEnabled: !!u.mfaEnabled,
isAdmin: !!u.isAdmin
};
}
// ---------------------------------------------------------------------------
// Auth routes
// ---------------------------------------------------------------------------
// Is initial setup done? (any user exists)
app.get('/api/auth/status', (req, res) => {
const d = loadUsers();
res.json({ setupComplete: d.users.length > 0 });
});
// First-run setup: create the very first admin (only when no users exist).
// MFA is optional, so we don't force enrollment — we just sign them in.
app.post('/api/auth/setup', (req, res) => {
const d = loadUsers();
if (d.users.length > 0) return res.status(403).json({ error: 'Setup already completed' });
const { username, password, displayName } = req.body || {};
if (!username || !password) return res.status(400).json({ error: 'Username and password required' });
if (String(password).length < 8) return res.status(400).json({ error: 'Password must be at least 8 characters' });
const { salt, hash } = hashPassword(password);
const user = {
username: String(username).trim(),
salt, hash,
mfaSecret: '', // generated only if/when the user enables MFA
mfaEnabled: false,
displayName: (displayName || '').trim() || String(username).trim(),
avatar: '',
isAdmin: true,
createdAt: new Date().toISOString()
};
d.users.push(user);
saveUsers(d);
res.json({ status: 'ok', token: issueToken(user.username), profile: safeProfile(user) });
});
// Login. MFA is OPTIONAL: a code is only required for users who have
// chosen to enable it. Everyone else logs in with username + password.
app.post('/api/auth/login', (req, res) => {
const { username, password, code } = req.body || {};
const d = loadUsers();
const u = d.users.find(x => x.username === String(username || '').trim());
if (!u || !verifyPassword(password, u.salt, u.hash)) {
return res.status(401).json({ error: 'Invalid username or password' });
}
// Only enforce a second factor if this account has MFA switched on.
if (u.mfaEnabled) {
if (!code) return res.json({ status: 'mfa' });
if (!verifyTotp(u.mfaSecret, code)) return res.status(401).json({ status: 'mfa', error: 'Invalid code' });
}
return res.json({ status: 'ok', token: issueToken(u.username), profile: safeProfile(u) });
});
app.post('/api/auth/logout', (req, res) => {
const h = req.headers.authorization || '';
const t = h.startsWith('Bearer ') ? h.slice(7) : null;
if (t) sessions.delete(t);
res.json({ success: true });
});
// Current profile (session restore)
app.get('/api/auth/me', requireAuth, (req, res) => {
res.json({ profile: safeProfile(req.user) });
});
// Update own profile (avatar / display name)
app.post('/api/auth/profile', requireAuth, (req, res) => {
const d = loadUsers();
const u = d.users.find(x => x.username === req.user.username);
if (!u) return res.status(404).json({ error: 'User not found' });
if (typeof req.body.displayName === 'string') u.displayName = req.body.displayName.trim() || u.username;
if (typeof req.body.avatar === 'string') u.avatar = req.body.avatar; // '' clears it
saveUsers(d);
res.json({ profile: safeProfile(u) });
});
// --- Optional MFA management (per-user, opt-in) ---
// Begin enabling MFA: generate a fresh secret + QR. Does NOT enable it yet.
app.post('/api/auth/mfa/setup', requireAuth, (req, res) => {
const d = loadUsers();
const u = d.users.find(x => x.username === req.user.username);
if (!u) return res.status(404).json({ error: 'User not found' });
u.mfaSecret = genTotpSecret();
u.mfaEnabled = false;
saveUsers(d);
const url = otpauthUrl(u.username, u.mfaSecret);
QRCode.toDataURL(url, (err, qr) => {
res.json({ otpauthUrl: url, qrDataUrl: err ? '' : qr, secret: u.mfaSecret });
});
});
// Confirm a code and switch MFA on for this account.
app.post('/api/auth/mfa/enable', requireAuth, (req, res) => {
const { code } = req.body || {};
const d = loadUsers();
const u = d.users.find(x => x.username === req.user.username);
if (!u) return res.status(404).json({ error: 'User not found' });
if (!u.mfaSecret) return res.status(400).json({ error: 'Start MFA setup first' });
if (!verifyTotp(u.mfaSecret, code)) return res.status(400).json({ error: 'Invalid code, try again' });
u.mfaEnabled = true;
saveUsers(d);
res.json({ profile: safeProfile(u) });
});
// Turn MFA back off (user is already authenticated).
app.post('/api/auth/mfa/disable', requireAuth, (req, res) => {
const d = loadUsers();
const u = d.users.find(x => x.username === req.user.username);
if (!u) return res.status(404).json({ error: 'User not found' });
u.mfaEnabled = false;
u.mfaSecret = '';
saveUsers(d);
res.json({ profile: safeProfile(u) });
});
// Change your own password (any authenticated user, admins included).
app.post('/api/auth/password', requireAuth, (req, res) => {
const { currentPassword, newPassword } = req.body || {};
if (!currentPassword || !newPassword) return res.status(400).json({ error: 'Current and new password required' });
if (String(newPassword).length < 8) return res.status(400).json({ error: 'New password must be at least 8 characters' });
const d = loadUsers();
const u = d.users.find(x => x.username === req.user.username);
if (!u) return res.status(404).json({ error: 'User not found' });
if (!verifyPassword(currentPassword, u.salt, u.hash)) {
return res.status(401).json({ error: 'Current password is incorrect' });
}
const { salt, hash } = hashPassword(newPassword);
u.salt = salt;
u.hash = hash;
saveUsers(d);
// Keep the current session but sign this account out everywhere else.
const h = req.headers.authorization || '';
const cur = h.startsWith('Bearer ') ? h.slice(7) : null;
for (const [tok, uname] of sessions) if (uname === u.username && tok !== cur) sessions.delete(tok);
res.json({ success: true });
});
app.get('/api/auth/users', requireAdmin, (req, res) => {
const d = loadUsers();
res.json({ users: d.users.map(safeProfile) });
});
// Add a new user (they enroll their own MFA on first login). Only admins may
// add users, and they choose whether the new account is an admin or view-only.
app.post('/api/auth/users', requireAdmin, (req, res) => {
const { username, password, displayName, isAdmin } = req.body || {};
if (!username || !password) return res.status(400).json({ error: 'Username and password required' });
if (String(password).length < 8) return res.status(400).json({ error: 'Password must be at least 8 characters' });
const d = loadUsers();
if (d.users.some(u => u.username === String(username).trim())) {
return res.status(409).json({ error: 'Username already exists' });
}
const { salt, hash } = hashPassword(password);
d.users.push({
username: String(username).trim(),
salt, hash,
mfaSecret: '',
mfaEnabled: false,
displayName: (displayName || '').trim() || String(username).trim(),
avatar: '',
isAdmin: !!isAdmin,
createdAt: new Date().toISOString()
});
saveUsers(d);
res.json({ success: true });
});
// Delete a user (cannot delete yourself or the last remaining user)
app.delete('/api/auth/users/:username', requireAdmin, (req, res) => {
const target = req.params.username;
if (target === req.user.username) return res.status(400).json({ error: "You can't delete your own account" });
const d = loadUsers();
if (d.users.length <= 1) return res.status(400).json({ error: 'Cannot delete the last user' });
const before = d.users.length;
d.users = d.users.filter(u => u.username !== target);
if (d.users.length === before) return res.status(404).json({ error: 'User not found' });
saveUsers(d);
// Invalidate any active sessions for that user
for (const [tok, uname] of sessions) if (uname === target) sessions.delete(tok);
res.json({ success: true });
});
// ---------------------------------------------------------------------------
// Contacts data routes (unchanged behaviour)
// ---------------------------------------------------------------------------
app.get('/api/data', requireAuth, (req, res) => {
try {
const data = JSON.parse(fs.readFileSync(DATA_FILE, 'utf8'));
res.json(data);
} catch (error) {
console.error('Error reading data:', error);
res.status(500).json({ error: 'Failed to read data' });
}
});
app.post('/api/data', requireAdmin, (req, res) => {
try {
fs.writeFileSync(DATA_FILE, JSON.stringify(req.body, null, 2));
res.json({ success: true });
} catch (error) {
console.error('Error saving data:', error);
res.status(500).json({ error: 'Failed to save data' });
}
});
app.post('/api/backup', requireAdmin, (req, res) => {
try {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupFile = path.join(__dirname, `backup_${timestamp}.json`);
const data = fs.readFileSync(DATA_FILE, 'utf8');
fs.writeFileSync(backupFile, data);
res.json({ success: true, file: backupFile });
} catch (error) {
console.error('Error creating backup:', error);
res.status(500).json({ error: 'Failed to create backup' });
}
});
if (require.main === module) {
app.listen(PORT, () => {
const d = loadUsers();
console.log('=================================');
console.log(`Server running at http://localhost:${PORT}`);
console.log(`Data file: ${DATA_FILE}`);
console.log(`Users file: ${USERS_FILE}`);
console.log(`Setup complete: ${d.users.length > 0} (${d.users.length} user(s))`);
console.log('=================================');
});
}
module.exports = app;