Personalisierung per Einladung statt Aktivierungscode (ADR-0041, 24.10.2026)

Kunden-Admin trägt die Adresse ein (nur mit Merkmal), der Link (7 Tage,
einmalig) führt zu Anmeldename und Passwort; Stand-Seite per abgeleitetem
Token. Migration 0072: Stand eingeladen, Code-Spalten optional, laufende
Codes zurückgezogen. Code-Endpunkte entfernt; Passwort-vergessen-Kiosk
bleibt bis zur Umstellung der Rolle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-24 10:52:14 +02:00
1 parent 2f6dfd585b
commit bcfe411290
2 files changed
+204 -164

No files matched your search

+171 -164
View File
@@ -6161,18 +6161,17 @@ def health():
}
# --- Personalisierung (ADR-0041, Schritt 2) ----------------------------------
# --- Personalisierung (ADR-0041) ---------------------------------------------
#
# Der Kunden-Admin erzeugt in der Geräte-Info einen Aktivierungscode, am Gerät
# löst ihn das Formular der Kundenplattform ein (Kiosk im Konto
# "Personalisierung"), die Bestätigung kommt per Mail-Link. Die Kundenplattform
# hasht das Passwort, bevor es hierher kommt; Klartext erreicht anode nie.
# Angelegt wird das Konto erst in Schritt 3 vom Agent.
# Vereinfachter Ablauf (Ergänzung 24.10.2026): Der Kunden-Admin trägt in der
# Geräte-Info die Adresse des Benutzers ein, die Kundenplattform schickt einen
# Einladungslink. Über den Link wählt der Benutzer Anmeldename und Passwort;
# der Link belegt zugleich den Besitz der Adresse. Die Kundenplattform hasht
# das Passwort, bevor es hierher kommt; Klartext erreicht anode nie.
# Angelegt wird das Konto beim nächsten Start vom Agent.
PERSONALISIERUNG_CODE_ZEICHEN = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
PERSONALISIERUNG_CODE_GUELTIG = timedelta(days=7)
PERSONALISIERUNG_BESTAETIGUNG_FRIST = timedelta(hours=1)
PERSONALISIERUNG_LAUFEND = ("code_offen", "eingereicht", "bestaetigt")
PERSONALISIERUNG_EINLADUNG_GUELTIG = timedelta(days=7)
PERSONALISIERUNG_LAUFEND = ("eingeladen", "bestaetigt")
# Anmeldenamen: Kleinbuchstaben, Ziffern, Punkt, Binde- und Unterstrich,
# beginnend mit einem Buchstaben. Systemkonten prüft die Rolle am Gerät
@@ -6193,23 +6192,10 @@ DOMAIN_MUSTER = re.compile(r"^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.
EMAIL_MUSTER = re.compile(r"^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]{1,64}@([A-Za-z0-9.-]{1,253})$")
def personalisierung_code_normalisieren(code: str) -> str:
return re.sub(r"[\s-]", "", code or "").upper()
def personalisierung_code_hash(code: str) -> str:
return hashlib.sha256(personalisierung_code_normalisieren(code).encode()).hexdigest()
def personalisierung_token_hash(token: str) -> str:
return hashlib.sha256((token or "").encode()).hexdigest()
def personalisierung_code_erzeugen() -> str:
zeichen = "".join(secrets.choice(PERSONALISIERUNG_CODE_ZEICHEN) for _ in range(12))
return f"{zeichen[0:4]}-{zeichen[4:8]}-{zeichen[8:12]}"
def anmeldename_fehler(anmeldename: str) -> str | None:
if not PERSONALISIERUNG_ANMELDENAME_MUSTER.match(anmeldename):
return (
@@ -6221,6 +6207,17 @@ def anmeldename_fehler(anmeldename: str) -> str | None:
return None
UMLAUTE = str.maketrans({"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss"})
def anmeldename_vorschlag(email: str) -> str:
"""Vorschlag aus dem Teil vor dem @ (m.hahn@schule.de -> m.hahn), sonst leer."""
lokal = (email or "").split("@", 1)[0].lower().translate(UMLAUTE)
name = re.sub(r"^[^a-z]+", "", re.sub(r"[^a-z0-9._-]", "", lokal))[:31].rstrip("._-")
return name if anmeldename_fehler(name) is None else ""
def email_domaenen_normalisieren(domaenen: list[str]) -> tuple[list[str], str | None]:
ergebnis = []
for eintrag in domaenen:
@@ -6235,15 +6232,13 @@ def email_domaenen_normalisieren(domaenen: list[str]) -> tuple[list[str], str |
def personalisierung_verfallene_markieren(cur) -> None:
# Lazy statt Zeitgeber: jeder Zugriff räumt zuerst auf. Verfallene
# Einreichungen verlieren Passwort-Hash und Adresse (nie bestätigt, also
# womöglich ein Tippfehler oder eine fremde Adresse).
# Lazy statt Zeitgeber: jeder Zugriff räumt zuerst auf. Die Adresse bleibt
# stehen, sie stammt vom Kunden-Admin (Migration 0072).
cur.execute(
"""
UPDATE personalisierungen
SET status = 'verfallen', passwort_hash = NULL, email = NULL, bestaetigung_token_hash = NULL
WHERE (status = 'code_offen' AND code_gueltig_bis < now())
OR (status = 'eingereicht' AND bestaetigung_bis < now())
SET status = 'verfallen', passwort_hash = NULL, bestaetigung_token_hash = NULL
WHERE status = 'eingeladen' AND bestaetigung_bis < now()
"""
)
@@ -6251,7 +6246,7 @@ def personalisierung_verfallene_markieren(cur) -> None:
# Merkmal "Personalisierung möglich": der Check-in sagt der Rolle in den
# Optionen, wofür das Konto "Personalisierung" am Anmeldebildschirm gerade da
# ist (ADR-0041, Ergänzungen 18.10.2026):
# personalisieren - kein persönliches Konto oder ein Code läuft
# personalisieren - kein persönliches Konto oder ein Vorgang läuft
# passwort - ein Konto ist eingerichtet: Eintrag "Passwort vergessen?"
# aus - nur gesperrte Konten: kein Eintrag
PERSONALISIERUNG_MERKMAL = "personalisierung"
@@ -6482,9 +6477,9 @@ def fetch_personalisierungen_organisation(organization_id: str) -> dict[str, dic
personalisierung_verfallene_markieren(cur)
cur.execute(
f"SELECT {PERSONALISIERUNG_SPALTEN} FROM personalisierungen p "
"WHERE p.organization_id = %s AND p.status IN ('code_offen', 'eingereicht', 'bestaetigt', 'angelegt') "
"WHERE p.organization_id = %s AND (p.status = ANY(%s) OR p.status = 'angelegt') "
"ORDER BY p.erstellt_am",
(organization_id,),
(organization_id, list(PERSONALISIERUNG_LAUFEND)),
)
rows = cur.fetchall()
@@ -6499,25 +6494,67 @@ def fetch_personalisierungen_organisation(organization_id: str) -> dict[str, dic
return ergebnis
def create_personalisierung_code(device_id: str, bestehenden_behalten: bool, erstellt_von: str | None):
code = personalisierung_code_erzeugen()
def personalisierung_merkmal_aktiv(device_id: str) -> bool:
"""Ob das Gerät das Merkmal "Personalisierung möglich" hat (Workspace, OE, Gruppe)."""
# Die Ansicht der Geräte-Info, nicht fetch_auftragskatalog_state(): Der
# Check-in-Weg reichert Optionen an und erzeugt dabei etwa Veyon-Schlüssel.
return any(
e["merkmal"] == PERSONALISIERUNG_MERKMAL and e["state"] == "present"
for e in fetch_auftragskatalog_listing(device_id) or []
)
# Die Stand-Seite nach dem Speichern weist sich mit der ID und diesem Token
# aus. Abgeleitet statt gespeichert, wie das Kiosk-Token; es zeigt nur den
# Stand dieses einen Vorgangs.
def personalisierung_stand_token(personalisierung_id) -> str:
schluessel = (ENCRYPTION_KEY or "").encode()
if not schluessel:
return ""
return hmac.new(schluessel, f"personalisierung-stand:{personalisierung_id}".encode(), hashlib.sha256).hexdigest()[:32]
def create_personalisierung_einladung(device_id: str, email: str, bestehenden_behalten: bool, erstellt_von: str | None):
"""
Gibt (ergebnis, fehler) zurück. Das Ergebnis enthält das Token für den
Link in der Mail, die kundenplattform verschickt.
"""
if not personalisierung_merkmal_aktiv(device_id):
return None, "Das Gerät hat das Merkmal „Personalisierung möglich“ nicht."
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute("SELECT organization_id FROM devices WHERE id = %s", (device_id,))
cur.execute(
"""
SELECT d.organization_id, d.hostname, o.name, o.email_domaenen
FROM devices d JOIN organizations o ON o.id = d.organization_id
WHERE d.id = %s
""",
(device_id,),
)
row = cur.fetchone()
if row is None:
return None
return None, "Das Gerät wurde nicht gefunden."
organization_id, hostname, org_name, domaenen = row
treffer = EMAIL_MUSTER.match(email)
if not treffer:
return None, "Die E-Mail-Adresse ist ungültig."
if domaenen and treffer.group(1).lower() not in domaenen:
return None, "Erlaubt sind nur Adressen der Einrichtung (" + ", ".join("@" + d for d in domaenen) + ")."
personalisierung_verfallene_markieren(cur)
# Ein neuer Code ersetzt einen laufenden Vorgang.
# Eine neue Einladung ersetzt einen laufenden Vorgang; der alte
# Link wird damit ungültig.
cur.execute(
"""
UPDATE personalisierungen
SET status = 'zurueckgezogen', passwort_hash = NULL, bestaetigung_token_hash = NULL
WHERE device_id = %s AND status IN ('code_offen', 'eingereicht', 'bestaetigt')
WHERE device_id = %s AND status = ANY(%s)
""",
(device_id,),
(device_id, list(PERSONALISIERUNG_LAUFEND)),
)
# Ohne "Bestehenden Zugang behalten" wird das bisherige Konto
# sofort gesperrt; entfernt wird es erst, wenn das neue angelegt
@@ -6527,23 +6564,32 @@ def create_personalisierung_code(device_id: str, bestehenden_behalten: bool, ers
"UPDATE personalisierungen SET gesperrt = TRUE WHERE device_id = %s AND status = 'angelegt'",
(device_id,),
)
token = secrets.token_urlsafe(32)
personalisierung_id = uuid4()
cur.execute(
"""
INSERT INTO personalisierungen
(id, device_id, organization_id, status, code_hash, code_gueltig_bis,
bestehenden_behalten, erstellt_von)
VALUES (%s, %s, %s, 'code_offen', %s, %s, %s, %s)
RETURNING code_gueltig_bis
(id, device_id, organization_id, status, email, bestaetigung_token_hash,
bestaetigung_bis, bestehenden_behalten, erstellt_von)
VALUES (%s, %s, %s, 'eingeladen', %s, %s, %s, %s, %s)
RETURNING bestaetigung_bis
""",
(
uuid4(), device_id, row[0], personalisierung_code_hash(code),
datetime.now(timezone.utc) + PERSONALISIERUNG_CODE_GUELTIG,
personalisierung_id, device_id, organization_id, email, personalisierung_token_hash(token),
datetime.now(timezone.utc) + PERSONALISIERUNG_EINLADUNG_GUELTIG,
bestehenden_behalten, erstellt_von,
),
)
gueltig_bis = cur.fetchone()[0]
return {"code": code, "code_gueltig_bis": gueltig_bis.isoformat()}
return {
"personalisierung_id": str(personalisierung_id),
"token": token,
"gueltig_bis": gueltig_bis.isoformat(),
"email": email,
"hostname": hostname,
"organisation": org_name,
}, None
def withdraw_personalisierung(device_id: str) -> bool:
@@ -6554,70 +6600,52 @@ def withdraw_personalisierung(device_id: str) -> bool:
"""
UPDATE personalisierungen
SET status = 'zurueckgezogen', passwort_hash = NULL, bestaetigung_token_hash = NULL
WHERE device_id = %s AND status IN ('code_offen', 'eingereicht', 'bestaetigt')
WHERE device_id = %s AND status = ANY(%s)
""",
(device_id,),
(device_id, list(PERSONALISIERUNG_LAUFEND)),
)
return cur.rowcount > 0
def check_personalisierung_code(code: str):
"""
Erster Schritt des Formulars: zeigt Einrichtung, Gerät und erlaubte
Domänen an, ohne den Code zu verbrauchen.
"""
def _einladung(cur, token: str, sperren: bool = False):
personalisierung_verfallene_markieren(cur)
cur.execute(
"""
SELECT p.id, p.device_id, p.email, d.hostname, o.name
FROM personalisierungen p
JOIN devices d ON d.id = p.device_id
JOIN organizations o ON o.id = p.organization_id
WHERE p.bestaetigung_token_hash = %s AND p.status = 'eingeladen'
""" + (" FOR UPDATE OF p" if sperren else ""),
(personalisierung_token_hash(token),),
)
return cur.fetchone()
def einladung_pruefen(token: str) -> dict | None:
"""Seite hinter dem Link: zeigt Einrichtung, Gerät und Namensvorschlag, verbraucht nichts."""
with get_database_connection() as conn:
with conn.cursor() as cur:
personalisierung_verfallene_markieren(cur)
cur.execute(
"""
SELECT o.name, o.email_domaenen, d.hostname
FROM personalisierungen p
JOIN organizations o ON o.id = p.organization_id
JOIN devices d ON d.id = p.device_id
WHERE p.code_hash = %s AND p.status = 'code_offen'
""",
(personalisierung_code_hash(code),),
)
row = cur.fetchone()
row = _einladung(cur, token)
if row is None:
return None
return {"organisation": row[0], "domaenen": list(row[1] or []), "hostname": row[2]}
_pid, _device_id, email, hostname, org_name = row
return {"email": email, "hostname": hostname, "organisation": org_name, "vorschlag": anmeldename_vorschlag(email)}
def submit_personalisierung(code: str, email: str, anmeldename: str, passwort_hash: str):
def einladung_einloesen(token: str, anmeldename: str, passwort_hash: str):
"""
Gibt (ergebnis, fehler) zurück. Unbekannte, verbrauchte und abgelaufene
Codes ergeben dieselbe Meldung - sie verrät nicht, ob ein Code existiert.
Links ergeben dieselbe Meldung.
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
personalisierung_verfallene_markieren(cur)
cur.execute(
"""
SELECT p.id, p.device_id, o.name, o.email_domaenen, d.hostname
FROM personalisierungen p
JOIN organizations o ON o.id = p.organization_id
JOIN devices d ON d.id = p.device_id
WHERE p.code_hash = %s AND p.status = 'code_offen'
FOR UPDATE OF p
""",
(personalisierung_code_hash(code),),
)
row = cur.fetchone()
row = _einladung(cur, token, sperren=True)
if row is None:
return None, "Der Aktivierungscode ist ungültig, schon verwendet oder abgelaufen."
personalisierung_id, device_id, org_name, domaenen, hostname = row
treffer = EMAIL_MUSTER.match(email)
if not treffer:
return None, "Die E-Mail-Adresse ist ungültig."
if domaenen and treffer.group(1).lower() not in domaenen:
return None, "Bitte eine Adresse deiner Einrichtung verwenden (" + ", ".join("@" + d for d in domaenen) + ")."
return None, "Der Link ist ungültig, schon verwendet oder abgelaufen."
personalisierung_id, device_id, _email, hostname, org_name = row
fehler = anmeldename_fehler(anmeldename)
if fehler:
@@ -6630,49 +6658,46 @@ def submit_personalisierung(code: str, email: str, anmeldename: str, passwort_ha
if cur.fetchone():
return None, "Diesen Anmeldenamen gibt es auf diesem Gerät schon."
token = secrets.token_urlsafe(32)
cur.execute(
"""
UPDATE personalisierungen
SET status = 'eingereicht', email = %s, anmeldename = %s, passwort_hash = %s,
bestaetigung_token_hash = %s, eingereicht_am = now(), bestaetigung_bis = %s
SET status = 'bestaetigt', anmeldename = %s, passwort_hash = %s, bestaetigung_token_hash = NULL,
eingereicht_am = now(), bestaetigt_am = now()
WHERE id = %s
RETURNING bestaetigung_bis
""",
(
email, anmeldename, passwort_hash, personalisierung_token_hash(token),
datetime.now(timezone.utc) + PERSONALISIERUNG_BESTAETIGUNG_FRIST, personalisierung_id,
),
(anmeldename, passwort_hash, personalisierung_id),
)
bestaetigung_bis = cur.fetchone()[0]
return {
"personalisierung_id": str(personalisierung_id),
"token": token,
"bestaetigung_bis": bestaetigung_bis.isoformat(),
"organisation": org_name,
"stand_token": personalisierung_stand_token(personalisierung_id),
"anmeldename": anmeldename,
"hostname": hostname,
"organisation": org_name,
}, None
def confirm_personalisierung(token: str):
def personalisierung_stand(personalisierung_id: str, token: str) -> dict | None:
erwartet = personalisierung_stand_token(personalisierung_id)
if not erwartet or not hmac.compare_digest(erwartet, (token or "").strip()):
return None
with get_database_connection() as conn:
with conn.cursor() as cur:
personalisierung_verfallene_markieren(cur)
cur.execute(
"""
UPDATE personalisierungen
SET status = 'bestaetigt', bestaetigt_am = now(), bestaetigung_token_hash = NULL
WHERE bestaetigung_token_hash = %s AND status = 'eingereicht'
RETURNING anmeldename, (SELECT hostname FROM devices WHERE id = device_id)
SELECT p.status, p.anmeldename, p.geraet_meldung, d.hostname, o.name
FROM personalisierungen p
JOIN devices d ON d.id = p.device_id
JOIN organizations o ON o.id = p.organization_id
WHERE p.id::text = %s
""",
(personalisierung_token_hash(token),),
(personalisierung_id,),
)
row = cur.fetchone()
if row is None:
return None
return {"anmeldename": row[0], "hostname": row[1]}
status, anmeldename, meldung, hostname, org_name = row
return {"status": status, "anmeldename": anmeldename, "geraet_meldung": meldung, "hostname": hostname, "organisation": org_name}
def set_email_domaenen(organization_id: str, domaenen: list[str]) -> bool:
@@ -6832,9 +6857,9 @@ def personalisierungen_beenden_bei_neuinstallation(cur, device_id) -> None:
UPDATE personalisierungen
SET status = 'beendet', beendet_am = now(), passwort_hash = NULL, bestaetigung_token_hash = NULL,
reset_passwort_hash = NULL
WHERE device_id = %s AND status IN ('code_offen', 'eingereicht', 'bestaetigt', 'angelegt')
WHERE device_id = %s AND (status = ANY(%s) OR status = 'angelegt')
""",
(device_id,),
(device_id, list(PERSONALISIERUNG_LAUFEND)),
)
@@ -6862,18 +6887,19 @@ class SetGesperrtRequest(BaseModel):
class CreatePersonalisierungRequest(BaseModel):
email: str
bestehenden_behalten: bool = False
erstellt_von: str | None = None
class SubmitPersonalisierungRequest(BaseModel):
code: str
email: str
anmeldename: str
passwort_hash: str
class EinladungRequest(BaseModel):
token: str
anmeldename: str | None = None
passwort_hash: str | None = None
class ConfirmPersonalisierungRequest(BaseModel):
class StandRequest(BaseModel):
id: str
token: str
@@ -6900,7 +6926,7 @@ def get_personalisierung_geraet(device_id: str, authorization: str | None = Head
@app.post("/api/v1/devices/{device_id}/personalisierung")
def post_personalisierung_code(
def post_personalisierung_einladung(
device_id: str,
payload: CreatePersonalisierungRequest,
authorization: str | None = Header(default=None),
@@ -6908,9 +6934,11 @@ def post_personalisierung_code(
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
ergebnis = create_personalisierung_code(device_id, payload.bestehenden_behalten, payload.erstellt_von)
if ergebnis is None:
return {"success": False, "error": "device_not_found", "message": "Das Gerät wurde nicht gefunden."}
ergebnis, fehler = create_personalisierung_einladung(
device_id, payload.email.strip(), payload.bestehenden_behalten, payload.erstellt_von
)
if fehler:
return {"success": False, "error": "abgelehnt", "message": fehler}
return {"success": True, **ergebnis}
@@ -6988,42 +7016,36 @@ def agent_persoenliche_konten(
return {"success": True}
class CheckPersonalisierungRequest(BaseModel):
code: str
@app.post("/api/v1/personalisierung/einladung")
def post_personalisierung_einladung_link(payload: EinladungRequest, authorization: str | None = Header(default=None)):
"""Ohne passwort_hash nur prüfen (Seite hinter dem Link), mit Name und Hash einlösen."""
@app.post("/api/v1/personalisierung/pruefen")
def post_personalisierung_pruefen(
payload: CheckPersonalisierungRequest,
authorization: str | None = Header(default=None),
):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
ergebnis = check_personalisierung_code(payload.code)
if ergebnis is None:
return {"success": False, "error": "abgelehnt", "message": "Der Aktivierungscode ist ungültig, schon verwendet oder abgelaufen."}
return {"success": True, **ergebnis}
@app.post("/api/v1/personalisierung/einreichen")
def post_personalisierung_einreichen(
payload: SubmitPersonalisierungRequest,
authorization: str | None = Header(default=None),
):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
if payload.passwort_hash is None:
ergebnis = einladung_pruefen(payload.token)
if ergebnis is None:
return {"success": False, "error": "ungueltig", "message": "Der Link ist ungültig, schon verwendet oder abgelaufen."}
return {"success": True, **ergebnis}
if not ROOT_PASSWORD_HASH_PATTERN.match(payload.passwort_hash):
return {"success": False, "error": "invalid_request", "message": "Ungültiger Passwort-Hash."}
ergebnis, fehler = submit_personalisierung(
payload.code, payload.email.strip(), payload.anmeldename.strip(), payload.passwort_hash
)
ergebnis, fehler = einladung_einloesen(payload.token, (payload.anmeldename or "").strip(), payload.passwort_hash)
if fehler:
return {"success": False, "error": "abgelehnt", "message": fehler}
return {"success": True, **ergebnis}
@app.post("/api/v1/personalisierung/stand")
def post_personalisierung_stand(payload: StandRequest, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
ergebnis = personalisierung_stand(payload.id, payload.token)
if ergebnis is None:
return {"success": False, "error": "ungueltig", "message": "Unbekannter Vorgang."}
return {"success": True, **ergebnis}
@@ -7067,21 +7089,6 @@ def post_neues_passwort(payload: NeuesPasswortRequest, authorization: str | None
return {"success": True, **ergebnis}
@app.post("/api/v1/personalisierung/bestaetigen")
def post_personalisierung_bestaetigen(
payload: ConfirmPersonalisierungRequest,
authorization: str | None = Header(default=None),
):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
ergebnis = confirm_personalisierung(payload.token)
if ergebnis is None:
return {"success": False, "error": "ungueltig", "message": "Der Link ist ungültig, schon verwendet oder abgelaufen."}
return {"success": True, **ergebnis}
@app.put("/api/v1/organizations/{organization_id}/email-domaenen")
def put_email_domaenen(
organization_id: str,
@@ -0,0 +1,33 @@
BEGIN;
-- Vereinfachte Personalisierung (ADR-0041, Ergänzung 24.10.2026): Der
-- Kunden-Admin trägt die Adresse des Benutzers beim Gerät ein, der Benutzer
-- wählt Anmeldename und Passwort über einen Mail-Link. Kein Aktivierungscode
-- und keine eigene Bestätigung mehr; der Link belegt den Besitz der Adresse.
--
-- Neuer Ablauf der Stände:
-- eingeladen -> bestaetigt -> angelegt
-- Der Link nutzt bestaetigung_token_hash/bestaetigung_bis (7 Tage). Die
-- Adresse bleibt beim Verfall erhalten: Sie stammt vom Kunden-Admin, nicht
-- von einer unbestätigten Eingabe.
ALTER TABLE personalisierungen DROP CONSTRAINT personalisierungen_status_check;
ALTER TABLE personalisierungen ADD CONSTRAINT personalisierungen_status_check CHECK (status IN (
'eingeladen', 'code_offen', 'eingereicht', 'bestaetigt', 'angelegt',
'zurueckgezogen', 'verfallen', 'fehlgeschlagen', 'beendet'
));
ALTER TABLE personalisierungen ALTER COLUMN code_hash DROP NOT NULL;
ALTER TABLE personalisierungen ALTER COLUMN code_gueltig_bis DROP NOT NULL;
-- Laufende Vorgänge aus dem Code-Weg gibt es nach dem Umbau nicht mehr.
UPDATE personalisierungen
SET status = 'zurueckgezogen', passwort_hash = NULL, bestaetigung_token_hash = NULL
WHERE status IN ('code_offen', 'eingereicht');
DROP INDEX personalisierungen_ein_offener_je_geraet;
CREATE UNIQUE INDEX personalisierungen_ein_offener_je_geraet
ON personalisierungen (device_id)
WHERE status IN ('eingeladen', 'code_offen', 'eingereicht', 'bestaetigt');
COMMIT;