Add user authentication and registration features

- Implement login and registration HTML templates for user authentication.
- Create user management functions in user.py for handling user data, including adding, deleting, and updating users.
- Introduce password hashing and strength checking for secure user credentials.
- Add terms of service and privacy policy pages to the website.
- Enhance main page with dynamic content based on user session status.
This commit is contained in:
Maximilian G.
2026-03-23 17:39:26 +00:00
committed by GitHub
parent 316581dd8f
commit 283a37638b
20 changed files with 4293 additions and 5 deletions
+5 -5
View File
@@ -22,7 +22,7 @@ import json
root = Path.cwd()
settings_path = root / "settings.json"
version_file = root / "Code" / "version.txt"
version_file = root / "version.txt"
version = "0.1.0"
if settings_path.is_file():
@@ -40,10 +40,10 @@ if settings_path.is_file():
version_file.write_text(f"{version}\n", encoding="utf-8")
print(f"Prepared {version_file} with version: {version}")
PY
python -m nuitka --standalone --follow-imports --include-data-dir=Code/templates=templates --include-data-dir=Code/static=static --include-data-file=licenses.json=licenses.json --assume-yes-for-downloads --output-dir=Inventarsystem_Lizenz_Verwaltung --remove-output Code/main.py
if [[ -x "./build/main.dist/main.bin" ]]; then
./build/main.dist/main.bin
python -m nuitka --standalone --follow-imports --include-data-dir=templates=templates --include-data-dir=static=static --include-data-file=licenses.json=licenses.json --assume-yes-for-downloads --output-dir=Inventarsystem_Lizenz_Verwaltung --remove-output main.py
if [[ -x "./Inventarsystem_Lizenz_Verwaltung/main.bin" ]]; then
./Inventarsystem_Lizenz_Verwaltung/main.bin
else
echo "Build step finished but executable not found at ./build/main.dist/main.bin"
echo "Build step finished but executable not found at ./Inventarsystem_Lizenz_Verwaltung/main.bin"
exit 1
fi
+16
View File
@@ -0,0 +1,16 @@
[
{
"id": "a-1774281965267",
"username": "Aiirondev",
"display_name": "Maximilian Gr\u00fcndinger",
"date": "2026-03-24",
"time": "10:00",
"subject": "Test",
"note": "",
"status": "Abgelehnt",
"created_at": "2026-03-23T16:06:05Z",
"response": "Es geht leider nicht um die von ihnen angefragte Uhrzeit, bitte versuchen sie es zwei Tage sp\u00e4ter",
"responded_at": "2026-03-23T16:06:52Z",
"responded_by": "Aiirondev"
}
]
+1
View File
@@ -0,0 +1 @@
[]
+9
View File
@@ -0,0 +1,9 @@
[
{
"username": "Aiirondev",
"display_name": "Maximilian Gr\u00fcndinger",
"password_hash": "scrypt:32768:8:1$nfN2TTigH6GNrZig$548ac55b59b2c10f2730a4a45b4140c84eba33e2719f95175c05ab1f3a157959ff7a5d11c3d38bd2ea172b0143fa3a0e0e410b8bfcc81b8d91cfbbfc12b30399",
"is_admin": true,
"created_at": "2026-03-23T16:05:42Z"
}
]
+473
View File
@@ -0,0 +1,473 @@
from flask_jwt_extended import JWTManager, create_access_token, jwt_required
from flask import Flask, render_template, request, jsonify, flash, redirect, url_for, get_flashed_messages, session
import os
import json
import calendar
from datetime import timedelta, datetime, date
from pathlib import Path
from functools import wraps
from werkzeug.security import generate_password_hash, check_password_hash
import bleach
from markupsafe import escape
app = Flask(__name__)
app.secret_key = "ASDfhbsdfseiufhgildsrfrjg874368546987s6e8468f4s"
app.config["JWT_SECRET_KEY"] = os.environ.get("JWT_SECRET_KEY", app.secret_key)
app.config["JWT_ACCESS_TOKEN_EXPIRES"] = timedelta(hours=12)
app.config["SESSION_COOKIE_HTTPONLY"] = True
app.config["SESSION_COOKIE_SAMESITE"] = "Strict"
app.config["SESSION_COOKIE_SECURE"] = os.environ.get("SESSION_COOKIE_SECURE", "0") == "1"
app.config["PREFERRED_URL_SCHEME"] = "https" if os.environ.get("SESSION_COOKIE_SECURE") == "1" else "http"
jwt = JWTManager(app)
@app.after_request
def set_security_headers(response):
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "SAMEORIGIN"
response.headers["X-XSS-Protection"] = "1; mode=block"
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
response.headers["Content-Security-Policy"] = "default-src 'self'; script-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com https://fonts.googleapis.com; img-src 'self' data:; connect-src 'self';"
return response
BASE_DIR = Path(__file__).resolve().parent
DATA_DIR = BASE_DIR / "data"
USERS_FILE = DATA_DIR / "users.json"
APPOINTMENTS_FILE = DATA_DIR / "appointments.json"
POSTS_FILE = DATA_DIR / "posts.json"
def _issue_access_token() -> str:
return create_access_token(identity="license-validation-client")
def _ensure_data_files() -> None:
DATA_DIR.mkdir(parents=True, exist_ok=True)
if not USERS_FILE.exists():
USERS_FILE.write_text("[]", encoding="utf-8")
if not APPOINTMENTS_FILE.exists():
APPOINTMENTS_FILE.write_text("[]", encoding="utf-8")
if not POSTS_FILE.exists():
POSTS_FILE.write_text("[]", encoding="utf-8")
def _read_json(file_path: Path) -> list:
_ensure_data_files()
try:
return json.loads(file_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return []
def _write_json(file_path: Path, payload: list) -> None:
_ensure_data_files()
file_path.write_text(json.dumps(payload, indent=2, ensure_ascii=True), encoding="utf-8")
def _sanitize_text(text: str, max_length: int = 255) -> str:
"""Sanitize user text input: strip, limit length, and escape HTML."""
text = (text or "").strip()
if len(text) > max_length:
text = text[:max_length]
return text
def _sanitize_html(html_content: str, max_length: int = 50000) -> str:
"""Sanitize HTML content: allow safe tags only."""
if not html_content:
return ""
html_content = html_content[:max_length]
allowed_tags = ["p", "br", "strong", "em", "u", "h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "li", "a", "blockquote", "code", "pre"]
allowed_attrs = {"a": ["href", "title"]}
return bleach.clean(html_content, tags=allowed_tags, attributes=allowed_attrs, strip=True)
def _validate_username(username: str) -> bool:
"""Validate username format: alphanumeric, underscore, dash, 3-30 chars."""
if not username or not isinstance(username, str):
return False
username = username.strip()
if len(username) < 3 or len(username) > 30:
return False
return all(c.isalnum() or c in "_-" for c in username)
def _find_user(username: str):
username_key = (username or "").strip().lower()
if not username_key:
return None
users = _read_json(USERS_FILE)
for entry in users:
if entry.get("username", "").lower() == username_key:
return entry
return None
def login_required(view_func):
@wraps(view_func)
def wrapped(*args, **kwargs):
if "username" not in session:
flash("Bitte zuerst einloggen.", "error")
return redirect(url_for("login"))
return view_func(*args, **kwargs)
return wrapped
def admin_required(view_func):
@wraps(view_func)
def wrapped(*args, **kwargs):
if "username" not in session:
flash("Bitte zuerst einloggen.", "error")
return redirect(url_for("login"))
current_user = _find_user(session.get("username"))
if not current_user or not current_user.get("is_admin", False):
flash("Zugriff verweigert. Admin-Rechte erforderlich.", "error")
return redirect(url_for("default"))
return view_func(*args, **kwargs)
return wrapped
"""-----------------------------------------------------------------------------------------------------------------"""
@app.route("/", methods=["GET", "POST"])
def default():
return render_template("main.html")
@app.route('/login', methods=['GET', 'POST'])
def login():
if 'username' in session:
return redirect(url_for('default'))
if request.method == 'POST':
data = request.get_json(silent=True) or {}
username = (data.get("username") or request.form.get('username') or "").strip()
password = data.get("password") or request.form.get('password') or ""
if not username or not password:
if request.is_json:
return jsonify({"error": "Missing login data"}), 400
flash('Bitte Benutzername und Passwort eingeben.', 'error')
return redirect(url_for('login'))
stored_user = _find_user(username)
if stored_user and check_password_hash(stored_user.get("password_hash", ""), password):
session['username'] = stored_user.get("username")
session['display_name'] = stored_user.get("display_name") or stored_user.get("username")
session['is_admin'] = stored_user.get("is_admin", False)
session['access_token'] = _issue_access_token()
if request.is_json:
return jsonify({"access_token": session['access_token'], "token_type": "Bearer"}), 200
return redirect(url_for('default'))
if request.is_json:
return jsonify({"error": "Invalid credentials"}), 401
flash('Login fehlgeschlagen. Bitte pruefen Sie Ihre Eingaben.', 'error')
get_flashed_messages()
return render_template('login.html')
@app.route("/register", methods=["GET", "POST"])
def register():
if request.method == "POST":
username = (request.form.get("username") or "").strip()
display_name = (request.form.get("display_name") or "").strip()
password = request.form.get("password") or ""
password_repeat = request.form.get("password_repeat") or ""
if not username or not display_name or not password or not password_repeat:
flash("Bitte alle Felder ausfuellen.", "error")
return redirect(url_for("register"))
if not _validate_username(username):
flash("Benutzername muss 3-30 Zeichen lang sein und nur Buchstaben, Zahlen, - und _ enthalten.", "error")
return redirect(url_for("register"))
if len(password) < 8:
flash("Das Passwort muss mindestens 8 Zeichen haben.", "error")
return redirect(url_for("register"))
if password != password_repeat:
flash("Die Passwoerter stimmen nicht ueberein.", "error")
return redirect(url_for("register"))
if _find_user(username):
flash("Benutzername bereits vergeben.", "error")
return redirect(url_for("register"))
display_name = _sanitize_text(display_name, 100)
users = _read_json(USERS_FILE)
is_admin = len(users) == 0
users.append(
{
"username": username,
"display_name": display_name,
"password_hash": generate_password_hash(password),
"is_admin": is_admin,
"created_at": datetime.utcnow().isoformat(timespec="seconds") + "Z",
}
)
_write_json(USERS_FILE, users)
flash("Registrierung erfolgreich. Bitte jetzt einloggen.", "success")
return redirect(url_for("login"))
return render_template("register.html")
@app.route('/appointments', methods=['GET', 'POST'])
@login_required
def appointments():
today = date.today()
month = request.args.get("month", type=int) or today.month
year = request.args.get("year", type=int) or today.year
month = 1 if month < 1 else 12 if month > 12 else month
year = 1970 if year < 1970 else year
if request.method == 'POST':
selected_date = (request.form.get("selected_date") or "").strip()
appointment_time = (request.form.get("appointment_time") or "").strip()
subject = (request.form.get("subject") or "").strip()
note = (request.form.get("note") or "").strip()
try:
date.fromisoformat(selected_date)
except ValueError:
flash("Bitte einen gueltigen Termin im Kalender auswaehlen.", "error")
return redirect(url_for("appointments", month=month, year=year))
if not appointment_time or not subject:
flash("Bitte Uhrzeit und Betreff ausfuellen.", "error")
return redirect(url_for("appointments", month=month, year=year))
subject = _sanitize_text(subject, 200)
note = _sanitize_text(note, 2000)
entries = _read_json(APPOINTMENTS_FILE)
entries.append(
{
"id": f"a-{int(datetime.utcnow().timestamp() * 1000)}",
"username": session.get("username"),
"display_name": session.get("display_name"),
"date": selected_date,
"time": appointment_time,
"subject": subject,
"note": note,
"status": "Angefragt",
"created_at": datetime.utcnow().isoformat(timespec="seconds") + "Z",
}
)
_write_json(APPOINTMENTS_FILE, entries)
flash("Termin erfolgreich angefragt.", "success")
selected = date.fromisoformat(selected_date)
return redirect(url_for("appointments", month=selected.month, year=selected.year))
cal = calendar.Calendar(firstweekday=0)
month_grid = cal.monthdayscalendar(year, month)
month_name = calendar.month_name[month]
previous_month = month - 1
previous_year = year
if previous_month == 0:
previous_month = 12
previous_year -= 1
next_month = month + 1
next_year = year
if next_month == 13:
next_month = 1
next_year += 1
all_appointments = _read_json(APPOINTMENTS_FILE)
user_appointments = [item for item in all_appointments if item.get("username") == session.get("username")]
user_appointments.sort(key=lambda item: (item.get("date", ""), item.get("time", "")), reverse=False)
return render_template(
"appointments.html",
month=month,
month_name=month_name,
year=year,
month_grid=month_grid,
today_iso=today.isoformat(),
previous_month=previous_month,
previous_year=previous_year,
next_month=next_month,
next_year=next_year,
user_appointments=user_appointments,
)
@app.route('/admin/dashboard')
@admin_required
def admin_dashboard():
all_appointments = _read_json(APPOINTMENTS_FILE)
all_appointments.sort(key=lambda x: (x.get("date", ""), x.get("time", "")), reverse=True)
status_counts = {
"Angefragt": len([a for a in all_appointments if a.get("status") == "Angefragt"]),
"Bestaetigt": len([a for a in all_appointments if a.get("status") == "Bestaetigt"]),
"Abgelehnt": len([a for a in all_appointments if a.get("status") == "Abgelehnt"]),
}
posts = _read_json(POSTS_FILE)
total_posts = len(posts)
return render_template(
"admin_dashboard.html",
appointments=all_appointments,
status_counts=status_counts,
total_posts=total_posts,
)
@app.route('/admin/appointment/<appointment_id>', methods=['POST'])
@admin_required
def update_appointment(appointment_id):
action = request.form.get("action", "").strip()
response_text = _sanitize_text(request.form.get("response") or "", 5000)
if action not in ["confirm", "reject"]:
flash("Ungueltige Aktion.", "error")
return redirect(url_for("admin_dashboard"))
all_appointments = _read_json(APPOINTMENTS_FILE)
appointment = None
for item in all_appointments:
if item.get("id") == appointment_id:
appointment = item
break
if not appointment:
flash("Termin nicht gefunden.", "error")
return redirect(url_for("admin_dashboard"))
if action == "confirm":
appointment["status"] = "Bestaetigt"
else:
appointment["status"] = "Abgelehnt"
appointment["response"] = response_text
appointment["responded_at"] = datetime.utcnow().isoformat(timespec="seconds") + "Z"
appointment["responded_by"] = session.get("username")
_write_json(APPOINTMENTS_FILE, all_appointments)
flash(f"Termin wurde {('bestaetigt' if action == 'confirm' else 'abgelehnt')}.", "success")
return redirect(url_for("admin_dashboard"))
@app.route('/admin/blog', methods=['GET', 'POST'])
@admin_required
def admin_blog():
if request.method == 'POST':
action = request.form.get("action", "").strip()
if action == "create":
title = _sanitize_text(request.form.get("title") or "", 200)
content = _sanitize_html(request.form.get("content") or "", 50000)
excerpt = _sanitize_text(request.form.get("excerpt") or "", 500)
if not title or not content:
flash("Bitte Titel und inhalt ausfuellen.", "error")
return redirect(url_for("admin_blog"))
posts = _read_json(POSTS_FILE)
posts.append({
"id": f"p-{int(datetime.utcnow().timestamp() * 1000)}",
"title": title,
"excerpt": excerpt or (content[:150] + "...") if len(content) > 150 else content,
"content": content,
"author": session.get("username"),
"created_at": datetime.utcnow().isoformat(timespec="seconds") + "Z",
"published": True,
})
_write_json(POSTS_FILE, posts)
flash("Beitrag veroeffentlicht.", "success")
return redirect(url_for("admin_blog"))
elif action == "delete":
post_id = (request.form.get("post_id") or "").strip()
if not post_id or not post_id.startswith("p-"):
flash("Ungueltige Beitrag-ID.", "error")
return redirect(url_for("admin_blog"))
posts = _read_json(POSTS_FILE)
original_count = len(posts)
posts = [p for p in posts if p.get("id") != post_id]
if len(posts) == original_count:
flash("Beitrag nicht gefunden.", "error")
return redirect(url_for("admin_blog"))
_write_json(POSTS_FILE, posts)
flash("Beitrag geloescht.", "success")
return redirect(url_for("admin_blog"))
posts = _read_json(POSTS_FILE)
posts.sort(key=lambda x: x.get("created_at", ""), reverse=True)
return render_template("admin_blog.html", posts=posts)
@app.route('/blog')
def blog():
posts = _read_json(POSTS_FILE)
posts.sort(key=lambda x: x.get("created_at", ""), reverse=True)
return render_template("blog.html", posts=posts)
@app.route('/blog/<post_id>')
def blog_post(post_id):
if not post_id or not post_id.startswith("p-"):
flash("Ungueltige Beitrag-ID.", "error")
return redirect(url_for("blog"))
posts = _read_json(POSTS_FILE)
post = None
for p in posts:
if p.get("id") == post_id:
post = p
break
if not post:
flash("Beitrag nicht gefunden.", "error")
return redirect(url_for("blog"))
return render_template("blog_post.html", post=post)
@app.route('/datenschutz')
def datenschutz():
return render_template("datenschutz.html")
@app.route('/impressum')
def impressum():
return render_template("impressum.html")
@app.route('/nutzungsbedingungen')
def nutzungsbedingungen():
return render_template("nutzungsbedingungen.html")
@app.route('/logout')
def logout():
session.pop('username', None)
session.pop('display_name', None)
session.pop('is_admin', None)
session.pop('access_token', None)
flash('Logged out successfully', 'info')
return redirect(url_for('login'))
def main():
app.run(host="0.0.0.0", port=4999, debug=False)
if __name__ == "__main__":
main()
+401
View File
@@ -0,0 +1,401 @@
/**
* iOS Specific Enhancements for Inventarsystem
*
* This file contains fixes specific to iOS devices for the Inventarsystem application,
* focusing on upload and duplication functionality.
*
* Copyright 2025 Maximilian Gründinger
* Licensed under the Apache License, Version 2.0
*/
// Initialize when document is loaded
document.addEventListener('DOMContentLoaded', function() {
if (isIOSDevice()) {
console.log('iOS device detected, applying iOS-specific enhancements');
applyIOSFixes();
}
});
// Apply all iOS-specific fixes
function applyIOSFixes() {
// Fix file input issues
fixIOSFileInputs();
// Fix form submission
fixIOSFormSubmission();
// Fix image handling
fixIOSImageHandling();
// Fix duplication
fixIOSDuplication();
// Apply CSS fixes
applyIOSCSSFixes();
}
// Fix iOS file input issues
function fixIOSFileInputs() {
// Find all file inputs
const fileInputs = document.querySelectorAll('input[type="file"]');
fileInputs.forEach(input => {
// Create a touch-friendly wrapper
const wrapper = document.createElement('div');
wrapper.className = 'ios-file-input-wrapper';
wrapper.style.position = 'relative';
// Style the original input to be more touch-friendly
input.style.padding = '20px 0';
// Wrap the input
if (input.parentNode) {
input.parentNode.insertBefore(wrapper, input);
wrapper.appendChild(input);
// Add a visible button for better touch target
const fakeButton = document.createElement('div');
fakeButton.className = 'ios-file-button';
fakeButton.textContent = 'Wählen Sie Bilder/Videos aus';
fakeButton.style.display = 'inline-block';
fakeButton.style.padding = '10px 15px';
fakeButton.style.backgroundColor = '#4CAF50';
fakeButton.style.color = 'white';
fakeButton.style.borderRadius = '4px';
fakeButton.style.textAlign = 'center';
fakeButton.style.margin = '10px 0';
fakeButton.style.fontWeight = 'bold';
wrapper.appendChild(fakeButton);
// Make sure the real input covers the fake button
input.style.position = 'absolute';
input.style.top = '0';
input.style.left = '0';
input.style.width = '100%';
input.style.height = '100%';
input.style.opacity = '0';
input.style.zIndex = '10';
}
// Add custom event handling
input.addEventListener('change', function() {
// Update visual feedback
const fileCount = this.files ? this.files.length : 0;
let fileText = fakeButton.textContent;
if (fileCount > 0) {
fileText = `${fileCount} Datei${fileCount !== 1 ? 'en' : ''} ausgewählt`;
fakeButton.style.backgroundColor = '#2E7D32';
} else {
fileText = 'Wählen Sie Bilder/Videos aus';
fakeButton.style.backgroundColor = '#4CAF50';
}
fakeButton.textContent = fileText;
});
});
}
// Fix iOS form submission issues
function fixIOSFormSubmission() {
// Find upload forms
const uploadForms = document.querySelectorAll('form[action*="upload_item"]');
uploadForms.forEach(form => {
// Add an iOS submission handler
form.addEventListener('submit', function(e) {
// Only intercept on iOS
if (!isIOSDevice()) return;
e.preventDefault();
// Show that we're processing
const loadingMessage = document.createElement('div');
loadingMessage.className = 'ios-loading-message';
loadingMessage.textContent = 'Wird verarbeitet...';
loadingMessage.style.padding = '10px';
loadingMessage.style.backgroundColor = '#f0f0f0';
loadingMessage.style.borderRadius = '5px';
loadingMessage.style.margin = '10px 0';
loadingMessage.style.textAlign = 'center';
form.appendChild(loadingMessage);
// Disable the submit button
const submitButton = form.querySelector('button[type="submit"]');
if (submitButton) {
submitButton.disabled = true;
}
// Use timeout to allow UI to update before heavy processing
setTimeout(() => {
// Gather form data with special handling for iOS
const formData = new FormData(form);
// Add iOS flag
formData.append('is_ios', 'true');
// Submit with fetch API which works better on iOS
fetch(form.action, {
method: 'POST',
body: formData,
credentials: 'same-origin'
})
.then(response => response.json())
.then(data => {
// Remove loading message
if (loadingMessage.parentNode) {
loadingMessage.parentNode.removeChild(loadingMessage);
}
// Re-enable submit button
if (submitButton) {
submitButton.disabled = false;
}
if (data.success) {
// Show success and redirect
alert(data.message || 'Element erfolgreich hinzugefügt');
// Redirect to the appropriate page
if (data.itemId) {
window.location.href = `/home_admin?highlight_item=${data.itemId}`;
} else {
window.location.href = '/home_admin';
}
} else {
// Show error
alert(data.message || 'Ein Fehler ist aufgetreten');
}
})
.catch(error => {
console.error('iOS form submission error:', error);
// Remove loading message
if (loadingMessage.parentNode) {
loadingMessage.parentNode.removeChild(loadingMessage);
}
// Re-enable submit button
if (submitButton) {
submitButton.disabled = false;
}
// Show error
alert('Ein Netzwerkfehler ist aufgetreten. Bitte versuchen Sie es erneut.');
});
}, 100);
});
});
}
// Fix iOS image handling issues
function fixIOSImageHandling() {
// Reduce image preview quality on iOS
if (typeof ImagePreviewGenerator !== 'undefined') {
// Override the compression quality for iOS
const originalCompressImage = ImagePreviewGenerator.prototype._compressImage;
if (originalCompressImage) {
ImagePreviewGenerator.prototype._compressImage = function(dataUrl) {
// Lower quality for iOS
this.quality = 0.5;
this.maxWidth = 600;
this.maxHeight = 600;
return originalCompressImage.call(this, dataUrl);
};
}
}
// Fix image preview display
document.querySelectorAll('.image-preview-container').forEach(container => {
// Add iOS-specific styling
container.style.webkitOverflowScrolling = 'touch';
container.style.maxHeight = '150px';
container.style.overflow = 'auto';
});
}
// Fix iOS duplication issues
function fixIOSDuplication() {
// Override duplicateItem function
if (typeof duplicateItem !== 'undefined') {
const originalDuplicateItem = duplicateItem;
duplicateItem = function(itemId) {
// Use localStorage on iOS instead of sessionStorage
const useFunction = originalDuplicateItem;
useFunction(itemId);
// Additional iOS-specific handling
const checkForSessionData = setInterval(() => {
const sessionData = sessionStorage.getItem('duplicateItemData');
if (sessionData) {
// Copy from sessionStorage to localStorage
try {
localStorage.setItem('duplicateItemData', sessionData);
localStorage.setItem('duplicateItemTimestamp', Date.now().toString());
console.log('Copied duplicate data to localStorage for iOS reliability');
} catch (e) {
console.warn('Failed to copy to localStorage:', e);
}
clearInterval(checkForSessionData);
}
}, 100);
// Clear interval after 3 seconds regardless
setTimeout(() => clearInterval(checkForSessionData), 3000);
};
}
// Fix prefill function
if (typeof prefillFormWithDuplicateData !== 'undefined') {
const originalPrefill = prefillFormWithDuplicateData;
prefillFormWithDuplicateData = function() {
// Try to get data from localStorage first
let data = null;
try {
const localData = localStorage.getItem('duplicateItemData');
if (localData) {
data = JSON.parse(localData);
localStorage.removeItem('duplicateItemData');
localStorage.removeItem('duplicateItemTimestamp');
console.log('Using duplicate data from localStorage');
}
} catch (e) {
console.warn('Error accessing localStorage:', e);
}
// If we got data from localStorage, manually populate the form
if (data) {
console.log('Manually populating form with iOS optimization');
// Basic fields
document.getElementById('name').value = data.name || '';
document.getElementById('beschreibung').value = data.description || '';
// Handle filters with a delay
setTimeout(() => {
// Filter 1
if (data.filter1 && Array.isArray(data.filter1)) {
data.filter1.forEach((val, idx) => {
const field = document.getElementById(`filter1-${idx+1}`);
if (field && val) field.value = val;
});
}
// Filter 2
if (data.filter2 && Array.isArray(data.filter2)) {
data.filter2.forEach((val, idx) => {
const field = document.getElementById(`filter2-${idx+1}`);
if (field && val) field.value = val;
});
}
// Filter 3
if (data.filter3 && Array.isArray(data.filter3)) {
data.filter3.forEach((val, idx) => {
const field = document.getElementById(`filter3-${idx+1}`);
if (field && val) field.value = val;
});
}
}, 300);
// Location
setTimeout(() => {
const location = document.getElementById('ort');
if (location && data.location) location.value = data.location;
}, 400);
// Year and cost
const year = document.getElementById('anschaffungsjahr');
if (year && data.year) year.value = data.year;
const cost = document.getElementById('anschaffungskosten');
if (cost && data.cost) cost.value = data.cost;
// Handle images - limited for iOS
const form = document.querySelector('form');
if (form && data.images && Array.isArray(data.images)) {
// Add hidden field to indicate duplication
const isDuplicatingField = document.createElement('input');
isDuplicatingField.type = 'hidden';
isDuplicatingField.name = 'is_duplicating';
isDuplicatingField.value = 'true';
form.appendChild(isDuplicatingField);
// Only use first 3 images on iOS for performance
const limitedImages = data.images.slice(0, 3);
limitedImages.forEach(imageName => {
const imageField = document.createElement('input');
imageField.type = 'hidden';
imageField.name = 'duplicate_images';
imageField.value = imageName;
form.appendChild(imageField);
});
// Add a message about limited images
if (data.images.length > 3) {
const messageDiv = document.createElement('div');
messageDiv.style.margin = '10px 0';
messageDiv.style.padding = '10px';
messageDiv.style.backgroundColor = '#fff3cd';
messageDiv.style.borderRadius = '5px';
messageDiv.style.color = '#856404';
messageDiv.textContent = `Aus Leistungsgründen werden nur ${limitedImages.length} von ${data.images.length} Bildern verwendet`;
const fileInput = document.getElementById('images');
if (fileInput && fileInput.parentNode) {
fileInput.parentNode.appendChild(messageDiv);
} else {
form.appendChild(messageDiv);
}
}
}
} else {
// Use original function as fallback
originalPrefill();
}
};
}
}
// Apply iOS-specific CSS fixes
function applyIOSCSSFixes() {
// Create a style element
const style = document.createElement('style');
style.textContent = `
/* Improve touch targets for iOS */
button, select, input[type="submit"], .ausleihen, .edit-button,
.delete-button, .duplicate-button, .details-button {
min-height: 44px !important;
padding: 10px 15px !important;
}
/* Improve scrolling on iOS */
.items-container, .filter-options, .modal-content {
-webkit-overflow-scrolling: touch !important;
}
/* Prevent zoom on inputs */
input, select, textarea {
font-size: 16px !important;
}
/* Fix iOS image display */
.item-image {
-webkit-transform: translateZ(0);
}
/* Improve form fields on iOS */
.form-group {
margin-bottom: 15px !important;
}
`;
document.head.appendChild(style);
}
+286
View File
@@ -0,0 +1,286 @@
/**
* Mobile Compatibility Utilities
*
* This module provides utility functions for better mobile device compatibility,
* especially for iOS devices where certain browser APIs have limited support.
*
* Copyright 2025 Maximilian Gründinger
* Licensed under the Apache License, Version 2.0
*/
// Detect mobile devices including tablets
function isMobileDevice() {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
}
// Specifically detect iOS devices
function isIOSDevice() {
return /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
}
// Mobile-compatible file tracker to replace DataTransfer API
class MobileCompatFileTracker {
constructor() {
this.files = [];
this.removedIndices = new Set();
}
// Add files from a FileList
addFiles(fileList) {
// Convert FileList to Array for easier handling
Array.from(fileList).forEach(file => {
this.files.push(file);
});
}
// Remove a file by index
removeFile(index) {
if (index >= 0 && index < this.files.length) {
this.removedIndices.add(index);
}
}
// Get all non-removed files
getFiles() {
return this.files.filter((_, index) => !this.removedIndices.has(index));
}
// Convert to FormData for upload
appendToFormData(formData, fieldName) {
this.getFiles().forEach(file => {
formData.append(fieldName, file);
});
}
// Clear all files
clear() {
this.files = [];
this.removedIndices.clear();
}
}
// Mobile optimized image preview generator
class ImagePreviewGenerator {
constructor(options = {}) {
this.maxWidth = options.maxWidth || 800;
this.maxHeight = options.maxHeight || 600;
this.quality = options.quality || 0.8;
this.maxPreviewsOnMobile = options.maxPreviewsOnMobile || 3;
}
// Create optimized image preview element
createPreview(file, index) {
return new Promise((resolve, reject) => {
if (!file) {
reject(new Error('No file provided'));
return;
}
const isVideo = file.type.startsWith('video/');
if (isVideo) {
this._createVideoPreview(file, index).then(resolve).catch(reject);
} else if (file.type.startsWith('image/')) {
this._createImagePreview(file, index).then(resolve).catch(reject);
} else {
reject(new Error('Unsupported file type'));
}
});
}
// Create image preview with optimization for mobile
_createImagePreview(file, index) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
// For mobile, we'll compress the image before creating the preview
if (isMobileDevice()) {
this._compressImage(e.target.result)
.then(compressedDataUrl => {
const previewElement = this._createPreviewElement(compressedDataUrl, file.name, index, false);
resolve(previewElement);
})
.catch(err => {
// Fallback to original if compression fails
console.warn('Image compression failed, using original:', err);
const previewElement = this._createPreviewElement(e.target.result, file.name, index, false);
resolve(previewElement);
});
} else {
// For desktop, use the original image
const previewElement = this._createPreviewElement(e.target.result, file.name, index, false);
resolve(previewElement);
}
};
reader.onerror = () => reject(new Error('Failed to read file'));
reader.readAsDataURL(file);
});
}
// Create video preview with thumbnail generation
_createVideoPreview(file, index) {
return new Promise((resolve, reject) => {
const videoUrl = URL.createObjectURL(file);
const video = document.createElement('video');
video.src = videoUrl;
video.onloadedmetadata = () => {
// Create preview element
const previewElement = this._createPreviewElement(videoUrl, file.name, index, true);
resolve(previewElement);
// Revoke object URL after a delay to ensure it's loaded
setTimeout(() => {
URL.revokeObjectURL(videoUrl);
}, 3000);
};
video.onerror = () => {
URL.revokeObjectURL(videoUrl);
reject(new Error('Failed to load video'));
};
// Set a timeout in case the video never loads
setTimeout(() => {
if (!video.videoWidth) {
URL.revokeObjectURL(videoUrl);
reject(new Error('Video load timeout'));
}
}, 5000);
});
}
// Create HTML element for preview
_createPreviewElement(src, fileName, index, isVideo) {
const previewDiv = document.createElement('div');
previewDiv.className = 'image-preview-item';
if (isVideo) {
const video = document.createElement('video');
video.src = src;
video.controls = true;
video.preload = 'metadata';
video.style.maxWidth = '150px';
video.style.maxHeight = '150px';
video.style.objectFit = 'cover';
previewDiv.appendChild(video);
} else {
const img = document.createElement('img');
img.src = src;
img.alt = fileName;
img.style.maxWidth = '150px';
img.style.maxHeight = '150px';
img.style.objectFit = 'cover';
previewDiv.appendChild(img);
}
const controlsDiv = document.createElement('div');
controlsDiv.className = 'image-controls';
const removeButton = document.createElement('button');
removeButton.type = 'button';
removeButton.textContent = 'Entfernen';
removeButton.style.background = '#dc3545';
removeButton.style.color = 'white';
removeButton.style.border = 'none';
removeButton.style.padding = '5px 10px';
removeButton.style.borderRadius = '3px';
removeButton.style.cursor = 'pointer';
removeButton.style.marginLeft = '10px';
removeButton.dataset.index = index;
removeButton.addEventListener('click', function() {
const indexToRemove = parseInt(this.dataset.index, 10);
// We'll define removeImagePreview elsewhere
if (typeof window.removeImagePreview === 'function') {
window.removeImagePreview(indexToRemove);
}
});
controlsDiv.appendChild(removeButton);
previewDiv.appendChild(controlsDiv);
return previewDiv;
}
// Compress image for mobile devices
_compressImage(dataUrl) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
let width = img.width;
let height = img.height;
// Scale down if needed
if (width > this.maxWidth || height > this.maxHeight) {
const ratio = Math.min(this.maxWidth / width, this.maxHeight / height);
width *= ratio;
height *= ratio;
}
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, width, height);
// Get compressed data URL
const compressedDataUrl = canvas.toDataURL('image/jpeg', this.quality);
// Clean up
canvas.width = 0;
canvas.height = 0;
resolve(compressedDataUrl);
};
img.onerror = () => reject(new Error('Failed to load image for compression'));
img.src = dataUrl;
});
}
}
// Mobile-optimized debounce function for event handlers
function debounce(func, wait = 300) {
let timeout;
return function(...args) {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), wait);
};
}
// Mobile-safe promise-based setTimeout
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// Log mobile-specific issues to console or server
function logMobileIssue(action, error) {
if (isMobileDevice()) {
const diagnosticInfo = {
action: action,
error: error.message,
browser: navigator.userAgent,
timestamp: new Date().toISOString(),
memoryInfo: performance?.memory ? {
jsHeapSizeLimit: performance.memory.jsHeapSizeLimit,
totalJSHeapSize: performance.memory.totalJSHeapSize,
usedJSHeapSize: performance.memory.usedJSHeapSize
} : 'Not available'
};
console.error('Mobile Issue:', diagnosticInfo);
// Optionally send to server
fetch('/log_mobile_issue', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(diagnosticInfo)
}).catch(() => {
// Silently fail if logging fails
});
}
}
+206
View File
@@ -0,0 +1,206 @@
{% extends "base.html" %}
{% block title %}Blog verwalten{% endblock %}
{% block head %}
{{ super() }}
<style>
.blog-admin-grid {
display: grid;
grid-template-columns: 0.7fr 1.3fr;
gap: 1.2rem;
}
.new-post-form {
background: #ffffff;
border: 1px solid #d4e0e9;
border-radius: 16px;
padding: 1.4rem;
box-shadow: 0 14px 32px rgba(20, 39, 55, 0.06);
height: fit-content;
position: sticky;
top: 100px;
}
.new-post-form h2 {
font-size: 1.2rem;
color: #0f354d;
margin: 0 0 0.9rem;
}
.field {
margin-bottom: 0.9rem;
}
.field label {
display: block;
margin-bottom: 0.3rem;
font-weight: 700;
color: #1f4b65;
font-size: 0.9rem;
}
.field input,
.field textarea {
width: 100%;
border: 1px solid #c4d4e0;
border-radius: 10px;
padding: 0.65rem 0.72rem;
font: inherit;
color: #143f5d;
background: #ffffff;
}
.field textarea {
min-height: 120px;
resize: vertical;
}
.field input:focus,
.field textarea:focus {
outline: none;
border-color: #2f79a7;
box-shadow: 0 0 0 3px rgba(58, 133, 180, 0.2);
}
.submit-btn {
width: 100%;
border: none;
border-radius: 999px;
padding: 0.72rem;
font-size: 0.95rem;
font-weight: 700;
color: #ffffff;
background: linear-gradient(120deg, #0a5f8f 0%, #0b4567 100%);
cursor: pointer;
}
.posts-list {
display: grid;
gap: 0.8rem;
}
.post-card {
background: #ffffff;
border: 1px solid #d4e0e9;
border-radius: 14px;
padding: 1.1rem;
box-shadow: 0 10px 28px rgba(20, 40, 55, 0.05);
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
}
.post-info h3 {
font-size: 1.05rem;
color: #113d59;
margin: 0 0 0.25rem;
}
.post-info p {
margin: 0.2rem 0;
font-size: 0.9rem;
color: #5a7a8f;
}
.post-meta {
font-size: 0.8rem;
color: #7a8fa2;
margin-top: 0.3rem;
}
.delete-form {
display: flex;
}
.delete-btn {
border: none;
border-radius: 6px;
padding: 0.4rem 0.7rem;
font-size: 0.8rem;
font-weight: 700;
color: #ffffff;
background: linear-gradient(120deg, #a74444 0%, #8a3535 100%);
cursor: pointer;
}
@media (max-width: 980px) {
.blog-admin-grid {
grid-template-columns: 1fr;
}
.new-post-form {
position: static;
}
}
</style>
{% endblock %}
{% block content %}
<h1 style="font-size: 2rem; color: #0f354d; margin-bottom: 1.2rem;">Blog verwalten</h1>
<section class="blog-admin-grid">
<aside class="new-post-form">
<h2>Neuer Beitrag</h2>
<form method="POST" action="{{ url_for('admin_blog') }}">
<div class="field">
<label for="title">Titel</label>
<input
id="title"
name="title"
type="text"
placeholder="Beitragstitel"
required
/>
</div>
<div class="field">
<label for="excerpt">Zusammenfassung</label>
<textarea
id="excerpt"
name="excerpt"
placeholder="Optionale Kurzbeschreibung"
></textarea>
</div>
<div class="field">
<label for="content">Inhalt</label>
<textarea
id="content"
name="content"
placeholder="Beitrag schreiben..."
required
></textarea>
</div>
<input type="hidden" name="action" value="create" />
<button type="submit" class="submit-btn">Veroeffentlichen</button>
</form>
</aside>
<article>
<h2 style="font-size: 1.2rem; color: #0f354d; margin: 0 0 0.8rem;">Veroeffentlichte Beitraege</h2>
<section class="posts-list">
{% if posts %}
{% for post in posts %}
<article class="post-card">
<div class="post-info">
<h3>{{ post.title }}</h3>
<p>{{ post.excerpt }}</p>
<div class="post-meta">
<strong>Autor:</strong> {{ post.author }}<br />
<strong>Veroeffentlicht:</strong> {{ post.created_at[:10] }}
</div>
</div>
<form method="POST" action="{{ url_for('admin_blog') }}" class="delete-form">
<input type="hidden" name="action" value="delete" />
<input type="hidden" name="post_id" value="{{ post.id }}" />
<button type="submit" class="delete-btn" onclick="return confirm('Sicher?')">Loeschen</button>
</form>
</article>
{% endfor %}
{% else %}
<p>Noch keine Beitraege veroeffentlicht.</p>
{% endif %}
</section>
</article>
</section>
{% endblock %}
+214
View File
@@ -0,0 +1,214 @@
{% extends "base.html" %}
{% block title %}Admin Dashboard{% endblock %}
{% block head %}
{{ super() }}
<style>
.admin-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1rem;
margin-bottom: 1.2rem;
}
.stat-card {
background: #ffffff;
border: 1px solid #d4e0e9;
border-radius: 14px;
padding: 1.2rem;
box-shadow: 0 10px 28px rgba(20, 40, 55, 0.05);
text-align: center;
}
.stat-value {
font-size: 2.2rem;
font-weight: 700;
color: #0a5c88;
margin-bottom: 0.3rem;
}
.stat-label {
font-size: 0.9rem;
color: #5a7a8f;
font-weight: 600;
}
.appointments-section {
background: #ffffff;
border: 1px solid #d4e0e9;
border-radius: 16px;
padding: 1.4rem;
box-shadow: 0 14px 32px rgba(20, 39, 55, 0.06);
}
.appointments-section h2 {
font-size: 1.3rem;
color: #0f354d;
margin-bottom: 0.8rem;
}
.appointment-card {
border: 1px solid #d8e3ec;
border-radius: 12px;
padding: 1rem;
margin-bottom: 0.8rem;
background: #f9fbfd;
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
}
.appointment-info > strong {
display: block;
color: #113d59;
margin-bottom: 0.25rem;
}
.appointment-info p {
margin: 0.15rem 0;
font-size: 0.9rem;
}
.appointment-status {
display: inline-block;
border-radius: 999px;
padding: 0.25rem 0.6rem;
font-size: 0.8rem;
font-weight: 700;
margin-top: 0.35rem;
}
.status-pending {
border: 1px solid #fba316;
background: #fffbf0;
color: #b8730f;
}
.status-confirmed {
border: 1px solid #0e9f6e;
background: #f0fdf4;
color: #0b5c47;
}
.status-rejected {
border: 1px solid #d3413e;
background: #fdf5f5;
color: #8b2a27;
}
.appointment-actions {
display: flex;
gap: 0.4rem;
}
.action-form {
display: grid;
gap: 0.6rem;
}
.response-input {
width: 100%;
min-height: 60px;
border: 1px solid #c4d4e0;
border-radius: 8px;
padding: 0.5rem;
font-family: inherit;
font-size: 0.85rem;
resize: vertical;
}
.action-btn {
border: none;
border-radius: 6px;
padding: 0.4rem 0.7rem;
font-size: 0.85rem;
font-weight: 700;
cursor: pointer;
color: #ffffff;
}
.confirm-btn {
background: linear-gradient(120deg, #0a7c4e 0%, #065f3f 100%);
}
.reject-btn {
background: linear-gradient(120deg, #a74444 0%, #8a3535 100%);
}
@media (max-width: 980px) {
.admin-grid {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 600px) {
.admin-grid {
grid-template-columns: 1fr;
}
.appointment-card {
flex-direction: column;
}
}
</style>
{% endblock %}
{% block content %}
<h1 style="font-size: 2rem; color: #0f354d; margin-bottom: 1rem;">Admin Dashboard</h1>
<section class="admin-grid">
<article class="stat-card">
<div class="stat-value">{{ status_counts.Angefragt }}</div>
<div class="stat-label">Neue Anfragen</div>
</article>
<article class="stat-card">
<div class="stat-value">{{ status_counts.Bestaetigt }}</div>
<div class="stat-label">Bestaetigt</div>
</article>
<article class="stat-card">
<div class="stat-value">{{ total_posts }}</div>
<div class="stat-label">Blog-Beitraege</div>
</article>
</section>
<section class="appointments-section">
<h2>Terminanfragen verwalten</h2>
{% if appointments %}
{% for appointment in appointments %}
<article class="appointment-card">
<div class="appointment-info">
<strong>{{ appointment.display_name }} - {{ appointment.subject }}</strong>
<p><strong>Datum:</strong> {{ appointment.date }} um {{ appointment.time }}</p>
<p><strong>Angefragt:</strong> {{ appointment.created_at[:10] }}</p>
{% if appointment.note %}
<p><strong>Notiz:</strong> {{ appointment.note }}</p>
{% endif %}
{% if appointment.response %}
<p><strong>Antwort:</strong> {{ appointment.response }}</p>
{% endif %}
<span class="appointment-status status-{{ appointment.status|lower|replace('Ä', 'ae')|replace('ö', 'oe')|replace('ü', 'ue') if appointment.status == 'Angefragt' else 'confirmed' if appointment.status == 'Bestaetigt' else 'rejected' }}">{{ appointment.status }}</span>
</div>
{% if appointment.status == 'Angefragt' %}
<div class="appointment-actions">
<form method="POST" action="{{ url_for('update_appointment', appointment_id=appointment.id) }}" class="action-form" style="min-width: 280px;">
<textarea class="response-input" name="response" placeholder="Antwort (optional)"></textarea>
<div style="display: flex; gap: 0.4rem;">
<button type="submit" name="action" value="confirm" class="confirm-btn action-btn">Bestaetigen</button>
<button type="submit" name="action" value="reject" class="reject-btn action-btn">Ablehnen</button>
</div>
</form>
</div>
{% endif %}
</article>
{% endfor %}
{% else %}
<p>Keine Terminanfragen vorhanden.</p>
{% endif %}
</section>
<section style="margin-top: 1.5rem;">
<a href="{{ url_for('admin_blog') }}" style="color: #ffffff; background: linear-gradient(120deg, #0b5b89 0%, #0b4262 100%); padding: 0.68rem 1.15rem; border-radius: 999px; font-weight: 700; display: inline-block; text-decoration: none;">Blog verwalten</a>
</section>
{% endblock %}
+302
View File
@@ -0,0 +1,302 @@
{% extends "base.html" %}
{% block title %}Termin anfragen{% endblock %}
{% block head %}
{{ super() }}
<style>
.appointments-grid {
display: grid;
grid-template-columns: 1.2fr 0.8fr;
gap: 1rem;
align-items: start;
}
.panel {
background: #ffffff;
border: 1px solid #d4e0e9;
border-radius: 16px;
padding: 1.2rem;
box-shadow: 0 14px 32px rgba(20, 39, 55, 0.06);
}
.calendar-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.8rem;
margin-bottom: 0.9rem;
}
.calendar-header h1 {
font-size: 1.5rem;
color: #133f5d;
}
.month-nav {
display: inline-flex;
gap: 0.4rem;
}
.month-nav a {
border-radius: 999px;
border: 1px solid #bfd1de;
padding: 0.38rem 0.7rem;
color: #185170;
font-weight: 700;
background: #ffffff;
}
.weekday-row,
.day-grid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 0.3rem;
}
.weekday {
text-align: center;
font-size: 0.82rem;
font-weight: 700;
color: #587689;
padding-bottom: 0.2rem;
}
.day {
min-height: 74px;
border: 1px solid #d8e3ec;
border-radius: 10px;
background: #f9fcff;
color: #0f3f5d;
cursor: pointer;
font-weight: 700;
transition: all 0.15s ease;
}
.day:hover {
border-color: #9ec0d6;
transform: translateY(-1px);
}
.day.empty {
background: #f2f6f9;
border-style: dashed;
cursor: default;
}
.day.today {
border-color: #2d7fab;
}
.day.selected {
background: linear-gradient(140deg, #0d5c88 0%, #0a4464 100%);
border-color: #0d5c88;
color: #ffffff;
}
.booking-form h2,
.appointment-list h2 {
font-size: 1.2rem;
color: #143f5e;
margin: 0 0 0.8rem;
}
.selected-date-badge {
display: inline-block;
margin-bottom: 0.8rem;
border-radius: 999px;
padding: 0.28rem 0.6rem;
border: 1px solid #bfd2df;
color: #23526c;
font-weight: 700;
font-size: 0.9rem;
}
.field {
margin-bottom: 0.8rem;
}
.field label {
display: block;
margin-bottom: 0.3rem;
font-weight: 700;
color: #1f4b65;
}
.field input,
.field textarea {
width: 100%;
border: 1px solid #c4d4e0;
border-radius: 10px;
padding: 0.65rem 0.72rem;
font: inherit;
color: #143f5d;
background: #ffffff;
}
.field textarea {
min-height: 86px;
resize: vertical;
}
.submit-btn {
width: 100%;
border: none;
border-radius: 999px;
padding: 0.74rem;
font-size: 1rem;
font-weight: 700;
color: #ffffff;
background: linear-gradient(120deg, #0a5f8f 0%, #0b4567 100%);
cursor: pointer;
}
.entry {
border: 1px solid #d7e2ea;
border-radius: 12px;
padding: 0.72rem;
margin-bottom: 0.55rem;
background: #f9fbfd;
}
.entry strong {
color: #113d59;
}
.entry p {
margin-top: 0.18rem;
font-size: 0.92rem;
}
.status {
display: inline-block;
margin-top: 0.45rem;
border-radius: 999px;
border: 1px solid #c9d7e2;
padding: 0.15rem 0.55rem;
font-size: 0.8rem;
color: #2f5f79;
font-weight: 700;
background: #eef4f8;
}
@media (max-width: 960px) {
.appointments-grid {
grid-template-columns: 1fr;
}
}
</style>
{% endblock %}
{% block content %}
<section class="appointments-grid">
<article class="panel">
<div class="calendar-header">
<h1>{{ month_name }} {{ year }}</h1>
<div class="month-nav">
<a href="{{ url_for('appointments', month=previous_month, year=previous_year) }}">Zurueck</a>
<a href="{{ url_for('appointments', month=next_month, year=next_year) }}">Weiter</a>
</div>
</div>
<div class="weekday-row" aria-hidden="true">
<div class="weekday">Mo</div>
<div class="weekday">Di</div>
<div class="weekday">Mi</div>
<div class="weekday">Do</div>
<div class="weekday">Fr</div>
<div class="weekday">Sa</div>
<div class="weekday">So</div>
</div>
<div class="day-grid" id="dayGrid">
{% for week in month_grid %}
{% for day in week %}
{% if day == 0 %}
<button type="button" class="day empty" disabled></button>
{% else %}
{% set day_iso = "%04d-%02d-%02d"|format(year, month, day) %}
<button
type="button"
class="day {% if day_iso == today_iso %}today{% endif %}"
data-date="{{ day_iso }}"
>
{{ day }}
</button>
{% endif %}
{% endfor %}
{% endfor %}
</div>
</article>
<aside class="panel booking-form">
<h2>Termin anfragen</h2>
<span class="selected-date-badge" id="selectedDateBadge">Kein Datum gewaehlt</span>
<form method="POST" action="{{ url_for('appointments', month=month, year=year) }}">
<input type="hidden" id="selectedDateInput" name="selected_date" required>
<div class="field">
<label for="appointment_time">Uhrzeit</label>
<input id="appointment_time" name="appointment_time" type="time" required>
</div>
<div class="field">
<label for="subject">Betreff</label>
<input id="subject" name="subject" type="text" placeholder="z. B. Projekt Kickoff" required>
</div>
<div class="field">
<label for="note">Notiz (optional)</label>
<textarea id="note" name="note" placeholder="Kurzbeschreibung der Anfrage"></textarea>
</div>
<button class="submit-btn" type="submit">Termin anfragen</button>
</form>
</aside>
</section>
<section class="panel appointment-list" style="margin-top: 1rem;">
<h2>Meine Terminanfragen</h2>
{% if user_appointments %}
{% for item in user_appointments %}
<article class="entry">
<strong>{{ item.date }} um {{ item.time }} - {{ item.subject }}</strong>
{% if item.note %}
<p>{{ item.note }}</p>
{% endif %}
{% if item.response %}
<p><strong>Admin-Antwort:</strong> {{ item.response }}</p>
{% endif %}
<span class="status">{{ item.status }}</span>
</article>
{% endfor %}
{% else %}
<p>Noch keine Anfragen vorhanden.</p>
{% endif %}
</section>
<script>
(function () {
const dayGrid = document.getElementById("dayGrid");
const selectedDateInput = document.getElementById("selectedDateInput");
const selectedDateBadge = document.getElementById("selectedDateBadge");
function setSelection(dateValue, button) {
selectedDateInput.value = dateValue;
selectedDateBadge.textContent = "Ausgewaehlt: " + dateValue;
dayGrid.querySelectorAll(".day").forEach(function (node) {
node.classList.remove("selected");
});
if (button) {
button.classList.add("selected");
}
}
dayGrid.addEventListener("click", function (event) {
const target = event.target.closest(".day[data-date]");
if (!target) {
return;
}
setSelection(target.dataset.date, target);
});
})();
</script>
{% endblock %}
+325
View File
@@ -0,0 +1,325 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>{% block title %}IT Loesungen{% endblock %}</title>
{% block head %}
<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=Space+Grotesk:wght@400;500;600;700&family=Source+Sans+3:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--bg-main: #f5f8fa;
--bg-soft: #e8eef2;
--surface: #ffffff;
--text-main: #112332;
--text-soft: #466176;
--line: #d8e1e8;
--brand: #0a4c74;
--brand-strong: #073754;
--accent: #f28c28;
--radius-md: 14px;
--shadow-sm: 0 10px 30px rgba(17, 35, 50, 0.08);
}
* { box-sizing: border-box; }
html,
body {
margin: 0;
padding: 0;
min-height: 100%;
font-family: "Source Sans 3", system-ui, sans-serif;
color: var(--text-main);
background: radial-gradient(circle at 85% -10%, #c5d8e5 0%, transparent 35%),
radial-gradient(circle at -10% 50%, #dce8ef 0%, transparent 42%),
var(--bg-main);
}
h1, h2, h3, h4 {
margin: 0;
font-family: "Space Grotesk", system-ui, sans-serif;
line-height: 1.08;
}
p {
margin: 0;
line-height: 1.55;
color: var(--text-soft);
}
a {
color: inherit;
text-decoration: none;
}
img {
max-width: 100%;
display: block;
}
.site-shell {
width: min(1240px, calc(100% - 2rem));
margin: 0 auto;
}
.site-nav {
position: sticky;
top: 0;
z-index: 30;
backdrop-filter: blur(10px);
background: rgba(245, 248, 250, 0.86);
border-bottom: 1px solid var(--line);
}
.site-nav-inner {
min-height: 78px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.brand {
font-family: "Space Grotesk", system-ui, sans-serif;
font-size: 1.1rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--brand-strong);
}
.nav-links {
display: flex;
align-items: center;
gap: 1rem;
font-weight: 600;
color: var(--text-soft);
}
.nav-links a:hover {
color: var(--brand);
}
.nav-actions {
display: flex;
align-items: center;
gap: 0.6rem;
}
.nav-btn {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
border: 1px solid #bcd0de;
padding: 0.48rem 0.95rem;
font-weight: 700;
color: #0e425f;
background: #ffffff;
transition: transform 0.15s ease;
}
.nav-btn:hover {
transform: translateY(-1px);
}
.nav-btn.primary {
color: #ffffff;
border-color: #0a4c74;
background: linear-gradient(120deg, #0b5b89 0%, #0b4262 100%);
}
.main-wrap {
padding: 2rem 0 3.4rem;
}
.flashes {
margin-bottom: 1rem;
display: grid;
gap: 0.75rem;
}
.flash {
padding: 0.8rem 1rem;
border-radius: 10px;
border: 1px solid var(--line);
background: var(--surface);
box-shadow: var(--shadow-sm);
font-weight: 600;
}
.flash.success { border-left: 5px solid #0e9f6e; }
.flash.error { border-left: 5px solid #d3413e; }
.flash.info { border-left: 5px solid var(--brand); }
@media (max-width: 820px) {
.site-nav-inner {
min-height: 64px;
}
.nav-links {
display: none;
}
.nav-actions {
gap: 0.4rem;
}
.nav-btn {
padding: 0.42rem 0.72rem;
font-size: 0.85rem;
}
}
</style>
{% endblock %}
</head>
<body>
<nav class="site-nav">
<div class="site-shell site-nav-inner">
<a class="brand" href="{{ url_for('default') }}">Invario</a>
<div class="nav-links" aria-label="Main Navigation">
<a href="{{ url_for('default') }}#leistungen">Dienstleistungen</a>
<a href="{{ url_for('default') }}#projekte">Projekte</a>
<a href="{{ url_for('default') }}#team">Team</a>
<a href="{{ url_for('default') }}#kontakt">Kontakt</a>
</div>
<div class="nav-actions">
{% if 'username' in session %}
<a class="nav-btn" href="{{ url_for('blog') }}">Blog</a>
<a class="nav-btn" href="{{ url_for('appointments') }}">Termin anfragen</a>
{% if session.get('is_admin') %}
<a class="nav-btn" href="{{ url_for('admin_dashboard') }}">Admin</a>
{% endif %}
<a class="nav-btn" href="{{ url_for('logout') }}">Logout</a>
{% else %}
<a class="nav-btn" href="{{ url_for('blog') }}">Blog</a>
<a class="nav-btn" href="{{ url_for('login') }}">Login</a>
<a class="nav-btn primary" href="{{ url_for('register') }}">Registrieren</a>
{% endif %}
</div>
</div>
</nav>
<main class="site-shell main-wrap">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flashes">
{% for category, message in messages %}
<div class="flash {{ category }}">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
<footer class="site-footer">
<div class="site-shell footer-inner">
<div class="footer-content">
<div class="footer-section">
<h4>Über uns</h4>
<p>Wir bieten professionelle IT-Lösungen und Dienstleistungen für Ihr Unternehmen.</p>
</div>
<div class="footer-section">
<h4>Schnelllinks</h4>
<ul class="footer-links">
<li><a href="{{ url_for('default') }}">Startseite</a></li>
<li><a href="{{ url_for('blog') }}">Blog</a></li>
{% if 'username' in session %}
<li><a href="{{ url_for('appointments') }}">Termine</a></li>
{% endif %}
</ul>
</div>
<div class="footer-section">
<h4>Rechtliches</h4>
<ul class="footer-links">
<li><a href="{{ url_for('datenschutz') }}">Datenschutz</a></li>
<li><a href="{{ url_for('impressum') }}">Impressum</a></li>
<li><a href="{{ url_for('nutzungsbedingungen') }}">Nutzungsbedingungen</a></li>
</ul>
</div>
</div>
<div class="footer-bottom">
<p>&copy; 2026 Invario. Alle Rechte vorbehalten.</p>
</div>
</div>
<style>
.site-footer {
background: linear-gradient(135deg, var(--brand-strong) 0%, #0a4c74 100%);
color: #ffffff;
margin-top: 4rem;
padding: 3rem 0 1.5rem;
border-top: 1px solid var(--line);
}
.footer-inner {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 2rem;
margin-bottom: 2rem;
}
.footer-section {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.footer-section h4 {
font-size: 1rem;
font-weight: 700;
color: #ffffff;
}
.footer-section p {
color: rgba(255, 255, 255, 0.8);
font-size: 0.95rem;
}
.footer-links {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.footer-links a {
color: rgba(255, 255, 255, 0.8);
font-size: 0.95rem;
transition: color 0.2s ease;
}
.footer-links a:hover {
color: #ffffff;
}
.footer-bottom {
text-align: center;
padding-top: 1.5rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.footer-bottom p {
color: rgba(255, 255, 255, 0.7);
font-size: 0.9rem;
margin: 0;
}
@media (max-width: 700px) {
.footer-inner {
grid-template-columns: 1fr;
gap: 1.5rem;
}
}
</style>
</footer>
<!-- Mobile compatibility scripts -->
<script src="{{ url_for('static', filename='js/mobile_compatibility.js') }}"></script>
<script src="{{ url_for('static', filename='js/ios_fixes.js') }}"></script>
</body>
</html>
+117
View File
@@ -0,0 +1,117 @@
{% extends "base.html" %}
{% block title %}Blog{% endblock %}
{% block head %}
{{ super() }}
<style>
.blog-header {
text-align: center;
margin-bottom: 2rem;
}
.blog-header h1 {
font-size: 2.4rem;
color: #0f354d;
margin-bottom: 0.5rem;
}
.blog-header p {
font-size: 1.05rem;
color: #5a7a8f;
}
.posts-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1.2rem;
margin-bottom: 1.5rem;
}
.post-link {
text-decoration: none;
color: inherit;
}
.post-item {
background: #ffffff;
border: 1px solid #d4e0e9;
border-radius: 14px;
padding: 1.2rem;
box-shadow: 0 10px 28px rgba(20, 40, 55, 0.05);
transition: transform 0.2s ease, box-shadow 0.2s ease;
display: flex;
flex-direction: column;
}
.post-link:hover .post-item {
transform: translateY(-3px);
box-shadow: 0 16px 40px rgba(20, 40, 55, 0.12);
}
.post-item h2 {
font-size: 1.2rem;
color: #113d59;
margin: 0 0 0.5rem;
}
.post-item p {
margin: 0;
color: #5a7a8f;
font-size: 0.95rem;
flex: 1;
}
.post-meta {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0.8rem;
padding-top: 0.8rem;
border-top: 1px solid #e8f0f6;
font-size: 0.8rem;
color: #7a8fa2;
}
.read-more {
color: #0a5c88;
font-weight: 700;
}
@media (max-width: 768px) {
.posts-grid {
grid-template-columns: 1fr;
}
.blog-header h1 {
font-size: 1.8rem;
}
}
</style>
{% endblock %}
{% block content %}
<section class="blog-header">
<h1>Blog</h1>
<p>Neuigkeiten und Insights rund um IT-Loesungen und Projekte</p>
</section>
{% if posts %}
<section class="posts-grid">
{% for post in posts %}
<a href="{{ url_for('blog_post', post_id=post.id) }}" class="post-link">
<article class="post-item">
<h2>{{ post.title }}</h2>
<p>{{ post.excerpt }}</p>
<div class="post-meta">
<span>{{ post.created_at[:10] }}</span>
<span class="read-more">Zum Beitrag →</span>
</div>
</article>
</a>
{% endfor %}
</section>
{% else %}
<p style="text-align: center; color: #5a7a8f; padding: 2rem 0;">Noch keine Beitraege veroeffentlicht.</p>
{% endif %}
{% endblock %}
+127
View File
@@ -0,0 +1,127 @@
{% extends "base.html" %}
{% block title %}{{ post.title }}{% endblock %}
{% block head %}
{{ super() }}
<style>
.post-container {
max-width: 760px;
margin: 0 auto;
background: #ffffff;
border: 1px solid #d4e0e9;
border-radius: 16px;
padding: 2rem;
box-shadow: 0 14px 32px rgba(20, 39, 55, 0.06);
}
.post-header {
margin-bottom: 1.5rem;
padding-bottom: 1.2rem;
border-bottom: 1px solid #e8f0f6;
}
.post-header h1 {
font-size: 2.2rem;
color: #0f354d;
margin: 0 0 0.6rem;
}
.post-header-meta {
display: flex;
align-items: center;
gap: 1rem;
color: #7a8fa2;
font-size: 0.9rem;
}
.author-badge {
display: inline-block;
border-radius: 999px;
padding: 0.25rem 0.6rem;
background: #eef4f8;
border: 1px solid #bcd0de;
color: #185170;
font-weight: 700;
}
.post-content {
line-height: 1.8;
color: #2f4556;
font-size: 1.05rem;
}
.post-content h2 {
font-size: 1.5rem;
color: #113d59;
margin: 1.5rem 0 0.8rem;
}
.post-content h3 {
font-size: 1.2rem;
color: #113d59;
margin: 1.2rem 0 0.6rem;
}
.post-content p {
margin: 0 0 1rem;
}
.post-content ul,
.post-content ol {
margin: 0 0 1rem 1.5rem;
}
.post-content li {
margin-bottom: 0.5rem;
}
.back-link {
display: inline-block;
margin-top: 1rem;
padding: 0.6rem 1rem;
border-radius: 999px;
border: 1px solid #bcd0de;
color: #0a5c88;
font-weight: 700;
text-decoration: none;
transition: transform 0.15s ease;
}
.back-link:hover {
transform: translateY(-1px);
}
@media (max-width: 768px) {
.post-container {
padding: 1.4rem;
}
.post-header h1 {
font-size: 1.6rem;
}
.post-content {
font-size: 1rem;
}
}
</style>
{% endblock %}
{% block content %}
<article class="post-container">
<header class="post-header">
<h1>{{ post.title }}</h1>
<div class="post-header-meta">
<span class="author-badge">{{ post.author }}</span>
<span>{{ post.created_at[:10] }}</span>
</div>
</header>
<section class="post-content">
{{ post.content }}
</section>
<a href="{{ url_for('blog') }}" class="back-link">← Zurueck zum Blog</a>
</article>
{% endblock %}
+238
View File
@@ -0,0 +1,238 @@
{% extends "base.html" %}
{% block title %}Datenschutz | Invario{% endblock %}
{% block content %}
<div class="legal-page">
<div class="legal-header">
<h1>Datenschutzerklärung</h1>
<p>Gültig ab: 1. Januar 2026</p>
</div>
<div class="legal-content">
<section>
<h2>1. Verantwortlicher</h2>
<p>Verantwortlich für die Datenverarbeitung ist:</p>
<p>
Invario GmbH<br>
Musterstraße 123<br>
12345 Musterstadt<br>
Deutschland<br>
E-Mail: info@invario.de
</p>
</section>
<section>
<h2>2. Datenschutzbeauftragter</h2>
<p>Unseren Datenschutzbeauftragten erreichen Sie unter:</p>
<p>
datenschutz@invario.de
</p>
</section>
<section>
<h2>3. Erhobene Daten</h2>
<p>Wir erheben und verarbeiten folgende Kategorien von personenbezogenen Daten:</p>
<ul>
<li><strong>Registrierungsdaten:</strong> Benutzername, Display-Name, E-Mail (falls vorhanden), Passwort-Hash</li>
<li><strong>Termin- und Buchungsdaten:</strong> Datum, Uhrzeit, Betreff, Notizen, Status der Termine</li>
<li><strong>Blog-Daten:</strong> Veröffentlichte Artikel, Autoreninformationen</li>
<li><strong>Technische Daten:</strong> IP-Adresse, Browser-Informationen, Cookie-Daten, Zeitstempel</li>
</ul>
</section>
<section>
<h2>4. Zweck der Datenverarbeitung</h2>
<p>Wir verarbeiten Ihre Daten zu folgenden Zwecken:</p>
<ul>
<li>Bereitstellung und Verwaltung Ihres Benutzerkontos</li>
<li>Abwicklung von Terminbuchungen und Anfragen</li>
<li>Kommunikation mit Ihnen zu Ihren Anfragen</li>
<li>Bereitstellung personalisierter Inhalte</li>
<li>Sicherheit und Schutz vor Missbrauch</li>
<li>Verbesserung unserer Dienstleistungen</li>
<li>Erfüllung gesetzlicher Verpflichtungen</li>
</ul>
</section>
<section>
<h2>5. Rechtsgrundlagen</h2>
<p>Die Verarbeitung personenbezogener Daten erfolgt auf Grundlage von:</p>
<ul>
<li><strong>Artikel 6 Abs. 1 lit. b) DSGVO:</strong> Abwicklung von Verträgen und Anfragen</li>
<li><strong>Artikel 6 Abs. 1 lit. a) DSGVO:</strong> Ihre ausdrückliche Einwilligung</li>
<li><strong>Artikel 6 Abs. 1 lit. c) DSGVO:</strong> Erfüllung rechtlicher Verpflichtungen</li>
<li><strong>Artikel 6 Abs. 1 lit. f) DSGVO:</strong> Berechtigte Interessen zur Sicherheit und Verbesserung</li>
</ul>
</section>
<section>
<h2>6. Speicherdauer</h2>
<p>Wir speichern Ihre Daten so lange, wie dies erforderlich ist:</p>
<ul>
<li>Kontodaten: Solange Ihr Konto aktiv ist, danach 12 Monate</li>
<li>Terminbuchungen: Bis 3 Jahre nach Abwicklung</li>
<li>Blog-Kommentare und Inhalte: Bis zur Löschung des Inhalts</li>
<li>Technische Logs: Maximal 90 Tage</li>
</ul>
</section>
<section>
<h2>7. Empfänger von Daten</h2>
<p>Wir geben Ihre Daten nicht an Dritte weiter, außer:</p>
<ul>
<li>Unsere technischen Dienstanbieter (Webhoting, Cloud-Services)</li>
<li>Behörden und Gerichte bei gesetzlicher Verpflichtung</li>
<li>Mit Ihrer ausdrücklichen Einwilligung</li>
</ul>
</section>
<section>
<h2>8. Ihre Rechte</h2>
<p>Sie haben folgende Rechte bezügliche Ihrer personenbezogenen Daten:</p>
<ul>
<li><strong>Auskunftsrecht:</strong> Recht auf Auskunft über Ihre Daten (Artikel 15 DSGVO)</li>
<li><strong>Berichtigungsrecht:</strong> Recht auf Korrektur unrichtiger Daten (Artikel 16 DSGVO)</li>
<li><strong>Löschungsrecht:</strong> Recht auf Löschung Ihrer Daten (Artikel 17 DSGVO)</li>
<li><strong>Einschränkungsrecht:</strong> Recht auf Einschränkung der Verarbeitung (Artikel 18 DSGVO)</li>
<li><strong>Recht auf Datenportabilität:</strong> Recht auf Übertragung Ihrer Daten (Artikel 20 DSGVO)</li>
<li><strong>Widerspruchsrecht:</strong> Recht auf Widerspruch gegen Verarbeitung (Artikel 21 DSGVO)</li>
<li><strong>Recht auf Beschwerde:</strong> Beschwerde bei einer Aufsichtsbehörde</li>
</ul>
<p>Um Ihre Rechte auszuüben, kontaktieren Sie uns unter: datenschutz@invario.de</p>
</section>
<section>
<h2>9. Cookies</h2>
<p>Wir verwenden Cookies zur Verwaltung von Sitzungen und Authentifizierung:</p>
<ul>
<li><strong>Session-Cookies:</strong> Erforderlich für die Anmeldung und Funktionalität</li>
<li><strong>Sicherheits-Cookies:</strong> Schutz vor CSRF und XSS-Angriffen</li>
<li><strong>Analytik-Cookies:</strong> Optional, zur Verbesserung unserer Dienste</li>
</ul>
<p>Sie können Cookies in den Einstellungen Ihres Browsers deaktivieren. Dies kann jedoch die Funktionalität beeinträchtigen.</p>
</section>
<section>
<h2>10. Sicherheit</h2>
<p>Wir schützen Ihre Daten durch:</p>
<ul>
<li>Verschlüsselte Übertragung (SSL/TLS)</li>
<li>Sichere Passwortspeicherung (Hashing)</li>
<li>Firewalls und Zugriffskontrollen</li>
<li>Regelmäßige Sicherheitsupdates</li>
</ul>
</section>
<section>
<h2>11. Externe Links</h2>
<p>Diese Website enthält möglicherweise Links zu externen Websites. Wir sind nicht verantwortlich für deren Datenschutzpraktiken. Bitte lesen Sie deren Datenschutzerklärungen.</p>
</section>
<section>
<h2>12. Änderungen dieser Datenschutzerklärung</h2>
<p>Wir können diese Datenschutzerklärung jederzeit aktualisieren. Die aktuelle Version ist immer auf unserer Website verfügbar. Bei wesentlichen Änderungen werden Sie benachrichtigt.</p>
</section>
<section>
<h2>13. Kontakt</h2>
<p>Bei Fragen zur Datenschutzerklärung kontaktieren Sie uns unter:</p>
<p>
E-Mail: datenschutz@invario.de<br>
Telefon: +49 (0) 123 456789<br>
Adresse: Musterstraße 123, 12345 Musterstadt
</p>
</section>
</div>
</div>
<style>
.legal-page {
max-width: 900px;
margin: 0 auto;
}
.legal-header {
margin-bottom: 3rem;
padding-bottom: 2rem;
border-bottom: 2px solid var(--line);
}
.legal-header h1 {
font-size: 2.2rem;
margin-bottom: 0.5rem;
color: var(--brand);
}
.legal-header p {
color: var(--text-soft);
font-size: 0.95rem;
}
.legal-content {
display: flex;
flex-direction: column;
gap: 2.5rem;
}
section {
display: flex;
flex-direction: column;
gap: 1rem;
}
section h2 {
font-size: 1.3rem;
color: var(--brand);
margin-bottom: 0.5rem;
}
section p {
line-height: 1.7;
color: var(--text-soft);
}
section ul {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
section ul li {
padding-left: 1.5rem;
position: relative;
color: var(--text-soft);
line-height: 1.6;
}
section ul li:before {
content: "→";
position: absolute;
left: 0;
color: var(--accent);
font-weight: 700;
}
section strong {
color: var(--text-main);
font-weight: 600;
}
@media (max-width: 700px) {
.legal-header h1 {
font-size: 1.6rem;
}
section h2 {
font-size: 1.1rem;
}
.legal-content {
gap: 1.8rem;
}
}
</style>
{% endblock %}
+279
View File
@@ -0,0 +1,279 @@
{% extends "base.html" %}
{% block title %}Impressum | Invario{% endblock %}
{% block content %}
<div class="legal-page">
<div class="legal-header">
<h1>Impressum</h1>
<p>Angaben gemäß TMG (Telemediengesetz) und MDStV (Mediendienstestaatsvertrag)</p>
</div>
<div class="legal-content">
<section>
<h2>1. Betreiber der Website</h2>
<p>
<strong>Invario GmbH</strong><br>
Musterstraße 123<br>
12345 Musterstadt<br>
Deutschland
</p>
</section>
<section>
<h2>2. Registergericht und Registernummer</h2>
<p>
<strong>Registergericht:</strong> Amtsgericht Musterstadt<br>
<strong>Registernummer:</strong> HRB 123456<br>
<strong>USt-ID:</strong> DE 123 456 789
</p>
</section>
<section>
<h2>3. Geschäftsführung</h2>
<p>
<strong>Geschäftsführer:</strong> Max Mustermann und Erika Musterfrau
</p>
</section>
<section>
<h2>4. Kontaktinformationen</h2>
<p>
<strong>Telefon:</strong> +49 (0) 123 / 456789<br>
<strong>E-Mail:</strong> info@invario.de<br>
<strong>Website:</strong> www.invario.de
</p>
</section>
<section>
<h2>5. Verantwortliche Redaktion</h2>
<p>
<strong>Verantwortlich nach § 55 Abs. 2 RStV:</strong><br>
Max Mustermann<br>
Invario GmbH<br>
Musterstraße 123<br>
12345 Musterstadt
</p>
</section>
<section>
<h2>6. Haftungsausschluss</h2>
<p>
<strong>Haftung für Inhalte:</strong><br>
Trotz sorgfältiger Überprüfung übernehmen wir keine Haftung für die Inhalte externer Links.
Die auf dieser Website verlinkten Seiten sind ausschließlich Verantwortung des jeweiligen Betreibers.
Zum Zeitpunkt der Link-Erstellung waren keine illegalen Inhalte erkennbar.
</p>
<p>
<strong>Haftung für Links:</strong><br>
Sollte eine Seite, auf die wir verlinken, in Zukunft illegale oder sittenwidrige Inhalte enthalten,
werden wir den Link entfernen, sobald wir davon Kenntnis erlangen. Die Funktionsfähigkeit der Website
wird trotz gründlicher Prüfung nicht garantiert.
</p>
<p>
<strong>Haftung für Dienste und Angebote:</strong><br>
Alle unterbreiteten Informationen sind freibleibend und unverbindlich. Wir behalten uns ausdrücklich vor,
Teile der Website oder das gesamte Angebot ohne separate Ankündigung zu verändern, zu ergänzen, zu löschen oder
die Veröffentlichung zeitweise oder endgültig einzustellen.
</p>
</section>
<section>
<h2>7. Urheberrecht und Nutzungsrechte</h2>
<p>
Die auf dieser Website veröffentlichten Inhalte und Werke unterliegen dem deutschen Urheberrecht.
Eine Vervielfältigung, Bearbeitung, Verbreitung und jede Art der Verwertung außerhalb der Grenzen des Urheberrechts
bedürfen der schriftlichen Zustimmung des Urhebers oder Erstellers. Downloads und Kopien dieser
Seite sind nur für den privaten, nicht kommerziellen Gebrauch gestattet.
</p>
<p>
<strong>© 2026 Invario GmbH. Alle Rechte vorbehalten.</strong>
</p>
</section>
<section>
<h2>8. Besonderheiten in Bezug auf Inhalte</h2>
<p>
<strong>Blog und Benutzergenerierte Inhalte:</strong><br>
Für Inhalte, die von Benutzern oder Administratoren veröffentlicht werden, trägt der jeweilige Autor/Verfasser
die Verantwortung. Wir überprüfen regelmäßig alle Inhalte auf der Website. Sollten Sie der Meinung sein,
dass gegen Ihr Urheberrecht verstoßen wird oder illegale Inhalte vorliegen, teilen Sie uns dies bitte
umgehend mit.
</p>
</section>
<section>
<h2>9. Zahlungsarten und Geschäftsbedingungen</h2>
<p>
Die Nutzung unserer Dienstleistungen unterliegt den auf dieser Website veröffentlichten Nutzungsbedingungen
und unseren allgemeinen Geschäftsbedingungen. Diese finden Sie unter
<a href="{{ url_for('nutzungsbedingungen') }}">Nutzungsbedingungen</a>.
</p>
</section>
<section>
<h2>10. Datenschutz</h2>
<p>
Weitere Informationen zur Verarbeitung Ihrer personenbezogenen Daten finden Sie in unserer
<a href="{{ url_for('datenschutz') }}">Datenschutzerklärung</a>.
</p>
</section>
<section>
<h2>11. Außergerichtliche Streitbeilegung</h2>
<p>
Der Europäischen Kommission steht ein Online-Portal zur Beilegung von Streitigkeiten
(Plattform zur Online-Streitbeilegung - OS) zur Verfügung:
<a href="https://ec.europa.eu/consumers/odr/" target="_blank">https://ec.europa.eu/consumers/odr/</a>
</p>
<p>
Wir sind nicht bereit oder verpflichtet, an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen.
</p>
</section>
<section>
<h2>12. Geltendes Recht</h2>
<p>
Für alle Rechtsbeziehungen zwischen dem Nutzer und uns gilt ausschließlich deutsches Recht.
Es gilt das TMG und das MDStV in ihrer jeweils aktuellen Fassung. Erfüllungsort ist Musterstadt.
</p>
</section>
<section>
<h2>13. Kontakt zum Datenschutzbeauftragten</h2>
<p>
<strong>Datenschutzbeauftragter:</strong><br>
E-Mail: datenschutz@invario.de<br>
Telefon: +49 (0) 123 / 456789
</p>
</section>
<section>
<h2>14. Zu beachtende Gebote in Bezug auf Konten</h2>
<ul>
<li>Die Anmeldung erfordert einen gültigen Benutzernamen und ein Passwort</li>
<li>Sie sind verantwortlich für die Geheimhaltung des Passworts</li>
<li>Sie haften für alle Aktivitäten in Ihrem Konto</li>
<li>Missbrauch und Sicherheitslücken müssen uns sofort mitgeteilt werden</li>
<li>Eine Konto-Weitergabe ist nicht gestattet</li>
</ul>
</section>
<div class="last-updated">
<p><strong>Zuletzt aktualisiert:</strong> 1. Januar 2026</p>
</div>
</div>
</div>
<style>
.legal-page {
max-width: 900px;
margin: 0 auto;
}
.legal-header {
margin-bottom: 3rem;
padding-bottom: 2rem;
border-bottom: 2px solid var(--line);
}
.legal-header h1 {
font-size: 2.2rem;
margin-bottom: 0.5rem;
color: var(--brand);
}
.legal-header p {
color: var(--text-soft);
font-size: 0.95rem;
}
.legal-content {
display: flex;
flex-direction: column;
gap: 2.5rem;
}
section {
display: flex;
flex-direction: column;
gap: 1rem;
}
section h2 {
font-size: 1.3rem;
color: var(--brand);
margin-bottom: 0.5rem;
}
section p {
line-height: 1.7;
color: var(--text-soft);
}
section p a {
color: var(--brand);
text-decoration: underline;
}
section p a:hover {
color: var(--accent);
}
section ul {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
section ul li {
padding-left: 1.5rem;
position: relative;
color: var(--text-soft);
line-height: 1.6;
}
section ul li:before {
content: "→";
position: absolute;
left: 0;
color: var(--accent);
font-weight: 700;
}
section strong {
color: var(--text-main);
font-weight: 600;
}
.last-updated {
background: var(--bg-soft);
padding: 1.5rem;
border-radius: var(--radius-md);
margin-top: 2rem;
}
.last-updated p {
margin: 0;
text-align: center;
color: var(--text-soft);
}
@media (max-width: 700px) {
.legal-header h1 {
font-size: 1.6rem;
}
section h2 {
font-size: 1.1rem;
}
.legal-content {
gap: 1.8rem;
}
}
</style>
{% endblock %}
+110
View File
@@ -0,0 +1,110 @@
<!--
Copyright 2025 Maximilian Gründinger
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
{% extends "base.html" %}
{% block title %}Login{% endblock %}
{% block head %}
{{ super() }}
<style>
.auth-wrap {
max-width: 520px;
margin: 2rem auto;
padding: 2rem;
border-radius: 18px;
border: 1px solid #d4e0e9;
background: #ffffff;
box-shadow: 0 20px 44px rgba(14, 32, 48, 0.08);
}
.auth-wrap h1 {
font-size: 2rem;
color: #0f344d;
margin-bottom: 0.4rem;
}
.auth-wrap p {
margin-bottom: 1.3rem;
}
.field {
margin-bottom: 0.9rem;
}
.field label {
display: block;
margin-bottom: 0.35rem;
font-weight: 700;
color: #19445f;
}
.field input {
width: 100%;
padding: 0.68rem 0.78rem;
border-radius: 10px;
border: 1px solid #c4d5e2;
font-size: 1rem;
color: #12384f;
}
.field input:focus {
outline: none;
border-color: #2f79a7;
box-shadow: 0 0 0 3px rgba(58, 133, 180, 0.2);
}
.auth-btn {
width: 100%;
border: none;
border-radius: 999px;
padding: 0.72rem;
font-size: 1rem;
font-weight: 700;
color: #ffffff;
background: linear-gradient(120deg, #0a5f8f 0%, #0b4567 100%);
cursor: pointer;
}
.auth-meta {
margin-top: 1rem;
color: #547184;
}
.auth-meta a {
color: #0a5f8f;
font-weight: 700;
}
</style>
{% endblock %}
{% block content %}
<section class="auth-wrap">
<h1>Login</h1>
<p>Bitte melden Sie sich mit Ihrem Konto an, um Termine anzufragen.</p>
<form method="POST" action="{{ url_for('login') }}">
<div class="field">
<label for="username">Benutzername</label>
<input id="username" name="username" type="text" autocomplete="username" required>
</div>
<div class="field">
<label for="password">Passwort</label>
<input id="password" name="password" type="password" autocomplete="current-password" required>
</div>
<button class="auth-btn" type="submit">Login</button>
</form>
<p class="auth-meta">Noch kein Konto? <a href="{{ url_for('register') }}">Jetzt registrieren</a></p>
</section>
{% endblock %}
+450
View File
@@ -0,0 +1,450 @@
{% extends "base.html" %}
{% block title %}IT Loesungen | Modern{% endblock %}
{% block head %}
{{ super() }}
<style>
.hero {
margin-top: 1.2rem;
padding: 3.8rem;
border-radius: 24px;
background: linear-gradient(135deg, #f8fcff 0%, #e1ecf3 70%, #d4e3ed 100%);
border: 1px solid #cfdbe4;
display: grid;
grid-template-columns: 1.2fr 1fr;
gap: 2rem;
position: relative;
overflow: hidden;
box-shadow: 0 25px 55px rgba(17, 35, 50, 0.1);
}
.hero::after {
content: "";
position: absolute;
right: -80px;
bottom: -90px;
width: 280px;
height: 280px;
border-radius: 999px;
background: radial-gradient(circle at center, #0b5b89 0%, #0b5b89 40%, transparent 70%);
opacity: 0.12;
pointer-events: none;
}
.eyebrow {
display: inline-block;
margin-bottom: 0.85rem;
padding: 0.3rem 0.65rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #0c4f78;
background: #d9ebf6;
}
.hero h1 {
font-size: clamp(2rem, 5.1vw, 3.4rem);
color: #06293e;
text-wrap: balance;
}
.hero p {
max-width: 56ch;
margin-top: 1.1rem;
font-size: 1.06rem;
}
.cta-row {
margin-top: 1.8rem;
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
border: 1px solid transparent;
padding: 0.68rem 1.15rem;
font-weight: 700;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.btn:hover {
transform: translateY(-1px);
}
.btn-primary {
color: #ffffff;
background: linear-gradient(120deg, #0c5a86 0%, #08486c 100%);
box-shadow: 0 8px 20px rgba(11, 87, 128, 0.26);
}
.btn-secondary {
color: #0d4567;
background: #ffffff;
border-color: #b8cddc;
}
.hero-card {
align-self: end;
background: #0e2f44;
color: #dcf0ff;
border-radius: 18px;
padding: 1.2rem;
border: 1px solid #194b68;
}
.hero-card h3 {
font-size: 1.15rem;
margin-bottom: 0.6rem;
}
.hero-card p {
color: #b6d6ea;
font-size: 0.96rem;
margin: 0;
}
.stats {
margin-top: 1rem;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.55rem;
}
.stats div {
padding: 0.55rem 0.65rem;
border-radius: 12px;
background: #153a52;
border: 1px solid #2a5673;
}
.stats strong {
display: block;
font-family: "Space Grotesk", system-ui, sans-serif;
color: #ffffff;
font-size: 1.08rem;
}
.stats span {
color: #9ec5dc;
font-size: 0.8rem;
}
.section {
margin-top: 2.2rem;
padding: 2rem;
border-radius: 18px;
border: 1px solid #d4dde5;
background: #ffffff;
box-shadow: 0 15px 35px rgba(20, 40, 55, 0.05);
animation: rise 700ms ease both;
}
.section h2 {
font-size: clamp(1.4rem, 2.5vw, 2rem);
margin-bottom: 0.5rem;
color: #0f334b;
}
.section-grid {
margin-top: 1.1rem;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.9rem;
}
.card {
padding: 1rem;
border-radius: 14px;
border: 1px solid #d9e2ea;
background: linear-gradient(160deg, #fbfdff 0%, #f4f8fb 100%);
}
.card h3 {
font-size: 1.07rem;
color: #113c58;
margin-bottom: 0.45rem;
}
.card p {
font-size: 0.96rem;
}
.project-list {
margin-top: 1.1rem;
display: grid;
gap: 0.8rem;
}
.project {
padding: 0.95rem 1rem;
border-radius: 14px;
border: 1px solid #cfdae4;
background: #f8fbfd;
display: flex;
gap: 1rem;
justify-content: space-between;
align-items: center;
}
.project b {
display: block;
color: #0b3753;
margin-bottom: 0.2rem;
}
.project span {
font-size: 0.88rem;
color: #4a6579;
border: 1px solid #bfd0dd;
padding: 0.2rem 0.55rem;
border-radius: 999px;
white-space: nowrap;
}
.process {
margin-top: 1.2rem;
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 0.6rem;
}
.step {
padding: 0.8rem 0.65rem;
text-align: center;
border-radius: 12px;
border: 1px solid #ccdae5;
background: #f7fafc;
}
.step strong {
display: block;
margin-bottom: 0.2rem;
font-size: 0.98rem;
color: #0f3650;
}
.step small {
color: #5f7b8f;
}
.team-note {
margin-top: 0.85rem;
padding: 0.9rem 1rem;
border-radius: 12px;
border: 1px solid #f0c18b;
background: linear-gradient(120deg, #fffaf3 0%, #ffeed8 100%);
color: #7d4f14;
font-weight: 600;
}
.contact {
margin-top: 2rem;
border-radius: 20px;
padding: 2.3rem;
border: 1px solid #0f496f;
background: linear-gradient(120deg, #114e75 0%, #0a3d5c 100%);
color: #d2ebfa;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.contact h2 {
color: #ffffff;
font-size: clamp(1.25rem, 2.7vw, 2rem);
margin-bottom: 0.4rem;
}
.contact p {
color: #b9d8ec;
}
.contact .btn-secondary {
border-color: rgba(255, 255, 255, 0.35);
background: rgba(255, 255, 255, 0.14);
color: #ffffff;
}
.site-footer {
margin-top: 2rem;
padding: 1rem 0 0.3rem;
color: #6a8192;
display: flex;
justify-content: space-between;
gap: 1rem;
font-size: 0.95rem;
}
@keyframes rise {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 980px) {
.hero {
grid-template-columns: 1fr;
padding: 2rem;
}
.section-grid {
grid-template-columns: 1fr 1fr;
}
.process {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 700px) {
.section-grid {
grid-template-columns: 1fr;
}
.project {
flex-direction: column;
align-items: flex-start;
}
.process {
grid-template-columns: 1fr 1fr;
}
.contact {
flex-direction: column;
align-items: flex-start;
padding: 1.55rem;
}
.site-footer {
flex-direction: column;
padding-bottom: 1.2rem;
}
}
</style>
{% endblock %}
{% block content %}
<section class="hero" id="top">
<div>
<span class="eyebrow">IT Loesungen mit Haltung</span>
<h1>Zuhören. Verstehen. Planen. Realisieren.</h1>
<p>
Wir unterstuetzen Unternehmen ganzheitlich bei Enterprise-Architektur,
Projektmanagement und IT-Betrieb. Die Anforderung bleibt im Mittelpunkt,
die Umsetzung wird klar, messbar und belastbar.
</p>
<div class="cta-row">
{% if 'username' in session %}
<a class="btn btn-primary" href="{{ url_for('appointments') }}">Termin anfragen</a>
{% else %}
<a class="btn btn-primary" href="{{ url_for('login') }}">Termin anfragen</a>
{% endif %}
<a class="btn btn-primary" href="#kontakt">Kontakt aufnehmen</a>
<a class="btn btn-secondary" href="#leistungen">Dienstleistungen ansehen</a>
</div>
</div>
<aside class="hero-card" aria-label="Kurzfakten">
<h3>IT Projekte mit Substanz</h3>
<p>
Von Infrastruktur bis Governance: Wir verbinden konzeptionelle Tiefe mit
pragmatischer Umsetzung im Tagesgeschaeft.
</p>
<div class="stats">
<div><strong>25+</strong><span>aktive Mandate</span></div>
<div><strong>98%</strong><span>Zieltermine erreicht</span></div>
<div><strong>24/7</strong><span>Servicebereit</span></div>
</div>
</aside>
</section>
<section class="section" id="leistungen" style="animation-delay: 100ms;">
<h2>Dienstleistungen</h2>
<p>Modulare Leistungen, individuell kombiniert fuer Ihre IT-Roadmap.</p>
<div class="section-grid">
<article class="card">
<h3>Support und Service</h3>
<p>Betriebskritische Systeme stabil halten, proaktiv ueberwachen und schnell reagieren.</p>
</article>
<article class="card">
<h3>Optimierung und Tuning</h3>
<p>Strukturen, Kosten und Performance datenbasiert verbessern statt nur Symptome behandeln.</p>
</article>
<article class="card">
<h3>Health Check und Verify</h3>
<p>Technische Due-Diligence, klare Handlungsplaene und belastbare Priorisierung.</p>
</article>
</div>
</section>
<section class="section" id="projekte" style="animation-delay: 180ms;">
<h2>Projektfelder</h2>
<p>Ausgewählte Schwerpunkte aus laufenden und abgeschlossenen Kundenprojekten.</p>
<div class="project-list">
<div class="project">
<div>
<b>Virtualisierung und Hybrid-Cloud</b>
Migration in wartbare Plattformen mit planbaren Betriebskosten.
</div>
<span>Cloud Computing</span>
</div>
<div class="project">
<div>
<b>Netzwerk und Sicherheit</b>
Segmentierung, Monitoring und Security-Standards auf Enterprise-Niveau.
</div>
<span>Security</span>
</div>
<div class="project">
<div>
<b>Projektmanagement und EAM</b>
Governance, Architekturprinzipien und Umsetzung in einer Linie.
</div>
<span>Transformation</span>
</div>
</div>
</section>
<section class="section" id="team" style="animation-delay: 260ms;">
<h2>Unsere Arbeitsweise</h2>
<p>Klassische Beratungsdisziplin trifft auf moderne Delivery-Kultur.</p>
<div class="process" aria-label="Prozessschritte">
<div class="step"><strong>Zuhoeren</strong><small>Anforderungen erfassen</small></div>
<div class="step"><strong>Verstehen</strong><small>Kontext analysieren</small></div>
<div class="step"><strong>Besprechen</strong><small>Optionen priorisieren</small></div>
<div class="step"><strong>Planen</strong><small>Roadmap festlegen</small></div>
<div class="step"><strong>Realisieren</strong><small>Loesung liefern</small></div>
<div class="step"><strong>Unterstuetzen</strong><small>Betrieb absichern</small></div>
</div>
<p class="team-note">
Aktuell verfuegbar: Ein IT-Infrastruktur-Architekt und ein IT-Governance-Experte
mit technischem Hintergrund fuer neue Aufgaben.
</p>
</section>
<section class="contact" id="kontakt">
<div>
<h2>Bereit fuer das naechste IT-Projekt?</h2>
<p>In einem kurzen Erstgespraech klaeren wir Zielbild, Risiken und realistische Umsetzungsschritte.</p>
</div>
{% if 'username' in session %}
<a class="btn btn-secondary" href="{{ url_for('appointments') }}">Termin anfragen</a>
{% else %}
<a class="btn btn-secondary" href="{{ url_for('login') }}">Termin anfragen</a>
{% endif %}
</section>
{% endblock %}
+354
View File
@@ -0,0 +1,354 @@
{% extends "base.html" %}
{% block title %}Nutzungsbedingungen | Invario{% endblock %}
{% block content %}
<div class="legal-page">
<div class="legal-header">
<h1>Nutzungsbedingungen</h1>
<p>Allgemeine Geschäftsbedingungen (AGB) für die Nutzung von Invario</p>
</div>
<div class="legal-content">
<section>
<h2>1. Geltungsbereich und Vertragsparteien</h2>
<p>
Diese Nutzungsbedingungen regeln die Beziehung zwischen Ihnen als Nutzer und der Invario GmbH
bei der Nutzung unserer Website und Dienstleistungen. Mit der Nutzung unserer Website und Dienste
akzeptieren Sie diese Bedingungen vollständig und verbindlich.
</p>
<p>
<strong>Vertragsparteien sind:</strong>
</p>
<ul>
<li>Invario GmbH (Anbieter)</li>
<li>Der Nutzer (Sie)</li>
</ul>
</section>
<section>
<h2>2. Registrierung und Konto</h2>
<p>
<strong>Anforderungen zur Registrierung:</strong><br>
Für die Nutzung bestimmter Funktionen müssen Sie sich registrieren. Sie verpflichten sich,
wahrheitsgemäß und vollständig folgende Informationen bereitzustellen:
</p>
<ul>
<li>Einen eindeutigen Benutzernamen</li>
<li>Einen Anzeigenamen (Display Name)</li>
<li>Ein sicheres Passwort (mindestens 8 Zeichen)</li>
</ul>
<p>
<strong>Ihre Verantwortung:</strong><br>
Sie sind verantwortlich für den Schutz Ihres Passworts und für alle Aktivitäten in Ihrem Konto.
Sie müssen uns sofort benachrichtigen, wenn Sie unbefugten Zugriff vermuten.
</p>
</section>
<section>
<h2>3. Zulässige Nutzung</h2>
<p>
<strong>Ihre Nutzung unserer Dienste ist auf folgende Weise begrenzt:</strong>
</p>
<ul>
<li>Sie werden die Website nicht für illegale oder schädliche Zwecke nutzen</li>
<li>Sie werden keine Malware, Viren oder zerstörerischen Code hochladen</li>
<li>Sie werden nicht versuchen, unbefugten Zugang zu erhalten</li>
<li>Sie werden die Website oder deren Dienste nicht missbrauchen</li>
<li>Sie werden keine personenbezogenen Daten anderer Nutzer sammeln</li>
<li>Sie werden die Rechte anderer Nutzer respektieren</li>
<li>Sie werden keine rassistischen, rassistischen oder fremdenfeindlichen Inhalte teilen</li>
<li>Sie werden keine Belästigung oder Bedrohung durchführen</li>
</ul>
</section>
<section>
<h2>4. Terminbuchungen und Anfragen</h2>
<p>
<strong>Buchungsprozess:</strong><br>
Sie können über unsere Plattform Termine anfordern. Ein Termin ist nicht automatisch bestätigt,
sondern muss von unserem Team überprüft und genehmigt werden.
</p>
<p>
<strong>Termine inkl. Folgende Rechte und Pflichten:</strong>
</p>
<ul>
<li>Sie können einen Termin anfordern, wir sind jedoch nicht verpflichtet, diesen zu bestätigen</li>
<li>Ein Termin wird erst vermittelt durch eine Bestätigung durch unser Team</li>
<li>Bei Ablehnung erhalten Sie eine Begründung</li>
<li>Sie sind verantwortlich für die Richtigkeit Ihrer Angaben</li>
<li>Im Falle von Stornierungen beachten Sie bitte unsere Stornierungsrichtlinien</li>
</ul>
</section>
<section>
<h2>5. Blog und benutzergenerierte Inhalte</h2>
<p>
<strong>Veröffentlichung von Inhalten:</strong><br>
Admins können Blog-Beiträge veröffentlichen. Diese Beiträge müssen folgende Anforderungen erfüllen:
</p>
<ul>
<li>Keine illegalen oder extremistischen Inhalte</li>
<li>Keine rassistischen oder rassistischen Aussagen</li>
<li>Keine Verletzung von Urheberrechten dritter Parteien</li>
<li>Keine persönlichen Daten von anderen Personen ohne ihre Zustimmung</li>
<li>Keine Spam oder kommerzielle Werbung (außer für autorisierte Partner)</li>
</ul>
<p>
<strong>Recht zur Modernis und Lösch:</strong><br>
Wir behalten uns das Recht vor, Inhalte zu moderieren, zu bearbeiten oder zu löschen,
die gegen diese Bedingungen verstoßen. Dies geschieht ohne Ankündigung und ohne Haftung.
</p>
</section>
<section>
<h2>6. Geistige Eigentum</h2>
<p>
<strong>Alle Inhalte und Materialien auf dieser Website, einschließlich, aber nicht begrenzt auf:</strong>
</p>
<ul>
<li>Texte, Grafiken, Logos, Bilder, Audio- und Video-Dateien</li>
<li>Software, Quellcode und Datenbanken</li>
<li>Die Auswahl, Koordination und Anordnung solcher Materialien</li>
</ul>
<p>
...sind Eigentum der Invario GmbH oder der lizenzierten Parteien und durch Urheberrechts-
und sonstige intellektuelle Eigentumsgesetze geschützt.
</p>
</section>
<section>
<h2>7. Haftungsbeschränkung</h2>
<p>
<strong>Haftungsausschluss:</strong><br>
Soweit von Gesetzes wegen zulässig, erklären wir uns nicht verantwortlich für:
</p>
<ul>
<li>Indirekte, zufällige oder Folgeschäden</li>
<li>Entgangene Gewinne oder Geschäftsmöglichkeiten</li>
<li>Verlust von Daten oder Informationen</li>
<li>Unterbrechungen oder Ausfallzeiten</li>
<li>Schäden durch unbefugten Zugriff</li>
<li>Schäden durch Dritte oder externe Faktoren</li>
</ul>
<p>
<strong>Haftungsbegrenzung:</strong><br>
Falls wir haftbar sind, ist unsere Haftung auf den Betrag begrenzt, den Sie an uns bezahlt haben
oder den geringeren Betrag von 100 EUR.
</p>
</section>
<section>
<h2>8. Sicherheit und Datenschutz</h2>
<p>
Wir nehmen die Sicherheit Ihrer Daten ernst. Weitere Details finden Sie in unserer
<a href="{{ url_for('datenschutz') }}">Datenschutzerklärung</a>.
</p>
<p>
<strong>Sie verpflichten sich:</strong>
</p>
<ul>
<li>Ihr Passwort geheim zu halten</li>
<li>Sich abzumelden, wenn Sie andere das System nutzen</li>
<li>Uns über Sicherheitsverletzungen zu benachrichtigen</li>
</ul>
</section>
<section>
<h2>9. Änderungen der Bedingungen und Dienste</h2>
<p>
<strong>Recht zur Änderung:</strong><br>
Wir behalten uns das Recht vor, diese Nutzungsbedingungen jederzeit zu ändern.
Sie werden benachrichtigt, wenn wesentliche Änderungen vorgenommen werden.
Die weitere Nutzung der Website bedeutet Ihre Annahme der neuen Bedingungen.
</p>
<p>
<strong>Änderungen an Diensten:</strong><br>
Wir behalten uns das Recht vor, Dienste jederzeit zu ändern, zu erweitern oder zu entfernen,
ohne davor verpflichtet zu sein, Sie zu informieren.
</p>
</section>
<section>
<h2>10. Beendigung des Kontos</h2>
<p>
<strong>Beendigung durch den Nutzer:</strong><br>
Sie können Ihr Konto jederzeit beenden, indem Sie uns eine E-Mail senden.
</p>
<p>
<strong>Beendigung durch uns:</strong><br>
Wir können Ihr Konto sofort beenden, wenn Sie diese Nutzungsbedingungen verletzen oder
verdächtige Aktivitäten aufweisen. Dies geschieht ohne Vorankündigung.
</p>
</section>
<section>
<h2>11. Webhooks und automatische Integration</h2>
<p>
<strong>Funktionalität:</strong><br>
Unsere Plattform ermöglicht die Verwaltung von Terminen und Inhalten.
Diese Daten sind nur für Sie und bei Berechtigungen für unseren Admin-Team zugänglich.
</p>
</section>
<section>
<h2>12. Benachrichtigungen und Kommunikation</h2>
<p>
<strong>Kommunikationsmittel:</strong><br>
Wir können Sie per E-Mail, Website-Benachrichtigungen oder anderen Mitteln benachrichtigen.
Sie erklären sich damit einverstanden, solche Benachrichtigungen zu erhalten.
</p>
<p>
<strong>Häufigkeit:</strong><br>
Wir verpflichten uns, Sie nicht übermäßig zu bombardieren, aber wir können Ihnen relevante Aktualisierungen zusenden.
</p>
</section>
<section>
<h2>13. Verfügbarkeit und Support</h2>
<p>
Wir bieten Support während normaler Geschäftszeiten. Während Wartungsarbeiten oder ungeplanten Ausfallzeiten
ist die Website möglicherweise nicht verfügbar. Wir entschuldigen uns für jegliche Unannehmlichkeiten.
</p>
</section>
<section>
<h2>14. Anwendbares Recht und Gerichtsstand</h2>
<p>
Diese Nutzungsbedingungen unterliegen deutschem Recht. Die Gerichtsstände für alle Streitigkeiten
sind in Musterstadt. Sie erklären sich damit einverstanden, dass alle Ansprüche in den deutschen Gerichten
eingereicht werden müssen.
</p>
</section>
<section>
<h2>15. Kontakt</h2>
<p>
Bei Fragen zu diesen Nutzungsbedingungen kontaktieren Sie uns unter:
</p>
<p>
Invario GmbH<br>
E-Mail: info@invario.de<br>
Telefon: +49 (0) 123 / 456789<br>
Adresse: Musterstraße 123, 12345 Musterstadt
</p>
</section>
<div class="last-updated">
<p><strong>Zuletzt aktualisiert:</strong> 1. Januar 2026</p>
</div>
</div>
</div>
<style>
.legal-page {
max-width: 900px;
margin: 0 auto;
}
.legal-header {
margin-bottom: 3rem;
padding-bottom: 2rem;
border-bottom: 2px solid var(--line);
}
.legal-header h1 {
font-size: 2.2rem;
margin-bottom: 0.5rem;
color: var(--brand);
}
.legal-header p {
color: var(--text-soft);
font-size: 0.95rem;
}
.legal-content {
display: flex;
flex-direction: column;
gap: 2.5rem;
}
section {
display: flex;
flex-direction: column;
gap: 1rem;
}
section h2 {
font-size: 1.3rem;
color: var(--brand);
margin-bottom: 0.5rem;
}
section p {
line-height: 1.7;
color: var(--text-soft);
}
section p a {
color: var(--brand);
text-decoration: underline;
}
section p a:hover {
color: var(--accent);
}
section ul {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
section ul li {
padding-left: 1.5rem;
position: relative;
color: var(--text-soft);
line-height: 1.6;
}
section ul li:before {
content: "→";
position: absolute;
left: 0;
color: var(--accent);
font-weight: 700;
}
section strong {
color: var(--text-main);
font-weight: 600;
}
.last-updated {
background: var(--bg-soft);
padding: 1.5rem;
border-radius: var(--radius-md);
margin-top: 2rem;
}
.last-updated p {
margin: 0;
text-align: center;
color: var(--text-soft);
}
@media (max-width: 700px) {
.legal-header h1 {
font-size: 1.6rem;
}
section h2 {
font-size: 1.1rem;
}
.legal-content {
gap: 1.8rem;
}
}
</style>
{% endblock %}
+103
View File
@@ -0,0 +1,103 @@
{% extends "base.html" %}
{% block title %}Registrieren{% endblock %}
{% block head %}
{{ super() }}
<style>
.auth-wrap {
max-width: 560px;
margin: 2rem auto;
padding: 2rem;
border-radius: 18px;
border: 1px solid #d4e0e9;
background: #ffffff;
box-shadow: 0 20px 44px rgba(14, 32, 48, 0.08);
}
.auth-wrap h1 {
font-size: 2rem;
color: #0f344d;
margin-bottom: 0.4rem;
}
.auth-wrap p {
margin-bottom: 1.3rem;
}
.field {
margin-bottom: 0.9rem;
}
.field label {
display: block;
margin-bottom: 0.35rem;
font-weight: 700;
color: #19445f;
}
.field input {
width: 100%;
padding: 0.68rem 0.78rem;
border-radius: 10px;
border: 1px solid #c4d5e2;
font-size: 1rem;
color: #12384f;
}
.field input:focus {
outline: none;
border-color: #2f79a7;
box-shadow: 0 0 0 3px rgba(58, 133, 180, 0.2);
}
.auth-btn {
width: 100%;
border: none;
border-radius: 999px;
padding: 0.72rem;
font-size: 1rem;
font-weight: 700;
color: #ffffff;
background: linear-gradient(120deg, #0a5f8f 0%, #0b4567 100%);
cursor: pointer;
}
.auth-meta {
margin-top: 1rem;
color: #547184;
}
.auth-meta a {
color: #0a5f8f;
font-weight: 700;
}
</style>
{% endblock %}
{% block content %}
<section class="auth-wrap">
<h1>Registrierung</h1>
<p>Legen Sie ein Konto an, um den Kalender fuer Terminanfragen zu nutzen.</p>
<form method="POST" action="{{ url_for('register') }}">
<div class="field">
<label for="display_name">Name</label>
<input id="display_name" name="display_name" type="text" autocomplete="name" required>
</div>
<div class="field">
<label for="username">Benutzername</label>
<input id="username" name="username" type="text" autocomplete="username" required>
</div>
<div class="field">
<label for="password">Passwort (mind. 8 Zeichen)</label>
<input id="password" name="password" type="password" autocomplete="new-password" required>
</div>
<div class="field">
<label for="password_repeat">Passwort wiederholen</label>
<input id="password_repeat" name="password_repeat" type="password" autocomplete="new-password" required>
</div>
<button class="auth-btn" type="submit">Konto erstellen</button>
</form>
<p class="auth-meta">Bereits registriert? <a href="{{ url_for('login') }}">Zum Login</a></p>
</section>
{% endblock %}
+277
View File
@@ -0,0 +1,277 @@
from pymongo import MongoClient
import hashlib
from bson.objectid import ObjectId
def check_password_strength(password):
"""
Check if a password meets minimum security requirements.
Args:
password (str): Password to check
Returns:
bool: True if password is strong enough, False otherwise
"""
if len(password) < 6:
return False
return True
def hashing(password):
"""
Hash a password using SHA-512.
Args:
password (str): Password to hash
Returns:
str: Hexadecimal digest of the hashed password
"""
return hashlib.sha512(password.encode()).hexdigest()
def check_nm_pwd(username, password):
"""
Verify username and password combination.
Args:
username (str): Username to check
password (str): Password to verify
Returns:
dict: User document if credentials are valid, None otherwise
"""
client = MongoClient('localhost', 27017)
db = client['Inventarsystem']
users = db['users']
hashed_password = hashlib.sha512(password.encode()).hexdigest()
user = users.find_one({'Username': username, 'Password': hashed_password})
client.close()
return user
def add_user(username, password, name, last_name):
"""
Add a new user to the database.
Args:
username (str): Username for the new user
password (str): Password for the new user
Returns:
bool: True if user was added successfully, False if password was too weak
"""
client = MongoClient('localhost', 27017)
db = client['Inventarsystem']
users = db['users']
if not check_password_strength(password):
return False
users.insert_one({'Username': username, 'Password': hashing(password), 'Admin': False, 'active_ausleihung': None, 'name': name, 'last_name': last_name})
client.close()
return True
def make_admin(username):
"""
Grant administrator privileges to a user.
Args:
username (str): Username of the user to promote
Returns:
bool: True if user was promoted successfully
"""
client = MongoClient('localhost', 27017)
db = client['Inventarsystem']
users = db['users']
users.update_one({'Username': username}, {'$set': {'Admin': True}})
client.close()
return True
def remove_admin(username):
"""
Remove administrator privileges from a user.
Args:
username (str): Username of the user to demote
Returns:
bool: True if user was demoted successfully
"""
client = MongoClient('localhost', 27017)
db = client['Inventarsystem']
users = db['users']
users.update_one({'Username': username}, {'$set': {'Admin': False}})
client.close()
return True
def get_user(username):
"""
Retrieve a specific user by username.
Args:
username (str): Username to search for
Returns:
dict: User document or None if not found
"""
client = MongoClient('localhost', 27017)
db = client['Inventarsystem']
users = db['users']
users_return = users.find_one({'Username': username})
client.close()
return users_return
def check_admin(username):
"""
Check if a user has administrator privileges.
Args:
username (str): Username to check
Returns:
bool: True if user is an administrator, False otherwise
"""
client = MongoClient('localhost', 27017)
db = client['Inventarsystem']
users = db['users']
user = users.find_one({'Username': username})
client.close()
return user and user.get('Admin', False)
def delete_user(username):
"""
Delete a user from the database.
Administrative function for removing user accounts.
Args:
username (str): Username of the account to delete
Returns:
bool: True if user was deleted successfully, False otherwise
"""
client = MongoClient('localhost', 27017)
db = client['Inventarsystem']
users = db['users']
result = users.delete_one({'username': username})
client.close()
if result.deleted_count == 0:
# Try with different field name
client = MongoClient('localhost', 27017)
db = client['Inventarsystem']
users = db['users']
result = users.delete_one({'Username': username})
client.close()
return result.deleted_count > 0
def get_name(username):
"""
Retrieve the name that is assosiated with the username.
Returns:
str: String of name
"""
client = MongoClient('localhost', 27017)
db = client['Inventarsystem']
users = db['users']
user = users.find_one({'Username': username})
name = user.get("name")
return name
def get_last_name(username):
"""
Retrieve the last_name that is assosiated with the username.
Returns:
str: String of last_name
"""
client = MongoClient('localhost', 27017)
db = client['Inventarsystem']
users = db['users']
user = users.find_one({'Username': username})
name = user.get("last_name")
return name
def get_all_users():
"""
Retrieve all users from the database.
Administrative function for user management.
Returns:
list: List of all user documents
"""
try:
client = MongoClient('localhost', 27017)
db = client['Inventarsystem']
users = db['users']
all_users = list(users.find())
client.close()
return all_users
except Exception as e:
return []
def update_password(username, new_password):
"""
Update a user's password with a new one.
Args:
username (str): Username of the user
new_password (str): New password to set
Returns:
bool: True if password was updated successfully, False otherwise
"""
try:
if not check_password_strength(new_password):
return False
client = MongoClient('localhost', 27017)
db = client['Inventarsystem']
users = db['users']
# Hash the new password
hashed_password = hashing(new_password)
# Update the user's password
result = users.update_one(
{'Username': username},
{'$set': {'Password': hashed_password}}
)
client.close()
return result.modified_count > 0
except Exception as e:
print(f"Error updating password: {e}")
return False
def update_user_name(username, name, last_name):
"""
Update a user's name and last name.
Args:
username (str): Username of the user
name (str): New first name
last_name (str): New last name
Returns:
bool: True if updated successfully, False otherwise
"""
try:
client = MongoClient('localhost', 27017)
db = client['Inventarsystem']
users = db['users']
result = users.update_one(
{'Username': username},
{'$set': {'name': name, 'last_name': last_name}}
)
client.close()
return True
except Exception as e:
print(f"Error updating user name: {e}")
return False