commit 7caa62a428c1aa91a210c267bc62fd7e5d16ee6d Author: nearxos Date: Wed Feb 18 08:18:50 2026 +0200 Initial commit: Portal Auth Admin Dashboard Co-authored-by: Cursor diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..51c662a --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# Auth DB (portal_auth on Auth LXC) - same as portal .env +DB_AUTH_HOST=10.110.60.210 +DB_AUTH_PORT=5432 +DB_AUTH_NAME=portal_auth +DB_AUTH_USER=portal_user +DB_AUTH_PASSWORD=your_password_here + +# Dashboard +SECRET_KEY=change-me-to-a-random-secret-key +FLASK_ENV=development diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1bbbfa8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.env +venv/ +__pycache__/ +*.pyc +*.pyo +.cursor/ +*.egg-info/ +.eggs/ +dist/ +build/ diff --git a/AUTH_LXC_PORTAL_AUTH_DATABASE.md b/AUTH_LXC_PORTAL_AUTH_DATABASE.md new file mode 100644 index 0000000..e182908 --- /dev/null +++ b/AUTH_LXC_PORTAL_AUTH_DATABASE.md @@ -0,0 +1,200 @@ +# Auth LXC – Shared Portal Auth Database + +## Purpose + +The **Auth LXC** is a dedicated container that runs PostgreSQL and hosts the **shared auth database** (`portal_auth`). It allows multiple portal applications (e.g. FreePBX Portal, future billing or other portals) to use the **same user accounts** without duplicating users or maintaining separate credentials per portal. + +- **One set of users:** Create an account once; it works for every portal that points to this DB. +- **Central auth data:** Users, sessions, auth logs, and API tokens live here. +- **Portal-specific data stays elsewhere:** Each portal keeps its own database (e.g. extensions, companies, assignments) on its own host or on this LXC; only auth is shared. + +--- + +## Host Details + +| Item | Value | +|------|--------| +| **Host / LXC** | Auth (DB) LXC | +| **IP address** | `10.110.60.210` | +| **SSH access** | `ssh root@10.110.60.210` | +| **OS** | Debian GNU/Linux 13 (trixie) | +| **PostgreSQL** | 17 (Debian package) | +| **Listen** | All interfaces, port `5432` | + +--- + +## What This LXC Hosts + +### Database: `portal_auth` + +This is the **only database** used for shared authentication. It contains four tables: + +| Table | Purpose | +|-------|--------| +| `users` | User accounts (username, password hash, role, active flag) | +| `sessions` | Active login sessions (session_id, username, expires_at) | +| `auth_logs` | Auth events (login, logout, failed login, account_locked, deactivated) | +| `api_tokens` | API tokens for programmatic access (token hash, username, expiry, active) | + +No other application data (extensions, companies, trunks, activity logs, etc.) is stored here; that remains in each portal’s own database. + +--- + +## Database Structure (`portal_auth`) + +### 1. `users` + +| Column | Type | Constraints | Description | +|--------|------|-------------|-------------| +| `id` | SERIAL | PRIMARY KEY | Auto-incrementing user ID | +| `username` | VARCHAR(255) | UNIQUE, NOT NULL | Login username | +| `password_hash` | TEXT | NOT NULL | Bcrypt or legacy salt:hash | +| `role` | VARCHAR(50) | NOT NULL, DEFAULT 'support' | `admin` or `support` | +| `created_at` | TIMESTAMP | NOT NULL | Account creation (UTC) | +| `created_by` | VARCHAR(255) | NULL | Creator username | +| `is_active` | BOOLEAN | DEFAULT TRUE | Whether the account can log in | + +### 2. `sessions` + +| Column | Type | Constraints | Description | +|--------|------|-------------|-------------| +| `session_id` | VARCHAR(255) | PRIMARY KEY | Session identifier (cookie value) | +| `username` | VARCHAR(255) | NOT NULL, FK → users(username) | Owner of the session | +| `created_at` | TIMESTAMP | NOT NULL | Session start (UTC) | +| `expires_at` | TIMESTAMP | NOT NULL | Session end (UTC) | + +Sessions are validated on each request; expired rows can be purged by the app (e.g. on startup). + +### 3. `auth_logs` + +| Column | Type | Constraints | Description | +|--------|------|-------------|-------------| +| `id` | SERIAL | PRIMARY KEY | Log entry ID | +| `username` | VARCHAR(255) | NOT NULL | User involved | +| `event_type` | VARCHAR(50) | NOT NULL | `login`, `logout`, `login_failed`, `deactivated`, `account_locked` | +| `timestamp` | TIMESTAMP | NOT NULL | Event time (UTC) | +| `ip_address` | VARCHAR(45) | NULL | Client IP (IPv4/IPv6) | +| `user_agent` | TEXT | NULL | Browser/client string | +| `details` | TEXT | NULL | Extra info | + +**Indexes:** `idx_auth_logs_username`, `idx_auth_logs_timestamp`, `idx_auth_logs_event_type` + +### 4. `api_tokens` + +| Column | Type | Constraints | Description | +|--------|------|-------------|-------------| +| `id` | SERIAL | PRIMARY KEY | Token ID | +| `token_hash` | VARCHAR(255) | UNIQUE, NOT NULL | SHA-256 hash of the token | +| `token_prefix` | VARCHAR(20) | NOT NULL | First 20 chars (for display only) | +| `username` | VARCHAR(255) | NOT NULL, FK → users(username) | Token owner | +| `description` | TEXT | NULL | Optional label | +| `created_at` | TIMESTAMP | NOT NULL | Creation time (UTC) | +| `created_by` | VARCHAR(255) | NULL | Creator username | +| `last_used_at` | TIMESTAMP | NULL | Last validation time | +| `expires_at` | TIMESTAMP | NULL | Optional expiry | +| `is_active` | BOOLEAN | DEFAULT TRUE | Whether the token is valid | + +**Indexes:** `idx_api_tokens_token_hash`, `idx_api_tokens_username`, `idx_api_tokens_is_active` + +### Relationships + +``` +users (username) + ├── sessions.username → users(username) + └── api_tokens.username → users(username) ON DELETE CASCADE +``` + +`auth_logs` references users only by `username` (no FK); it is an append-only log. + +--- + +## Access and Security + +- **DB user:** `portal_user` (password set on the server; same as used in portal `.env` for `DB_AUTH_PASSWORD`). +- **Allowed clients (pg_hba.conf):** + - `10.110.60.0/24` – portal LXCs on the same subnet + - `192.168.200.0/24` – gateway/subnet used by the production portal host to reach this LXC +- **Authentication:** `scram-sha-256`. +- **Timezone:** Connections use UTC. + +Adding another portal: ensure its host IP (or subnet) is allowed in `/etc/postgresql/17/main/pg_hba.conf` for database `portal_auth` and user `portal_user`, then reload PostgreSQL. + +--- + +## How Portals Use This LXC + +Each portal (e.g. FreePBX Portal) is configured with two DB targets: + +1. **Auth DB (this LXC):** `DB_AUTH_HOST=10.110.60.210`, `DB_AUTH_NAME=portal_auth`, etc. + Used for: login, sessions, auth logs, user management, API tokens. + +2. **Portal DB (local or elsewhere):** `DB_HOST`, `DB_NAME=freepbx_portal`, etc. + Used for: extensions, companies, assignments, activity logs, and all other app data. + +In the portal `.env`: + +```ini +# Shared auth (this LXC) +DB_AUTH_HOST=10.110.60.210 +DB_AUTH_PORT=5432 +DB_AUTH_NAME=portal_auth +DB_AUTH_USER=portal_user +DB_AUTH_PASSWORD= + +# Portal’s own data (e.g. same host or another DB server) +DB_HOST=localhost +DB_NAME=freepbx_portal +DB_USER=portal_user +DB_PASSWORD= +``` + +If `DB_AUTH_HOST` is not set, the portal runs in single-DB mode and uses only its own database for everything (no use of this LXC). + +--- + +## Verification + +- **Connections from a portal:** On the Auth LXC, as `postgres`: + ```sql + SELECT count(*), client_addr + FROM pg_stat_activity + WHERE datname = 'portal_auth' + GROUP BY client_addr; + ``` + You should see connections from the portal host(s). + +- **Recent logins:** After logging in via a portal: + ```sql + SELECT username, event_type, timestamp, ip_address + FROM auth_logs + ORDER BY timestamp DESC + LIMIT 10; + ``` + +- **Active sessions:** + ```sql + SELECT session_id, username, created_at, expires_at + FROM sessions; + ``` + +--- + +## Maintenance + +- **Backups:** Backup the `portal_auth` database (e.g. `pg_dump -U postgres -d portal_auth`) regularly; it holds all shared identities and sessions. +- **Log retention:** The portal app can purge old `auth_logs` rows (e.g. via `AUTH_LOG_RETENTION_DAYS`); expired `sessions` are removed on app startup or by scheduled jobs. +- **PostgreSQL:** Managed with systemd (`postgresql.service`); config under `/etc/postgresql/17/main/` (`postgresql.conf`, `pg_hba.conf`). + +--- + +## Summary + +| What | Where | +|------|--------| +| **LXC** | 10.110.60.210 (Auth / DB LXC) | +| **Use** | Shared user accounts and auth for multiple portals | +| **Database** | `portal_auth` | +| **Tables** | `users`, `sessions`, `auth_logs`, `api_tokens` | +| **Portal config** | Set `DB_AUTH_HOST=10.110.60.210` (and related `DB_AUTH_*`) in each portal’s `.env` | + +For full schema details of auth and portal tables, see [DATABASE_SCHEMA.md](DATABASE_SCHEMA.md). diff --git a/README_DASHBOARD.md b/README_DASHBOARD.md new file mode 100644 index 0000000..22aa727 --- /dev/null +++ b/README_DASHBOARD.md @@ -0,0 +1,76 @@ +# Portal Auth Admin Dashboard + +Web dashboard to view and edit the `portal_auth` database. **Only users with role `admin`** in the `users` table can log in. + +## Setup + +1. Copy `.env.example` to `.env` and set: + - `DB_AUTH_HOST`, `DB_AUTH_PORT`, `DB_AUTH_NAME`, `DB_AUTH_USER`, `DB_AUTH_PASSWORD` (same as your portal auth DB) + - `SECRET_KEY` (random string for session signing) + +2. Install dependencies: + ```bash + pip install -r requirements.txt + ``` + +3. Run the app: + ```bash + python app.py + ``` + By default it listens on `http://0.0.0.0:5000`. + +## Features + +- **Login**: Admin only (role = `admin`, `is_active` = true). Passwords verified with bcrypt (or legacy salt:hash). +- **Dashboard**: Links to each table. +- **Users**: View all; create new user; edit role (admin/support) and active flag; change password. +- **Sessions**: View active sessions; revoke (delete) a session. +- **Auth logs**: View only (append-only log). +- **API tokens**: View; activate/deactivate; delete. + +## Deployment to Auth LXC (10.110.60.210) + +From your machine (with SSH access to the server): + +```bash +./deploy/deploy.sh +``` + +This will: +- Rsync app files to `/opt/portal-auth-dashboard` on `root@10.110.60.210` +- Create a Python venv and install dependencies (including gunicorn) +- Create `.env` from `deploy/.env.server` if missing (you must edit it on the server to set `DB_AUTH_PASSWORD` and `SECRET_KEY`) +- Install and start the systemd unit `portal-auth-dashboard` (listens on port 5000) + +**After first deploy**, on the server set the real credentials: + +```bash +ssh root@10.110.60.210 +nano /opt/portal-auth-dashboard/.env # set DB_AUTH_PASSWORD and SECRET_KEY +systemctl restart portal-auth-dashboard +``` + +Dashboard URL: **http://10.110.60.210:5000** + +### Manual deploy (if the script is not used) + +On the server (Debian): + +```bash +apt-get update && apt-get install -y python3-venv python3-pip rsync +mkdir -p /opt/portal-auth-dashboard +``` + +From your machine, sync the project (excluding `.env`, `venv`, `.git`), then on the server: + +```bash +cd /opt/portal-auth-dashboard +python3 -m venv venv && ./venv/bin/pip install -r requirements.txt +cp deploy/.env.server .env # then edit .env +cp deploy/portal-auth-dashboard.service /etc/systemd/system/ +systemctl daemon-reload && systemctl enable --now portal-auth-dashboard +``` + +## Production notes + +Run behind a reverse proxy (e.g. nginx) with HTTPS. Set `SECRET_KEY` and ensure the app can reach the Auth DB (on the same host use `DB_AUTH_HOST=127.0.0.1`). diff --git a/app.py b/app.py new file mode 100644 index 0000000..c5cdaba --- /dev/null +++ b/app.py @@ -0,0 +1,202 @@ +from flask import Flask, render_template, request, redirect, url_for, session, flash +import config +from auth_helpers import verify_admin +from db import get_cursor + +app = Flask(__name__) +app.secret_key = config.SECRET_KEY + + +def login_required(f): + from functools import wraps + @wraps(f) + def inner(*args, **kwargs): + if not session.get("admin_username"): + flash("Please log in as an admin.", "error") + return redirect(url_for("login")) + return f(*args, **kwargs) + return inner + + +@app.route("/login", methods=["GET", "POST"]) +def login(): + if request.method == "GET": + if session.get("admin_username"): + return redirect(url_for("index")) + return render_template("login.html") + username = (request.form.get("username") or "").strip() + password = request.form.get("password") or "" + if not username or not password: + flash("Username and password required.", "error") + return render_template("login.html") + if not verify_admin(username, password): + flash("Invalid credentials or not an admin user.", "error") + return render_template("login.html") + session["admin_username"] = username + session.permanent = True + return redirect(url_for("index")) + + +@app.route("/logout") +def logout(): + session.pop("admin_username", None) + flash("Logged out.", "info") + return redirect(url_for("login")) + + +@app.route("/") +@login_required +def index(): + return render_template("index.html") + + +TABLE_COLUMNS = { + "users": ["id", "username", "password_hash", "role", "created_at", "created_by", "is_active"], + "sessions": ["session_id", "username", "created_at", "expires_at"], + "auth_logs": ["id", "username", "event_type", "timestamp", "ip_address", "user_agent", "details"], + "api_tokens": ["id", "token_hash", "token_prefix", "username", "description", "created_at", "created_by", "last_used_at", "expires_at", "is_active"], +} + + +@app.route("/table/") +@login_required +def table_view(name): + allowed = {"users", "sessions", "auth_logs", "api_tokens"} + if name not in allowed: + flash("Unknown table.", "error") + return redirect(url_for("index")) + with get_cursor() as cur: + cur.execute(f'SELECT * FROM "{name}" ORDER BY 1 DESC LIMIT 500') + rows = cur.fetchall() + # Convert dict rows to list of dicts with string values for display; keep raw row for actions (e.g. session_id, id) + data = [] + for r in rows: + data.append({k: (str(v) if v is not None else "") for k, v in r.items()}) + # Keep original rows for action URLs (session_id, id) so we don't rely on truncated display + raw_rows = rows + columns = list(rows[0].keys()) if rows else TABLE_COLUMNS.get(name, []) + return render_template("table.html", table_name=name, rows=data, raw_rows=raw_rows, columns=columns) + + +@app.route("/table//edit/", methods=["GET", "POST"]) +@login_required +def table_edit_user(name, pk): + if name != "users" or pk < 1: + flash("Invalid edit target.", "error") + return redirect(url_for("index")) + with get_cursor() as cur: + if request.method == "GET": + cur.execute('SELECT id, username, role, is_active FROM users WHERE id = %s', (pk,)) + row = cur.fetchone() + if not row: + flash("User not found.", "error") + return redirect(url_for("table_view", name="users")) + return render_template("edit_user.html", row=row) + role = (request.form.get("role") or "support").strip() + if role not in ("admin", "support"): + role = "support" + is_active = request.form.get("is_active") == "on" + cur.execute( + "UPDATE users SET role = %s, is_active = %s WHERE id = %s", + (role, is_active, pk), + ) + if cur.rowcount: + flash("User updated.", "success") + return redirect(url_for("table_view", name="users")) + + +@app.route("/table/users/new", methods=["GET", "POST"]) +@login_required +def user_new(): + import bcrypt + from datetime import datetime, timezone + if request.method == "GET": + return render_template("new_user.html") + username = (request.form.get("username") or "").strip() + password = request.form.get("password") or "" + role = (request.form.get("role") or "support").strip() + if role not in ("admin", "support"): + role = "support" + if not username: + flash("Username required.", "error") + return render_template("new_user.html") + if len(password) < 8: + flash("Password must be at least 8 characters.", "error") + return render_template("new_user.html", username=username, role=role) + hashed = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") + now = datetime.now(timezone.utc) + admin = session.get("admin_username") or "" + with get_cursor() as cur: + try: + cur.execute( + """INSERT INTO users (username, password_hash, role, created_at, created_by, is_active) + VALUES (%s, %s, %s, %s, %s, TRUE)""", + (username, hashed, role, now, admin if admin else None), + ) + except Exception as e: + if "unique" in str(e).lower() or "duplicate" in str(e).lower(): + flash("Username already exists.", "error") + else: + flash("Could not create user.", "error") + return render_template("new_user.html", username=username, role=role) + flash("User created.", "success") + return redirect(url_for("table_view", name="users")) + + +@app.route("/table/users/password/", methods=["GET", "POST"]) +@login_required +def user_password(pk): + import bcrypt + with get_cursor() as cur: + cur.execute("SELECT id, username FROM users WHERE id = %s", (pk,)) + row = cur.fetchone() + if not row: + flash("User not found.", "error") + return redirect(url_for("table_view", name="users")) + if request.method == "GET": + return render_template("edit_password.html", row=row) + password = request.form.get("password") or "" + if len(password) < 8: + flash("Password must be at least 8 characters.", "error") + return render_template("edit_password.html", row=row) + hashed = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") + cur.execute("UPDATE users SET password_hash = %s WHERE id = %s", (hashed, pk)) + flash("Password updated.", "success") + return redirect(url_for("table_view", name="users")) + + +@app.route("/table/sessions/delete", methods=["POST"]) +@login_required +def session_delete(): + session_id = (request.form.get("session_id") or "").strip() + if not session_id: + flash("Session ID required.", "error") + return redirect(url_for("table_view", name="sessions")) + with get_cursor() as cur: + cur.execute("DELETE FROM sessions WHERE session_id = %s", (session_id,)) + flash("Session deleted.", "success") + return redirect(url_for("table_view", name="sessions")) + + +@app.route("/table/api_tokens/toggle/", methods=["POST"]) +@login_required +def api_token_toggle(pk): + with get_cursor() as cur: + cur.execute("UPDATE api_tokens SET is_active = NOT is_active WHERE id = %s RETURNING is_active", (pk,)) + row = cur.fetchone() + if row: + flash("Token " + ("activated" if row["is_active"] else "deactivated") + ".", "success") + return redirect(url_for("table_view", name="api_tokens")) + + +@app.route("/table/api_tokens/delete/", methods=["POST"]) +@login_required +def api_token_delete(pk): + with get_cursor() as cur: + cur.execute("DELETE FROM api_tokens WHERE id = %s", (pk,)) + flash("Token deleted.", "success") + return redirect(url_for("table_view", name="api_tokens")) + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5000, debug=True) diff --git a/auth_helpers.py b/auth_helpers.py new file mode 100644 index 0000000..e9f3e3e --- /dev/null +++ b/auth_helpers.py @@ -0,0 +1,28 @@ +import bcrypt +from db import get_cursor + +def verify_admin(username: str, password: str) -> bool: + """Verify that the user exists, is admin, is active, and password matches.""" + with get_cursor() as cur: + cur.execute( + """ + SELECT username, password_hash + FROM users + WHERE username = %s AND role = 'admin' AND is_active = TRUE + """, + (username,), + ) + row = cur.fetchone() + if not row: + return False + stored = row["password_hash"] + # Support bcrypt (e.g. $2b$...) or legacy salt:hash + if stored.startswith("$2"): + return bcrypt.checkpw(password.encode("utf-8"), stored.encode("utf-8")) + # Legacy: "salt:hash" (e.g. md5 or similar) - optional simple check + if ":" in stored: + salt, expected = stored.split(":", 1) + import hashlib + got = hashlib.sha256((salt + password).encode()).hexdigest() + return got == expected + return False diff --git a/config.py b/config.py new file mode 100644 index 0000000..a48c53f --- /dev/null +++ b/config.py @@ -0,0 +1,15 @@ +import os +from pathlib import Path + +_path = Path(__file__).resolve().parent +_env = _path / ".env" +if _env.exists(): + from dotenv import load_dotenv + load_dotenv(_env) + +SECRET_KEY = os.environ.get("SECRET_KEY", "dev-secret-change-in-production") +DB_AUTH_HOST = os.environ.get("DB_AUTH_HOST", "localhost") +DB_AUTH_PORT = int(os.environ.get("DB_AUTH_PORT", "5432")) +DB_AUTH_NAME = os.environ.get("DB_AUTH_NAME", "portal_auth") +DB_AUTH_USER = os.environ.get("DB_AUTH_USER", "portal_user") +DB_AUTH_PASSWORD = os.environ.get("DB_AUTH_PASSWORD", "") diff --git a/db.py b/db.py new file mode 100644 index 0000000..d2381c3 --- /dev/null +++ b/db.py @@ -0,0 +1,25 @@ +import psycopg2 +from psycopg2.extras import RealDictCursor +from contextlib import contextmanager +import config + +@contextmanager +def get_cursor(): + conn = psycopg2.connect( + host=config.DB_AUTH_HOST, + port=config.DB_AUTH_PORT, + dbname=config.DB_AUTH_NAME, + user=config.DB_AUTH_USER, + password=config.DB_AUTH_PASSWORD, + options="-c timezone=UTC", + ) + try: + cur = conn.cursor(cursor_factory=RealDictCursor) + yield cur + conn.commit() + except Exception: + conn.rollback() + raise + finally: + cur.close() + conn.close() diff --git a/deploy/.env.server b/deploy/.env.server new file mode 100644 index 0000000..492ee7e --- /dev/null +++ b/deploy/.env.server @@ -0,0 +1,9 @@ +# Auth DB (localhost on this host) +DB_AUTH_HOST=127.0.0.1 +DB_AUTH_PORT=5432 +DB_AUTH_NAME=portal_auth +DB_AUTH_USER=portal_user +DB_AUTH_PASSWORD=REPLACE_WITH_ACTUAL_PASSWORD + +# Dashboard +SECRET_KEY=REPLACE_WITH_RANDOM_SECRET_KEY diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100755 index 0000000..d91d2af --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,46 @@ +#!/bin/bash +# Deploy Portal Auth Dashboard to root@10.110.60.210 +set -e +TARGET="root@10.110.60.210" +APP_DIR="/opt/portal-auth-dashboard" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" + +echo "=== Syncing files to $TARGET:$APP_DIR ===" +ssh "$TARGET" "mkdir -p $APP_DIR" +rsync -av \ + --exclude '.env' \ + --exclude '__pycache__' \ + --exclude '*.pyc' \ + --exclude '.git' \ + --exclude 'venv' \ + "$PROJECT_DIR/app.py" \ + "$PROJECT_DIR/config.py" \ + "$PROJECT_DIR/db.py" \ + "$PROJECT_DIR/auth_helpers.py" \ + "$PROJECT_DIR/requirements.txt" \ + "$PROJECT_DIR/templates/" \ + "$PROJECT_DIR/static/" \ + "$PROJECT_DIR/deploy/" \ + "$TARGET:$APP_DIR/" + +echo "=== Setting up Python venv and dependencies ===" +ssh "$TARGET" "cd $APP_DIR && python3 -m venv venv && ./venv/bin/pip install -r requirements.txt" + +echo "=== Ensuring .env exists (skip if already configured) ===" +ssh "$TARGET" "if [ ! -f $APP_DIR/.env ]; then cp $APP_DIR/deploy/.env.server $APP_DIR/.env 2>/dev/null || echo 'DB_AUTH_HOST=127.0.0.1 +DB_AUTH_PORT=5432 +DB_AUTH_NAME=portal_auth +DB_AUTH_USER=portal_user +DB_AUTH_PASSWORD= +SECRET_KEY=change-me' > $APP_DIR/.env; fi" + +echo "=== Installing systemd unit ===" +ssh "$TARGET" "cp $APP_DIR/deploy/portal-auth-dashboard.service /etc/systemd/system/ && systemctl daemon-reload && systemctl enable portal-auth-dashboard && systemctl restart portal-auth-dashboard" + +echo "=== Status ===" +ssh "$TARGET" "systemctl status portal-auth-dashboard --no-pager" + +echo "" +echo "Done. Dashboard: http://10.110.60.210:5000" +echo "Edit $APP_DIR/.env on the server to set DB_AUTH_PASSWORD and SECRET_KEY, then: systemctl restart portal-auth-dashboard" diff --git a/deploy/portal-auth-dashboard.service b/deploy/portal-auth-dashboard.service new file mode 100644 index 0000000..5fb56f6 --- /dev/null +++ b/deploy/portal-auth-dashboard.service @@ -0,0 +1,17 @@ +[Unit] +Description=Portal Auth Admin Dashboard +After=network.target postgresql.service + +[Service] +Type=simple +User=root +Group=root +WorkingDirectory=/opt/portal-auth-dashboard +Environment="PATH=/opt/portal-auth-dashboard/venv/bin" +ExecStart=/opt/portal-auth-dashboard/venv/bin/gunicorn -w 1 -b 0.0.0.0:5000 --access-logfile - --error-logfile - app:app +ExecReload=/bin/kill -s HUP $MAINPID +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1b88171 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +flask>=3.0 +psycopg2-binary>=2.9 +bcrypt>=4.0 +python-dotenv>=1.0 +gunicorn>=21.0 diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..85e4ae4 --- /dev/null +++ b/static/style.css @@ -0,0 +1,494 @@ +:root { + --bg: #0f1216; + --bg-card: #161b22; + --border: #2d333b; + --text: #e6edf3; + --text-muted: #8b949e; + --accent: #3fb950; + --accent-dim: #238636; + --danger: #f85149; + --warning: #d29922; + --font-sans: "Outfit", system-ui, sans-serif; + --font-mono: "JetBrains Mono", monospace; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background: var(--bg); + color: var(--text); + font-family: var(--font-sans); + font-size: 15px; + line-height: 1.5; +} + +.site-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1.5rem; + background: var(--bg-card); + border-bottom: 1px solid var(--border); +} + +.brand { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.brand-icon { + color: var(--accent); + font-size: 1.25rem; +} + +.brand-text { + font-weight: 600; + font-family: var(--font-mono); +} + +.nav { + display: flex; + align-items: center; + gap: 1.25rem; +} + +.nav a { + color: var(--text-muted); + text-decoration: none; +} + +.nav a:hover { + color: var(--accent); +} + +.nav .user { + color: var(--text-muted); + font-size: 0.9rem; + padding-left: 0.5rem; + border-left: 1px solid var(--border); +} + +.logout { + color: var(--danger) !important; +} + +.main { + max-width: 1280px; + margin: 0 auto; + padding: 1.5rem; +} + +.flashes { + list-style: none; + padding: 0; + margin: 0 0 1rem; +} + +.flash { + padding: 0.6rem 1rem; + border-radius: 6px; + margin-bottom: 0.5rem; +} + +.flash.error { + background: rgba(248, 81, 73, 0.15); + color: var(--danger); + border: 1px solid rgba(248, 81, 73, 0.3); +} + +.flash.success { + background: rgba(63, 185, 80, 0.15); + color: var(--accent); + border: 1px solid rgba(63, 185, 80, 0.3); +} + +.flash.info { + background: rgba(139, 148, 158, 0.15); + color: var(--text-muted); + border: 1px solid var(--border); +} + +/* Login */ +.login-card { + max-width: 380px; + margin: 4rem auto; + padding: 2rem; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 10px; +} + +.login-card h1 { + margin: 0 0 0.5rem; + font-size: 1.5rem; +} + +.login-hint { + color: var(--text-muted); + font-size: 0.9rem; + margin-bottom: 1.5rem; +} + +.login-hint code { + font-family: var(--font-mono); + background: var(--bg); + padding: 0.15rem 0.4rem; + border-radius: 4px; +} + +.login-form label { + display: block; + margin-top: 1rem; + margin-bottom: 0.35rem; + color: var(--text-muted); + font-size: 0.9rem; +} + +.login-form input { + width: 100%; + padding: 0.6rem 0.75rem; + font-size: 1rem; + font-family: inherit; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); +} + +.login-form input:focus { + outline: none; + border-color: var(--accent); +} + +.login-form button { + margin-top: 1.5rem; + width: 100%; + padding: 0.65rem; + font-size: 1rem; + font-weight: 600; + font-family: inherit; + background: var(--accent); + color: var(--bg); + border: none; + border-radius: 6px; + cursor: pointer; +} + +.login-form button:hover { + background: var(--accent-dim); +} + +/* Dashboard */ +.dashboard h1 { + margin: 0 0 0.5rem; + font-size: 1.75rem; +} + +.lead { + color: var(--text-muted); + margin-bottom: 2rem; +} + +.lead code { + font-family: var(--font-mono); + background: var(--bg-card); + padding: 0.15rem 0.4rem; + border-radius: 4px; +} + +.table-cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 1rem; +} + +.card { + display: block; + padding: 1.25rem; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + text-decoration: none; + color: inherit; + transition: border-color 0.15s, box-shadow 0.15s; +} + +.card:hover { + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent); +} + +.card-icon { + font-family: var(--font-mono); + font-size: 0.8rem; + color: var(--accent); + display: block; + margin-bottom: 0.5rem; +} + +.card h2 { + margin: 0 0 0.35rem; + font-size: 1.15rem; +} + +.card p { + margin: 0; + color: var(--text-muted); + font-size: 0.9rem; +} + +/* Table view */ +.table-page { + margin-top: 0; +} + +.table-header { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 1rem; + margin-bottom: 1rem; +} + +.table-header-actions { + display: flex; + align-items: center; + gap: 1rem; +} + +.btn-primary { + padding: 0.5rem 1rem; + font-size: 0.9rem; + font-weight: 600; + font-family: inherit; + background: var(--accent); + color: var(--bg); + border: none; + border-radius: 6px; + text-decoration: none; + display: inline-block; +} + +.btn-primary:hover { + background: var(--accent-dim); + color: var(--bg); +} + +.table-header h1 { + margin: 0; + font-size: 1.35rem; +} + +.table-header h1 code { + font-family: var(--font-mono); + color: var(--accent); +} + +.back { + color: var(--text-muted); + text-decoration: none; +} + +.back:hover { + color: var(--accent); +} + +.table-wrapper { + overflow-x: auto; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-card); +} + +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; +} + +.data-table th, +.data-table td { + padding: 0.6rem 0.75rem; + text-align: left; + border-bottom: 1px solid var(--border); +} + +.data-table th { + font-weight: 600; + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 0.8rem; + text-transform: lowercase; + white-space: nowrap; +} + +.data-table td { + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.data-table tbody tr:hover { + background: rgba(255, 255, 255, 0.03); +} + +.data-table .actions { + max-width: none; + white-space: nowrap; +} + +.data-table .actions a, +.data-table .actions button { + margin-right: 0.5rem; + font-size: 0.85rem; +} + +.data-table .actions a { + color: var(--accent); + text-decoration: none; +} + +.data-table .actions a:hover { + text-decoration: underline; +} + +.data-table .actions button { + padding: 0.25rem 0.5rem; + font-family: inherit; + font-size: 0.85rem; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text); + cursor: pointer; +} + +.data-table .actions button:hover { + border-color: var(--accent); + color: var(--accent); +} + +.data-table .actions button.danger { + color: var(--danger); + border-color: rgba(248, 81, 73, 0.4); +} + +.data-table .actions button.danger:hover { + background: rgba(248, 81, 73, 0.1); +} + +.inline-form { + display: inline; +} + +.empty { + color: var(--text-muted); + padding: 2rem; + text-align: center; +} + +/* Forms */ +.form-page { + max-width: 420px; +} + +.form-page h1 { + margin: 0 0 1rem; + font-size: 1.35rem; +} + +.form-page h1 code { + font-family: var(--font-mono); + color: var(--accent); +} + +.edit-form label { + display: block; + margin-top: 1rem; + margin-bottom: 0.35rem; + color: var(--text-muted); + font-size: 0.9rem; +} + +.edit-form input[type="text"], +.edit-form input[type="password"], +.edit-form select { + width: 100%; + padding: 0.6rem 0.75rem; + font-size: 1rem; + font-family: inherit; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); +} + +.edit-form input:focus, +.edit-form select:focus { + outline: none; + border-color: var(--accent); +} + +.checkbox-label { + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 1rem; + cursor: pointer; +} + +.checkbox-label input { + width: auto; +} + +.form-actions { + margin-top: 1.5rem; + display: flex; + align-items: center; + gap: 1rem; +} + +.edit-form button { + padding: 0.6rem 1.2rem; + font-size: 1rem; + font-weight: 600; + font-family: inherit; + background: var(--accent); + color: var(--bg); + border: none; + border-radius: 6px; + cursor: pointer; +} + +.edit-form button:hover { + background: var(--accent-dim); +} + +.btn-link { + color: var(--text-muted); + text-decoration: none; +} + +.btn-link:hover { + color: var(--accent); +} + +.form-page p { + margin-top: 1.5rem; +} + +.form-page p a { + color: var(--accent); + text-decoration: none; +} + +.form-page p a:hover { + text-decoration: underline; +} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..618fd55 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,43 @@ + + + + + + {% block title %}Portal Auth Admin{% endblock %} + + + + + + + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
    + {% for cat, msg in messages %} +
  • {{ msg }}
  • + {% endfor %} +
