Initial commit: Portal Auth Admin Dashboard
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
10
.env.example
Normal file
10
.env.example
Normal 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
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
.env
|
||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.cursor/
|
||||||
|
*.egg-info/
|
||||||
|
.eggs/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
200
AUTH_LXC_PORTAL_AUTH_DATABASE.md
Normal file
200
AUTH_LXC_PORTAL_AUTH_DATABASE.md
Normal 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 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=<same password as on Auth LXC>
|
||||||
|
|
||||||
|
# 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=<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).
|
||||||
76
README_DASHBOARD.md
Normal file
76
README_DASHBOARD.md
Normal 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
202
app.py
Normal 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
28
auth_helpers.py
Normal 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
15
config.py
Normal 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
25
db.py
Normal 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
9
deploy/.env.server
Normal 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
46
deploy/deploy.sh
Executable 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"
|
||||||
17
deploy/portal-auth-dashboard.service
Normal file
17
deploy/portal-auth-dashboard.service
Normal 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
5
requirements.txt
Normal 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
494
static/style.css
Normal 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
43
templates/base.html
Normal 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>
|
||||||
15
templates/edit_password.html
Normal file
15
templates/edit_password.html
Normal 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
22
templates/edit_user.html
Normal 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
30
templates/index.html
Normal 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
15
templates/login.html
Normal 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
22
templates/new_user.html
Normal 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
63
templates/table.html
Normal 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 %}
|
||||||
Reference in New Issue
Block a user