diff --git a/Deloy.md b/Deloy.md new file mode 100644 index 0000000..a6b14ec --- /dev/null +++ b/Deloy.md @@ -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= +JWT_SECRET_KEY= + +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= + +# 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` | diff --git a/app/static/css/app.css b/app/static/css/app.css index fe44b65..a08de56 100644 --- a/app/static/css/app.css +++ b/app/static/css/app.css @@ -154,6 +154,14 @@ ul { border-color: var(--color-primary); 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 { position: relative; @@ -305,12 +313,13 @@ ul { /* ── App layout (vault) ──────────────────────────────────────────── */ .vault-page { height: 100vh; + height: 100dvh; overflow: hidden; } .app-layout { display: flex; - height: 100vh; + height: 100%; } /* ── Sidebar ─────────────────────────────────────────────────────── */ @@ -402,6 +411,7 @@ ul { flex: 1; padding: 8px 0; overflow-y: auto; + -webkit-overflow-scrolling: touch; } .sidebar-section-header { @@ -623,6 +633,8 @@ ul { font-size: 18px; font-weight: 600; white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .vault-toolbar { @@ -683,6 +695,7 @@ ul { flex: 1; overflow-y: auto; padding: 12px 24px 24px; + -webkit-overflow-scrolling: touch; } /* Content max-width — keeps items readable on very wide screens */ @@ -763,8 +776,8 @@ ul { } .btn-icon { - width: 30px; - height: 30px; + width: 36px; + height: 36px; border-radius: 6px; display: flex; align-items: center; @@ -774,11 +787,19 @@ ul { transition: background var(--transition), color var(--transition); + /* Expand touch target without changing visual size */ + touch-action: manipulation; } .btn-icon:hover { background: var(--color-bg); color: var(--color-text); } +@media (max-width: 768px) { + .btn-icon { + min-width: 44px; + min-height: 44px; + } +} /* ── Spinner ─────────────────────────────────────────────────────── */ .spinner-overlay { @@ -829,7 +850,9 @@ ul { width: 100%; max-width: 480px; max-height: 90vh; + max-height: 90dvh; overflow-y: auto; + -webkit-overflow-scrolling: touch; padding: 28px; } @@ -932,6 +955,7 @@ ul { flex: 1; overflow-y: auto; padding: 20px 24px; + -webkit-overflow-scrolling: touch; } /* ── Tabs ────────────────────────────────────────────────────────── */ @@ -1105,6 +1129,7 @@ ul { display: flex; flex-direction: column; gap: 24px; + -webkit-overflow-scrolling: touch; } .sec-notice { @@ -1416,7 +1441,7 @@ ul { flex-shrink: 0; } -/* Sidebar backdrop — only active on mobile */ +/* Sidebar backdrop */ .sidebar-backdrop { display: none; position: fixed; @@ -1428,44 +1453,47 @@ ul { display: block; } -/* ── Phone (≤ 640px) ─────────────────────────────────────────────── */ -@media (max-width: 640px) { - /* Layout: stack topbar above main, no sidebar space in flow */ +/* ── Phone + iPad portrait (≤ 768px) ───────────────────────────── */ +@media (max-width: 768px) { .app-layout { flex-direction: column; - height: 100vh; + height: 100%; + min-height: 100dvh; overflow: hidden; } - /* Topbar always visible */ .mobile-topbar { display: flex; } - /* Sidebar slides in from the left as an overlay */ + /* Sidebar: completely out of layout flow — fixed overlay only */ .sidebar { - position: fixed; + display: none !important; + position: fixed !important; top: 0; left: 0; bottom: 0; - width: 260px !important; /* always full-width on mobile regardless of collapsed state */ + width: 280px !important; z-index: 90; - transform: translateX(-100%); - transition: transform 0.25s ease; overflow-y: auto; + transition: none; } .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 { opacity: 1 !important; max-width: none !important; + overflow: visible !important; } .sidebar .sidebar-section-header { opacity: 1 !important; height: auto !important; padding: 12px 16px 4px !important; + pointer-events: auto !important; } .sidebar .sidebar-item { justify-content: flex-start !important; @@ -1475,18 +1503,27 @@ ul { .sidebar .sidebar-item::after { 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 { - display: none; + display: none !important; } - /* Main area fills everything below topbar */ .vault-main { flex: 1; overflow: hidden; + min-height: 0; } - /* Tighter padding */ .vault-header { padding: 12px 16px; gap: 8px; @@ -1496,7 +1533,7 @@ ul { } .vault-list { padding: 8px 12px 80px; - } /* bottom pad for mobile FAB */ + } .panel-body { padding: 16px; } @@ -1505,7 +1542,6 @@ ul { gap: 16px; } - /* Search takes full width, hide sort */ .vault-toolbar { gap: 8px; } @@ -1516,33 +1552,40 @@ ul { display: none; } - /* FAB in header is hidden on mobile (use topbar FAB) */ .vault-header .btn-fab { display: none; } - /* Forms */ .form-row { flex-direction: column; gap: 0; } - .modal { - padding: 20px; - } .modal-actions { 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 { padding: 16px; gap: 12px; + flex-wrap: wrap; } .sec-score { font-size: 40px; } .sec-stats { gap: 8px; + flex-wrap: wrap; } .sec-stat { padding: 8px 12px; @@ -1552,25 +1595,6 @@ ul { 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 { flex-direction: column; gap: 4px; @@ -1580,14 +1604,11 @@ ul { } } -/* ── Tablet (641px – 1024px) ─────────────────────────────────────── */ -@media (min-width: 641px) and (max-width: 1024px) { - /* Sidebar always shows as icon rail on tablet — user can expand with toggle */ +/* ── iPad landscape / small laptop (769px – 1024px) ─────────────── */ +@media (min-width: 769px) and (max-width: 1024px) { .sidebar:not(.collapsed) { width: 200px; } - - /* Slightly tighter padding */ .vault-header { padding: 14px 18px; } @@ -1601,13 +1622,9 @@ ul { padding: 16px; gap: 16px; } - - /* Search a bit narrower */ .search-wrapper { max-width: 280px; } - - /* Security stats wrap gracefully */ .security-summary { flex-wrap: wrap; gap: 12px; @@ -1616,13 +1633,6 @@ ul { flex-wrap: wrap; gap: 10px; } - - /* Form rows stay side-by-side */ - .form-row { - flex-direction: row; - } - - /* Modal fits tablet width */ .modal { max-width: 460px; } @@ -1630,7 +1640,6 @@ ul { /* ── Laptop (1025px – 1440px) ───────────────────────────────────── */ @media (min-width: 1025px) and (max-width: 1440px) { - /* Full sidebar, comfortable padding */ .vault-header { padding: 16px 24px; } @@ -1644,7 +1653,6 @@ ul { /* ── Large PC (> 1440px) ────────────────────────────────────────── */ @media (min-width: 1441px) { - /* Wider sidebar, more generous spacing */ .sidebar:not(.collapsed) { 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-section-danger { border-top: 2px solid var(--color-danger, #e53e3e); diff --git a/app/static/js/vault.js b/app/static/js/vault.js index 665804c..56d185c 100644 --- a/app/static/js/vault.js +++ b/app/static/js/vault.js @@ -2648,24 +2648,46 @@ const Vault = (() => { // Mobile sidebar: hamburger opens overlay, backdrop/item-click closes it const backdrop = document.getElementById("sidebar-backdrop"); const mobileMenuBtn = document.getElementById("btn-mobile-menu"); + const mobileQuery = window.matchMedia("(max-width: 768px)"); + function openMobileSidebar() { sidebar?.classList.add("mobile-open"); backdrop?.classList.add("open"); - document.body.style.overflow = "hidden"; + // iOS Safari: lock scroll on , not + document.documentElement.style.overflow = "hidden"; } function closeMobileSidebar() { sidebar?.classList.remove("mobile-open"); backdrop?.classList.remove("open"); - document.body.style.overflow = ""; + document.documentElement.style.overflow = ""; } + mobileMenuBtn?.addEventListener("click", openMobileSidebar); + // Both click and touchstart for responsive feel on iOS 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) => { 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 document .getElementById("btn-add-item-mobile") diff --git a/app/templates/base.html b/app/templates/base.html index e6aede6..ce6a1b3 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -1,20 +1,28 @@ - + - - - - - - {% block title %}PassKeeper{% endblock %} - - {% block head_extra %}{% endblock %} - - - {% block body %}{% endblock %} + + + + + + {% block title %}PassKeeper{% endblock %} + + {% block head_extra %}{% endblock %} + + + {% block body %}{% endblock %} -
+
- {% block scripts %}{% endblock %} - + {% block scripts %}{% endblock %} +