v_1.5
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
.env
|
||||
.DS_Store
|
||||
backup_*.json
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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! 🎉
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user