Initial commit: Portal Auth Admin Dashboard

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-18 08:18:50 +02:00
commit 7caa62a428
20 changed files with 1347 additions and 0 deletions

10
.env.example Normal file
View File

@@ -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

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
.env
venv/
__pycache__/
*.pyc
*.pyo
.cursor/
*.egg-info/
.eggs/
dist/
build/

View File

@@ -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 portals 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=<same password as on Auth LXC>
# Portals own data (e.g. same host or another DB server)
DB_HOST=localhost
DB_NAME=freepbx_portal
DB_USER=portal_user
DB_PASSWORD=<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 portals `.env` |
For full schema details of auth and portal tables, see [DATABASE_SCHEMA.md](DATABASE_SCHEMA.md).

76
README_DASHBOARD.md Normal file
View File

@@ -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`).

202
app.py Normal file
View File

@@ -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/<name>")
@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/<name>/edit/<int:pk>", 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/<int:pk>", 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/<int:pk>", 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/<int:pk>", 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)

28
auth_helpers.py Normal file
View File

@@ -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

15
config.py Normal file
View File

@@ -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", "")

25
db.py Normal file
View File

@@ -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()

9
deploy/.env.server Normal file
View File

@@ -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

46
deploy/deploy.sh Executable file
View File

@@ -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"

View File

@@ -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

5
requirements.txt Normal file
View File

@@ -0,0 +1,5 @@
flask>=3.0
psycopg2-binary>=2.9
bcrypt>=4.0
python-dotenv>=1.0
gunicorn>=21.0

494
static/style.css Normal file
View File

@@ -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;
}

43
templates/base.html Normal file
View File

@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Portal Auth Admin{% endblock %}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&family=Outfit:wght@400;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<header class="site-header">
<div class="brand">
<span class="brand-icon"></span>
<span class="brand-text">Portal Auth</span>
</div>
{% if session.get('admin_username') %}
<nav class="nav">
<a href="{{ url_for('index') }}">Dashboard</a>
<a href="{{ url_for('table_view', name='users') }}">Users</a>
<a href="{{ url_for('table_view', name='sessions') }}">Sessions</a>
<a href="{{ url_for('table_view', name='auth_logs') }}">Auth logs</a>
<a href="{{ url_for('table_view', name='api_tokens') }}">API tokens</a>
<span class="user">{{ session.admin_username }}</span>
<a href="{{ url_for('logout') }}" class="logout">Log out</a>
</nav>
{% endif %}
</header>
<main class="main">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<ul class="flashes">
{% for cat, msg in messages %}
<li class="flash {{ cat }}">{{ msg }}</li>
{% endfor %}
</ul>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
</body>
</html>

View File

@@ -0,0 +1,15 @@
{% extends "base.html" %}
{% block title %}Change password — Portal Auth Admin{% endblock %}
{% block content %}
<div class="form-page">
<h1>Change password: <code>{{ row.username }}</code></h1>
<form method="post" action="{{ url_for('user_password', pk=row.id) }}" class="edit-form">
<label for="password">New password</label>
<input type="password" id="password" name="password" required minlength="8" autocomplete="new-password">
<div class="form-actions">
<button type="submit">Update password</button>
<a href="{{ url_for('table_view', name='users') }}" class="btn-link">Cancel</a>
</div>
</form>
</div>
{% endblock %}

22
templates/edit_user.html Normal file
View File

@@ -0,0 +1,22 @@
{% extends "base.html" %}
{% block title %}Edit user — Portal Auth Admin{% endblock %}
{% block content %}
<div class="form-page">
<h1>Edit user: <code>{{ row.username }}</code></h1>
<form method="post" action="{{ url_for('table_edit_user', name='users', pk=row.id) }}" class="edit-form">
<label for="role">Role</label>
<select id="role" name="role">
<option value="admin" {{ 'selected' if row.role == 'admin' }}>admin</option>
<option value="support" {{ 'selected' if row.role == 'support' }}>support</option>
</select>
<label class="checkbox-label">
<input type="checkbox" name="is_active" {{ 'checked' if row.is_active }}> Active (can log in)
</label>
<div class="form-actions">
<button type="submit">Save</button>
<a href="{{ url_for('table_view', name='users') }}" class="btn-link">Cancel</a>
</div>
</form>
<p><a href="{{ url_for('user_password', pk=row.id) }}">Change password</a></p>
</div>
{% endblock %}

30
templates/index.html Normal file
View File

@@ -0,0 +1,30 @@
{% extends "base.html" %}
{% block title %}Dashboard — Portal Auth Admin{% endblock %}
{% block content %}
<div class="dashboard">
<h1>Dashboard</h1>
<p class="lead">View and manage the <code>portal_auth</code> database tables.</p>
<div class="table-cards">
<a href="{{ url_for('table_view', name='users') }}" class="card">
<span class="card-icon">users</span>
<h2>Users</h2>
<p>Accounts, roles, active flag. Edit role and password.</p>
</a>
<a href="{{ url_for('table_view', name='sessions') }}" class="card">
<span class="card-icon">sessions</span>
<h2>Sessions</h2>
<p>Active login sessions. View and revoke.</p>
</a>
<a href="{{ url_for('table_view', name='auth_logs') }}" class="card">
<span class="card-icon">auth_logs</span>
<h2>Auth logs</h2>
<p>Login, logout, failed attempts. Read-only.</p>
</a>
<a href="{{ url_for('table_view', name='api_tokens') }}" class="card">
<span class="card-icon">api_tokens</span>
<h2>API tokens</h2>
<p>Tokens for programmatic access. Activate or revoke.</p>
</a>
</div>
</div>
{% endblock %}

15
templates/login.html Normal file
View File

@@ -0,0 +1,15 @@
{% extends "base.html" %}
{% block title %}Login — Portal Auth Admin{% endblock %}
{% block content %}
<div class="login-card">
<h1>Admin login</h1>
<p class="login-hint">Only users with role <code>admin</code> can access this dashboard.</p>
<form method="post" action="{{ url_for('login') }}" class="login-form">
<label for="username">Username</label>
<input type="text" id="username" name="username" required autofocus autocomplete="username">
<label for="password">Password</label>
<input type="password" id="password" name="password" required autocomplete="current-password">
<button type="submit">Log in</button>
</form>
</div>
{% endblock %}

22
templates/new_user.html Normal file
View File

@@ -0,0 +1,22 @@
{% extends "base.html" %}
{% block title %}New user — Portal Auth Admin{% endblock %}
{% block content %}
<div class="form-page">
<h1>Create user</h1>
<form method="post" action="{{ url_for('user_new') }}" class="edit-form">
<label for="username">Username</label>
<input type="text" id="username" name="username" required value="{{ username|default('', true) }}" autocomplete="username">
<label for="password">Password</label>
<input type="password" id="password" name="password" required minlength="8" autocomplete="new-password">
<label for="role">Role</label>
<select id="role" name="role">
<option value="support" {{ 'selected' if role|default('support') == 'support' }}>support</option>
<option value="admin" {{ 'selected' if role|default('support') == 'admin' }}>admin</option>
</select>
<div class="form-actions">
<button type="submit">Create user</button>
<a href="{{ url_for('table_view', name='users') }}" class="btn-link">Cancel</a>
</div>
</form>
</div>
{% endblock %}

63
templates/table.html Normal file
View File

@@ -0,0 +1,63 @@
{% extends "base.html" %}
{% block title %}{{ table_name }} — Portal Auth Admin{% endblock %}
{% block content %}
<div class="table-page">
<div class="table-header">
<h1>Table: <code>{{ table_name }}</code></h1>
<div class="table-header-actions">
{% if table_name == 'users' %}
<a href="{{ url_for('user_new') }}" class="btn-primary">Add user</a>
{% endif %}
<a href="{{ url_for('index') }}" class="back">← Dashboard</a>
</div>
</div>
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
{% for key in columns %}
<th>{{ key }}</th>
{% endfor %}
{% if table_name in ['users', 'sessions', 'api_tokens'] %}
<th class="actions">Actions</th>
{% endif %}
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr>
{% for key in columns %}
<td title="{{ row.get(key, '') }}">{{ (row.get(key, ''))[:80] }}{% if row.get(key, '') and (row.get(key, ''))|length > 80 %}…{% endif %}</td>
{% endfor %}
{% if table_name == 'users' and raw_rows %}
<td class="actions">
<a href="{{ url_for('table_edit_user', name='users', pk=raw_rows[loop.index0].id) }}">Edit</a>
<a href="{{ url_for('user_password', pk=raw_rows[loop.index0].id) }}">Password</a>
</td>
{% elif table_name == 'sessions' and raw_rows %}
<td class="actions">
<form method="post" action="{{ url_for('session_delete') }}" class="inline-form" onsubmit="return confirm('Revoke this session?');">
<input type="hidden" name="session_id" value="{{ raw_rows[loop.index0].session_id }}">
<button type="submit">Revoke</button>
</form>
</td>
{% elif table_name == 'api_tokens' and raw_rows %}
<td class="actions">
<form method="post" action="{{ url_for('api_token_toggle', pk=raw_rows[loop.index0].id) }}" class="inline-form">
<button type="submit">{{ 'Deactivate' if row.is_active == 'True' else 'Activate' }}</button>
</form>
<form method="post" action="{{ url_for('api_token_delete', pk=raw_rows[loop.index0].id) }}" class="inline-form" onsubmit="return confirm('Delete this token?');">
<button type="submit" class="danger">Delete</button>
</form>
</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if not rows %}
<p class="empty">No rows.</p>
{% endif %}
</div>
{% endblock %}