04/18 fix web app responsive 3
This commit is contained in:
@@ -0,0 +1,686 @@
|
|||||||
|
# PassKeeper — Deployment Guide
|
||||||
|
|
||||||
|
Complete step-by-step instructions for deploying PassKeeper on a fresh Ubuntu server
|
||||||
|
from zero to a fully running production environment.
|
||||||
|
|
||||||
|
**Stack:** Ubuntu 22.04 LTS · Python 3.12 · MySQL 8 · Redis · Gunicorn · Nginx · Certbot (Let's Encrypt)
|
||||||
|
|
||||||
|
**Deployment paths used throughout this guide:**
|
||||||
|
|
||||||
|
- App directory: `/home/spuser/PassKeeper`
|
||||||
|
- Virtual env: `/home/spuser/.venv`
|
||||||
|
- Logs: `/home/spuser/logs/`
|
||||||
|
- Backups: `/home/spuser/backups/passkeeper/`
|
||||||
|
- System user: `spuser` (runs the app process)
|
||||||
|
- Domain: `pwkeeper.ngodanguyen.tech`
|
||||||
|
|
||||||
|
> Replace all occurrences of `pwkeeper.ngodanguyen.tech`, `spuser`, and passwords with
|
||||||
|
> your own values before running any command.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- A VPS or dedicated server running **Ubuntu 22.04 LTS**
|
||||||
|
- A registered domain name with an **A record** pointing to your server's public IP
|
||||||
|
- SSH access as a user with `sudo` privileges
|
||||||
|
- The PassKeeper source code (this repository) available on the server
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 1 — System packages
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt update && sudo apt upgrade -y
|
||||||
|
|
||||||
|
sudo apt install -y \
|
||||||
|
python3.12 \
|
||||||
|
python3.12-venv \
|
||||||
|
python3-pip \
|
||||||
|
mysql-server \
|
||||||
|
redis-server \
|
||||||
|
nginx \
|
||||||
|
certbot \
|
||||||
|
python3-certbot-nginx \
|
||||||
|
git \
|
||||||
|
curl \
|
||||||
|
ufw
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify Python version:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3.12 --version # must be 3.12.x
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 2 — Firewall
|
||||||
|
|
||||||
|
Allow only SSH, HTTP, and HTTPS. Everything else is dropped.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ufw default deny incoming
|
||||||
|
sudo ufw default allow outgoing
|
||||||
|
sudo ufw allow OpenSSH
|
||||||
|
sudo ufw allow 'Nginx Full'
|
||||||
|
sudo ufw enable
|
||||||
|
sudo ufw status
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 3 — MySQL setup
|
||||||
|
|
||||||
|
### 3a. Secure the MySQL installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mysql_secure_installation
|
||||||
|
```
|
||||||
|
|
||||||
|
Follow the prompts: set a root password, remove anonymous users, disallow remote root
|
||||||
|
login, remove the test database.
|
||||||
|
|
||||||
|
### 3b. Create the database and application user
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mysql -u root -p
|
||||||
|
```
|
||||||
|
|
||||||
|
Inside the MySQL shell:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE DATABASE passkeeper CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE USER 'spuser'@'127.0.0.1' IDENTIFIED BY 'your-strong-db-password-here';
|
||||||
|
GRANT ALL PRIVILEGES ON passkeeper.* TO 'spuser'@'127.0.0.1';
|
||||||
|
FLUSH PRIVILEGES;
|
||||||
|
EXIT;
|
||||||
|
```
|
||||||
|
|
||||||
|
> Use a strong, unique password. Update `MYSQL_PASSWORD` in `.env` (Step 6) to match.
|
||||||
|
|
||||||
|
Verify the connection:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mysql -u spuser -p -h 127.0.0.1 passkeeper -e "SELECT 1;"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 4 — Redis setup
|
||||||
|
|
||||||
|
Redis is required for shared rate limiting across all Gunicorn workers.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl enable redis-server
|
||||||
|
sudo systemctl start redis-server
|
||||||
|
redis-cli ping # should return PONG
|
||||||
|
```
|
||||||
|
|
||||||
|
Redis listens on `127.0.0.1:6379` by default. No password is required for local-only use.
|
||||||
|
If you want a Redis password, set it in `/etc/redis/redis.conf` and update
|
||||||
|
`RATELIMIT_STORAGE_URI` in `.env` accordingly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 5 — Deploy the application code
|
||||||
|
|
||||||
|
### 5a. Clone or upload the repository
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/spuser
|
||||||
|
git clone https://github.com/your-org/passkeeper.git PassKeeper
|
||||||
|
# — or — upload via scp/rsync:
|
||||||
|
# scp -r ./PassKeeper spuser@your-server:/home/spuser/PassKeeper
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5b. Create directory structure
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p /home/spuser/logs
|
||||||
|
mkdir -p /home/spuser/backups/passkeeper
|
||||||
|
chmod 700 /home/spuser/backups/passkeeper
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5c. Create the Python virtual environment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/spuser
|
||||||
|
python3.12 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install --upgrade pip
|
||||||
|
pip install -r PassKeeper/requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify key packages installed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip show flask gunicorn cryptography redis pyotp
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 6 — Configure environment variables
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/spuser/PassKeeper
|
||||||
|
cp .env.example .env # if an example file exists
|
||||||
|
# — or — create from scratch:
|
||||||
|
nano .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Paste the following, replacing every placeholder value:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
FLASK_ENV=production
|
||||||
|
SECRET_KEY=<generate: python3 -c "import secrets; print(secrets.token_hex(32))">
|
||||||
|
JWT_SECRET_KEY=<generate: python3 -c "import secrets; print(secrets.token_hex(32))">
|
||||||
|
|
||||||
|
MYSQL_HOST=127.0.0.1
|
||||||
|
MYSQL_PORT=3306
|
||||||
|
MYSQL_USER=spuser
|
||||||
|
MYSQL_PASSWORD=your-strong-db-password-here
|
||||||
|
MYSQL_DB=passkeeper
|
||||||
|
|
||||||
|
ARGON2_TIME_COST=3
|
||||||
|
ARGON2_MEMORY_COST=65536
|
||||||
|
ARGON2_PARALLELISM=4
|
||||||
|
|
||||||
|
# AES-256-GCM key for encrypting TOTP secrets at rest (REQUIRED)
|
||||||
|
# Generate: python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||||
|
TOTP_ENCRYPTION_KEY=<generate as above — 64 hex characters>
|
||||||
|
|
||||||
|
# Redis for shared rate limiting across Gunicorn workers
|
||||||
|
RATELIMIT_STORAGE_URI=redis://127.0.0.1:6379/0
|
||||||
|
|
||||||
|
# Lock CORS to your production domain
|
||||||
|
CORS_ORIGINS=https://pwkeeper.ngodanguyen.tech
|
||||||
|
|
||||||
|
# Backup settings
|
||||||
|
BACKUP_DIR=/home/spuser/backups/passkeeper
|
||||||
|
BACKUP_RETENTION_DAYS=30
|
||||||
|
```
|
||||||
|
|
||||||
|
Secure the file — it contains secrets:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod 600 /home/spuser/PassKeeper/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
Generate all three keys in one go:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -c "import secrets; [print(secrets.token_hex(32)) for _ in range(3)]"
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the three output lines for `SECRET_KEY`, `JWT_SECRET_KEY`, and `TOTP_ENCRYPTION_KEY`
|
||||||
|
respectively.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 7 — Initialise the database schema
|
||||||
|
|
||||||
|
Activate the virtual environment if not already active:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source /home/spuser/.venv/bin/activate
|
||||||
|
cd /home/spuser/PassKeeper
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option A — Fresh install (no existing data)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python reset_db.py
|
||||||
|
```
|
||||||
|
|
||||||
|
This drops and recreates all tables using `db.create_all()`. Safe only on a fresh
|
||||||
|
database with no data you care about.
|
||||||
|
|
||||||
|
### Option B — Existing database (run all migrations in order)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export FLASK_APP=wsgi.py
|
||||||
|
export FLASK_ENV=production
|
||||||
|
flask db upgrade
|
||||||
|
```
|
||||||
|
|
||||||
|
This runs the three Alembic migrations in sequence:
|
||||||
|
|
||||||
|
1. `71d7158dd3b9` — creates `audit_logs`, fixes `sharing_public_key` type
|
||||||
|
2. `a1b2c3d4e5f6` — widens `totp_secret` to `VARCHAR(255)`, adds `totp_iv`
|
||||||
|
3. `b2c3d4e5f6a7` — adds `recovery_enc_salt` and `recovery_iv`
|
||||||
|
|
||||||
|
### Re-encrypt existing TOTP secrets (only if migrating from an older version)
|
||||||
|
|
||||||
|
If you had users with plaintext TOTP secrets before Phase 5, run this one-time script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/reencrypt_totp_secrets.py
|
||||||
|
```
|
||||||
|
|
||||||
|
This is idempotent — it skips users who already have `totp_iv` set and rolls back
|
||||||
|
atomically on any error.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 8 — Test Gunicorn manually
|
||||||
|
|
||||||
|
Before installing the systemd service, verify Gunicorn can start the app:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source /home/spuser/.venv/bin/activate
|
||||||
|
cd /home/spuser/PassKeeper
|
||||||
|
gunicorn --workers 4 --bind 127.0.0.1:5000 --preload wsgi:app
|
||||||
|
```
|
||||||
|
|
||||||
|
You should see lines like:
|
||||||
|
|
||||||
|
```
|
||||||
|
[INFO] Listening at: http://127.0.0.1:5000
|
||||||
|
[INFO] Booting worker with pid: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Press `Ctrl+C` to stop. If you see errors, check `.env` values and DB connectivity.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 9 — systemd service
|
||||||
|
|
||||||
|
### 9a. Install the service unit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp /home/spuser/PassKeeper/scripts/passkeeper.service \
|
||||||
|
/etc/systemd/system/passkeeper.service
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9b. Verify the service file paths match your deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo nano /etc/systemd/system/passkeeper.service
|
||||||
|
```
|
||||||
|
|
||||||
|
Confirm these lines are correct for your setup:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
User=spuser
|
||||||
|
WorkingDirectory=/home/spuser/PassKeeper
|
||||||
|
EnvironmentFile=/home/spuser/PassKeeper/.env
|
||||||
|
ExecStart=/home/spuser/.venv/bin/gunicorn ...
|
||||||
|
--access-logfile /home/spuser/logs/access.log
|
||||||
|
--error-logfile /home/spuser/logs/error.log
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9c. Enable and start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable passkeeper
|
||||||
|
sudo systemctl start passkeeper
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9d. Verify it is running
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl status passkeeper
|
||||||
|
```
|
||||||
|
|
||||||
|
You should see `Active: active (running)`. If not:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
journalctl -xeu passkeeper.service --no-pager | tail -50
|
||||||
|
```
|
||||||
|
|
||||||
|
Test that Gunicorn is listening:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s http://127.0.0.1:5000/ | head -20
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 10 — Nginx configuration
|
||||||
|
|
||||||
|
### 10a. Install the site config
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp /home/spuser/PassKeeper/scripts/passkeeper-nginx.conf \
|
||||||
|
/etc/nginx/sites-available/passkeeper
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10b. Update the domain name
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo nano /etc/nginx/sites-available/passkeeper
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `pwkeeper.ngodanguyen.tech` and `passkeeper.ngodanguyen.tech` with your
|
||||||
|
actual domain(s) in the `server_name` directives.
|
||||||
|
|
||||||
|
Also update the static files alias if your deployment path differs:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location /static/ {
|
||||||
|
alias /home/spuser/PassKeeper/app/static/;
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10c. Enable the site
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ln -s /etc/nginx/sites-available/passkeeper /etc/nginx/sites-enabled/
|
||||||
|
sudo nginx -t # must output: configuration file ... syntax is ok
|
||||||
|
sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 11 — TLS certificate (Let's Encrypt)
|
||||||
|
|
||||||
|
Your DNS A record must already be pointing to this server before running Certbot.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo certbot --nginx -d pwkeeper.ngodanguyen.tech
|
||||||
|
```
|
||||||
|
|
||||||
|
Follow the prompts. When Certbot asks about redirects, choose option **2** (redirect
|
||||||
|
HTTP to HTTPS). Certbot will automatically modify the Nginx config to add the
|
||||||
|
`ssl_certificate` paths and HTTP → HTTPS redirect block.
|
||||||
|
|
||||||
|
Verify auto-renewal works:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo certbot renew --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
Confirm the final Nginx config is valid and reload:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo nginx -t && sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test from a browser
|
||||||
|
|
||||||
|
Open `https://pwkeeper.ngodanguyen.tech` — you should see the PassKeeper login page
|
||||||
|
served over HTTPS with a valid certificate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 12 — Log rotation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp /home/spuser/PassKeeper/scripts/passkeeper-logrotate \
|
||||||
|
/etc/logrotate.d/passkeeper
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify the config is valid:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo logrotate -d /etc/logrotate.d/passkeeper
|
||||||
|
```
|
||||||
|
|
||||||
|
Force an immediate test rotation (creates `.1.gz` files):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo logrotate -f /etc/logrotate.d/passkeeper
|
||||||
|
ls -lh /home/spuser/logs/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 13 — Automated database backups
|
||||||
|
|
||||||
|
### 13a. Make the backup script executable
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod +x /home/spuser/PassKeeper/scripts/backup_db.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 13b. Test the backup script manually
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash /home/spuser/PassKeeper/scripts/backup_db.sh
|
||||||
|
ls -lh /home/spuser/backups/passkeeper/
|
||||||
|
```
|
||||||
|
|
||||||
|
You should see a `.sql.gz` file. Check the log:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat /home/spuser/backups/passkeeper/backup.log
|
||||||
|
```
|
||||||
|
|
||||||
|
### 13c. Schedule via cron
|
||||||
|
|
||||||
|
```bash
|
||||||
|
crontab -e
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the following line (runs daily at 2:00 AM):
|
||||||
|
|
||||||
|
```
|
||||||
|
0 2 * * * /bin/bash /home/spuser/PassKeeper/scripts/backup_db.sh >> /home/spuser/backups/passkeeper/backup.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
Save and exit. Verify the cron entry was saved:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
crontab -l
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 14 — Final verification checklist
|
||||||
|
|
||||||
|
Run through each item to confirm the deployment is healthy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. MySQL is running and the DB is accessible
|
||||||
|
mysql -u spuser -p -h 127.0.0.1 passkeeper -e "SHOW TABLES;"
|
||||||
|
|
||||||
|
# 2. Redis is running
|
||||||
|
redis-cli ping
|
||||||
|
|
||||||
|
# 3. Gunicorn service is active
|
||||||
|
sudo systemctl status passkeeper
|
||||||
|
|
||||||
|
# 4. Nginx is active and config is valid
|
||||||
|
sudo systemctl status nginx
|
||||||
|
sudo nginx -t
|
||||||
|
|
||||||
|
# 5. App responds on localhost
|
||||||
|
curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:5000/
|
||||||
|
|
||||||
|
# 6. App responds on HTTPS (replace with your domain)
|
||||||
|
curl -s -o /dev/null -w "%{http_code}" https://pwkeeper.ngodanguyen.tech/
|
||||||
|
|
||||||
|
# 7. Security headers are present
|
||||||
|
curl -sI https://pwkeeper.ngodanguyen.tech/ | grep -E "Strict-Transport|X-Frame|X-Content|Content-Security"
|
||||||
|
|
||||||
|
# 8. Gunicorn logs are being written
|
||||||
|
tail -20 /home/spuser/logs/access.log
|
||||||
|
tail -20 /home/spuser/logs/error.log
|
||||||
|
|
||||||
|
# 9. No errors in the systemd journal
|
||||||
|
journalctl -u passkeeper --since "5 minutes ago" --no-pager
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected HTTP status codes: `200` from both `127.0.0.1:5000` and `https://yourdomain`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Routine Operations
|
||||||
|
|
||||||
|
### Deploying a code update
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/spuser/PassKeeper
|
||||||
|
|
||||||
|
# Pull new code
|
||||||
|
git pull origin main
|
||||||
|
|
||||||
|
# Install any new dependencies
|
||||||
|
source /home/spuser/.venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Run any new database migrations
|
||||||
|
export FLASK_APP=wsgi.py FLASK_ENV=production
|
||||||
|
flask db upgrade
|
||||||
|
|
||||||
|
# Reload Gunicorn zero-downtime (sends USR2 to master)
|
||||||
|
sudo systemctl reload passkeeper
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
sudo systemctl status passkeeper
|
||||||
|
```
|
||||||
|
|
||||||
|
### Restarting the app (full restart, brief downtime)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl restart passkeeper
|
||||||
|
```
|
||||||
|
|
||||||
|
### Viewing live logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Gunicorn access log (all HTTP requests)
|
||||||
|
tail -f /home/spuser/logs/access.log
|
||||||
|
|
||||||
|
# Gunicorn error log (Python exceptions, startup errors)
|
||||||
|
tail -f /home/spuser/logs/error.log
|
||||||
|
|
||||||
|
# systemd journal (service lifecycle events)
|
||||||
|
journalctl -fu passkeeper
|
||||||
|
```
|
||||||
|
|
||||||
|
### Checking rate limit behaviour (Redis)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
redis-cli
|
||||||
|
> KEYS *
|
||||||
|
> DBSIZE
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual database backup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash /home/spuser/PassKeeper/scripts/backup_db.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Restoring a database backup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List available backups
|
||||||
|
ls -lh /home/spuser/backups/passkeeper/*.sql.gz
|
||||||
|
|
||||||
|
# Restore (replace filename with the one you want)
|
||||||
|
gunzip -c /home/spuser/backups/passkeeper/passkeeper_20260418_020001.sql.gz \
|
||||||
|
| mysql -u spuser -p -h 127.0.0.1 passkeeper
|
||||||
|
```
|
||||||
|
|
||||||
|
### Renewing the TLS certificate manually
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo certbot renew
|
||||||
|
sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
Certbot auto-renewal runs twice daily via a systemd timer — manual renewal is usually
|
||||||
|
only needed for testing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### `502 Bad Gateway` from Nginx
|
||||||
|
|
||||||
|
Gunicorn is down or not listening. Check:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl status passkeeper
|
||||||
|
journalctl -xeu passkeeper.service --no-pager | tail -30
|
||||||
|
curl http://127.0.0.1:5000/
|
||||||
|
```
|
||||||
|
|
||||||
|
Common causes: wrong path in `EnvironmentFile`, missing `.env` variable, MySQL not
|
||||||
|
reachable at startup. Fix the root cause, then:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl restart passkeeper
|
||||||
|
```
|
||||||
|
|
||||||
|
### `Connection refused` on port 5000
|
||||||
|
|
||||||
|
Gunicorn crashed. Check the error log:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tail -50 /home/spuser/logs/error.log
|
||||||
|
```
|
||||||
|
|
||||||
|
If it's a startup error (e.g. bad `TOTP_ENCRYPTION_KEY` format), fix `.env` and restart.
|
||||||
|
|
||||||
|
### `500 Internal Server Error`
|
||||||
|
|
||||||
|
Application exception. Check:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tail -50 /home/spuser/logs/error.log
|
||||||
|
journalctl -u passkeeper --since "1 minute ago" --no-pager
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database connection errors
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test connectivity directly
|
||||||
|
mysql -u spuser -p -h 127.0.0.1 passkeeper -e "SELECT 1;"
|
||||||
|
|
||||||
|
# Check MySQL is running
|
||||||
|
sudo systemctl status mysql
|
||||||
|
|
||||||
|
# Check .env has correct credentials
|
||||||
|
grep MYSQL /home/spuser/PassKeeper/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
### Certbot / TLS renewal fails
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check DNS is resolving to this server
|
||||||
|
dig +short pwkeeper.ngodanguyen.tech
|
||||||
|
|
||||||
|
# Check Nginx serves port 80 (needed for ACME challenge)
|
||||||
|
sudo nginx -t
|
||||||
|
sudo systemctl status nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rate limiting too aggressive (429 errors)
|
||||||
|
|
||||||
|
Check Redis is running (if Redis is down, Flask-Limiter falls back to in-memory
|
||||||
|
counters that reset on every request, which may cause unexpected behaviour):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
redis-cli ping
|
||||||
|
sudo systemctl status redis-server
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Variable Reference
|
||||||
|
|
||||||
|
| Variable | Required | Description |
|
||||||
|
| ----------------------- | -------- | ---------------------------------------------------------------- |
|
||||||
|
| `FLASK_ENV` | Yes | `production` or `development` |
|
||||||
|
| `SECRET_KEY` | Yes | Flask session signing key — 32-byte hex |
|
||||||
|
| `JWT_SECRET_KEY` | Yes | JWT signing key — 32-byte hex |
|
||||||
|
| `MYSQL_HOST` | Yes | MySQL host (usually `127.0.0.1`) |
|
||||||
|
| `MYSQL_PORT` | Yes | MySQL port (usually `3306`) |
|
||||||
|
| `MYSQL_USER` | Yes | MySQL username |
|
||||||
|
| `MYSQL_PASSWORD` | Yes | MySQL password |
|
||||||
|
| `MYSQL_DB` | Yes | Database name (`passkeeper`) |
|
||||||
|
| `TOTP_ENCRYPTION_KEY` | Yes | 64-char hex — AES-256-GCM key for TOTP secrets |
|
||||||
|
| `RATELIMIT_STORAGE_URI` | Yes | `redis://127.0.0.1:6379/0` in production |
|
||||||
|
| `CORS_ORIGINS` | Yes | Your production domain, e.g. `https://pwkeeper.ngodanguyen.tech` |
|
||||||
|
| `ARGON2_TIME_COST` | No | Default `3` |
|
||||||
|
| `ARGON2_MEMORY_COST` | No | Default `65536` (64 MB) |
|
||||||
|
| `ARGON2_PARALLELISM` | No | Default `4` |
|
||||||
|
| `BACKUP_DIR` | No | Default `/home/spuser/backups/passkeeper` |
|
||||||
|
| `BACKUP_RETENTION_DAYS` | No | Default `30` |
|
||||||
+87
-65
@@ -154,6 +154,14 @@ ul {
|
|||||||
border-color: var(--color-primary);
|
border-color: var(--color-primary);
|
||||||
box-shadow: 0 0 0 3px rgba(208, 2, 27, 0.12);
|
box-shadow: 0 0 0 3px rgba(208, 2, 27, 0.12);
|
||||||
}
|
}
|
||||||
|
/* Prevent iOS viewport auto-zoom on input focus (requires font-size >= 16px) */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.form-group input,
|
||||||
|
.form-group select,
|
||||||
|
.form-group textarea {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.input-with-toggle {
|
.input-with-toggle {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -305,12 +313,13 @@ ul {
|
|||||||
/* ── App layout (vault) ──────────────────────────────────────────── */
|
/* ── App layout (vault) ──────────────────────────────────────────── */
|
||||||
.vault-page {
|
.vault-page {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
height: 100dvh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-layout {
|
.app-layout {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 100vh;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Sidebar ─────────────────────────────────────────────────────── */
|
/* ── Sidebar ─────────────────────────────────────────────────────── */
|
||||||
@@ -402,6 +411,7 @@ ul {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 8px 0;
|
padding: 8px 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-section-header {
|
.sidebar-section-header {
|
||||||
@@ -623,6 +633,8 @@ ul {
|
|||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vault-toolbar {
|
.vault-toolbar {
|
||||||
@@ -683,6 +695,7 @@ ul {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 12px 24px 24px;
|
padding: 12px 24px 24px;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Content max-width — keeps items readable on very wide screens */
|
/* Content max-width — keeps items readable on very wide screens */
|
||||||
@@ -763,8 +776,8 @@ ul {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-icon {
|
.btn-icon {
|
||||||
width: 30px;
|
width: 36px;
|
||||||
height: 30px;
|
height: 36px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -774,11 +787,19 @@ ul {
|
|||||||
transition:
|
transition:
|
||||||
background var(--transition),
|
background var(--transition),
|
||||||
color var(--transition);
|
color var(--transition);
|
||||||
|
/* Expand touch target without changing visual size */
|
||||||
|
touch-action: manipulation;
|
||||||
}
|
}
|
||||||
.btn-icon:hover {
|
.btn-icon:hover {
|
||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.btn-icon {
|
||||||
|
min-width: 44px;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Spinner ─────────────────────────────────────────────────────── */
|
/* ── Spinner ─────────────────────────────────────────────────────── */
|
||||||
.spinner-overlay {
|
.spinner-overlay {
|
||||||
@@ -829,7 +850,9 @@ ul {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 480px;
|
max-width: 480px;
|
||||||
max-height: 90vh;
|
max-height: 90vh;
|
||||||
|
max-height: 90dvh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
padding: 28px;
|
padding: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -932,6 +955,7 @@ ul {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 20px 24px;
|
padding: 20px 24px;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Tabs ────────────────────────────────────────────────────────── */
|
/* ── Tabs ────────────────────────────────────────────────────────── */
|
||||||
@@ -1105,6 +1129,7 @@ ul {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sec-notice {
|
.sec-notice {
|
||||||
@@ -1416,7 +1441,7 @@ ul {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sidebar backdrop — only active on mobile */
|
/* Sidebar backdrop */
|
||||||
.sidebar-backdrop {
|
.sidebar-backdrop {
|
||||||
display: none;
|
display: none;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -1428,44 +1453,47 @@ ul {
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Phone (≤ 640px) ─────────────────────────────────────────────── */
|
/* ── Phone + iPad portrait (≤ 768px) ───────────────────────────── */
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 768px) {
|
||||||
/* Layout: stack topbar above main, no sidebar space in flow */
|
|
||||||
.app-layout {
|
.app-layout {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100vh;
|
height: 100%;
|
||||||
|
min-height: 100dvh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Topbar always visible */
|
|
||||||
.mobile-topbar {
|
.mobile-topbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sidebar slides in from the left as an overlay */
|
/* Sidebar: completely out of layout flow — fixed overlay only */
|
||||||
.sidebar {
|
.sidebar {
|
||||||
position: fixed;
|
display: none !important;
|
||||||
|
position: fixed !important;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 260px !important; /* always full-width on mobile regardless of collapsed state */
|
width: 280px !important;
|
||||||
z-index: 90;
|
z-index: 90;
|
||||||
transform: translateX(-100%);
|
|
||||||
transition: transform 0.25s ease;
|
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
transition: none;
|
||||||
}
|
}
|
||||||
.sidebar.mobile-open {
|
.sidebar.mobile-open {
|
||||||
transform: translateX(0);
|
display: flex !important;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
/* Show all labels inside the mobile overlay sidebar */
|
|
||||||
|
/* Mobile sidebar: always show full expanded state */
|
||||||
.sidebar .sidebar-label {
|
.sidebar .sidebar-label {
|
||||||
opacity: 1 !important;
|
opacity: 1 !important;
|
||||||
max-width: none !important;
|
max-width: none !important;
|
||||||
|
overflow: visible !important;
|
||||||
}
|
}
|
||||||
.sidebar .sidebar-section-header {
|
.sidebar .sidebar-section-header {
|
||||||
opacity: 1 !important;
|
opacity: 1 !important;
|
||||||
height: auto !important;
|
height: auto !important;
|
||||||
padding: 12px 16px 4px !important;
|
padding: 12px 16px 4px !important;
|
||||||
|
pointer-events: auto !important;
|
||||||
}
|
}
|
||||||
.sidebar .sidebar-item {
|
.sidebar .sidebar-item {
|
||||||
justify-content: flex-start !important;
|
justify-content: flex-start !important;
|
||||||
@@ -1475,18 +1503,27 @@ ul {
|
|||||||
.sidebar .sidebar-item::after {
|
.sidebar .sidebar-item::after {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
/* Hide desktop collapse toggle on mobile */
|
.sidebar .sidebar-header {
|
||||||
|
justify-content: flex-start !important;
|
||||||
|
padding: 14px 12px !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
}
|
||||||
|
.sidebar .sidebar-header .logo-icon,
|
||||||
|
.sidebar .sidebar-header .logo-text {
|
||||||
|
opacity: 1 !important;
|
||||||
|
width: auto !important;
|
||||||
|
pointer-events: auto !important;
|
||||||
|
}
|
||||||
.btn-sidebar-toggle {
|
.btn-sidebar-toggle {
|
||||||
display: none;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Main area fills everything below topbar */
|
|
||||||
.vault-main {
|
.vault-main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tighter padding */
|
|
||||||
.vault-header {
|
.vault-header {
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -1496,7 +1533,7 @@ ul {
|
|||||||
}
|
}
|
||||||
.vault-list {
|
.vault-list {
|
||||||
padding: 8px 12px 80px;
|
padding: 8px 12px 80px;
|
||||||
} /* bottom pad for mobile FAB */
|
}
|
||||||
.panel-body {
|
.panel-body {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
@@ -1505,7 +1542,6 @@ ul {
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Search takes full width, hide sort */
|
|
||||||
.vault-toolbar {
|
.vault-toolbar {
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
@@ -1516,33 +1552,40 @@ ul {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* FAB in header is hidden on mobile (use topbar FAB) */
|
|
||||||
.vault-header .btn-fab {
|
.vault-header .btn-fab {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Forms */
|
|
||||||
.form-row {
|
.form-row {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
}
|
}
|
||||||
.modal {
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
.modal-actions {
|
.modal-actions {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Security stats wrap */
|
.modal-overlay {
|
||||||
|
padding: 0;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
max-width: 100% !important;
|
||||||
|
max-height: 92dvh;
|
||||||
|
border-radius: 16px 16px 0 0;
|
||||||
|
padding: 20px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.security-summary {
|
.security-summary {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
.sec-score {
|
.sec-score {
|
||||||
font-size: 40px;
|
font-size: 40px;
|
||||||
}
|
}
|
||||||
.sec-stats {
|
.sec-stats {
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
.sec-stat {
|
.sec-stat {
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
@@ -1552,25 +1595,6 @@ ul {
|
|||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Modal: full-screen on phones */
|
|
||||||
.modal-overlay {
|
|
||||||
padding: 0;
|
|
||||||
align-items: flex-end;
|
|
||||||
}
|
|
||||||
.modal {
|
|
||||||
max-width: 100%;
|
|
||||||
max-height: 92vh;
|
|
||||||
border-radius: 16px 16px 0 0;
|
|
||||||
padding: 20px 16px;
|
|
||||||
}
|
|
||||||
.modal-settings {
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
.modal-item {
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Detail label stacks above value on small screens */
|
|
||||||
.detail-field {
|
.detail-field {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
@@ -1580,14 +1604,11 @@ ul {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Tablet (641px – 1024px) ─────────────────────────────────────── */
|
/* ── iPad landscape / small laptop (769px – 1024px) ─────────────── */
|
||||||
@media (min-width: 641px) and (max-width: 1024px) {
|
@media (min-width: 769px) and (max-width: 1024px) {
|
||||||
/* Sidebar always shows as icon rail on tablet — user can expand with toggle */
|
|
||||||
.sidebar:not(.collapsed) {
|
.sidebar:not(.collapsed) {
|
||||||
width: 200px;
|
width: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Slightly tighter padding */
|
|
||||||
.vault-header {
|
.vault-header {
|
||||||
padding: 14px 18px;
|
padding: 14px 18px;
|
||||||
}
|
}
|
||||||
@@ -1601,13 +1622,9 @@ ul {
|
|||||||
padding: 16px;
|
padding: 16px;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Search a bit narrower */
|
|
||||||
.search-wrapper {
|
.search-wrapper {
|
||||||
max-width: 280px;
|
max-width: 280px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Security stats wrap gracefully */
|
|
||||||
.security-summary {
|
.security-summary {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -1616,13 +1633,6 @@ ul {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Form rows stay side-by-side */
|
|
||||||
.form-row {
|
|
||||||
flex-direction: row;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Modal fits tablet width */
|
|
||||||
.modal {
|
.modal {
|
||||||
max-width: 460px;
|
max-width: 460px;
|
||||||
}
|
}
|
||||||
@@ -1630,7 +1640,6 @@ ul {
|
|||||||
|
|
||||||
/* ── Laptop (1025px – 1440px) ───────────────────────────────────── */
|
/* ── Laptop (1025px – 1440px) ───────────────────────────────────── */
|
||||||
@media (min-width: 1025px) and (max-width: 1440px) {
|
@media (min-width: 1025px) and (max-width: 1440px) {
|
||||||
/* Full sidebar, comfortable padding */
|
|
||||||
.vault-header {
|
.vault-header {
|
||||||
padding: 16px 24px;
|
padding: 16px 24px;
|
||||||
}
|
}
|
||||||
@@ -1644,7 +1653,6 @@ ul {
|
|||||||
|
|
||||||
/* ── Large PC (> 1440px) ────────────────────────────────────────── */
|
/* ── Large PC (> 1440px) ────────────────────────────────────────── */
|
||||||
@media (min-width: 1441px) {
|
@media (min-width: 1441px) {
|
||||||
/* Wider sidebar, more generous spacing */
|
|
||||||
.sidebar:not(.collapsed) {
|
.sidebar:not(.collapsed) {
|
||||||
width: 260px;
|
width: 260px;
|
||||||
}
|
}
|
||||||
@@ -1678,6 +1686,20 @@ ul {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Auth pages — phone ──────────────────────────────────────────── */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.auth-container {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.auth-card {
|
||||||
|
padding: 28px 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
.auth-title {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Settings — new sections (Phase 6) ──────────────────────────── */
|
/* ── Settings — new sections (Phase 6) ──────────────────────────── */
|
||||||
.settings-section-danger {
|
.settings-section-danger {
|
||||||
border-top: 2px solid var(--color-danger, #e53e3e);
|
border-top: 2px solid var(--color-danger, #e53e3e);
|
||||||
|
|||||||
+26
-4
@@ -2648,24 +2648,46 @@ const Vault = (() => {
|
|||||||
// Mobile sidebar: hamburger opens overlay, backdrop/item-click closes it
|
// Mobile sidebar: hamburger opens overlay, backdrop/item-click closes it
|
||||||
const backdrop = document.getElementById("sidebar-backdrop");
|
const backdrop = document.getElementById("sidebar-backdrop");
|
||||||
const mobileMenuBtn = document.getElementById("btn-mobile-menu");
|
const mobileMenuBtn = document.getElementById("btn-mobile-menu");
|
||||||
|
const mobileQuery = window.matchMedia("(max-width: 768px)");
|
||||||
|
|
||||||
function openMobileSidebar() {
|
function openMobileSidebar() {
|
||||||
sidebar?.classList.add("mobile-open");
|
sidebar?.classList.add("mobile-open");
|
||||||
backdrop?.classList.add("open");
|
backdrop?.classList.add("open");
|
||||||
document.body.style.overflow = "hidden";
|
// iOS Safari: lock scroll on <html>, not <body>
|
||||||
|
document.documentElement.style.overflow = "hidden";
|
||||||
}
|
}
|
||||||
function closeMobileSidebar() {
|
function closeMobileSidebar() {
|
||||||
sidebar?.classList.remove("mobile-open");
|
sidebar?.classList.remove("mobile-open");
|
||||||
backdrop?.classList.remove("open");
|
backdrop?.classList.remove("open");
|
||||||
document.body.style.overflow = "";
|
document.documentElement.style.overflow = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
mobileMenuBtn?.addEventListener("click", openMobileSidebar);
|
mobileMenuBtn?.addEventListener("click", openMobileSidebar);
|
||||||
|
// Both click and touchstart for responsive feel on iOS
|
||||||
backdrop?.addEventListener("click", closeMobileSidebar);
|
backdrop?.addEventListener("click", closeMobileSidebar);
|
||||||
// Close mobile sidebar whenever a nav item is clicked
|
backdrop?.addEventListener("touchstart", closeMobileSidebar, {
|
||||||
|
passive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close mobile sidebar whenever a nav item is clicked (mobile only)
|
||||||
document.querySelectorAll(".sidebar-item").forEach((item) => {
|
document.querySelectorAll(".sidebar-item").forEach((item) => {
|
||||||
item.addEventListener("click", () => {
|
item.addEventListener("click", () => {
|
||||||
if (window.innerWidth <= 640) closeMobileSidebar();
|
if (mobileQuery.matches) closeMobileSidebar();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Close on Escape key
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Escape" && sidebar?.classList.contains("mobile-open")) {
|
||||||
|
closeMobileSidebar();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure sidebar is closed and scroll unlocked when switching to desktop
|
||||||
|
mobileQuery.addEventListener("change", (e) => {
|
||||||
|
if (!e.matches) closeMobileSidebar();
|
||||||
|
});
|
||||||
|
|
||||||
// Mobile FAB mirrors the desktop add-item button
|
// Mobile FAB mirrors the desktop add-item button
|
||||||
document
|
document
|
||||||
.getElementById("btn-add-item-mobile")
|
.getElementById("btn-add-item-mobile")
|
||||||
|
|||||||
+19
-11
@@ -1,20 +1,28 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta
|
||||||
<meta http-equiv="Content-Security-Policy"
|
name="viewport"
|
||||||
content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self';">
|
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
|
||||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
/>
|
||||||
|
<meta
|
||||||
|
http-equiv="Content-Security-Policy"
|
||||||
|
content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self';"
|
||||||
|
/>
|
||||||
|
<meta name="csrf-token" content="{{ csrf_token() }}" />
|
||||||
<title>{% block title %}PassKeeper{% endblock %}</title>
|
<title>{% block title %}PassKeeper{% endblock %}</title>
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/app.css') }}">
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="{{ url_for('static', filename='css/app.css') }}"
|
||||||
|
/>
|
||||||
{% block head_extra %}{% endblock %}
|
{% block head_extra %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body class="{% block body_class %}{% endblock %}">
|
<body class="{% block body_class %}{% endblock %}">
|
||||||
{% block body %}{% endblock %}
|
{% block body %}{% endblock %}
|
||||||
|
|
||||||
<div id="toast" class="toast" aria-live="polite"></div>
|
<div id="toast" class="toast" aria-live="polite"></div>
|
||||||
|
|
||||||
{% block scripts %}{% endblock %}
|
{% block scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user