feat: Render deployment and demo mode for single-user demo

- Add render.yaml Blueprint: PostgreSQL + Python web service, auto-deploy on push
- Add demo mode (DEMO_MODE, DEMO_USERNAME, DEMO_PASSWORD): single fixed user only
- Login page shows demo credentials when demo mode is active
- Disable self-registration, admin user creation, and OIDC user creation in demo mode
- DB init creates demo user with password when DEMO_MODE is true
- Add docs/deploy/RENDER.md with deployment and demo mode instructions
This commit is contained in:
Dries Peeters
2026-02-16 20:34:32 +01:00
parent def875431c
commit a6b60b16dd
8 changed files with 238 additions and 122 deletions
+58 -26
View File
@@ -1459,22 +1459,38 @@ def create_app(config=None):
# Check and migrate Issues table if needed
migrate_issues_table()
# Create default admin user if it doesn't exist
admin_username = app.config.get("ADMIN_USERNAMES", ["admin"])[0]
if not User.query.filter_by(username=admin_username).first():
from app.models import Role
# Create default admin user or demo user if it doesn't exist
if app.config.get("DEMO_MODE"):
demo_username = (app.config.get("DEMO_USERNAME") or "demo").strip().lower()
if not User.query.filter_by(username=demo_username).first():
from app.models import Role
admin_user = User(username=admin_username, role="admin")
admin_user.is_active = True
demo_user = User(username=demo_username, role="admin")
demo_user.is_active = True
demo_user.set_password(app.config.get("DEMO_PASSWORD", "demo"))
# Assign admin role from the new Role system
admin_role = Role.query.filter_by(name="admin").first()
if admin_role:
admin_user.roles.append(admin_role)
admin_role = Role.query.filter_by(name="admin").first()
if admin_role:
demo_user.roles.append(admin_role)
db.session.add(admin_user)
db.session.commit()
print(f"Created default admin user: {admin_username}")
db.session.add(demo_user)
db.session.commit()
print(f"Created demo user: {demo_username}")
else:
admin_username = app.config.get("ADMIN_USERNAMES", ["admin"])[0]
if not User.query.filter_by(username=admin_username).first():
from app.models import Role
admin_user = User(username=admin_username, role="admin")
admin_user.is_active = True
admin_role = Role.query.filter_by(name="admin").first()
if admin_role:
admin_user.roles.append(admin_role)
db.session.add(admin_user)
db.session.commit()
print(f"Created default admin user: {admin_username}")
print("Database initialized successfully")
except Exception as e:
@@ -1657,22 +1673,38 @@ def init_database(app):
# Check and migrate Task Management tables if needed
migrate_task_management_tables()
# Create default admin user if it doesn't exist
admin_username = app.config.get("ADMIN_USERNAMES", ["admin"])[0]
if not User.query.filter_by(username=admin_username).first():
from app.models import Role
# Create default admin user or demo user if it doesn't exist
if app.config.get("DEMO_MODE"):
demo_username = (app.config.get("DEMO_USERNAME") or "demo").strip().lower()
if not User.query.filter_by(username=demo_username).first():
from app.models import Role
admin_user = User(username=admin_username, role="admin")
admin_user.is_active = True
demo_user = User(username=demo_username, role="admin")
demo_user.is_active = True
demo_user.set_password(app.config.get("DEMO_PASSWORD", "demo"))
# Assign admin role from the new Role system
admin_role = Role.query.filter_by(name="admin").first()
if admin_role:
admin_user.roles.append(admin_role)
admin_role = Role.query.filter_by(name="admin").first()
if admin_role:
demo_user.roles.append(admin_role)
db.session.add(admin_user)
db.session.commit()
print(f"Created default admin user: {admin_username}")
db.session.add(demo_user)
db.session.commit()
print(f"Created demo user: {demo_username}")
else:
admin_username = app.config.get("ADMIN_USERNAMES", ["admin"])[0]
if not User.query.filter_by(username=admin_username).first():
from app.models import Role
admin_user = User(username=admin_username, role="admin")
admin_user.is_active = True
admin_role = Role.query.filter_by(name="admin").first()
if admin_role:
admin_user.roles.append(admin_role)
db.session.add(admin_user)
db.session.commit()
print(f"Created default admin user: {admin_username}")
print("Database initialized successfully")
except Exception as e:
+5
View File
@@ -45,6 +45,11 @@ class Config:
ALLOW_SELF_REGISTER = os.getenv("ALLOW_SELF_REGISTER", "true").lower() == "true"
ADMIN_USERNAMES = [u.strip() for u in os.getenv("ADMIN_USERNAMES", "admin").split(",") if u.strip()]
# Demo mode: single fixed user, credentials shown on login, no other account creation
DEMO_MODE = os.getenv("DEMO_MODE", "false").lower() == "true"
DEMO_USERNAME = (os.getenv("DEMO_USERNAME", "demo") or "demo").strip().lower()
DEMO_PASSWORD = os.getenv("DEMO_PASSWORD", "demo")
# Authentication method: 'none' | 'local' | 'oidc' | 'both'
# 'none' = no password authentication (username only)
# 'local' = password authentication required
+3
View File
@@ -744,6 +744,9 @@ def list_users():
def create_user():
"""Create a new user"""
if request.method == "POST":
if current_app.config.get("DEMO_MODE"):
flash(_("User creation is disabled in demo mode."), "error")
return redirect(url_for("admin.list_users"))
username = request.form.get("username", "").strip().lower()
role_name = request.form.get("role", "user") # This will be a role name from the Role system
default_password = request.form.get("default_password", "").strip()
+50 -94
View File
@@ -41,6 +41,25 @@ def get_avatar_upload_folder() -> str:
return upload_folder
def _login_template_vars():
"""Common template variables for auth/login.html, including demo mode when enabled."""
allow_self_register = ConfigManager.get_setting("allow_self_register", Config.ALLOW_SELF_REGISTER)
auth_method = (current_app.config.get("AUTH_METHOD", "local") or "local").strip().lower()
requires_password = auth_method in ("local", "both")
vars = {
"allow_self_register": allow_self_register,
"auth_method": auth_method,
"requires_password": requires_password,
}
if current_app.config.get("DEMO_MODE"):
vars["demo_mode"] = True
vars["demo_username"] = (current_app.config.get("DEMO_USERNAME") or "demo").strip().lower()
vars["demo_password"] = current_app.config.get("DEMO_PASSWORD", "demo")
else:
vars["demo_mode"] = False
return vars
@auth_bp.route("/login", methods=["GET", "POST"])
@limiter.limit("5 per minute", methods=["POST"]) # rate limit login attempts
def login():
@@ -96,13 +115,15 @@ def login():
except (ValueError, Exception) as e:
log_event("auth.login_failed", reason="invalid_username", auth_method=auth_method)
flash(_("Invalid username format"), "error")
allow_self_register = ConfigManager.get_setting("allow_self_register", Config.ALLOW_SELF_REGISTER)
return render_template(
"auth/login.html",
allow_self_register=allow_self_register,
auth_method=auth_method,
requires_password=requires_password,
)
return render_template("auth/login.html", **_login_template_vars())
# Demo mode: only the configured demo user can log in; no self-registration
if current_app.config.get("DEMO_MODE"):
demo_username = (current_app.config.get("DEMO_USERNAME") or "demo").strip().lower()
if username != demo_username:
log_event("auth.login_failed", username=username, reason="demo_mode_only_demo_user", auth_method=auth_method)
flash(_("Only the demo account can be used. Please use the credentials shown below."), "error")
return render_template("auth/login.html", **_login_template_vars())
# Normalize admin usernames from config
try:
@@ -122,20 +143,10 @@ def login():
if requires_password:
if not password:
flash(_("Password is required to create an account."), "error")
return render_template(
"auth/login.html",
allow_self_register=allow_self_register,
auth_method=auth_method,
requires_password=requires_password,
)
return render_template("auth/login.html", **_login_template_vars())
if len(password) < 8:
flash(_("Password must be at least 8 characters long."), "error")
return render_template(
"auth/login.html",
allow_self_register=allow_self_register,
auth_method=auth_method,
requires_password=requires_password,
)
return render_template("auth/login.html", **_login_template_vars())
# Create new user, promote to admin if username is configured as admin
role_name = "admin" if username in admin_usernames else "user"
@@ -164,12 +175,7 @@ def login():
flash(
_("Could not create your account due to a database error. Please try again later."), "error"
)
return render_template(
"auth/login.html",
allow_self_register=allow_self_register,
auth_method=auth_method,
requires_password=requires_password,
)
return render_template("auth/login.html", **_login_template_vars())
current_app.logger.info("Created new user '%s'", username)
# Track onboarding started for new user
@@ -181,12 +187,7 @@ def login():
else:
log_event("auth.login_failed", username=username, reason="user_not_found", auth_method=auth_method)
flash(_("User not found. Please contact an administrator."), "error")
return render_template(
"auth/login.html",
allow_self_register=allow_self_register,
auth_method=auth_method,
requires_password=requires_password,
)
return render_template("auth/login.html", **_login_template_vars())
else:
# If existing user matches admin usernames, ensure admin role
if username in admin_usernames and user.role != "admin":
@@ -194,25 +195,13 @@ def login():
if not safe_commit("promote_admin_user", {"username": username}):
current_app.logger.error("Failed to promote '%s' to admin due to DB error", username)
flash(_("Could not update your account role due to a database error."), "error")
allow_self_register = ConfigManager.get_setting("allow_self_register", Config.ALLOW_SELF_REGISTER)
return render_template(
"auth/login.html",
allow_self_register=allow_self_register,
auth_method=auth_method,
requires_password=requires_password,
)
return render_template("auth/login.html", **_login_template_vars())
# Check if user is active
if not user.is_active:
log_event("auth.login_failed", user_id=user.id, reason="account_disabled", auth_method=auth_method)
flash(_("Account is disabled. Please contact an administrator."), "error")
allow_self_register = ConfigManager.get_setting("allow_self_register", Config.ALLOW_SELF_REGISTER)
return render_template(
"auth/login.html",
allow_self_register=allow_self_register,
auth_method=auth_method,
requires_password=requires_password,
)
return render_template("auth/login.html", **_login_template_vars())
# Handle password authentication based on mode
if requires_password:
@@ -224,26 +213,14 @@ def login():
"auth.login_failed", user_id=user.id, reason="password_required", auth_method=auth_method
)
flash(_("Password is required"), "error")
allow_self_register = ConfigManager.get_setting("allow_self_register", Config.ALLOW_SELF_REGISTER)
return render_template(
"auth/login.html",
allow_self_register=allow_self_register,
auth_method=auth_method,
requires_password=requires_password,
)
return render_template("auth/login.html", **_login_template_vars())
if not user.check_password(password):
log_event(
"auth.login_failed", user_id=user.id, reason="invalid_password", auth_method=auth_method
)
flash(_("Invalid username or password"), "error")
allow_self_register = ConfigManager.get_setting("allow_self_register", Config.ALLOW_SELF_REGISTER)
return render_template(
"auth/login.html",
allow_self_register=allow_self_register,
auth_method=auth_method,
requires_password=requires_password,
)
return render_template("auth/login.html", **_login_template_vars())
else:
# User doesn't have password set - require password to be provided
if not password:
@@ -253,13 +230,7 @@ def login():
_("No password is set for your account. Please enter a password to set one and log in."),
"error",
)
allow_self_register = ConfigManager.get_setting("allow_self_register", Config.ALLOW_SELF_REGISTER)
return render_template(
"auth/login.html",
allow_self_register=allow_self_register,
auth_method=auth_method,
requires_password=requires_password,
)
return render_template("auth/login.html", **_login_template_vars())
# Password provided - validate and set it
if len(password) < 8:
@@ -267,26 +238,14 @@ def login():
"auth.login_failed", user_id=user.id, reason="password_too_short", auth_method=auth_method
)
flash(_("Password must be at least 8 characters long."), "error")
allow_self_register = ConfigManager.get_setting("allow_self_register", Config.ALLOW_SELF_REGISTER)
return render_template(
"auth/login.html",
allow_self_register=allow_self_register,
auth_method=auth_method,
requires_password=requires_password,
)
return render_template("auth/login.html", **_login_template_vars())
# Set the password and continue to login
user.set_password(password)
if not safe_commit("set_initial_password", {"user_id": user.id, "username": user.username}):
current_app.logger.error("Failed to set initial password for '%s' due to DB error", user.username)
flash(_("Could not set password due to a database error. Please try again."), "error")
allow_self_register = ConfigManager.get_setting("allow_self_register", Config.ALLOW_SELF_REGISTER)
return render_template(
"auth/login.html",
allow_self_register=allow_self_register,
auth_method=auth_method,
requires_password=requires_password,
)
return render_template("auth/login.html", **_login_template_vars())
current_app.logger.info("User '%s' set initial password during login", user.username)
flash(_("Password has been set. You are now logged in."), "success")
else:
@@ -337,21 +296,9 @@ def login():
except Exception as e:
current_app.logger.exception("Login error: %s", e)
flash(_("Unexpected error during login. Please try again or check server logs."), "error")
allow_self_register = ConfigManager.get_setting("allow_self_register", Config.ALLOW_SELF_REGISTER)
return render_template(
"auth/login.html",
allow_self_register=allow_self_register,
auth_method=auth_method,
requires_password=requires_password,
)
return render_template("auth/login.html", **_login_template_vars())
allow_self_register = ConfigManager.get_setting("allow_self_register", Config.ALLOW_SELF_REGISTER)
return render_template(
"auth/login.html",
allow_self_register=allow_self_register,
auth_method=auth_method,
requires_password=requires_password,
)
return render_template("auth/login.html", **_login_template_vars())
@auth_bp.route("/logout")
@@ -644,6 +591,10 @@ def update_theme_preference():
@auth_bp.route("/login/oidc")
def login_oidc():
"""Start OIDC login using Authlib."""
if current_app.config.get("DEMO_MODE"):
flash(_("Demo mode: only the demo account can be used. Please use the credentials on the login page."), "warning")
return redirect(url_for("auth.login"))
try:
auth_method = (current_app.config.get("AUTH_METHOD", "local") or "local").strip().lower()
except Exception:
@@ -980,6 +931,11 @@ def oidc_callback():
user = User.query.filter_by(username=username).first()
if not user:
# Demo mode: do not create users via OIDC
if current_app.config.get("DEMO_MODE"):
current_app.logger.info("OIDC callback redirect to login: reason=demo_mode_no_oidc_create")
flash(_("Demo mode: only the demo account can be used. Please use the credentials on the login page."), "error")
return redirect(url_for("auth.login"))
# Create if allowed (use ConfigManager to respect database settings)
allow_self_register = ConfigManager.get_setting("allow_self_register", Config.ALLOW_SELF_REGISTER)
if not allow_self_register:
+11 -2
View File
@@ -39,6 +39,13 @@
</div>
<div class="p-8">
<h2 class="text-2xl font-bold tracking-tight">{{ _('Sign in to your account') }}</h2>
{% if demo_mode %}
<div class="mt-4 p-4 rounded-lg bg-primary/10 dark:bg-primary/20 border border-primary/30 dark:border-primary/40">
<p class="text-sm font-medium text-primary dark:text-primary/90 mb-2">{{ _('Demo credentials') }}</p>
<p class="text-sm text-text-light dark:text-text-dark"><span class="font-medium">{{ _('Username') }}:</span> <code class="bg-background-light dark:bg-gray-700 px-1.5 py-0.5 rounded">{{ demo_username }}</code></p>
<p class="text-sm text-text-light dark:text-text-dark mt-1"><span class="font-medium">{{ _('Password') }}:</span> <code class="bg-background-light dark:bg-gray-700 px-1.5 py-0.5 rounded">{{ demo_password }}</code></p>
</div>
{% endif %}
<form class="mt-6" method="POST" action="{{ url_for('auth.login') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label for="username" class="block mb-2 text-sm font-medium">{{ _('Username') }}</label>
@@ -57,11 +64,13 @@
<button type="submit" class="btn btn-primary w-full mt-6">{{ _('Sign in') }}</button>
{% if allow_self_register %}
{% if demo_mode %}
<p class="mt-3 text-xs text-text-muted-light dark:text-text-muted-dark text-center">{{ _('Use the demo credentials above.') }}</p>
{% elif allow_self_register %}
<p class="mt-3 text-xs text-text-muted-light dark:text-text-muted-dark text-center">{{ _('Tip: Enter a new username to create your account.') }}</p>
{% endif %}
{% if auth_method and auth_method|string|lower == 'both' %}
{% if not demo_mode and auth_method and auth_method|string|lower == 'both' %}
<div class="relative my-6">
<div class="absolute inset-0 flex items-center" aria-hidden="true">
<div class="w-full border-t border-border-light dark:border-border-dark"></div>
+4
View File
@@ -29,6 +29,10 @@ class ConfigManager:
Returns:
Setting value
"""
# Demo mode: self-registration is always disabled
if key == "allow_self_register" and current_app and current_app.config.get("DEMO_MODE"):
return False
# Check Settings model first (WebUI changes have highest priority)
# Only use values from persisted Settings instances (those with an id)
# to avoid using fallback instances initialized from .env file
+66
View File
@@ -0,0 +1,66 @@
# Deploy TimeTracker on Render
This guide explains how to host TimeTracker as a Web Service on [Render](https://render.com) with optional **demo mode** (single user, credentials shown on the login page).
## Prerequisites
- A [Render](https://render.com) account
- This repository connected to your GitHub (or GitLab) account
## Deploy with the Blueprint
1. In the Render Dashboard, click **New****Blueprint**.
2. Connect your Git provider and select the TimeTracker repository.
3. Render will detect the `render.yaml` in the repository root.
4. Review the blueprint: it creates one **PostgreSQL** database and one **Web Service**.
5. Click **Apply** to create the database and deploy the app.
After the first deploy, every push to the connected branch will trigger a new build and deploy (auto-updates on push).
## Environment variables
The blueprint sets:
- **FLASK_ENV**: `production`
- **FLASK_APP**: `app:create_app()` (used for `flask db upgrade` in the release step)
- **SECRET_KEY**: Auto-generated by Render (recommended; you can override in the Dashboard)
- **DATABASE_URL**: Filled automatically from the linked PostgreSQL database
- **AUTH_METHOD**: `local` (username/password login)
- **REDIS_ENABLED**: `false` (rate limiting uses in-memory storage; no Redis required for demo)
## Demo mode (single-user demo)
To run a **demo** instance where only one user can log in and the credentials are shown on the login page:
1. In the Render Dashboard, open your **timetracker** web service.
2. Go to **Environment** and add (or uncomment in `render.yaml` and redeploy):
- **DEMO_MODE**: `true`
- **DEMO_USERNAME**: `demo` (or any username you want)
- **DEMO_PASSWORD**: Set a strong password (use a **Secret** so it is not visible in the dashboard to others)
3. Redeploy the service.
In demo mode:
- Only the user with **DEMO_USERNAME** can log in.
- The login page shows the demo username and password.
- Self-registration and admin user creation are disabled; OIDC cannot create new users.
- The demo user is created automatically on first run if it does not exist.
**Security:** Use a strong **DEMO_PASSWORD** for any public demo. Do not use `DEMO_MODE=true` for production multi-user deployments.
## Optional: Run without the Blueprint
If you prefer to create the database and web service manually:
1. Create a **PostgreSQL** database and note the **Internal Database URL** (or **External** if your app runs elsewhere).
2. Create a **Web Service**, connect the repo, and set:
- **Build Command**: `pip install -r requirements.txt && npm ci && npm run build:docker`
- **Start Command**: `gunicorn --bind 0.0.0.0:$PORT --worker-class eventlet --workers 1 --timeout 120 "app:create_app()"`
- **Release Command**: `flask db upgrade`
3. In **Environment**, set **FLASK_APP** to `app:create_app()`, **DATABASE_URL** to the Postgres URL, **SECRET_KEY**, and any demo-mode variables as above.
## Troubleshooting
- **Migrations**: The release command runs `flask db upgrade`. If it fails, check that **FLASK_APP** is set to `app:create_app()` and that **DATABASE_URL** is set and reachable from Render.
- **Build**: The build installs Python dependencies and compiles Tailwind CSS. If `npm run build:docker` fails, ensure `package.json` and `app/static/src/input.css` are in the repo.
- **Database URL**: Renders PostgreSQL URL is usually in `postgres://` form; the app uses `postgresql+psycopg2`. If you see connection errors, try setting **DATABASE_URL** to the same URL with the scheme changed to `postgresql://` (Render may also provide a direct URL in the database dashboard).
+41
View File
@@ -0,0 +1,41 @@
# Render Blueprint: TimeTracker Web Service + PostgreSQL
# Connect this repo in Render Dashboard (New > Blueprint) for auto-deploy on push.
databases:
- name: timetracker-db
databaseName: timetracker
user: timetracker
plan: free
services:
- type: web
name: timetracker
runtime: python
region: oregon
buildCommand: pip install -r requirements.txt && npm ci && npm run build:docker
startCommand: gunicorn --bind 0.0.0.0:$PORT --worker-class eventlet --workers 1 --timeout 120 "app:create_app()"
releaseCommand: flask db upgrade
envVars:
- key: FLASK_ENV
value: production
- key: FLASK_APP
value: app:create_app()
- key: SECRET_KEY
generateValue: true
- key: DATABASE_URL
fromDatabase:
name: timetracker-db
property: connectionString
- key: AUTH_METHOD
value: local
- key: REDIS_ENABLED
value: "false"
# Demo mode: uncomment and set DEMO_PASSWORD in Render Dashboard for a single-user demo
# - key: DEMO_MODE
# value: "true"
# - key: DEMO_USERNAME
# value: demo
# - key: DEMO_PASSWORD
# sync: false