diff --git a/.gitignore b/.gitignore index ccc51e2..9f37740 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,3 @@ Package.resolved xcuserdata/ DerivedData/ -build/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 30501d3..1d25f93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,25 +4,6 @@ Alle Änderungen werden hier dokumentiert. Format orientiert an [Keep a Changelog](https://keepachangelog.com), Versionierung nach [Semver](https://semver.org). -## [1.5.0] — 2026-05-14 - -Minor — `getProfile()` + `ProfileInfo`. Apps können den 2FA-Status -des eingeloggten Users lesen, damit AccountView entscheidet ob -"Aktivieren" oder "Deaktivieren" angezeigt wird. - -### Neu - -- `ProfileInfo` (public struct) — `id`, `email`, `name`, - `emailVerified`, `twoFactorEnabled`. -- `AuthClient.getProfile() -> ProfileInfo` — lädt aktuelles Profil - vom Server (`GET /api/v1/auth/profile` → Better Auths - `/api/auth/get-session`). Nutzt Session-Token als Bearer. - -### Tests - -- 4 neue Tests (twoFactor-on, twoFactor-off, ohne Session, - unauthorized). 70/70 grün. - ## [1.4.0] — 2026-05-14 Minor — 2FA-Enrollment (Mini-Sprint B). Setzt Mini-Sprint A diff --git a/Sources/ManaCore/Auth/AuthClient+Account.swift b/Sources/ManaCore/Auth/AuthClient+Account.swift index b717004..06bf86b 100644 --- a/Sources/ManaCore/Auth/AuthClient+Account.swift +++ b/Sources/ManaCore/Auth/AuthClient+Account.swift @@ -635,101 +635,3 @@ 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? -} diff --git a/Tests/ManaCoreTests/AuthClientProfileTests.swift b/Tests/ManaCoreTests/AuthClientProfileTests.swift deleted file mode 100644 index 69c0daf..0000000 --- a/Tests/ManaCoreTests/AuthClientProfileTests.swift +++ /dev/null @@ -1,77 +0,0 @@ -import Foundation -import Testing -@testable import ManaCore - -@Suite("AuthClient getProfile") -@MainActor -struct AuthClientProfileTests { - private func signedInAuth() async -> MockedAuth { - let mocked = makeMockedAuth() - let access = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1MSIsImV4cCI6MjAwMDAwMDAwMH0.sig" - mocked.setHandler { _ in - (200, Data(#"{"accessToken":"\#(access)","refreshToken":"session-tok"}"#.utf8)) - } - await mocked.auth.signIn(email: "u@x.de", password: "pw") - return mocked - } - - @Test("getProfile mit twoFactor on") - func profileTwoFactorOn() async throws { - let mocked = await signedInAuth() - let captured = MockURLProtocol.Capture() - mocked.setHandler { request in - captured.store(request) - return (200, Data(#""" - {"user":{"id":"u1","email":"u@x.de","name":"Test","emailVerified":true,"twoFactorEnabled":true}, - "session":{"id":"s1"}} - """#.utf8)) - } - - let profile = try await mocked.auth.getProfile() - #expect(profile.id == "u1") - #expect(profile.email == "u@x.de") - #expect(profile.name == "Test") - #expect(profile.emailVerified == true) - #expect(profile.twoFactorEnabled == true) - - let request = try #require(captured.request) - #expect(request.httpMethod == "GET") - #expect(request.url?.path == "/api/v1/auth/profile") - // Bearer-Header trägt den Session-Token - #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer session-tok") - } - - @Test("getProfile mit twoFactor off (Feld fehlt)") - func profileTwoFactorOff() async throws { - let mocked = await signedInAuth() - mocked.setHandler { _ in - (200, Data(#""" - {"user":{"id":"u1","email":"u@x.de","name":null,"emailVerified":true}} - """#.utf8)) - } - - let profile = try await mocked.auth.getProfile() - #expect(profile.twoFactorEnabled == false) - #expect(profile.name == nil) - } - - @Test("getProfile ohne Session → notSignedIn") - func profileNoSession() async { - let mocked = makeMockedAuth() - await #expect(throws: AuthError.notSignedIn) { - try await mocked.auth.getProfile() - } - } - - @Test("getProfile mit abgelaufenem Token → unauthorized") - func profileUnauthorized() async { - let mocked = await signedInAuth() - mocked.setHandler { _ in - (401, Data(#"{"error":"UNAUTHORIZED","status":401}"#.utf8)) - } - - await #expect(throws: AuthError.unauthorized) { - try await mocked.auth.getProfile() - } - } -}