mirror of
https://github.com/tmdinosaurcenter/kiosk-guestbook.git
synced 2026-06-03 22:48:20 -06:00
refactor: migrate admin auth from HTTP Basic to Flask-Login sessions
Replaces browser-cached Basic Auth credentials with proper server-side session management. Logout now fully invalidates the session. Adds an HTML login form at /admin/login, SECRET_KEY env var support, and updates README with key generation instructions and role table.
This commit is contained in:
@@ -98,9 +98,29 @@ Once deployed, open your browser and navigate to http://<your-server-ip>:8000 (o
|
|||||||
|
|
||||||
## Admin Interface
|
## Admin Interface
|
||||||
|
|
||||||
A password-protected admin panel is available at `/admin`. It displays all guest entries in a paginated table and allows individual entries to be deleted.
|
A password-protected admin panel is available at `/admin`. It displays all guest entries in a paginated table and allows individual entries to be deleted. Authentication uses session cookies with an HTML login form — logging out fully invalidates the session so credentials are never cached by the browser.
|
||||||
|
|
||||||
Access requires `ADMIN_USER` and `ADMIN_PASSWORD` to be set in your `.env`. If either variable is missing, the admin interface will return a 503 error rather than allowing access with blank credentials.
|
Access requires `ADMIN_USER`, `ADMIN_PASSWORD`, and `SECRET_KEY` to be set in your `.env`. If either of the admin credentials are missing the interface returns 503. If `SECRET_KEY` is not set a default development key is used, which is insecure in production — always set your own.
|
||||||
|
|
||||||
|
### Generating a `SECRET_KEY`
|
||||||
|
|
||||||
|
Use Python to generate a cryptographically random key:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||||
|
```
|
||||||
|
|
||||||
|
Paste the output as the value for `SECRET_KEY` in your `.env`.
|
||||||
|
|
||||||
|
### User Roles
|
||||||
|
|
||||||
|
The bootstrap superadmin (set via `ADMIN_USER` / `ADMIN_PASSWORD`) can manage additional users at `/admin/users`:
|
||||||
|
|
||||||
|
| Role | View entries | Delete entries | Manage users |
|
||||||
|
| ---------- | :----------: | :------------: | :----------: |
|
||||||
|
| superadmin | ✓ | ✓ | ✓ |
|
||||||
|
| admin | ✓ | ✓ | — |
|
||||||
|
| viewer | ✓ | — | — |
|
||||||
|
|
||||||
## API Access
|
## API Access
|
||||||
|
|
||||||
@@ -112,14 +132,28 @@ Set the `API_KEY` variable in your `.env` and pass it in requests as the `X-API-
|
|||||||
|
|
||||||
## Upgrading
|
## Upgrading
|
||||||
|
|
||||||
When upgrading from a previous version, compare your `.env` against `example.env` to check for newly required variables. As of v2.1.0, the following variables are required if you want to use the admin interface:
|
When upgrading from a previous version, compare your `.env` against `example.env` to check for newly required variables.
|
||||||
|
|
||||||
|
As of **v2.1.0**, the following variables are required for the admin interface:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
ADMIN_USER=admin
|
ADMIN_USER=admin
|
||||||
ADMIN_PASSWORD=changeme
|
ADMIN_PASSWORD=changeme
|
||||||
```
|
```
|
||||||
|
|
||||||
Replace the placeholder values with your own credentials before deploying.
|
As of **v2.3.0**, a `SECRET_KEY` is also required for session-based authentication:
|
||||||
|
|
||||||
|
```env
|
||||||
|
SECRET_KEY=your-random-secret-key-here
|
||||||
|
```
|
||||||
|
|
||||||
|
Generate one with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace all placeholder values with your own before deploying.
|
||||||
|
|
||||||
## Additional Notes
|
## Additional Notes
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
from flask import Flask, render_template, request, redirect, url_for, jsonify, abort, Response, g
|
|
||||||
from flask_limiter import Limiter
|
|
||||||
from flask_limiter.util import get_remote_address
|
|
||||||
from email_validator import validate_email, EmailNotValidError
|
|
||||||
from werkzeug.security import generate_password_hash, check_password_hash
|
|
||||||
from functools import wraps
|
|
||||||
import sqlite3
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
from email_validator import validate_email, EmailNotValidError
|
||||||
|
from flask import Flask, render_template, request, redirect, url_for, jsonify, abort
|
||||||
|
from flask_limiter import Limiter
|
||||||
|
from flask_limiter.util import get_remote_address
|
||||||
|
from flask_login import (
|
||||||
|
LoginManager, UserMixin, login_user, logout_user, login_required, current_user
|
||||||
|
)
|
||||||
|
from werkzeug.security import generate_password_hash, check_password_hash
|
||||||
|
|
||||||
# Set up logging
|
# Set up logging
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
@@ -15,8 +18,53 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
DATABASE = os.environ.get('DATABASE_PATH', 'guestbook.db')
|
DATABASE = os.environ.get('DATABASE_PATH', 'guestbook.db')
|
||||||
|
app.secret_key = os.environ.get('SECRET_KEY', 'dev-secret-key-change-in-production')
|
||||||
|
|
||||||
limiter = Limiter(get_remote_address, app=app, default_limits=[])
|
limiter = Limiter(get_remote_address, app=app, default_limits=[])
|
||||||
|
|
||||||
|
login_manager = LoginManager(app)
|
||||||
|
login_manager.login_view = 'admin_login'
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# User model
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class User(UserMixin):
|
||||||
|
"""Lightweight user object stored in the session."""
|
||||||
|
def __init__(self, user_id, username, role):
|
||||||
|
# user_id format: 's:<username>' for superadmin, 'u:<db_id>' for DB users
|
||||||
|
self.id = user_id
|
||||||
|
self.username = username
|
||||||
|
self.role = role
|
||||||
|
|
||||||
|
|
||||||
|
@login_manager.user_loader
|
||||||
|
def load_user(user_id):
|
||||||
|
if user_id.startswith('s:'):
|
||||||
|
username = user_id[2:]
|
||||||
|
admin_user = os.environ.get('ADMIN_USER')
|
||||||
|
if admin_user and username == admin_user:
|
||||||
|
return User(user_id, username, 'superadmin')
|
||||||
|
return None
|
||||||
|
if user_id.startswith('u:'):
|
||||||
|
db_id = user_id[2:]
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(DATABASE)
|
||||||
|
c = conn.cursor()
|
||||||
|
row = c.execute(
|
||||||
|
'SELECT id, username, role FROM users WHERE id = ?', (db_id,)
|
||||||
|
).fetchone()
|
||||||
|
conn.close()
|
||||||
|
if row:
|
||||||
|
return User(f'u:{row[0]}', row[1], row[2])
|
||||||
|
except sqlite3.Error as e:
|
||||||
|
logger.error("Database error in user_loader: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Profanity filter
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def load_banned_words():
|
def load_banned_words():
|
||||||
banned_words = set()
|
banned_words = set()
|
||||||
file_path = os.path.join(os.path.dirname(__file__), 'en.txt')
|
file_path = os.path.join(os.path.dirname(__file__), 'en.txt')
|
||||||
@@ -53,6 +101,10 @@ def contains_banned_words(text):
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Database migrations
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
# Each entry is a list of SQL statements for that schema version.
|
# Each entry is a list of SQL statements for that schema version.
|
||||||
# To add a column or index in the future, append a new list — never modify existing entries.
|
# To add a column or index in the future, append a new list — never modify existing entries.
|
||||||
MIGRATIONS = [
|
MIGRATIONS = [
|
||||||
@@ -121,6 +173,10 @@ def is_valid_email(email):
|
|||||||
with app.app_context():
|
with app.app_context():
|
||||||
migrate_db()
|
migrate_db()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public routes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@app.route('/', methods=['GET', 'POST'])
|
@app.route('/', methods=['GET', 'POST'])
|
||||||
@limiter.limit("5 per minute", methods=["POST"])
|
@limiter.limit("5 per minute", methods=["POST"])
|
||||||
def index():
|
def index():
|
||||||
@@ -188,65 +244,62 @@ def index():
|
|||||||
logger.info("Rendering index with %d guests.", len(guests))
|
logger.info("Rendering index with %d guests.", len(guests))
|
||||||
return render_template('index.html', error=error, guests=guests)
|
return render_template('index.html', error=error, guests=guests)
|
||||||
|
|
||||||
def _authenticate():
|
# ---------------------------------------------------------------------------
|
||||||
"""Returns (username, role) for the current request, or None if unauthenticated.
|
# Admin auth routes
|
||||||
Role is 'superadmin', 'admin', or 'viewer'."""
|
# ---------------------------------------------------------------------------
|
||||||
auth = request.authorization
|
|
||||||
if not auth:
|
def _admin_configured():
|
||||||
return None
|
return bool(os.environ.get('ADMIN_USER') and os.environ.get('ADMIN_PASSWORD'))
|
||||||
|
|
||||||
|
@app.route('/admin/login', methods=['GET', 'POST'])
|
||||||
|
def admin_login():
|
||||||
|
if not _admin_configured():
|
||||||
|
abort(503)
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
return redirect(url_for('admin'))
|
||||||
|
error = None
|
||||||
|
if request.method == 'POST':
|
||||||
|
username = request.form.get('username', '').strip()
|
||||||
|
password = request.form.get('password', '').strip()
|
||||||
admin_user = os.environ.get('ADMIN_USER')
|
admin_user = os.environ.get('ADMIN_USER')
|
||||||
admin_password = os.environ.get('ADMIN_PASSWORD')
|
admin_password = os.environ.get('ADMIN_PASSWORD')
|
||||||
if admin_user and auth.username == admin_user and auth.password == admin_password:
|
# Check superadmin first
|
||||||
return (auth.username, 'superadmin')
|
if admin_user and username == admin_user and password == admin_password:
|
||||||
|
login_user(User(f's:{username}', username, 'superadmin'))
|
||||||
|
logger.info("Superadmin '%s' logged in.", username)
|
||||||
|
return redirect(request.args.get('next') or url_for('admin'))
|
||||||
|
# Check DB users
|
||||||
try:
|
try:
|
||||||
conn = sqlite3.connect(DATABASE)
|
conn = sqlite3.connect(DATABASE)
|
||||||
c = conn.cursor()
|
c = conn.cursor()
|
||||||
row = c.execute(
|
row = c.execute(
|
||||||
'SELECT password_hash, role FROM users WHERE username = ?', (auth.username,)
|
'SELECT id, password_hash, role FROM users WHERE username = ?', (username,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
conn.close()
|
conn.close()
|
||||||
if row and check_password_hash(row[0], auth.password):
|
if row and check_password_hash(row[1], password):
|
||||||
return (auth.username, row[1])
|
login_user(User(f'u:{row[0]}', username, row[2]))
|
||||||
|
logger.info("User '%s' (role=%s) logged in.", username, row[2])
|
||||||
|
return redirect(request.args.get('next') or url_for('admin'))
|
||||||
except sqlite3.Error as e:
|
except sqlite3.Error as e:
|
||||||
logger.error("Database error during authentication: %s", e)
|
logger.error("Database error during login: %s", e)
|
||||||
return None
|
error = 'Invalid username or password.'
|
||||||
|
logger.warning("Failed login attempt for username '%s'.", username)
|
||||||
|
return render_template('admin_login.html', error=error)
|
||||||
|
|
||||||
def _unauthorized():
|
@app.route('/admin/logout')
|
||||||
return Response('Authentication required.', 401, {'WWW-Authenticate': 'Basic realm="Admin"'})
|
def admin_logout():
|
||||||
|
logout_user()
|
||||||
|
return redirect(url_for('admin_login'))
|
||||||
|
|
||||||
def require_any_auth(f):
|
# ---------------------------------------------------------------------------
|
||||||
"""Allows superadmin, admin, and viewer roles."""
|
# Admin routes
|
||||||
@wraps(f)
|
# ---------------------------------------------------------------------------
|
||||||
def decorated(*args, **kwargs):
|
|
||||||
if not os.environ.get('ADMIN_USER') or not os.environ.get('ADMIN_PASSWORD'):
|
|
||||||
logger.error("ADMIN_USER and ADMIN_PASSWORD must be set to enable the admin interface.")
|
|
||||||
abort(503)
|
|
||||||
user = _authenticate()
|
|
||||||
if user is None:
|
|
||||||
return _unauthorized()
|
|
||||||
g.current_user, g.current_role = user
|
|
||||||
return f(*args, **kwargs)
|
|
||||||
return decorated
|
|
||||||
|
|
||||||
def require_superadmin(f):
|
|
||||||
"""Allows only the bootstrap superadmin."""
|
|
||||||
@wraps(f)
|
|
||||||
def decorated(*args, **kwargs):
|
|
||||||
admin_user = os.environ.get('ADMIN_USER')
|
|
||||||
admin_password = os.environ.get('ADMIN_PASSWORD')
|
|
||||||
if not admin_user or not admin_password:
|
|
||||||
abort(503)
|
|
||||||
auth = request.authorization
|
|
||||||
if not auth or auth.username != admin_user or auth.password != admin_password:
|
|
||||||
return _unauthorized()
|
|
||||||
g.current_user = auth.username
|
|
||||||
g.current_role = 'superadmin'
|
|
||||||
return f(*args, **kwargs)
|
|
||||||
return decorated
|
|
||||||
|
|
||||||
@app.route('/admin')
|
@app.route('/admin')
|
||||||
@require_any_auth
|
@login_required
|
||||||
def admin():
|
def admin():
|
||||||
|
if not _admin_configured():
|
||||||
|
abort(503)
|
||||||
page = request.args.get('page', 1, type=int)
|
page = request.args.get('page', 1, type=int)
|
||||||
per_page = 25
|
per_page = 25
|
||||||
offset = (page - 1) * per_page
|
offset = (page - 1) * per_page
|
||||||
@@ -266,12 +319,14 @@ def admin():
|
|||||||
total = 0
|
total = 0
|
||||||
total_pages = (total + per_page - 1) // per_page
|
total_pages = (total + per_page - 1) // per_page
|
||||||
return render_template('admin.html', guests=guests, page=page, total_pages=total_pages,
|
return render_template('admin.html', guests=guests, page=page, total_pages=total_pages,
|
||||||
total=total, current_role=g.current_role)
|
total=total)
|
||||||
|
|
||||||
@app.route('/admin/delete/<int:entry_id>', methods=['POST'])
|
@app.route('/admin/delete/<int:entry_id>', methods=['POST'])
|
||||||
@require_any_auth
|
@login_required
|
||||||
def admin_delete(entry_id):
|
def admin_delete(entry_id):
|
||||||
if g.current_role == 'viewer':
|
if not _admin_configured():
|
||||||
|
abort(503)
|
||||||
|
if current_user.role == 'viewer':
|
||||||
abort(403)
|
abort(403)
|
||||||
try:
|
try:
|
||||||
conn = sqlite3.connect(DATABASE)
|
conn = sqlite3.connect(DATABASE)
|
||||||
@@ -284,18 +339,13 @@ def admin_delete(entry_id):
|
|||||||
logger.error("Database error deleting guest %d: %s", entry_id, e)
|
logger.error("Database error deleting guest %d: %s", entry_id, e)
|
||||||
return redirect(url_for('admin', page=request.args.get('page', 1)))
|
return redirect(url_for('admin', page=request.args.get('page', 1)))
|
||||||
|
|
||||||
@app.route('/admin/logout')
|
|
||||||
def admin_logout():
|
|
||||||
return Response(
|
|
||||||
'<p style="font-family:sans-serif">You have been logged out. '
|
|
||||||
'<a href="/admin">Log in again</a></p>',
|
|
||||||
401,
|
|
||||||
{'WWW-Authenticate': 'Basic realm="Admin"', 'Content-Type': 'text/html'}
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.route('/admin/users')
|
@app.route('/admin/users')
|
||||||
@require_superadmin
|
@login_required
|
||||||
def admin_users():
|
def admin_users():
|
||||||
|
if not _admin_configured():
|
||||||
|
abort(503)
|
||||||
|
if current_user.role != 'superadmin':
|
||||||
|
abort(403)
|
||||||
try:
|
try:
|
||||||
conn = sqlite3.connect(DATABASE)
|
conn = sqlite3.connect(DATABASE)
|
||||||
c = conn.cursor()
|
c = conn.cursor()
|
||||||
@@ -307,8 +357,12 @@ def admin_users():
|
|||||||
return render_template('admin_users.html', users=users)
|
return render_template('admin_users.html', users=users)
|
||||||
|
|
||||||
@app.route('/admin/users/add', methods=['POST'])
|
@app.route('/admin/users/add', methods=['POST'])
|
||||||
@require_superadmin
|
@login_required
|
||||||
def admin_users_add():
|
def admin_users_add():
|
||||||
|
if not _admin_configured():
|
||||||
|
abort(503)
|
||||||
|
if current_user.role != 'superadmin':
|
||||||
|
abort(403)
|
||||||
username = request.form.get('username', '').strip()
|
username = request.form.get('username', '').strip()
|
||||||
password = request.form.get('password', '').strip()
|
password = request.form.get('password', '').strip()
|
||||||
role = request.form.get('role', '').strip()
|
role = request.form.get('role', '').strip()
|
||||||
@@ -331,8 +385,12 @@ def admin_users_add():
|
|||||||
return redirect(url_for('admin_users'))
|
return redirect(url_for('admin_users'))
|
||||||
|
|
||||||
@app.route('/admin/users/delete/<int:user_id>', methods=['POST'])
|
@app.route('/admin/users/delete/<int:user_id>', methods=['POST'])
|
||||||
@require_superadmin
|
@login_required
|
||||||
def admin_users_delete(user_id):
|
def admin_users_delete(user_id):
|
||||||
|
if not _admin_configured():
|
||||||
|
abort(503)
|
||||||
|
if current_user.role != 'superadmin':
|
||||||
|
abort(403)
|
||||||
try:
|
try:
|
||||||
conn = sqlite3.connect(DATABASE)
|
conn = sqlite3.connect(DATABASE)
|
||||||
c = conn.cursor()
|
c = conn.cursor()
|
||||||
@@ -344,6 +402,10 @@ def admin_users_delete(user_id):
|
|||||||
logger.error("Database error deleting user %d: %s", user_id, e)
|
logger.error("Database error deleting user %d: %s", user_id, e)
|
||||||
return redirect(url_for('admin_users'))
|
return redirect(url_for('admin_users'))
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# API
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@app.route('/api/guests', methods=['GET'])
|
@app.route('/api/guests', methods=['GET'])
|
||||||
def api_guests():
|
def api_guests():
|
||||||
api_key = request.headers.get('X-API-Key')
|
api_key = request.headers.get('X-API-Key')
|
||||||
|
|||||||
@@ -12,3 +12,4 @@ SITE_TITLE="The Montana Dinosaur Center Visitor Log"
|
|||||||
LOGO_URL="/static/images/logo.png"
|
LOGO_URL="/static/images/logo.png"
|
||||||
ADMIN_USER=admin
|
ADMIN_USER=admin
|
||||||
ADMIN_PASSWORD=changeme
|
ADMIN_PASSWORD=changeme
|
||||||
|
SECRET_KEY=change-this-to-a-random-secret-key
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
Flask>=3.1.3
|
Flask>=3.1.3
|
||||||
Werkzeug>=3.0.6
|
Werkzeug>=3.0.6
|
||||||
Flask-Limiter>=3.0
|
Flask-Limiter>=3.0
|
||||||
|
Flask-Login>=0.6
|
||||||
email-validator>=2.0
|
email-validator>=2.0
|
||||||
gunicorn
|
gunicorn
|
||||||
@@ -11,8 +11,8 @@
|
|||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<h1 class="h3 mb-0">Guestbook Admin</h1>
|
<h1 class="h3 mb-0">Guestbook Admin</h1>
|
||||||
<div class="d-flex align-items-center gap-3">
|
<div class="d-flex align-items-center gap-3">
|
||||||
<span class="text-muted">{{ total }} total entries</span>
|
<span class="text-muted">{{ current_user.username }} · {{ total }} entries</span>
|
||||||
{% if current_role == 'superadmin' %}
|
{% if current_user.role == 'superadmin' %}
|
||||||
<a href="{{ url_for('admin_users') }}" class="btn btn-outline-secondary btn-sm">Manage Users</a>
|
<a href="{{ url_for('admin_users') }}" class="btn btn-outline-secondary btn-sm">Manage Users</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="{{ url_for('admin_logout') }}" class="btn btn-outline-danger btn-sm">Logout</a>
|
<a href="{{ url_for('admin_logout') }}" class="btn btn-outline-danger btn-sm">Logout</a>
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
<td>{{ 'Yes' if g[6] else 'No' }}</td>
|
<td>{{ 'Yes' if g[6] else 'No' }}</td>
|
||||||
<td class="text-nowrap">{{ g[7] }}</td>
|
<td class="text-nowrap">{{ g[7] }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if current_role != 'viewer' %}
|
{% if current_user.role != 'viewer' %}
|
||||||
<form method="POST" action="{{ url_for('admin_delete', entry_id=g[0]) }}?page={{ page }}"
|
<form method="POST" action="{{ url_for('admin_delete', entry_id=g[0]) }}?page={{ page }}"
|
||||||
onsubmit="return confirm('Delete entry for {{ g[1] }} {{ g[2] }}?')">
|
onsubmit="return confirm('Delete entry for {{ g[1] }} {{ g[2] }}?')">
|
||||||
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
|
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Guestbook Admin — Login</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
<body class="bg-light">
|
||||||
|
<div class="container py-5" style="max-width: 400px;">
|
||||||
|
<h1 class="h4 mb-4 text-center">Admin Login</h1>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
{% if error %}
|
||||||
|
<div class="alert alert-danger py-2">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<form method="POST" action="{{ url_for('admin_login', next=request.args.get('next', '')) }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="username" class="form-label">Username</label>
|
||||||
|
<input type="text" id="username" name="username" class="form-control"
|
||||||
|
autocomplete="username" required autofocus />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="password" class="form-label">Password</label>
|
||||||
|
<input type="password" id="password" name="password" class="form-control"
|
||||||
|
autocomplete="current-password" required />
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary w-100">Log In</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user