mana-swift-core/Sources/ManaCore/Auth/AuthClient+Account.swift
Till JS 7103d72a85 feat(AuthClient): updateName(_:) — Anzeigenamen nativ aendern
PATCH /api/v1/me/profile (mana-auth, JWT; derselbe Endpoint wie Avatar-
Baukasten + Web-Settings). 1-80 Zeichen, client-validiert. Schliesst die Luecke,
dass der Anzeigename bisher nur bei Registrierung/Web setzbar war, nicht nativ.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:44:56 +02:00

773 lines
29 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Foundation
/// Account-Lifecycle-Methoden: Registrierung, Passwort-Reset,
/// Email-Verifikation, Account-Management.
///
/// Diese Methoden ergänzen die Login-/Refresh-Maschine in ``AuthClient``
/// um die Flows, die eine native App für eine vollständige Auth-Reise
/// braucht (vergleichbar mit `mana-auth-web` aber API-basiert statt
/// Cookie-basiert).
///
/// **Server-Endpoints (alle JSON, alle unter `/api/v1/auth/*`):**
/// - `POST /register` — Sign-Up + (je nach Config) email-verify-required
/// - `POST /forgot-password` — Reset-Mail anfordern (immer 200, kein Enum-Leak)
/// - `POST /reset-password` — neues PW mit Token aus Reset-Mail
/// - `POST /resend-verification` — Verify-Mail erneut senden
/// - `POST /change-email` — Email ändern (verschickt Verify-Mail an neue Adresse)
/// - `POST /change-password` — Passwort ändern (current + new)
/// - `DELETE /account` — Account löschen (App-Store-Pflicht 5.1.1(v))
///
/// **Wire-Konvention für authenticated Account-Calls:**
/// `change-email`, `change-password` und `DELETE /account` forwarden
/// die Original-Request-Headers an Better Auth (`auth.api.changeEmail`
/// etc.). Better Auth's `bearer`-Plugin (seit 2026-05-13 in mana-auth
/// aktiv) konvertiert `Authorization: Bearer <session-token>` in
/// einen synthetischen Session-Cookie. **Native-Apps senden hier den
/// Session-Token (`refreshToken`-Feldwert aus /login bzw. /refresh),
/// NICHT den JWT.** Der JWT bleibt für app-eigene Backends
/// (memoro-api, cardecky-api etc.) der richtige Header.
///
/// Trade-Off: Session-Token wird häufiger versendet als der reine
/// Refresh-Pfad. Bei TLS-only-Baseline akzeptabel; Compromise-Surface
/// nicht relevant größer als JWT-Leak.
public extension AuthClient {
// MARK: - Registrierung
/// Registriert einen neuen Account.
///
/// - Parameters:
/// - email: Email-Adresse, wird als Login-Identifier genutzt.
/// - password: Klartext-Passwort, Server hasht via Better Auth.
/// - name: Anzeige-Name. Wenn nil, nutzt der Server den Email-Local-Part.
/// - sourceAppUrl: Basis-URL für den Verify-Email-Klick-Redirect.
/// Per-App-spezifisch — z.B. `https://cardecky.mana.how/auth/verify`
/// für einen Universal-Link-Handler. Der Server hängt `?token=…` an.
///
/// Bei Erfolg ist mit Default-Server-Config (`requireEmailVerification: true`)
/// noch **kein Login** gemacht — der User muss erst die Verify-Mail
/// klicken. Ein anschließendes `signIn(...)` mit denselben Credentials
/// liefert dann `.emailNotVerified` bis der Klick passiert.
///
/// Wenn der Server in der Antwort doch Tokens schickt (kann passieren
/// wenn Email-Verifikation off ist), wird die Session persistiert und
/// der Status auf `.signedIn` gesetzt.
///
/// - Throws: ``AuthError/emailAlreadyRegistered``,
/// ``AuthError/weakPassword(message:)``, ``AuthError/signupLimitReached``,
/// ``AuthError/validation(message:)`` und Netzwerk-Cases.
func register(
email: String,
password: String,
name: String? = nil,
sourceAppUrl: URL? = nil
) async throws {
let trimmed = email.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, !password.isEmpty else {
throw AuthError.validation(message: "Email und Passwort sind erforderlich")
}
let body = RegisterRequest(
email: trimmed,
password: password,
name: name,
sourceAppUrl: sourceAppUrl?.absoluteString
)
let (data, http) = try await postJSON(path: "/api/v1/auth/register", body: body)
guard http.statusCode == 200 else {
throw AuthError.classify(
status: http.statusCode,
data: data,
retryAfterHeader: http.retryAfterSeconds
)
}
let decoded = try JSONDecoder().decode(RegisterResponse.self, from: data)
if let access = decoded.accessToken, let refresh = decoded.refreshToken {
try persistSession(email: trimmed, accessToken: access, refreshToken: refresh)
CoreLog.auth.info("Register successful — auto-signed-in")
} else {
CoreLog.auth.info("Register successful — awaiting email verification")
}
}
// MARK: - Passwort-Reset
/// Fordert eine Passwort-Reset-Email an.
///
/// Der Server antwortet **immer mit 200**, unabhängig davon ob die
/// Email existiert (bewusst, um User-Enumeration zu verhindern).
/// Die UI sollte daher generisch melden ("Wenn dein Account existiert,
/// ist eine Email unterwegs").
///
/// - Parameters:
/// - email: Email-Adresse des Accounts.
/// - resetUniversalLink: Universal-Link der App, der die
/// Reset-Seite öffnet. Z.B. `https://cardecky.mana.how/auth/reset`.
/// Der Server hängt `?token=…` an und nutzt diesen Link im
/// Mail-Template.
///
/// - Throws: nur Netzwerk-Fehler. Server-Fehler werden vom Server
/// geschluckt (200 trotzdem).
func forgotPassword(email: String, resetUniversalLink: URL) async throws {
let trimmed = email.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
throw AuthError.validation(message: "Email ist erforderlich")
}
let body = ForgotPasswordRequest(email: trimmed, redirectTo: resetUniversalLink.absoluteString)
let (data, http) = try await postJSON(path: "/api/v1/auth/forgot-password", body: body)
guard http.statusCode == 200 else {
throw AuthError.classify(
status: http.statusCode,
data: data,
retryAfterHeader: http.retryAfterSeconds
)
}
CoreLog.auth.info("Password reset requested")
}
/// Setzt das Passwort mit einem Reset-Token aus der Reset-Email.
///
/// - Parameters:
/// - token: Reset-Token aus der Email (Query-Param `?token=…`).
/// - newPassword: Neues Klartext-Passwort.
///
/// Nach Erfolg ist der User **nicht** automatisch eingeloggt — der
/// `signIn(...)`-Call muss separat passieren.
///
/// - Throws: ``AuthError/tokenExpired``, ``AuthError/tokenInvalid``,
/// ``AuthError/weakPassword(message:)`` und Netzwerk-Cases.
func resetPassword(token: String, newPassword: String) async throws {
guard !token.isEmpty, !newPassword.isEmpty else {
throw AuthError.validation(message: "Token und neues Passwort sind erforderlich")
}
let body = ResetPasswordRequest(token: token, newPassword: newPassword)
let (data, http) = try await postJSON(path: "/api/v1/auth/reset-password", body: body)
guard http.statusCode == 200 else {
throw AuthError.classify(
status: http.statusCode,
data: data,
retryAfterHeader: http.retryAfterSeconds
)
}
CoreLog.auth.info("Password reset completed")
}
// MARK: - Email-Verifikation
/// Sendet die Email-Verifikations-Mail erneut.
///
/// Aufzurufen wenn `signIn(...)` mit ``AuthError/emailNotVerified``
/// zurückkommt — die UI bietet einen "Mail erneut senden"-Button.
///
/// - Parameters:
/// - email: Email-Adresse des Accounts.
/// - sourceAppUrl: Universal-Link für den Verify-Klick. Gleiche
/// Semantik wie bei ``register(email:password:name:sourceAppUrl:)``.
///
/// - Throws: ``AuthError/notFound`` wenn die Email nicht existiert,
/// ``AuthError/rateLimited(retryAfter:)`` bei zu vielen Versuchen.
func resendVerification(email: String, sourceAppUrl: URL? = nil) async throws {
let trimmed = email.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
throw AuthError.validation(message: "Email ist erforderlich")
}
let body = ResendVerificationRequest(
email: trimmed,
sourceAppUrl: sourceAppUrl?.absoluteString
)
let (data, http) = try await postJSON(path: "/api/v1/auth/resend-verification", body: body)
guard http.statusCode == 200 else {
throw AuthError.classify(
status: http.statusCode,
data: data,
retryAfterHeader: http.retryAfterSeconds
)
}
CoreLog.auth.info("Verification email resent")
}
// MARK: - Account-Management (erfordert eingeloggte Session)
/// Ändert die Email des aktuell eingeloggten Accounts.
///
/// Der Server schickt eine Verifikations-Mail an die **neue** Adresse.
/// Bis der User klickt, bleibt die alte Email aktiv.
///
/// - Parameters:
/// - newEmail: Neue Email-Adresse.
/// - callbackUniversalLink: Universal-Link, der nach erfolgter
/// Verifikation geöffnet wird (z.B.
/// `https://cardecky.mana.how/auth/email-changed`).
///
/// - Important: Aktuell server-seitig nicht Bearer-fähig — siehe
/// Doc-Header dieser Datei.
func changeEmail(newEmail: String, callbackUniversalLink: URL? = nil) async throws {
let trimmed = newEmail.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
throw AuthError.validation(message: "Neue Email ist erforderlich")
}
let body = ChangeEmailRequest(
newEmail: trimmed,
callbackURL: callbackUniversalLink?.absoluteString
)
let (data, http) = try await postJSON(
path: "/api/v1/auth/change-email",
body: body,
authenticated: true
)
guard http.statusCode == 200 else {
throw AuthError.classify(
status: http.statusCode,
data: data,
retryAfterHeader: http.retryAfterSeconds
)
}
CoreLog.auth.info("Email change requested — verification email sent")
}
/// Ändert den **Anzeigenamen** des aktuell eingeloggten Accounts
/// (`PATCH /api/v1/me/profile` auf mana-auth, JWT-authentifiziert; derselbe
/// Endpoint, den der Avatar-Baukasten + die Web-Settings nutzen).
///
/// - Important: Der neue Name landet erst beim nächsten JWT-Mint im Token.
/// Aufrufer aktualisieren ihre In-Memory-Sicht selbst (z.B. via
/// ``getProfile()``).
///
/// - Parameter newName: 1–80 Zeichen (server-validiert).
func updateName(_ newName: String) async throws {
let trimmed = newName.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
throw AuthError.validation(message: "Name ist erforderlich")
}
guard trimmed.count <= 80 else {
throw AuthError.validation(message: "Name darf höchstens 80 Zeichen haben")
}
let (data, http) = try await postJSON(
path: "/api/v1/me/profile",
method: "PATCH",
body: UpdateProfileRequest(name: trimmed),
authenticated: true
)
guard http.statusCode == 200 else {
throw AuthError.classify(
status: http.statusCode,
data: data,
retryAfterHeader: http.retryAfterSeconds
)
}
CoreLog.auth.info("Display name updated")
}
/// Ändert das Passwort des aktuell eingeloggten Accounts.
///
/// - Parameters:
/// - currentPassword: Aktuelles Klartext-Passwort (Re-Auth).
/// - newPassword: Neues Klartext-Passwort.
///
/// - Important: Aktuell server-seitig nicht Bearer-fähig — siehe
/// Doc-Header dieser Datei.
func changePassword(currentPassword: String, newPassword: String) async throws {
guard !currentPassword.isEmpty, !newPassword.isEmpty else {
throw AuthError.validation(message: "Aktuelles und neues Passwort sind erforderlich")
}
let body = ChangePasswordRequest(currentPassword: currentPassword, newPassword: newPassword)
let (data, http) = try await postJSON(
path: "/api/v1/auth/change-password",
body: body,
authenticated: true
)
guard http.statusCode == 200 else {
throw AuthError.classify(
status: http.statusCode,
data: data,
retryAfterHeader: http.retryAfterSeconds
)
}
CoreLog.auth.info("Password changed")
}
/// Löscht den aktuell eingeloggten Account vollständig.
///
/// Server löscht alle User-Daten (Auth, Credits, Sync-DB-Records).
/// Bei Erfolg wird der lokale Keychain gewiped und der Status auf
/// `.signedOut` gesetzt.
///
/// **App-Store-Pflicht 5.1.1(v):** jede App mit Account-Erstellung
/// muss eine Account-Löschung anbieten.
///
/// - Parameter password: Aktuelles Klartext-Passwort als Re-Auth.
///
/// - Important: Aktuell server-seitig nicht Bearer-fähig — siehe
/// Doc-Header dieser Datei.
func deleteAccount(password: String) async throws {
guard !password.isEmpty else {
throw AuthError.validation(message: "Passwort ist erforderlich")
}
let body = DeleteAccountRequest(password: password)
let (data, http) = try await postJSON(
path: "/api/v1/auth/account",
method: "DELETE",
body: body,
authenticated: true
)
guard http.statusCode == 200 else {
throw AuthError.classify(
status: http.statusCode,
data: data,
retryAfterHeader: http.retryAfterSeconds
)
}
clearSession()
CoreLog.auth.notice("Account deleted")
}
}
// MARK: - Private Helpers
extension AuthClient {
/// Generischer JSON-POST/DELETE-Helper. Wenn `authenticated == true`,
/// wird der Session-Token (`refreshToken`-Feldwert, von Better Auth
/// als Session-ID interpretierbar via `bearer`-Plugin) als Bearer-
/// Header mitgeschickt — NICHT der JWT. Siehe Doc-Header dieser Datei.
fileprivate func postJSON<Body: Encodable>(
path: String,
method: String = "POST",
body: Body,
authenticated: Bool = false
) async throws -> (Data, HTTPURLResponse) {
let url = config.authBaseURL.appending(path: path)
var request = URLRequest(url: url)
request.httpMethod = method
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
if authenticated {
let token = try currentSessionToken()
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
do {
request.httpBody = try JSONEncoder().encode(body)
} catch {
throw AuthError.encoding
}
do {
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw AuthError.networkFailure("Keine HTTP-Antwort")
}
return (data, http)
} catch let error as URLError {
throw AuthError.networkFailure(error.localizedDescription)
} catch let error as AuthError {
throw error
} catch {
throw AuthError.networkFailure(String(describing: error))
}
}
}
// MARK: - Wire-Format
private struct RegisterRequest: Encodable {
let email: String
let password: String
let name: String?
let sourceAppUrl: String?
}
/// Body für `PATCH /api/v1/me/profile` (Anzeigename ändern).
private struct UpdateProfileRequest: Encodable {
let name: String
}
/// Server-Antwort auf `/register`. Tokens sind optional weil
/// `requireEmailVerification: true` (Default) keine Session liefert.
private struct RegisterResponse: Decodable {
let user: RegisterUser?
let accessToken: String?
let refreshToken: String?
}
private struct RegisterUser: Decodable {
let id: String
let email: String?
}
private struct ForgotPasswordRequest: Encodable {
let email: String
let redirectTo: String
}
private struct ResetPasswordRequest: Encodable {
let token: String
let newPassword: String
}
private struct ResendVerificationRequest: Encodable {
let email: String
let sourceAppUrl: String?
}
private struct ChangeEmailRequest: Encodable {
let newEmail: String
let callbackURL: String?
}
private struct ChangePasswordRequest: Encodable {
let currentPassword: String
let newPassword: String
}
private struct DeleteAccountRequest: Encodable {
let password: String
}
// MARK: - Two-Factor (Login-Challenge)
public extension AuthClient {
/// Verifiziert einen TOTP-Code im 2FA-Login-Flow. Vorbedingung:
/// Status ist ``Status/twoFactorRequired(token:methods:email:)``
/// (typisch nach `signIn(...)`).
///
/// Bei Erfolg setzt der Server die Session, ManaCore persistiert
/// Tokens und der Status wird `.signedIn(email:)`.
///
/// - Parameters:
/// - code: 6-stelliger TOTP-Code aus der Authenticator-App.
/// - trustDevice: Wenn `true`, setzt der Server einen
/// `trust_device`-Cookie. Bei Native heute ohne Effekt
/// (Cookies werden nicht persistiert) — wir spiegeln das Flag
/// trotzdem für künftige Erweiterungen.
///
/// - Throws: ``AuthError/twoFactorFailed`` bei falschem Code,
/// ``AuthError/tokenExpired`` wenn der Challenge-Token alt ist,
/// plus Standard-Netzwerk-Fehler.
func verifyTotp(code: String, trustDevice: Bool = false) async throws {
try await verifyTwoFactor(
path: "/api/v1/auth/two-factor/verify-totp",
code: code,
trustDevice: trustDevice
)
}
/// Verifiziert einen Backup-Code im 2FA-Login-Flow. Same Pre-/
/// Postconditions wie ``verifyTotp(code:trustDevice:)`` — der
/// Server entscheidet anhand des Endpoints, welches Verfahren er
/// nutzt. Backup-Codes werden vom Server beim Setup generiert
/// und einmalig konsumiert.
func verifyBackupCode(code: String, trustDevice: Bool = false) async throws {
try await verifyTwoFactor(
path: "/api/v1/auth/two-factor/verify-backup-code",
code: code,
trustDevice: trustDevice
)
}
private func verifyTwoFactor(
path: String,
code: String,
trustDevice: Bool
) async throws {
guard case let .twoFactorRequired(token, _, email) = status else {
throw AuthError.validation(
message: "Kein 2FA-Challenge aktiv — verifyTotp setzt eine vorherige signIn-Antwort mit .twoFactorRequired voraus"
)
}
guard !code.isEmpty else {
throw AuthError.validation(message: "Code ist erforderlich")
}
let body = TwoFactorVerifyRequest(
code: code,
twoFactorToken: token,
trustDevice: trustDevice
)
let (data, http) = try await postJSON(path: path, body: body)
guard http.statusCode == 200 else {
throw AuthError.classify(
status: http.statusCode,
data: data,
retryAfterHeader: http.retryAfterSeconds
)
}
let token2 = try JSONDecoder().decode(TwoFactorVerifyResponse.self, from: data)
guard let access = token2.accessToken, let refresh = token2.refreshToken else {
// Server hat 200 zurück, aber keine Tokens. Sollte nicht
// passieren — UI bleibt im 2FA-Required-Zustand.
throw AuthError.decoding("Tokens fehlen in 2FA-Verify-Antwort")
}
try persistSession(email: email, accessToken: access, refreshToken: refresh)
CoreLog.auth.info("2FA verify successful — signed in")
}
}
private struct TwoFactorVerifyRequest: Encodable {
let code: String
let twoFactorToken: String
let trustDevice: Bool
}
private struct TwoFactorVerifyResponse: Decodable {
let success: Bool?
let accessToken: String?
let refreshToken: String?
}
// MARK: - Two-Factor (Enrollment)
/// Ergebnis von ``AuthClient/enrollTotp(password:)``: enthält die
/// otpauth-URI (für QR-Code-Display) und die Backup-Codes. Backup-
/// Codes sind einmalig nutzbar und sollten dem User zum Sichern
/// (Kopieren/Drucken) angeboten werden — der Server zeigt sie nie
/// mehr.
public struct TotpEnrollment: Sendable, Equatable {
/// `otpauth://totp/Issuer:email?secret=...&...`-URI. Direkt als
/// QR-Code rendern (z.B. via `CIFilter.qrCodeGenerator`).
public let totpURI: String
/// Liste der Backup-Codes (üblich 10 Stück). Der User sollte sie
/// sicher aufbewahren — bei Verlust des TOTP-Geräts sind sie der
/// einzige Fallback. Server merkt sich nur Hashes; bei Verbrauch
/// werden sie als consumed markiert.
public let backupCodes: [String]
}
public extension AuthClient {
/// Aktiviert TOTP-2FA für den aktuellen Account.
///
/// Re-Auth via aktuellem Passwort. Bei Erfolg generiert der Server
/// ein TOTP-Secret und gibt die otpauth-URI + Backup-Codes zurück.
/// Die App rendert die URI als QR-Code, der User scannt mit
/// Authenticator-App und gibt zur Bestätigung den ersten Code ein —
/// dieser zweite Schritt läuft über die regulären
/// ``verifyTotp(code:trustDevice:)``-Methode oder einen Re-Auth
/// signIn → twoFactorRequired-Flow (der Server entscheidet).
///
/// - Important: Nutzt den Session-Token als Bearer (Wire-Konvention
/// für mana-auth-Account-Endpoints, siehe Doc-Header dieser Datei).
func enrollTotp(password: String) async throws -> TotpEnrollment {
guard !password.isEmpty else {
throw AuthError.validation(message: "Passwort ist erforderlich")
}
let body = TotpEnableRequest(password: password)
let (data, http) = try await postJSON(
path: "/api/v1/auth/two-factor/enable",
body: body,
authenticated: true
)
guard http.statusCode == 200 else {
throw AuthError.classify(
status: http.statusCode,
data: data,
retryAfterHeader: http.retryAfterSeconds
)
}
let decoded = try JSONDecoder().decode(TotpEnableResponse.self, from: data)
guard let uri = decoded.totpURI else {
throw AuthError.decoding("totpURI fehlt in Enroll-Antwort")
}
CoreLog.auth.info("2FA TOTP enrollment initiated")
return TotpEnrollment(totpURI: uri, backupCodes: decoded.backupCodes ?? [])
}
/// Deaktiviert TOTP-2FA für den aktuellen Account. Re-Auth via
/// Passwort.
func disableTotp(password: String) async throws {
guard !password.isEmpty else {
throw AuthError.validation(message: "Passwort ist erforderlich")
}
let body = TotpEnableRequest(password: password)
let (data, http) = try await postJSON(
path: "/api/v1/auth/two-factor/disable",
body: body,
authenticated: true
)
guard http.statusCode == 200 else {
throw AuthError.classify(
status: http.statusCode,
data: data,
retryAfterHeader: http.retryAfterSeconds
)
}
CoreLog.auth.info("2FA TOTP disabled")
}
/// Liefert die otpauth-URI des aktuellen TOTP-Secrets. Nützlich
/// wenn der User den QR-Code erneut sehen will (z.B. zweites
/// Gerät einrichten). Re-Auth via Passwort.
///
/// - Returns: `otpauth://totp/…`-URI, direkt als QR-Code renderbar.
func getTotpUri(password: String) async throws -> String {
guard !password.isEmpty else {
throw AuthError.validation(message: "Passwort ist erforderlich")
}
let body = TotpEnableRequest(password: password)
let (data, http) = try await postJSON(
path: "/api/v1/auth/two-factor/get-totp-uri",
body: body,
authenticated: true
)
guard http.statusCode == 200 else {
throw AuthError.classify(
status: http.statusCode,
data: data,
retryAfterHeader: http.retryAfterSeconds
)
}
let decoded = try JSONDecoder().decode(TotpEnableResponse.self, from: data)
guard let uri = decoded.totpURI else {
throw AuthError.decoding("totpURI fehlt in get-totp-uri-Antwort")
}
return uri
}
/// Regeneriert die Backup-Codes. Alte Codes werden ungültig. Der
/// User sollte die neuen direkt sichern.
func regenerateBackupCodes(password: String) async throws -> [String] {
guard !password.isEmpty else {
throw AuthError.validation(message: "Passwort ist erforderlich")
}
let body = TotpEnableRequest(password: password)
let (data, http) = try await postJSON(
path: "/api/v1/auth/two-factor/generate-backup-codes",
body: body,
authenticated: true
)
guard http.statusCode == 200 else {
throw AuthError.classify(
status: http.statusCode,
data: data,
retryAfterHeader: http.retryAfterSeconds
)
}
let decoded = try JSONDecoder().decode(TotpEnableResponse.self, from: data)
guard let codes = decoded.backupCodes else {
throw AuthError.decoding("backupCodes fehlen in Antwort")
}
return codes
}
}
private struct TotpEnableRequest: Encodable {
let password: String
}
/// Antwort-Format für alle Enrollment-Endpoints. Felder sind optional
/// damit derselbe Type für `enable` (beide), `get-totp-uri` (nur URI)
/// und `generate-backup-codes` (nur Codes) dekodierbar ist.
private struct TotpEnableResponse: Decodable {
let totpURI: String?
let backupCodes: [String]?
}
// MARK: - Profile
/// Subset des Server-User-Profiles, das die Apps für UI-Entscheidungen
/// brauchen. Wird von ``AuthClient/getProfile()`` geliefert.
///
/// Server-Quelle: `GET /api/v1/auth/profile` (→ Better Auths
/// `/api/auth/get-session`). Returnt `{user: {…}, session: {…}}`.
/// Wir nehmen nur die UI-relevanten Felder mit.
public struct ProfileInfo: Sendable, Equatable {
public let id: String
public let email: String
public let name: String?
public let emailVerified: Bool
/// `true` wenn der User TOTP-2FA aktiviert hat. Apps zeigen
/// dann den Disable-/Regenerate-Pfad statt des Enroll-Wizards.
public let twoFactorEnabled: Bool
public init(
id: String,
email: String,
name: String?,
emailVerified: Bool,
twoFactorEnabled: Bool
) {
self.id = id
self.email = email
self.name = name
self.emailVerified = emailVerified
self.twoFactorEnabled = twoFactorEnabled
}
}
public extension AuthClient {
/// Lädt das aktuelle Profil des eingeloggten Users vom Server.
///
/// - Important: Nutzt den Session-Token als Bearer (Wire-Konvention
/// für mana-auth-Endpoints, siehe Doc-Header dieser Datei).
///
/// - Throws: ``AuthError/notSignedIn`` ohne Session,
/// ``AuthError/unauthorized`` wenn Server den Token rejected,
/// Netzwerk-Cases.
func getProfile() async throws -> ProfileInfo {
let token = try currentSessionToken()
let url = config.authBaseURL.appending(path: "/api/v1/auth/profile")
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
let (data, http): (Data, HTTPURLResponse)
do {
let (d, r) = try await session.data(for: request)
guard let h = r as? HTTPURLResponse else {
throw AuthError.networkFailure("Keine HTTP-Antwort")
}
data = d
http = h
} catch let error as URLError {
throw AuthError.networkFailure(error.localizedDescription)
}
guard http.statusCode == 200 else {
throw AuthError.classify(
status: http.statusCode,
data: data,
retryAfterHeader: http.retryAfterSeconds
)
}
let envelope = try JSONDecoder().decode(ProfileEnvelope.self, from: data)
guard let user = envelope.user else {
throw AuthError.decoding("user-Feld fehlt in profile-Antwort")
}
return ProfileInfo(
id: user.id,
email: user.email,
name: user.name,
emailVerified: user.emailVerified ?? false,
twoFactorEnabled: user.twoFactorEnabled ?? false
)
}
}
/// Wire-Format-Hülle für `GET /api/v1/auth/profile`. Better-Auth-
/// `/api/auth/get-session` returnt `{user: {...}, session: {...}}`;
/// wir lesen `user` und ignorieren `session`.
private struct ProfileEnvelope: Decodable {
let user: ProfileUser?
}
private struct ProfileUser: Decodable {
let id: String
let email: String
let name: String?
let emailVerified: Bool?
let twoFactorEnabled: Bool?
}