py app init + zitadel doku
This commit is contained in:
1 parent
5b649f96f2
commit
5a0260e4fd
23 files changed
+1071
-1
No files matched your search
@@ -0,0 +1,7 @@
|
|||||||
|
FLASK_APP=app
|
||||||
|
FLASK_ENV=development
|
||||||
|
SECRET_KEY=replace-with-a-secure-random-key-in-prod
|
||||||
|
ZITADEL_CLIENT_ID=your-zitadel-client-id
|
||||||
|
ZITADEL_CLIENT_SECRET=your-zitadel-client-secret
|
||||||
|
ZITADEL_DISCOVERY_URL=https://id.vmlx.de/.well-known/openid-configuration
|
||||||
|
DATABASE_URL=sqlite:///app.db
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.env
|
||||||
|
instance/
|
||||||
|
*.db
|
||||||
|
*.sqlite3
|
||||||
|
.DS_Store
|
||||||
|
docs/_build/
|
||||||
|
dist/
|
||||||
|
*.egg-info/
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN addgroup --system app && adduser --system --group app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN chown -R app:app /app
|
||||||
|
USER app
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "wsgi:app"]
|
||||||
@@ -1 +1,66 @@
|
|||||||
## oidc test
|
# OIDC Showcase – Flask + Zitadel RBAC
|
||||||
|
|
||||||
|
Proof-of-Concept-Webapplikation zur Demonstration von feingranularem, dynamischem
|
||||||
|
Role-Based Access Control (RBAC) und Multi-Tenancy mit **Zitadel** als externem
|
||||||
|
Identity Provider (IdP) über OIDC.
|
||||||
|
|
||||||
|
**Ziel:** Authentifizierung vollständig an Zitadel delegieren. Autorisierung,
|
||||||
|
Projektzuweisung und Datentrennung lokal über SQLite verwalten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technologie-Stack
|
||||||
|
|
||||||
|
| Komponente | Technologie |
|
||||||
|
|---|---|
|
||||||
|
| Backend | Python Flask 2.x |
|
||||||
|
| OIDC-Client | Authlib |
|
||||||
|
| Datenbank | SQLite via SQLAlchemy |
|
||||||
|
| IdP | Zitadel (https://id.vmlx.de) |
|
||||||
|
| Produktion | Docker / gunicorn |
|
||||||
|
|
||||||
|
## Rollen
|
||||||
|
|
||||||
|
| Rolle | Zugriff |
|
||||||
|
|---|---|
|
||||||
|
| `Admin` | Vollzugriff auf alle Clients, Projekte und Nutzer |
|
||||||
|
| `Mitarbeiter` | Nur zugewiesene Projekte (N:M-Tabelle lokal) |
|
||||||
|
| `Kunde` | Nur zugewiesene Projekte des eigenen Clients |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lokale Entwicklung
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
cp .env.example .env # Zitadel-Credentials eintragen (siehe ZITADEL-SETUP.md)
|
||||||
|
flask seed-db # Schema initialisieren + Testdaten einspielen
|
||||||
|
flask run # App startet auf http://localhost:5000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker / Produktion
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --build
|
||||||
|
# App erreichbar auf http://localhost:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
Ein Basis-Image-Update löst durch `pull_policy: always` automatisch einen Rebuild aus.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Zitadel einrichten
|
||||||
|
|
||||||
|
Anleitung zum Anlegen von Testnutzern, Rollen und OIDC-Credentials:
|
||||||
|
→ **[ZITADEL-SETUP.md](ZITADEL-SETUP.md)**
|
||||||
|
|
||||||
|
## Dokumentation generieren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install sphinx
|
||||||
|
sphinx-apidoc -o docs/source/ app/
|
||||||
|
cd docs && make html
|
||||||
|
# Doku unter docs/_build/html/index.html
|
||||||
|
```
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# Zitadel-Einrichtung – Schritt-für-Schritt
|
||||||
|
|
||||||
|
Zitadel-Instanz: **https://id.vmlx.de**
|
||||||
|
|
||||||
|
Ziel dieser Anleitung: Ein OIDC-Projekt anlegen, drei Testrollen definieren,
|
||||||
|
drei Testnutzer erstellen, ihnen Rollen zuweisen und die Zitadel-User-IDs
|
||||||
|
(`sub`-Werte) für das DB-Seeding ermitteln.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schritt 1 – Zitadel-Projekt anlegen
|
||||||
|
|
||||||
|
1. Login unter https://id.vmlx.de
|
||||||
|
2. Linke Navigation → **Projekte** → **Neu anlegen**
|
||||||
|
3. Name: `oidc-showcase` → Speichern
|
||||||
|
4. Im Projekt-Detail unter **Einstellungen** folgende Optionen aktivieren:
|
||||||
|
- ☑ **Assert Roles on Authentication** (Rollen werden in Token geschrieben)
|
||||||
|
- ☑ **Check Authorization on Authentication** (Zugriff nur für zugewiesene Nutzer)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schritt 2 – Rollen anlegen
|
||||||
|
|
||||||
|
Im Projekt → Tab **Rollen** → drei Rollen anlegen:
|
||||||
|
|
||||||
|
| Anzeigename | Schlüssel (Key) |
|
||||||
|
|---|---|
|
||||||
|
| Admin | `Admin` |
|
||||||
|
| Mitarbeiter | `Mitarbeiter` |
|
||||||
|
| Kunde | `Kunde` |
|
||||||
|
|
||||||
|
> Der Schlüssel (Key) muss **exakt** so geschrieben sein – er erscheint so im Token-Claim.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schritt 3 – OIDC-Anwendung anlegen
|
||||||
|
|
||||||
|
Im Projekt → Tab **Anwendungen** → **Neu anlegen**
|
||||||
|
|
||||||
|
| Feld | Wert |
|
||||||
|
|---|---|
|
||||||
|
| Name | `oidc-showcase-web` |
|
||||||
|
| Typ | **Web** |
|
||||||
|
| Auth-Methode | **Code** (Authorization Code mit Client Secret) |
|
||||||
|
|
||||||
|
Nach dem Erstellen:
|
||||||
|
- **Client-ID** und **Client-Secret** notieren → kommen in die `.env`
|
||||||
|
- Unter **Redirect-URIs** eintragen:
|
||||||
|
- `http://localhost:5000/auth/callback` (Lokal)
|
||||||
|
- `https://<deine-domain>/auth/callback` (Prod, falls zutreffend)
|
||||||
|
- Unter **Post-Logout-Redirect-URIs** eintragen:
|
||||||
|
- `http://localhost:5000/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schritt 4 – Testnutzer anlegen
|
||||||
|
|
||||||
|
Navigation: **Nutzer** (oberste Ebene, nicht innerhalb eines Projekts) → **Neu anlegen**
|
||||||
|
|
||||||
|
Drei Nutzer erstellen:
|
||||||
|
|
||||||
|
| Name | E-Mail | Passwort | Zitadel-Rolle |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Admin Test | admin@test.local | (eigenes PW) | Admin |
|
||||||
|
| Mitarbeiter Test | ma@test.local | (eigenes PW) | Mitarbeiter |
|
||||||
|
| Kunde Test | kunde@test.local | (eigenes PW) | Kunde |
|
||||||
|
|
||||||
|
> Bei der Erstellung: **Passwort beim ersten Login ändern** deaktivieren empfohlen, damit Tests flüssig laufen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schritt 5 – Rollen an Nutzer vergeben
|
||||||
|
|
||||||
|
Für jeden Nutzer:
|
||||||
|
|
||||||
|
1. **Nutzer** → Nutzer auswählen → Tab **Autorisierungen**
|
||||||
|
2. → **Neu** → Projekt `oidc-showcase` wählen → entsprechende Rolle wählen → Speichern
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schritt 6 – User-IDs (sub) ermitteln
|
||||||
|
|
||||||
|
Die User-ID (Zitadel-`sub`) ist die interne Zitadel-Nutzer-ID, **nicht** die E-Mail.
|
||||||
|
|
||||||
|
**Methode A – Zitadel-Konsole:**
|
||||||
|
Nutzer auswählen → oben in der Detailansicht steht die **User-ID** (z. B. `236968807784841234`)
|
||||||
|
|
||||||
|
**Methode B – Aus dem App-Log (nach Schritt 7):**
|
||||||
|
Nach dem ersten Login loggt die App in stdout:
|
||||||
|
```
|
||||||
|
OIDC Login – sub: 236968807784841234 | email: admin@test.local | roles: ['Admin']
|
||||||
|
```
|
||||||
|
|
||||||
|
Alle drei Sub-Werte notieren für den nächsten Schritt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schritt 7 – .env befüllen und DB seeden
|
||||||
|
|
||||||
|
`.env` aus `.env.example` kopieren und befüllen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
```env
|
||||||
|
ZITADEL_CLIENT_ID=<Client-ID aus Schritt 3>
|
||||||
|
ZITADEL_CLIENT_SECRET=<Client-Secret aus Schritt 3>
|
||||||
|
ZITADEL_DISCOVERY_URL=https://id.vmlx.de/.well-known/openid-configuration
|
||||||
|
SECRET_KEY=<zufälliger langer String>
|
||||||
|
```
|
||||||
|
|
||||||
|
Dann in `app/__init__.py` die Platzhalter in `seed_db()` mit den echten Sub-Werten ersetzen:
|
||||||
|
|
||||||
|
```python
|
||||||
|
ADMIN_ID = "236968807784841234" # <-- echte Zitadel-User-ID
|
||||||
|
MITARBEITER_ID = "..."
|
||||||
|
KUNDE_ID = "..."
|
||||||
|
```
|
||||||
|
|
||||||
|
Anschließend seeden:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source venv/bin/activate
|
||||||
|
flask seed-db
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Discovery-URL verifizieren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl https://id.vmlx.de/.well-known/openid-configuration | python3 -m json.tool
|
||||||
|
```
|
||||||
|
|
||||||
|
Wenn die Antwort ein JSON mit `issuer`, `authorization_endpoint` usw. ist, ist die URL korrekt.
|
||||||
|
Sollte die URL nicht funktionieren, alternative probieren:
|
||||||
|
`https://id.vmlx.de/oauth/v2/.well-known/openid-configuration`
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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")
|
||||||
@@ -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()
|
||||||
@@ -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
@@ -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)
|
||||||
@@ -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; }
|
||||||
@@ -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') }}">← Zurück zum Dashboard</a></p>
|
||||||
|
{% endblock %}
|
||||||
@@ -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 }} · <em>{{ session.user.role }}</em></span>
|
||||||
|
<a href="{{ url_for('main.logout') }}">Abmelden</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</nav>
|
||||||
|
<main>
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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> · 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>⚠ 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 %}
|
||||||
@@ -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()">← Zurück</button>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -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') }}">← Zurück</a></p>
|
||||||
|
{% endblock %}
|
||||||
@@ -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') }}">← Zurück zum Dashboard</a></p>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
services:
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
# pull: true sorgt dafür, dass ein aktualisiertes Basis-Image einen Rebuild auslöst
|
||||||
|
pull_policy: always
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
volumes:
|
||||||
|
- db_data:/app/instance
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db_data:
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Sphinx-Konfiguration – wird nach jedem Meilenstein mit flask seed-db und
|
||||||
|
# sphinx-apidoc -o docs/source/ app/ aktualisiert.
|
||||||
|
|
||||||
|
project = "OIDC Flask Showcase"
|
||||||
|
author = "Bjoern Runden"
|
||||||
|
release = "0.1"
|
||||||
|
|
||||||
|
extensions = [
|
||||||
|
"sphinx.ext.autodoc",
|
||||||
|
"sphinx.ext.viewcode",
|
||||||
|
]
|
||||||
|
|
||||||
|
exclude_patterns = ["_build"]
|
||||||
|
html_theme = "alabaster"
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
OIDC Flask Showcase – Dokumentation
|
||||||
|
=====================================
|
||||||
|
|
||||||
|
.. toctree::
|
||||||
|
:maxdepth: 2
|
||||||
|
:caption: Module:
|
||||||
|
|
||||||
|
.. automodule:: app
|
||||||
|
:members:
|
||||||
|
|
||||||
|
.. automodule:: app.config
|
||||||
|
:members:
|
||||||
|
|
||||||
|
.. automodule:: app.models
|
||||||
|
:members:
|
||||||
|
|
||||||
|
.. automodule:: app.auth
|
||||||
|
:members:
|
||||||
|
|
||||||
|
.. automodule:: app.routes
|
||||||
|
:members:
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
Flask==2.3.3
|
||||||
|
Flask-SQLAlchemy==3.1.1
|
||||||
|
authlib==1.3.1
|
||||||
|
requests==2.31.0
|
||||||
|
python-dotenv==1.0.0
|
||||||
|
gunicorn==21.2.0
|
||||||
Reference in new issue
Block a user