+ {% endif %} + {% endwith %} + {% block content %}{% endblock %} +
+ + diff --git a/templates/edit_password.html b/templates/edit_password.html new file mode 100644 index 0000000..abcec0f --- /dev/null +++ b/templates/edit_password.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} +{% block title %}Change password — Portal Auth Admin{% endblock %} +{% block content %} +
+

Change password: {{ row.username }}

+
+ + +
+ + Cancel +
+
+
+{% endblock %} diff --git a/templates/edit_user.html b/templates/edit_user.html new file mode 100644 index 0000000..acc1815 --- /dev/null +++ b/templates/edit_user.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{% block title %}Edit user — Portal Auth Admin{% endblock %} +{% block content %} +
+

Edit user: {{ row.username }}

+
+ + + +
+ + Cancel +
+
+

Change password

+
+{% endblock %} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..1f03182 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,30 @@ +{% extends "base.html" %} +{% block title %}Dashboard — Portal Auth Admin{% endblock %} +{% block content %} + +{% endblock %} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..c28e1ad --- /dev/null +++ b/templates/login.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} +{% block title %}Login — Portal Auth Admin{% endblock %} +{% block content %} + +{% endblock %} diff --git a/templates/new_user.html b/templates/new_user.html new file mode 100644 index 0000000..411e4de --- /dev/null +++ b/templates/new_user.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{% block title %}New user — Portal Auth Admin{% endblock %} +{% block content %} +
+

