py app init + zitadel doku

This commit is contained in:
br committed 2026-06-17 19:04:39 +02:00
1 parent 5b649f96f2
commit 5a0260e4fd
23 files changed
+1071 -1

No files matched your search

+96
View File
@@ -0,0 +1,96 @@
"""
Application Factory der OIDC Flask Showcase App.
Initialisiert Flask, SQLAlchemy und Authlib. Registriert alle Blueprints
und stellt den CLI-Befehl ``flask seed-db`` bereit.
:author: <Vorname Nachname>
:date: 2026-06-17
:version: 0.1
"""
import logging
import click
from flask import Flask
from .config import Config
from .database import db
from .auth import oauth, init_oauth
logging.basicConfig(level=logging.INFO)
def create_app(config_class=Config):
app = Flask(__name__)
app.config.from_object(config_class)
db.init_app(app)
init_oauth(app)
from .routes import main
app.register_blueprint(main)
_register_cli(app)
return app
def _register_cli(app):
@app.cli.command("seed-db")
def seed_db():
"""Initialisiert das Schema und befüllt die DB mit Testdaten."""
from datetime import datetime
from .models import (
User, Client, Project,
ProjectMitarbeiterMapping, ProjectKundenMapping, Note,
)
db.drop_all()
db.create_all()
# ------------------------------------------------------------------
# PLATZHALTER – nach Zitadel-Setup durch echte sub-Werte ersetzen.
# Anleitung: ZITADEL-SETUP.md, Schritt 6
# ------------------------------------------------------------------
ADMIN_ID = "ZITADEL_SUB_ADMIN"
MITARBEITER_ID = "ZITADEL_SUB_MITARBEITER"
KUNDE_ID = "ZITADEL_SUB_KUNDE"
db.session.add_all([
User(zitadel_user_id=ADMIN_ID, email="admin@test.local", global_role="Admin"),
User(zitadel_user_id=MITARBEITER_ID, email="ma@test.local", global_role="Mitarbeiter"),
User(zitadel_user_id=KUNDE_ID, email="kunde@test.local", global_role="Kunde"),
])
firma = Client(name="Firma Müller")
db.session.add(firma)
db.session.flush()
p1 = Project(name="Akquise Landkreis", client_id=firma.id)
p2 = Project(name="Akquise Bundesstaat", client_id=firma.id)
p3 = Project(name="Spezielle Produktpalette", client_id=firma.id)
db.session.add_all([p1, p2, p3])
db.session.flush()
# Mitarbeiter → Projekte 1 + 2 (nicht 3 → Failure-Test-Button)
db.session.add_all([
ProjectMitarbeiterMapping(user_id=MITARBEITER_ID, project_id=p1.id),
ProjectMitarbeiterMapping(user_id=MITARBEITER_ID, project_id=p2.id),
])
# Kunde → Projekt 1 (nicht 2 + 3 → Failure-Test-Button)
db.session.add(ProjectKundenMapping(user_id=KUNDE_ID, project_id=p1.id))
db.session.add_all([
Note(text="Erstkontakt aufgenommen.", project_id=p1.id,
user_id=MITARBEITER_ID, created_at=datetime.utcnow()),
Note(text="Angebot versendet.", project_id=p1.id,
user_id=MITARBEITER_ID, created_at=datetime.utcnow()),
Note(text="Folgetermin vereinbart.", project_id=p2.id,
user_id=MITARBEITER_ID, created_at=datetime.utcnow()),
])
db.session.commit()
click.echo("Datenbank initialisiert und mit Testdaten befüllt.")
click.echo(f" → Platzhalter-IDs aktiv: {ADMIN_ID}, {MITARBEITER_ID}, {KUNDE_ID}")
click.echo(" → Echte IDs aus ZITADEL-SETUP.md Schritt 6 eintragen, dann erneut seeden.")
+56
View File
@@ -0,0 +1,56 @@
"""
OIDC-Authentifizierung via Authlib und Zitadel.
Konfiguriert den OAuth2-Client und stellt ``login_required`` sowie
``role_required`` als Route-Guard-Dekoratoren bereit.
:author: <Vorname Nachname>
:date: 2026-06-17
:version: 0.1
"""
import logging
from functools import wraps
from authlib.integrations.flask_client import OAuth
from flask import session, redirect, url_for
logger = logging.getLogger(__name__)
oauth = OAuth()
def init_oauth(app):
oauth.init_app(app)
oauth.register(
name="zitadel",
server_metadata_url=app.config["ZITADEL_SERVER_METADATA_URL"],
client_id=app.config["ZITADEL_CLIENT_ID"],
client_secret=app.config["ZITADEL_CLIENT_SECRET"],
client_kwargs={"scope": "openid profile email"},
)
def get_current_user():
return session.get("user")
def login_required(f):
@wraps(f)
def decorated(*args, **kwargs):
if not session.get("user"):
return redirect(url_for("main.login"))
return f(*args, **kwargs)
return decorated
def role_required(*roles):
def decorator(f):
@wraps(f)
def decorated(*args, **kwargs):
user = session.get("user")
if not user:
return redirect(url_for("main.login"))
if user.get("role") not in roles:
return redirect(url_for("main.error"))
return f(*args, **kwargs)
return decorated
return decorator
+27
View File
@@ -0,0 +1,27 @@
"""
Konfiguration der Flask-Applikation.
Liest alle Einstellungen aus Umgebungsvariablen (.env) nach dem 12-Factor-App-Prinzip.
ZITADEL_SERVER_METADATA_URL ist der von Authlib erwartete Config-Key für die
OIDC Discovery URL.
:author: <Vorname Nachname>
:date: 2026-06-17
:version: 0.1
"""
import os
from dotenv import load_dotenv
load_dotenv()
class Config:
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-secret-change-me")
SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL", "sqlite:///app.db")
SQLALCHEMY_TRACK_MODIFICATIONS = False
ZITADEL_CLIENT_ID = os.environ.get("ZITADEL_CLIENT_ID")
ZITADEL_CLIENT_SECRET = os.environ.get("ZITADEL_CLIENT_SECRET")
# Authlib liest ZITADEL_SERVER_METADATA_URL automatisch aus app.config
ZITADEL_SERVER_METADATA_URL = os.environ.get("ZITADEL_DISCOVERY_URL")
+11
View File
@@ -0,0 +1,11 @@
"""
Datenbankinitialisierung mit Flask-SQLAlchemy.
:author: <Vorname Nachname>
:date: 2026-06-17
:version: 0.1
"""
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()
+58
View File
@@ -0,0 +1,58 @@
"""
SQLAlchemy-Datenmodelle für lokale RBAC- und Projektzuweisung.
Alle Nutzeridentitäten stammen aus Zitadel (``zitadel_user_id`` = OIDC ``sub``-Claim).
Die lokalen Tabellen regeln nur die Autorisierung – nie die Authentifizierung.
:author: <Vorname Nachname>
:date: 2026-06-17
:version: 0.1
"""
from datetime import datetime
from .database import db
class User(db.Model):
__tablename__ = "users"
zitadel_user_id = db.Column(db.String(128), primary_key=True)
email = db.Column(db.String(256), nullable=False)
global_role = db.Column(db.String(64), nullable=False)
class Client(db.Model):
__tablename__ = "clients"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(256), nullable=False)
projects = db.relationship("Project", back_populates="client")
class Project(db.Model):
__tablename__ = "projects"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(256), nullable=False)
client_id = db.Column(db.Integer, db.ForeignKey("clients.id"), nullable=False)
client = db.relationship("Client", back_populates="projects")
notes = db.relationship("Note", back_populates="project", order_by="Note.created_at.desc()")
class ProjectMitarbeiterMapping(db.Model):
__tablename__ = "project_mitarbeiter"
user_id = db.Column(db.String(128), db.ForeignKey("users.zitadel_user_id"), primary_key=True)
project_id = db.Column(db.Integer, db.ForeignKey("projects.id"), primary_key=True)
class ProjectKundenMapping(db.Model):
__tablename__ = "project_kunden"
user_id = db.Column(db.String(128), db.ForeignKey("users.zitadel_user_id"), primary_key=True)
project_id = db.Column(db.Integer, db.ForeignKey("projects.id"), primary_key=True)
class Note(db.Model):
__tablename__ = "notes"
id = db.Column(db.Integer, primary_key=True)
text = db.Column(db.Text, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
user_id = db.Column(db.String(128), db.ForeignKey("users.zitadel_user_id"), nullable=False)
project_id = db.Column(db.Integer, db.ForeignKey("projects.id"), nullable=False)
project = db.relationship("Project", back_populates="notes")
+184
View File
@@ -0,0 +1,184 @@
"""
Flask-Routen und Routing-Guards der OIDC-Showcase-App.
Alle geschützten Routen prüfen (1) den Zitadel-Claim aus der Session und
(2) die lokale DB-Zuordnung. Jede Verletzung leitet auf /error um.
:author: <Vorname Nachname>
:date: 2026-06-17
:version: 0.1
"""
import logging
from urllib.parse import urlencode
from flask import Blueprint, render_template, session, redirect, url_for
from .auth import oauth, login_required, role_required, get_current_user
from .database import db
from .models import (
User, Client, Project,
ProjectMitarbeiterMapping, ProjectKundenMapping, Note,
)
logger = logging.getLogger(__name__)
main = Blueprint("main", __name__)
# ---------------------------------------------------------------------------
# Öffentliche Routen
# ---------------------------------------------------------------------------
@main.route("/login")
def login():
redirect_uri = url_for("main.auth_callback", _external=True)
return oauth.zitadel.authorize_redirect(redirect_uri)
@main.route("/auth/callback")
def auth_callback():
token = oauth.zitadel.authorize_access_token()
# Authlib parst den ID-Token und legt die Claims unter token["userinfo"] ab.
# Zitadel muss "Assert Roles on Authentication" aktiviert haben (ZITADEL-SETUP.md).
userinfo = token.get("userinfo") or {}
sub = userinfo.get("sub")
email = userinfo.get("email", "")
# Zitadel-Roles-Claim: {"Admin": {"<org-id>": "<org-name>"}, ...}
roles_claim = userinfo.get("urn:zitadel:iam:org:project:roles", {})
logger.info(
"OIDC Login – sub: %s | email: %s | roles: %s | full_userinfo: %s",
sub, email, list(roles_claim.keys()), dict(userinfo),
)
if not roles_claim:
logger.warning("Kein Roles-Claim im Token für sub=%s – Zugriff verweigert.", sub)
return redirect(url_for("main.error"))
role = next(iter(roles_claim.keys()))
user = db.session.get(User, sub)
if user:
user.email = email
user.global_role = role
else:
user = User(zitadel_user_id=sub, email=email, global_role=role)
db.session.add(user)
db.session.commit()
session["user"] = {"sub": sub, "email": email, "role": role}
session["id_token"] = token.get("id_token")
return redirect(url_for("main.index"))
@main.route("/logout")
def logout():
id_token = session.pop("id_token", None)
session.clear()
try:
metadata = oauth.zitadel.load_server_metadata()
end_session_endpoint = metadata.get("end_session_endpoint")
if end_session_endpoint:
params = {"post_logout_redirect_uri": url_for("main.index", _external=True)}
if id_token:
params["id_token_hint"] = id_token
return redirect(f"{end_session_endpoint}?{urlencode(params)}")
except Exception as exc:
logger.error("Logout: Fehler beim Laden der Server-Metadaten: %s", exc)
return redirect(url_for("main.index"))
@main.route("/error")
def error():
return render_template("login_error.html"), 403
# ---------------------------------------------------------------------------
# Geschützte Routen
# ---------------------------------------------------------------------------
@main.route("/")
@login_required
def index():
user = get_current_user()
role = user.get("role")
sub = user.get("sub")
accessible_projects = []
forbidden_project = None
if role == "Mitarbeiter":
mappings = ProjectMitarbeiterMapping.query.filter_by(user_id=sub).all()
accessible_ids = {m.project_id for m in mappings}
accessible_projects = Project.query.filter(Project.id.in_(accessible_ids)).all()
forbidden_project = Project.query.filter(Project.id.notin_(accessible_ids)).first()
elif role == "Kunde":
mappings = ProjectKundenMapping.query.filter_by(user_id=sub).all()
accessible_ids = {m.project_id for m in mappings}
accessible_projects = Project.query.filter(Project.id.in_(accessible_ids)).all()
forbidden_project = Project.query.filter(Project.id.notin_(accessible_ids)).first()
return render_template(
"index.html",
user=user,
projects=accessible_projects,
forbidden_project=forbidden_project,
)
@main.route("/admin")
@role_required("Admin")
def admin():
user = get_current_user()
return render_template(
"admin.html",
user=user,
clients=Client.query.all(),
projects=Project.query.all(),
users=User.query.all(),
)
@main.route("/mitarbeiter")
@role_required("Mitarbeiter")
def mitarbeiter():
user = get_current_user()
sub = user["sub"]
mappings = ProjectMitarbeiterMapping.query.filter_by(user_id=sub).all()
projects = Project.query.filter(
Project.id.in_({m.project_id for m in mappings})
).all()
return render_template("mitarbeiter.html", user=user, projects=projects)
@main.route("/projekt/<int:project_id>")
@login_required
def projekt(project_id):
user = get_current_user()
role = user.get("role")
sub = user.get("sub")
project = db.session.get(Project, project_id)
if not project:
return redirect(url_for("main.error"))
if role == "Admin":
pass
elif role == "Mitarbeiter":
if not ProjectMitarbeiterMapping.query.filter_by(user_id=sub, project_id=project_id).first():
return redirect(url_for("main.error"))
elif role == "Kunde":
if not ProjectKundenMapping.query.filter_by(user_id=sub, project_id=project_id).first():
return redirect(url_for("main.error"))
else:
return redirect(url_for("main.error"))
notes = Note.query.filter_by(project_id=project_id).order_by(Note.created_at.desc()).all()
return render_template("projekt.html", user=user, project=project, notes=notes)
+90
View File
@@ -0,0 +1,90 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f4f6f9;
color: #1a1a2e;
line-height: 1.6;
}
/* Navigation */
nav {
display: flex;
justify-content: space-between;
align-items: center;
padding: .75rem 2rem;
background: #1a1a2e;
color: #fff;
}
.nav-brand { font-weight: 600; letter-spacing: .03em; }
.nav-user { display: flex; align-items: center; gap: 1.25rem; font-size: .9rem; }
nav a { color: #a8d8ea; text-decoration: none; }
nav a:hover { text-decoration: underline; }
/* Layout */
main { max-width: 960px; margin: 2rem auto; padding: 0 1.25rem; }
h1 { margin-bottom: .25rem; }
h2 { margin: 1.25rem 0 .6rem; font-size: 1.1rem; }
.subtitle { color: #555; margin-bottom: 1.75rem; font-size: .95rem; }
/* Card grid */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 1.25rem;
margin-top: 1.5rem;
}
.card {
background: #fff;
border: 1px solid #dde1e7;
border-radius: 8px;
padding: 1.25rem;
}
.card--warning { border-color: #e5a100; background: #fffbe6; }
/* Buttons */
.btn {
display: inline-block;
margin-top: .75rem;
padding: .45rem 1.2rem;
background: #1a1a2e;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
text-decoration: none;
font-size: .9rem;
transition: background .15s;
}
.btn:hover { background: #2d2d52; }
.btn--danger { background: #c0392b; }
.btn--danger:hover { background: #a93226; }
.btn:disabled { background: #aaa; cursor: not-allowed; }
/* Tables */
table { width: 100%; border-collapse: collapse; margin: .5rem 0 1rem; font-size: .9rem; }
th, td { padding: .5rem .75rem; text-align: left; border-bottom: 1px solid #e8eaed; }
th { background: #f0f2f5; font-weight: 600; }
tr:last-child td { border-bottom: none; }
code { font-size: .82rem; background: #f0f2f5; padding: .1rem .3rem; border-radius: 3px; }
/* Project list */
.project-list { list-style: none; margin: .5rem 0 1rem; }
.project-list li { padding: .45rem 0; border-bottom: 1px solid #e8eaed; }
.project-list li:last-child { border-bottom: none; }
.client-name { color: #777; font-size: .88rem; }
/* Error page */
.error-box {
text-align: center;
padding: 3rem 2rem;
background: #fff;
border-radius: 8px;
border: 1px solid #dde1e7;
max-width: 480px;
margin: 4rem auto;
}
.error-box h1 { color: #c0392b; margin-bottom: 1rem; }
.error-box p { color: #555; margin-bottom: 1.75rem; }
section { margin-bottom: 2rem; }
+71
View File
@@ -0,0 +1,71 @@
{#
Datei: templates/admin.html
Autor: <Vorname Nachname>
Datum: 2026-06-17
Zweck: Admin-Ansicht mit Übersicht aller Clients, Projekte und Nutzer.
Variablen: user, clients (list), projects (list), users (list)
#}
{% extends "base.html" %}
{% block title %}Admin Cockpit{% endblock %}
{% block content %}
<h1>Admin Cockpit</h1>
<section>
<h2>Clients</h2>
<table>
<thead><tr><th>ID</th><th>Name</th></tr></thead>
<tbody>
{% for c in clients %}
<tr><td>{{ c.id }}</td><td>{{ c.name }}</td></tr>
{% else %}
<tr><td colspan="2">Keine Clients vorhanden.</td></tr>
{% endfor %}
</tbody>
</table>
</section>
<section>
<h2>Projekte</h2>
<table>
<thead><tr><th>ID</th><th>Name</th><th>Client</th></tr></thead>
<tbody>
{% for p in projects %}
<tr>
<td>{{ p.id }}</td>
<td><a href="{{ url_for('main.projekt', project_id=p.id) }}">{{ p.name }}</a></td>
<td>{{ p.client.name }}</td>
</tr>
{% else %}
<tr><td colspan="3">Keine Projekte vorhanden.</td></tr>
{% endfor %}
</tbody>
</table>
</section>
<section>
<h2>Nutzer</h2>
<table>
<thead><tr><th>Zitadel-ID (sub)</th><th>E-Mail</th><th>Rolle</th></tr></thead>
<tbody>
{% for u in users %}
<tr>
<td><code>{{ u.zitadel_user_id }}</code></td>
<td>{{ u.email }}</td>
<td>{{ u.global_role }}</td>
</tr>
{% else %}
<tr><td colspan="3">Keine Nutzer in der DB.</td></tr>
{% endfor %}
</tbody>
</table>
</section>
<section>
<h2>Aktionen</h2>
<button class="btn" disabled title="Noch nicht implementiert">Gesamtexport (CSV)</button>
</section>
<p><a href="{{ url_for('main.index') }}">&larr; Zurück zum Dashboard</a></p>
{% endblock %}
+29
View File
@@ -0,0 +1,29 @@
{#
Datei: templates/base.html
Autor: <Vorname Nachname>
Datum: 2026-06-17
Zweck: Basis-Layout für alle Seiten. Erwartet: session.user (optional).
#}
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}OIDC Showcase{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
<nav>
<span class="nav-brand">OIDC Showcase</span>
{% if session.user %}
<div class="nav-user">
<span>{{ session.user.email }} &nbsp;·&nbsp; <em>{{ session.user.role }}</em></span>
<a href="{{ url_for('main.logout') }}">Abmelden</a>
</div>
{% endif %}
</nav>
<main>
{% block content %}{% endblock %}
</main>
</body>
</html>
+56
View File
@@ -0,0 +1,56 @@
{#
Datei: templates/index.html
Autor: <Vorname Nachname>
Datum: 2026-06-17
Zweck: Dashboard mit Test-Matrix. Zeigt Kacheln je nach Rolle des eingeloggten Nutzers.
Variablen: user (dict), projects (list[Project]), forbidden_project (Project|None)
#}
{% extends "base.html" %}
{% block title %}Dashboard{% endblock %}
{% block content %}
<h1>Dashboard</h1>
<p class="subtitle">Angemeldet als <strong>{{ user.email }}</strong> &nbsp;·&nbsp; Rolle: <em>{{ user.role }}</em></p>
<div class="card-grid">
{% if user.role == "Admin" %}
<div class="card">
<h2>Admin Cockpit</h2>
<p>Vollzugriff auf alle Clients, Projekte und Nutzer.</p>
<a href="{{ url_for('main.admin') }}" class="btn">Öffnen</a>
</div>
{% endif %}
{% if user.role == "Mitarbeiter" %}
<div class="card">
<h2>Mitarbeiter Cockpit</h2>
<p>Deine zugewiesenen Projekte im Überblick.</p>
<a href="{{ url_for('main.mitarbeiter') }}" class="btn">Meine Projekte</a>
</div>
{% endif %}
{% if projects %}
<div class="card">
<h2>Kundenprojekte</h2>
<ul class="project-list">
{% for p in projects %}
<li><a href="{{ url_for('main.projekt', project_id=p.id) }}">{{ p.name }}</a></li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if forbidden_project %}
<div class="card card--warning">
<h2>&#9888; Test: Unbefugter Zugriff</h2>
<p>Simuliert einen Angriff: Zugriff auf ein nicht zugewiesenes Projekt.</p>
<a href="{{ url_for('main.projekt', project_id=forbidden_project.id) }}" class="btn btn--danger">
Zugriff auf „{{ forbidden_project.name }}" erzwingen
</a>
</div>
{% endif %}
</div>
{% endblock %}
+17
View File
@@ -0,0 +1,17 @@
{#
Datei: templates/login_error.html
Autor: <Vorname Nachname>
Datum: 2026-06-17
Zweck: Fehlerseite bei verweigertem Zugriff. Keine Variablen erwartet.
#}
{% extends "base.html" %}
{% block title %}Zugriff verweigert{% endblock %}
{% block content %}
<div class="error-box">
<h1>Zugriff verweigert</h1>
<p>Bitte besuch uns später wieder, du bist im Moment nicht autorisiert.</p>
<button class="btn btn--danger" onclick="history.back()">&#8592; Zurück</button>
</div>
{% endblock %}
+28
View File
@@ -0,0 +1,28 @@
{#
Datei: templates/mitarbeiter.html
Autor: <Vorname Nachname>
Datum: 2026-06-17
Zweck: Projektübersicht für Mitarbeiter (nur zugewiesene Projekte).
Variablen: user, projects (list[Project])
#}
{% extends "base.html" %}
{% block title %}Meine Projekte{% endblock %}
{% block content %}
<h1>Mitarbeiter Cockpit</h1>
<p>Deine zugewiesenen Projekte:</p>
<ul class="project-list">
{% for p in projects %}
<li>
<a href="{{ url_for('main.projekt', project_id=p.id) }}">{{ p.name }}</a>
<span class="client-name">– {{ p.client.name }}</span>
</li>
{% else %}
<li>Keine Projekte zugewiesen.</li>
{% endfor %}
</ul>
<p><a href="{{ url_for('main.index') }}">&larr; Zurück</a></p>
{% endblock %}
+40
View File
@@ -0,0 +1,40 @@
{#
Datei: templates/projekt.html
Autor: <Vorname Nachname>
Datum: 2026-06-17
Zweck: Projekt-Cockpit mit Notizen-Log. Zugriff nur für zugewiesene Nutzer.
Variablen: user, project (Project), notes (list[Note])
#}
{% extends "base.html" %}
{% block title %}{{ project.name }}{% endblock %}
{% block content %}
<h1>{{ project.name }}</h1>
<p class="subtitle">Client: {{ project.client.name }}</p>
<section>
<h2>Korrespondenz / Notizen</h2>
{% if notes %}
<table class="notes-table">
<thead>
<tr><th>Datum</th><th>Autor (sub)</th><th>Notiz</th></tr>
</thead>
<tbody>
{% for n in notes %}
<tr>
<td>{{ n.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
<td><code>{{ n.user_id }}</code></td>
<td>{{ n.text }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<button class="btn" disabled title="Noch nicht implementiert">Notizen exportieren</button>
{% else %}
<p>Keine Notizen für dieses Projekt vorhanden.</p>
{% endif %}
</section>
<p><a href="{{ url_for('main.index') }}">&larr; Zurück zum Dashboard</a></p>
{% endblock %}