Add login functionality with password protection and create login page

This commit is contained in:
zwitschi committed 2026-07-24 23:59:03 +02:00
1 parent 443ed74093
commit c237564f61
4 files changed
+204 -1

No files matched your search

+2
View File
@@ -9,3 +9,5 @@ FLASK_DEBUG=0
# Seconds to cache the TeslaCam directory scan (default 300). Hit /refresh to
# force a re-scan.
SCAN_CACHE_TTL=3600
LOGIN_PASSWORD=patreon
+114
View File
@@ -520,3 +520,117 @@ header {
[hidden] {
display: none !important;
}
/* =============================================
Login Page
============================================= */
.login-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: radial-gradient(
ellipse at 50% 30%,
#1a1a22 0%,
var(--bg-primary) 70%
);
}
.login-card {
width: 100%;
max-width: 380px;
padding: 40px 36px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.login-brand {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
margin-bottom: 32px;
}
.login-brand .logo {
width: 48px;
height: 48px;
display: grid;
place-items: center;
color: var(--accent);
}
.login-brand .logo svg {
width: 100%;
height: 100%;
fill: var(--accent);
}
.login-brand h1 {
font-size: 20px;
font-weight: 700;
letter-spacing: 0.5px;
color: var(--text-primary);
}
.login-form {
display: flex;
flex-direction: column;
gap: 14px;
}
.login-form label {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.8px;
}
.login-form input[type="password"] {
width: 100%;
padding: 12px 16px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-size: 15px;
outline: none;
transition:
border-color 0.2s,
box-shadow 0.2s;
}
.login-form input[type="password"]:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(227, 25, 55, 0.15);
}
.login-form button {
width: 100%;
padding: 12px;
background: var(--accent);
border: none;
border-radius: var(--radius-sm);
color: #fff;
font-size: 15px;
font-weight: 700;
cursor: pointer;
transition: background 0.15s;
margin-top: 4px;
}
.login-form button:hover {
background: var(--accent-hover);
}
.login-error {
font-size: 13px;
color: var(--accent);
text-align: center;
padding: 8px;
background: rgba(227, 25, 55, 0.08);
border-radius: var(--radius-sm);
}
+43
View File
@@ -0,0 +1,43 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TeslaCam Player — Login</title>
<link
rel="stylesheet"
href="{{ url_for('static', filename='css/style.css') }}"
/>
</head>
<body class="login-page">
<div class="login-card">
<div class="login-brand">
<span class="logo" aria-hidden="true">
<svg viewBox="0 0 24 24">
<path
d="M12 3C7 3 3.2 4.4 3.2 4.4l.9 1.7s3.1-1 5-.9l.7 1.3C7.9 7.7 6 9 6 9s.6 2.4 3 4.6c2.5-2.2 3-4.6 3-4.6s-1.9-1.3-3.7-1.5l.6-1.3c1.9-.1 5 .9 5 .9l.9-1.7S17 3 12 3z"
/>
</svg>
</span>
<h1>TeslaCam Player</h1>
</div>
<form method="POST" action="{{ url_for('login') }}" class="login-form">
<label for="password">Password</label>
<input
type="password"
id="password"
name="password"
placeholder="Enter password"
autocomplete="current-password"
autofocus
required
/>
{% if error %}
<p class="login-error">{{ error }}</p>
{% endif %}
<button type="submit">Sign In</button>
</form>
</div>
</body>
</html>
+45 -1
View File
@@ -12,15 +12,26 @@ play videos from different camera views.
import os
import re
import time
import secrets
from functools import wraps
from dotenv import load_dotenv
from flask import Flask, render_template, send_from_directory, abort, jsonify
from flask import (
Flask, render_template, send_from_directory, abort, jsonify,
request, session, redirect, url_for
)
# Load variables from a .env file if present (does not override real env vars)
load_dotenv()
app = Flask(__name__)
# Session secret key — MUST be set in production. Generate with: python -c "import secrets; print(secrets.token_hex(32))"
app.secret_key = os.environ.get('SECRET_KEY', secrets.token_hex(32))
# Password for web login. Default: 'patreon'. Override via LOGIN_PASSWORD env var.
LOGIN_PASSWORD = os.environ.get('LOGIN_PASSWORD', 'patreon')
# Root TeslaCam directory containing RecentClips, SavedClips, SentryClips, etc.
# Override via the TESLACAM_DIRECTORY environment variable (see .env.example).
TESLACAM_DIRECTORY = os.environ.get('TESLACAM_DIRECTORY', '/mnt/TeslaCam')
@@ -113,7 +124,37 @@ def get_camera_display_name(camera_view):
return CAMERA_VIEW_MAPPING.get(camera_view, camera_view.replace('_', ' ').title())
def login_required(f):
"""Decorator: redirect to /login if not authenticated."""
@wraps(f)
def decorated_function(*args, **kwargs):
if not session.get('logged_in'):
return redirect(url_for('login'))
return f(*args, **kwargs)
return decorated_function
@app.route('/login', methods=['GET', 'POST'])
def login():
"""Simple password-based login page."""
error = None
if request.method == 'POST':
if request.form.get('password') == LOGIN_PASSWORD:
session['logged_in'] = True
return redirect(url_for('index'))
error = 'Invalid password. Please try again.'
return render_template('login.html', error=error)
@app.route('/logout')
def logout():
"""Clear session and return to login page."""
session.pop('logged_in', None)
return redirect(url_for('login'))
@app.route('/')
@login_required
def index():
"""
Main page listing all videos categorized by human-readable camera view.
@@ -133,6 +174,7 @@ def index():
@app.route('/camera/<camera_view>')
@login_required
def camera_view(camera_view):
"""
Displays all available videos for a single camera view.
@@ -150,6 +192,7 @@ def camera_view(camera_view):
@app.route('/video/<path:filepath>')
@login_required
def serve_video(filepath):
"""
Safely streams video files from the TeslaCam directory structure.
@@ -162,6 +205,7 @@ def serve_video(filepath):
@app.route('/refresh')
@login_required
def refresh():
"""
Forces a re-scan of the TeslaCam directory, bypassing the cache TTL.