forked from Ugric/cov-to-ics
make css better and add add calendar button
This commit is contained in:
1 parent
9bb598dada
commit
3bfe06a8ad
8 files changed
+694
-220
No files matched your search
@@ -3,7 +3,16 @@ from datetime import datetime, timezone
|
|||||||
import pytz
|
import pytz
|
||||||
import hashlib
|
import hashlib
|
||||||
import os
|
import os
|
||||||
from flask import Flask, request, send_file, render_template, Response, redirect, url_for
|
from flask import (
|
||||||
|
Flask,
|
||||||
|
request,
|
||||||
|
send_file,
|
||||||
|
render_template,
|
||||||
|
Response,
|
||||||
|
redirect,
|
||||||
|
url_for,
|
||||||
|
session,
|
||||||
|
)
|
||||||
import requests
|
import requests
|
||||||
from requests_ntlm import HttpNtlmAuth
|
from requests_ntlm import HttpNtlmAuth
|
||||||
import urllib
|
import urllib
|
||||||
@@ -12,112 +21,418 @@ from cov2ics import *
|
|||||||
import base64
|
import base64
|
||||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
import codecs
|
import json
|
||||||
import urllib.parse
|
from flask_wtf.csrf import CSRFProtect
|
||||||
|
from flask_wtf.csrf import CSRFProtect
|
||||||
|
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
def get_auth():
|
|
||||||
username = None
|
|
||||||
auth_encrypted_encoded = request.cookies.get("auth")
|
|
||||||
nonce_encoded = request.cookies.get("nonce")
|
|
||||||
|
|
||||||
if auth_encrypted_encoded and nonce_encoded:
|
# ---------------------------------------------------------------------------
|
||||||
auth_encrypted = base64.b64decode(auth_encrypted_encoded)
|
# Configuration
|
||||||
nonce = base64.b64decode(nonce_encoded)
|
# ---------------------------------------------------------------------------
|
||||||
auth = json.loads(aes.decrypt(nonce, auth_encrypted, None).decode("utf-8"))
|
|
||||||
return fetch_auth_status(f"COVENTRY\\{auth['u']}", auth['p']), auth['u'], auth["p"]
|
|
||||||
|
|
||||||
return False, None, None
|
|
||||||
|
|
||||||
def generate_auth(username, password):
|
|
||||||
nonce = os.urandom(12)
|
|
||||||
auth_encrypted = aes.encrypt(nonce, json.dumps({"u": username, "p": password}).encode(), None)
|
|
||||||
return auth_encrypted, nonce
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# print(URL.format(urllib.parse.quote_plus("hello/world")))
|
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
# Generate a 256-bit (32-byte) key
|
app.config["SECRET_KEY"] = os.getenv("SECRET_KEY")
|
||||||
# key = AESGCM.generate_key(bit_length=256)
|
|
||||||
|
|
||||||
key = codecs.decode(os.getenv("AES_KEY"), 'hex')
|
app.config.update(
|
||||||
|
SESSION_COOKIE_SECURE=True,
|
||||||
|
SESSION_COOKIE_HTTPONLY=True,
|
||||||
|
SESSION_COOKIE_SAMESITE="Lax",
|
||||||
|
)
|
||||||
|
|
||||||
# Create the AES-GCM cipher
|
csrf = CSRFProtect(app)
|
||||||
aes = AESGCM(key)
|
|
||||||
|
|
||||||
|
# AES_KEY should be a 64-character hexadecimal string representing
|
||||||
|
# exactly 32 random bytes (256 bits).
|
||||||
|
#
|
||||||
|
# Generate one with:
|
||||||
|
#
|
||||||
|
# python -c "import secrets; print(secrets.token_hex(32))"
|
||||||
|
#
|
||||||
|
# Store the result in your environment as AES_KEY.
|
||||||
|
aes_key = bytes.fromhex(os.environ["AES_KEY"])
|
||||||
|
|
||||||
|
if len(aes_key) != 32:
|
||||||
|
raise RuntimeError("AES_KEY must be exactly 32 bytes / 64 hex characters")
|
||||||
|
|
||||||
|
aes = AESGCM(aes_key)
|
||||||
|
|
||||||
|
|
||||||
|
URL = os.getenv("URL")
|
||||||
|
WEBCAL_URL = os.getenv("WEBCAL_URL")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Stateless authentication token
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
AUTH_TOKEN_VERSION = b"\x01"
|
||||||
|
AUTH_TOKEN_AAD = b"timtabla-auth-v1"
|
||||||
|
|
||||||
|
# 12 bytes is the recommended nonce size for AES-GCM.
|
||||||
|
NONCE_SIZE = 12
|
||||||
|
|
||||||
|
# Ten years for the browser login cookie.
|
||||||
|
AUTH_MAX_AGE = 10 * 365 * 24 * 60 * 60
|
||||||
|
|
||||||
|
|
||||||
|
def b64encode(data: bytes) -> str:
|
||||||
|
"""Base64URL encode without '=' padding."""
|
||||||
|
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def b64decode(data: str) -> bytes:
|
||||||
|
"""Base64URL decode with optional '=' padding."""
|
||||||
|
padding = "=" * (-len(data) % 4)
|
||||||
|
return base64.urlsafe_b64decode(data + padding)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_auth_token(username: str, password: str) -> str:
|
||||||
|
"""
|
||||||
|
Create a stateless encrypted authentication token.
|
||||||
|
|
||||||
|
Token layout:
|
||||||
|
|
||||||
|
version (1 byte)
|
||||||
|
nonce (12 bytes)
|
||||||
|
ciphertext + GCM authentication tag
|
||||||
|
|
||||||
|
The plaintext contains the university credentials.
|
||||||
|
"""
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"u": username,
|
||||||
|
"p": password,
|
||||||
|
"exp": int(datetime.now(timezone.utc).timestamp()) + AUTH_MAX_AGE,
|
||||||
|
}
|
||||||
|
|
||||||
|
plaintext = json.dumps(
|
||||||
|
payload,
|
||||||
|
separators=(",", ":"),
|
||||||
|
).encode("utf-8")
|
||||||
|
|
||||||
|
nonce = os.urandom(NONCE_SIZE)
|
||||||
|
|
||||||
|
ciphertext = aes.encrypt(
|
||||||
|
nonce,
|
||||||
|
plaintext,
|
||||||
|
AUTH_TOKEN_AAD,
|
||||||
|
)
|
||||||
|
|
||||||
|
token = AUTH_TOKEN_VERSION + nonce + ciphertext
|
||||||
|
|
||||||
|
return b64encode(token)
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_auth_token(token: str):
|
||||||
|
"""
|
||||||
|
Decrypt and validate an authentication token.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(username, password)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
None
|
||||||
|
|
||||||
|
if the token is invalid, expired, or has been tampered with.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = b64decode(token)
|
||||||
|
|
||||||
|
# Minimum:
|
||||||
|
# 1 byte version
|
||||||
|
# 12 byte nonce
|
||||||
|
# 16 byte GCM authentication tag
|
||||||
|
if len(raw) < 1 + NONCE_SIZE + 16:
|
||||||
|
return None
|
||||||
|
|
||||||
|
version = raw[0:1]
|
||||||
|
|
||||||
|
if version != AUTH_TOKEN_VERSION:
|
||||||
|
return None
|
||||||
|
|
||||||
|
nonce = raw[1 : 1 + NONCE_SIZE]
|
||||||
|
ciphertext = raw[1 + NONCE_SIZE :]
|
||||||
|
|
||||||
|
plaintext = aes.decrypt(
|
||||||
|
nonce,
|
||||||
|
ciphertext,
|
||||||
|
AUTH_TOKEN_AAD,
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = json.loads(plaintext.decode("utf-8"))
|
||||||
|
|
||||||
|
username = payload["u"]
|
||||||
|
password = payload["p"]
|
||||||
|
expiry = int(payload["exp"])
|
||||||
|
|
||||||
|
if not isinstance(username, str) or not isinstance(password, str):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if datetime.now(timezone.utc).timestamp() >= expiry:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return username, password
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
# This deliberately catches malformed Base64, invalid JSON,
|
||||||
|
# invalid GCM tags, missing fields, etc.
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_browser_auth():
|
||||||
|
"""
|
||||||
|
Get credentials from the browser's encrypted auth cookie.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(status, username, password)
|
||||||
|
"""
|
||||||
|
|
||||||
|
token = request.cookies.get("auth")
|
||||||
|
|
||||||
|
if not token:
|
||||||
|
return False, None, None
|
||||||
|
|
||||||
|
credentials = decrypt_auth_token(token)
|
||||||
|
|
||||||
|
if credentials is None:
|
||||||
|
return False, None, None
|
||||||
|
|
||||||
|
username, password = credentials
|
||||||
|
|
||||||
|
try:
|
||||||
|
status = fetch_auth_status(
|
||||||
|
f"COVENTRY\\{username}",
|
||||||
|
password,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
status = False
|
||||||
|
|
||||||
|
return status, username, password
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Routes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
def index():
|
def index():
|
||||||
status, username, password = get_auth()
|
status, username, password = get_browser_auth()
|
||||||
|
|
||||||
if status:
|
if status:
|
||||||
return redirect("/dashboard")
|
return redirect("/dashboard")
|
||||||
|
|
||||||
return render_template("home.jinja")
|
return render_template("home.jinja")
|
||||||
|
|
||||||
|
|
||||||
@app.route("/about")
|
@app.route("/about")
|
||||||
def about():
|
def about():
|
||||||
return render_template("about.jinja")
|
return render_template("about.jinja")
|
||||||
|
|
||||||
|
|
||||||
@app.route("/login", methods=["GET"])
|
@app.route("/login", methods=["GET"])
|
||||||
def login():
|
def login():
|
||||||
status, username, password = get_auth()
|
session.clear()
|
||||||
|
|
||||||
|
status, username, password = get_browser_auth()
|
||||||
|
|
||||||
if status:
|
if status:
|
||||||
return redirect("/dashboard")
|
return redirect("/dashboard")
|
||||||
|
|
||||||
return render_template("login.jinja")
|
return render_template("login.jinja")
|
||||||
|
|
||||||
|
|
||||||
@app.route("/login", methods=["POST"])
|
@app.route("/login", methods=["POST"])
|
||||||
def login_post():
|
def login_post():
|
||||||
username = request.form.get("username")
|
username = request.form.get("username", "").strip()
|
||||||
password = request.form.get("password")
|
password = request.form.get("password", "")
|
||||||
auth, nonce = generate_auth(username, password)
|
|
||||||
|
if not username or not password:
|
||||||
|
return (
|
||||||
|
render_template(
|
||||||
|
"login.jinja",
|
||||||
|
error="Username and password are required.",
|
||||||
|
),
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify the credentials before storing them in the encrypted cookie.
|
||||||
|
try:
|
||||||
|
status = fetch_auth_status(
|
||||||
|
f"COVENTRY\\{username}",
|
||||||
|
password,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
status = False
|
||||||
|
|
||||||
|
if not status:
|
||||||
|
return (
|
||||||
|
render_template(
|
||||||
|
"login.jinja",
|
||||||
|
error="Invalid university username or password.",
|
||||||
|
),
|
||||||
|
401,
|
||||||
|
)
|
||||||
|
|
||||||
|
token = generate_auth_token(username, password)
|
||||||
|
|
||||||
resp = redirect("/dashboard")
|
resp = redirect("/dashboard")
|
||||||
resp.set_cookie("auth", base64.b64encode(auth).decode("utf-8"), max_age=10 * 365 * 24 * 60 * 60)
|
|
||||||
resp.set_cookie("nonce", base64.b64encode(nonce).decode("utf-8"), max_age=10 * 365 * 24 * 60 * 60)
|
resp.set_cookie(
|
||||||
|
"auth",
|
||||||
|
token,
|
||||||
|
max_age=AUTH_MAX_AGE,
|
||||||
|
secure=True,
|
||||||
|
httponly=True,
|
||||||
|
samesite="Lax",
|
||||||
|
)
|
||||||
|
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/logout", methods=["POST"])
|
||||||
|
def logout():
|
||||||
|
session.clear()
|
||||||
|
|
||||||
|
resp = redirect("/")
|
||||||
|
|
||||||
|
resp.delete_cookie(
|
||||||
|
"auth",
|
||||||
|
secure=True,
|
||||||
|
httponly=True,
|
||||||
|
samesite="Lax",
|
||||||
|
)
|
||||||
|
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
@app.route("/dashboard")
|
@app.route("/dashboard")
|
||||||
def dashboard():
|
def dashboard():
|
||||||
status, username, password = get_auth()
|
status, username, password = get_browser_auth()
|
||||||
|
|
||||||
if not status:
|
if not status:
|
||||||
return redirect("/login")
|
return redirect("/login")
|
||||||
auth, nonce = generate_auth(username, password)
|
|
||||||
auth_encoded = base64.b64encode(auth).decode("utf-8")
|
# Generate a completely new token for the feed URL.
|
||||||
nonce_encoded = base64.b64encode(nonce).decode("utf-8")
|
#
|
||||||
url = "/timtabla.ics?"+urllib.parse.urlencode({"a": auth_encoded, "n": nonce_encoded})
|
# This means the browser cookie and the calendar feed do not
|
||||||
return render_template("dashboard.jinja", username=username, url=url)
|
# contain identical ciphertext.
|
||||||
|
token = generate_auth_token(username, password)
|
||||||
|
|
||||||
|
webcal_url = WEBCAL_URL+"/timtabla.ics?" + urllib.parse.urlencode({"t": token})
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"dashboard.jinja",
|
||||||
|
username=username,
|
||||||
|
webcal_url=webcal_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/download-ics", methods=["GET"])
|
@app.route("/download-ics", methods=["GET"])
|
||||||
def download_ics():
|
def download_ics():
|
||||||
status, username, password = get_auth()
|
status, username, password = get_browser_auth()
|
||||||
|
|
||||||
if not status:
|
if not status:
|
||||||
return redirect("/login")
|
return redirect("/login")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data, start_year, start_month, end_year, end_month = build_calendar(fetch_timetable(f"COVENTRY\\{username}", password))
|
data, start_year, start_month, end_year, end_month = build_calendar(
|
||||||
|
fetch_timetable(
|
||||||
|
f"COVENTRY\\{username}",
|
||||||
|
password,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
data,
|
data,
|
||||||
mimetype="text/calendar",
|
mimetype="text/calendar",
|
||||||
headers={"Content-Disposition": f'attachment; filename="{username}_timtabla_{start_month}-{start_year}_to_{end_month}-{end_year}.ics"'},
|
headers={
|
||||||
|
"Content-Disposition": (
|
||||||
|
f"attachment; "
|
||||||
|
f'filename="{username}_timtabla_'
|
||||||
|
f"{start_month}-{start_year}_to_"
|
||||||
|
f'{end_month}-{end_year}.ics"'
|
||||||
|
)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
except Exception as e:
|
|
||||||
|
except Exception:
|
||||||
return "Calendar Not Found", 404
|
return "Calendar Not Found", 404
|
||||||
|
|
||||||
|
|
||||||
@app.route("/timtabla.ics", methods=["GET"])
|
@app.route("/timtabla.ics", methods=["GET"])
|
||||||
def live_ics():
|
def live_ics():
|
||||||
auth_encrypted = base64.b64decode(request.args["a"])
|
"""
|
||||||
nonce = base64.b64decode(request.args["n"])
|
Live calendar endpoint.
|
||||||
auth = json.loads(aes.decrypt(nonce, auth_encrypted, None).decode("utf-8"))
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
/timtabla.ics?t=BASE64URL_TOKEN
|
||||||
|
|
||||||
|
The token is a bearer credential. Anyone possessing the URL
|
||||||
|
can access the timetable until the token expires or the
|
||||||
|
university password stops working.
|
||||||
|
"""
|
||||||
|
|
||||||
|
token = request.args.get("t")
|
||||||
|
|
||||||
|
if not token:
|
||||||
|
return "Invalid calendar URL", 401
|
||||||
|
|
||||||
|
credentials = decrypt_auth_token(token)
|
||||||
|
|
||||||
|
if credentials is None:
|
||||||
|
return "Invalid or expired calendar URL", 401
|
||||||
|
|
||||||
|
username, password = credentials
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data, start_year, start_month, end_year, end_month = build_calendar(fetch_timetable(f"COVENTRY\\{auth["u"]}", auth["p"]))
|
# Optionally verify that the university credentials still work.
|
||||||
return Response(
|
status = fetch_auth_status(
|
||||||
|
f"COVENTRY\\{username}",
|
||||||
|
password,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not status:
|
||||||
|
return "University authentication failed", 401
|
||||||
|
|
||||||
|
data, start_year, start_month, end_year, end_month = build_calendar(
|
||||||
|
fetch_timetable(
|
||||||
|
f"COVENTRY\\{username}",
|
||||||
|
password,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
response = Response(
|
||||||
data,
|
data,
|
||||||
mimetype="text/calendar",
|
mimetype="text/calendar",
|
||||||
headers={"Content-Disposition": f'attachment; filename="{auth["u"]}_timtabla.ics"'},
|
headers={
|
||||||
|
"Content-Disposition": (
|
||||||
|
f'attachment; filename="{username}_timtabla.ics"'
|
||||||
|
),
|
||||||
|
# Don't allow intermediary caches to retain the
|
||||||
|
# generated timetable.
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
# Prevent the private feed URL from being sent as
|
||||||
|
# a Referer header to another site.
|
||||||
|
"Referrer-Policy": "no-referrer",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
except Exception as e:
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
except Exception:
|
||||||
return "Calendar Not Found", 404
|
return "Calendar Not Found", 404
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app.run(debug=True, host="0.0.0.0", port=3000)
|
app.run(
|
||||||
|
debug=True,
|
||||||
|
host="0.0.0.0",
|
||||||
|
port=3000,
|
||||||
|
)
|
||||||
+285
-133
@@ -1,226 +1,367 @@
|
|||||||
:root {
|
:root {
|
||||||
--text: #e3f2fc;
|
--ink: #031621;
|
||||||
--background: #031621;
|
--surface: #0a2434;
|
||||||
--primary: #a1c5da;
|
--surface-border: #1c3d52;
|
||||||
--secondary: #6a139b;
|
--paper: #e3f2fc;
|
||||||
|
--paper-dim: #7fa5bd;
|
||||||
--accent: #52ace0;
|
--accent: #52ace0;
|
||||||
|
--accent-dim: #3d8bb8;
|
||||||
|
--grid-line: rgba(227, 242, 252, 0.04);
|
||||||
|
|
||||||
|
--font-sans: -apple-system, "Segoe UI", Inter, Helvetica, Arial, sans-serif;
|
||||||
|
--font-mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: Arial, sans-serif;
|
font-family: var(--font-sans);
|
||||||
color: var(--text);
|
color: var(--paper);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: var(--background);
|
background:
|
||||||
|
linear-gradient(var(--grid-line) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px),
|
||||||
|
var(--ink);
|
||||||
|
background-size: 48px 48px, 48px 48px, auto;
|
||||||
|
background-attachment: fixed;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
body::before {
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
content: "";
|
.header-mark {
|
||||||
position: fixed;
|
animation: mark-in 0.5s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
top: 0;
|
}
|
||||||
left: 0;
|
|
||||||
width: 200vw;
|
|
||||||
height: 200vh;
|
|
||||||
background: radial-gradient(circle at 50% 0%, var(--accent) 0%, var(--background) 70%);
|
|
||||||
animation: moveLight 10s ease-in-out infinite alternate;
|
|
||||||
z-index: -1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes moveLight {
|
@keyframes mark-in {
|
||||||
0% {
|
from {
|
||||||
transform: translate(-50%, -50%);
|
opacity: 0;
|
||||||
|
transform: translateY(6px);
|
||||||
}
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
50% {
|
a {
|
||||||
transform: translate(-25%, -40%);
|
color: inherit;
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
transform: translate(0%, -50%);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-content {
|
.page-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 1rem;
|
padding: 2rem 1rem;
|
||||||
|
gap: 2.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Site mark */
|
||||||
.title {
|
.title {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 80px;
|
align-items: baseline;
|
||||||
font-weight: bold;
|
gap: 0.6rem;
|
||||||
text-shadow:
|
|
||||||
0 20px 40px rgba(0, 0, 0, 0.2),
|
|
||||||
0 80px 200px rgba(255, 255, 255, 0.15);
|
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: whitesmoke;
|
color: var(--paper);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.title p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 3rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
text-shadow:
|
||||||
|
0 20px 40px rgba(0, 0, 0, 0.2),
|
||||||
|
0 80px 200px rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
.login-box {
|
/* Shared panel treatment for the login + text-content boxes */
|
||||||
width: fit-content;
|
.login-box,
|
||||||
height: fit-content;
|
.text-box {
|
||||||
max-height: 100dvh;
|
width: 100%;
|
||||||
background: var(--background);
|
max-width: 30rem;
|
||||||
padding: 30px;
|
background: var(--surface);
|
||||||
border-radius: 8px;
|
border: 1px solid var(--surface-border);
|
||||||
text-align: center;
|
border-radius: 12px;
|
||||||
border: solid 1px var(--accent);
|
padding: 2rem;
|
||||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: stretch;
|
||||||
|
gap: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-box {
|
.text-box {
|
||||||
font-size: 20px;
|
max-width: 40rem;
|
||||||
max-width: 90%;
|
font-size: 1.0625rem;
|
||||||
max-width: 800px;
|
line-height: 1.65;
|
||||||
height: fit-content;
|
color: var(--paper);
|
||||||
max-height: 100dvh;
|
|
||||||
background: var(--background);
|
|
||||||
padding: 30px;
|
|
||||||
border-radius: 8px;
|
|
||||||
text-align: center;
|
|
||||||
border: solid 1px var(--accent);
|
|
||||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text-box p {
|
||||||
|
margin: 0 0 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-box h1 {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0.6rem 0 0.2rem;
|
||||||
|
color: var(--paper);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-box code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
background: var(--ink);
|
||||||
|
border: 1px solid var(--surface-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.1rem 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-box b {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-box a:not(.button) {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-color: var(--accent-dim);
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-box a:hover:not(.button) {
|
||||||
|
color: var(--paper);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Login form */
|
||||||
|
.login-box {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-box h2 {
|
||||||
|
margin: 0 0 0.4rem;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-label {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--paper-dim);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
margin: 0.3rem 0 -0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
.login-box input {
|
.login-box input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 8px;
|
padding: 0.65rem 0.7rem;
|
||||||
margin: 8px 0;
|
background: var(--ink);
|
||||||
background-color: var(--background);
|
color: var(--paper);
|
||||||
color: var(--text);
|
border: 1px solid var(--surface-border);
|
||||||
border: solid 1px var(--accent);
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-box input::placeholder {
|
||||||
|
color: var(--paper-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-box input:focus,
|
||||||
|
.login-box input:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-box .hint {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--paper-dim);
|
||||||
|
margin: -0.3rem 0 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
button,
|
||||||
|
.button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.7rem 1.1rem;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
transition: background-color 0.15s ease, transform 0.1s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
margin-top: 1rem;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 10px;
|
margin-top: 0.5rem;
|
||||||
background: var(--accent);
|
|
||||||
color: var(--background);
|
|
||||||
font-size: large;
|
|
||||||
font-weight: bold;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.button {
|
.button {
|
||||||
margin-top: 1rem;
|
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
min-width: 7.5rem;
|
|
||||||
padding: 10px;
|
|
||||||
background: var(--accent);
|
|
||||||
color: var(--background);
|
|
||||||
font-size: large;
|
|
||||||
font-weight: bold;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.div-image {
|
button:hover,
|
||||||
background-size: cover;
|
.button:hover {
|
||||||
background-position: center;
|
background: var(--accent-dim);
|
||||||
max-width: 80%;
|
|
||||||
max-height: 80%;
|
|
||||||
aspect-ratio: 1/1;
|
|
||||||
object-fit: scale-down;
|
|
||||||
display: block;
|
|
||||||
margin: 0 auto;
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button:active,
|
||||||
|
.button:active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
button:hover {
|
button:focus-visible,
|
||||||
background: var(--primary);
|
.button:focus-visible,
|
||||||
color: var(--background);
|
a:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.secondary {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--paper);
|
||||||
|
border: 1px solid var(--surface-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.secondary:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-form {
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-form button {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--paper-dim);
|
||||||
|
border: 1px solid var(--surface-border);
|
||||||
|
width: auto;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-form button:hover {
|
||||||
|
color: var(--paper);
|
||||||
|
border-color: var(--accent-dim);
|
||||||
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
color: red;
|
font-family: var(--font-mono);
|
||||||
margin-top: 10px;
|
font-size: 0.82rem;
|
||||||
|
color: #ff8a7a;
|
||||||
|
background: rgba(255, 138, 122, 0.08);
|
||||||
|
border: 1px solid rgba(255, 138, 122, 0.3);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
margin-top: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
footer {
|
/* Dashboard */
|
||||||
--footer-bg: #0d1b2a;
|
.dashboard {
|
||||||
--footer-border: rgba(100, 160, 210, 0.15);
|
width: 100%;
|
||||||
--footer-text: rgba(255, 255, 255, 0.45);
|
max-width: 30rem;
|
||||||
--footer-text-bright: rgba(255, 255, 255, 0.75);
|
|
||||||
--footer-accent: #5ba4d4;
|
|
||||||
--footer-badge-bg: rgba(91, 164, 212, 0.1);
|
|
||||||
--footer-badge-border: rgba(91, 164, 212, 0.25);
|
|
||||||
|
|
||||||
background: var(--footer-bg);
|
|
||||||
border-top: 1px solid var(--footer-border);
|
|
||||||
padding: 1rem 0.75rem;
|
|
||||||
font-family: 'Segoe UI', system-ui, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
footer .title {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
gap: 1.25rem;
|
||||||
gap: 0.25rem;
|
}
|
||||||
font-size: 1.1rem;
|
|
||||||
font-weight: 700;
|
.dashboard .actions {
|
||||||
letter-spacing: 0.03em;
|
display: flex;
|
||||||
color: var(--footer-text-bright);
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard .actions .button {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard .logout-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-message {
|
||||||
|
margin: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
footer {
|
||||||
|
--footer-border: rgba(242, 239, 233, 0.08);
|
||||||
|
border-top: 1px solid var(--footer-border);
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
background: var(--ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
footer .width {
|
footer .width {
|
||||||
max-width: 860px;
|
max-width: 40rem;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 0.85rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
footer .title span {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--paper);
|
||||||
|
}
|
||||||
|
|
||||||
footer .links {
|
footer .links {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 0.5rem 0.75rem;
|
gap: 0.4rem 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
footer .link {
|
footer .link {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.35rem;
|
gap: 0.4rem;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
color: var(--footer-accent);
|
color: var(--paper-dim);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: opacity 0.15s ease;
|
transition: color 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
footer .link img {
|
footer .link img {
|
||||||
min-width: 16px;
|
min-width: 16px;
|
||||||
max-width: 45px;
|
max-width: 18px;
|
||||||
max-height: 28px;
|
max-height: 18px;
|
||||||
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
footer .link:hover {
|
footer .link:hover {
|
||||||
opacity: 0.75;
|
color: var(--accent);
|
||||||
text-decoration: underline;
|
|
||||||
color: whitesmoke;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
footer .divider {
|
footer .divider {
|
||||||
@@ -231,8 +372,19 @@ footer .divider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
footer .disclaimer {
|
footer .disclaimer {
|
||||||
font-size: 0.72rem;
|
font-size: 0.75rem;
|
||||||
color: var(--footer-text);
|
color: var(--paper-dim);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
.title p {
|
||||||
|
font-size: 1.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-box,
|
||||||
|
.text-box {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-12
@@ -1,17 +1,10 @@
|
|||||||
{%set page_title = "Home"%}
|
{%set page_title = "About"%}
|
||||||
{% extends "base.jinja" %}
|
{% extends "base.jinja" %}
|
||||||
{% block body %}
|
{% block body %}
|
||||||
|
|
||||||
<div class="text-box">
|
<div class="text-box">
|
||||||
<p>
|
<p>Timtabla is based on code originally created by Oliver Bell, and more recently updated by Will Bell (Ugric) and Ché Shand (TussockyJoker). It's open-source and licensed under the AGPL — the code is available at git.wbell.dev.</p>
|
||||||
Timtabla is based on code originally created by Oliver Bell and more recently updated by Will Bell (Ugric) and Ché
|
<h1>A brief explanation</h1>
|
||||||
Shand (TussockyJoker). It is open-source and licensed under the AGPL, the code is available at git.wbell.dev.
|
<p>Timtabla (or cov2ics) works by requesting your data from the Coventry University server using your username and password. These values are not stored on our server, and this is no less secure than logging in to the timetable site directly. This method isn't supported by the university, and is only possible because of their outdated system.</p>
|
||||||
</p>
|
|
||||||
<h1> A brief explanation </h1>
|
|
||||||
<p>
|
|
||||||
The way that timtabla (or cov2ics) works is by requesting your data from the Cov Uni server using your username and
|
|
||||||
password, these values are not stored on our server and this is no less secure than logging in to the timetable site directly.
|
|
||||||
Obviously this method is not supported by the University and is only possible due to their outdated system.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -17,13 +17,13 @@
|
|||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div class="page-content">
|
<div class="page-content">
|
||||||
<a href="{{ url_for('index') }}" class="title">
|
<a href="{{ url_for('index') }}" class="title header-mark">
|
||||||
<p >Timtabla</p>
|
<p>Timtabla</p>
|
||||||
</a>
|
</a>
|
||||||
{% block body %}{% endblock %}
|
{% block body %}{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
{%include "footer.jinja"%}
|
{%include "footer.jinja"%}
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|
||||||
</html>
|
|
||||||
|
</html>
|
||||||
@@ -1,7 +1,17 @@
|
|||||||
{%set page_title = "Dashboard"%}
|
{%set page_title = "Dashboard"%}
|
||||||
{% extends "base.jinja" %}
|
{% extends "base.jinja" %}
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div>
|
<div class="dashboard">
|
||||||
<p>{{url}}</p>
|
<p class="welcome-message">Hi, {{username}}!</p>
|
||||||
|
<div class="actions">
|
||||||
|
<a href="{{webcal_url}}" class="button">Add to calendar app</a>
|
||||||
|
<a href="/download-ics" class="button secondary">Download .ics file</a>
|
||||||
|
</div>
|
||||||
|
<div class="logout-row">
|
||||||
|
<form class="logout-form" method="POST" action="{{ url_for('logout') }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button type="submit">Log out</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -6,14 +6,14 @@
|
|||||||
|
|
||||||
<div class="links">
|
<div class="links">
|
||||||
<a href="https://git.wbell.dev/Ugric/cov-to-ics" class="link">
|
<a href="https://git.wbell.dev/Ugric/cov-to-ics" class="link">
|
||||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/gitea.svg">
|
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/gitea.svg" alt="">
|
||||||
Source Code
|
Source Code
|
||||||
</a>
|
</a>
|
||||||
|
<span class="divider" aria-hidden="true">·</span>
|
||||||
<a href="https://git.wbell.dev/TussockyJoker/Aula-Fixer/" class="link">
|
<a href="https://git.wbell.dev/TussockyJoker/Aula-Fixer/" class="link">
|
||||||
<img src="https://addons.mozilla.org/user-media/addon_icons/2983/2983234-64.png?modified=5425cf62">
|
<img src="https://addons.mozilla.org/user-media/addon_icons/2983/2983234-64.png?modified=5425cf62" alt="">
|
||||||
Aula Fixer Web Extension
|
Aula Fixer Web Extension
|
||||||
</a>
|
</a>
|
||||||
<span class="divider" aria-hidden="true">·</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="disclaimer">
|
<p class="disclaimer">
|
||||||
@@ -22,4 +22,4 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
@@ -3,11 +3,9 @@
|
|||||||
{% block body %}
|
{% block body %}
|
||||||
|
|
||||||
<div class="text-box">
|
<div class="text-box">
|
||||||
<p> Timtabla is a third-party website for Coventry University students to convert their timetable to an <code style="font-size: 17px;">.ics</code> file which can be imported into any modern calendar app.</p>
|
<p>Timtabla is a third-party website for Coventry University students to convert their timetable to an <code>.ics</code> file, which can be imported into any modern calendar app.</p>
|
||||||
<b> We do not store your university credentials.</b>
|
<b>We do not store your university credentials.</b>
|
||||||
<p> This website is based on open-source code developed by Coventry University
|
<p>This website is based on open-source code developed by Coventry University students. You can find this code at <a href="https://git.wbell.dev/Ugric/cov-to-ics">git.wbell.dev</a>.</p>
|
||||||
students, you can find this code at <a href="https://git.wbell.dev/Ugric/cov-to-ics">git.wbell.dev</a>
|
<a href="{{ url_for('login') }}" class="button">Convert timetable</a>
|
||||||
</p>
|
|
||||||
<a href="{{ url_for('login') }}" class="button"> Convert timetable </a>
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
+11
-5
@@ -2,14 +2,20 @@
|
|||||||
{% extends "base.jinja" %}
|
{% extends "base.jinja" %}
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<form class="login-box" method="POST" action="{{url_for('login_post')}}">
|
<form class="login-box" method="POST" action="{{url_for('login_post')}}">
|
||||||
<h2>Coventry University Login</h2>
|
<h2>Coventry University login</h2>
|
||||||
|
|
||||||
<input type="text" name="username" placeholder="Username" required>
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
<input type="password" name="password" placeholder="Password" required>
|
<div class="field-label">Username</div>
|
||||||
|
<input type="text" name="username" placeholder="JokerT53" required>
|
||||||
|
|
||||||
<button type="submit">Login</button>
|
<div class="field-label">Password</div>
|
||||||
|
<input type="password" name="password" placeholder="••••••••" required>
|
||||||
|
|
||||||
|
<p class="hint">Your credentials are sent straight to the university server and never stored on our servers.</p>
|
||||||
|
|
||||||
|
<button type="submit">Log in</button>
|
||||||
|
|
||||||
{% if error|trim %}<div class="error">{{error}}</div>{% endif %}
|
{% if error|trim %}<div class="error">{{error}}</div>{% endif %}
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
Reference in new issue
Block a user