Create user

+
+ + + + + + +
+ + Cancel +
+
+
+{% endblock %} diff --git a/templates/table.html b/templates/table.html new file mode 100644 index 0000000..c26d43f --- /dev/null +++ b/templates/table.html @@ -0,0 +1,63 @@ +{% extends "base.html" %} +{% block title %}{{ table_name }} — Portal Auth Admin{% endblock %} +{% block content %} +
+
+

Table: {{ table_name }}

+
+ {% if table_name == 'users' %} + Add user + {% endif %} + ← Dashboard +
+
+
+ + + + {% for key in columns %} + + {% endfor %} + {% if table_name in ['users', 'sessions', 'api_tokens'] %} + + {% endif %} + + + + {% for row in rows %} + + {% for key in columns %} + + {% endfor %} + {% if table_name == 'users' and raw_rows %} + + {% elif table_name == 'sessions' and raw_rows %} + + {% elif table_name == 'api_tokens' and raw_rows %} + + {% endif %} + + {% endfor %} + +
{{ key }}Actions
{{ (row.get(key, ''))[:80] }}{% if row.get(key, '') and (row.get(key, ''))|length > 80 %}…{% endif %} + Edit + Password + +
+ + +
+
+
+ +
+
+ +
+
+
+ {% if not rows %} +

No rows.

+ {% endif %} +
+{% endblock %}