v1.5.0 — getProfile() + ProfileInfo

Apps können den 2FA-Status des eingeloggten Users lesen, damit
AccountView entscheidet ob "Zwei-Faktor aktivieren" oder
"Zwei-Faktor deaktivieren" angezeigt wird.

ProfileInfo (public struct) — id, email, name, emailVerified,
twoFactorEnabled.

AuthClient.getProfile() -> ProfileInfo — lädt das Profil vom
Server (GET /api/v1/auth/profile → Better Auths /api/auth/get-session).
Nutzt Session-Token als Bearer (Wire-Konvention).

4 neue Tests, 70/70 grün.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-14 01:06:50 +02:00
parent 0a79083b58
commit fe607c15d2
397 changed files with 2876 additions and 0 deletions

View file

@ -4,6 +4,25 @@ Alle Änderungen werden hier dokumentiert. Format orientiert an
[Keep a Changelog](https://keepachangelog.com), Versionierung nach [Keep a Changelog](https://keepachangelog.com), Versionierung nach
[Semver](https://semver.org). [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 ## [1.4.0] — 2026-05-14
Minor — 2FA-Enrollment (Mini-Sprint B). Setzt Mini-Sprint A Minor — 2FA-Enrollment (Mini-Sprint B). Setzt Mini-Sprint A

View file

@ -635,3 +635,101 @@ private struct TotpEnableResponse: Decodable {
let totpURI: String? let totpURI: String?
let backupCodes: [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?
}

View file

@ -0,0 +1,77 @@
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()
}
}
}

View file

@ -0,0 +1,4 @@
module ManaCore {
header "ManaCore-Swift.h"
export *
}

View file

@ -0,0 +1,4 @@
module ManaTokens {
header "ManaTokens-Swift.h"
export *
}

View file

@ -0,0 +1,9 @@
{
"ABIRoot": {
"kind": "Root",
"name": "NO_MODULE",
"printedName": "NO_MODULE",
"json_format_version": 8
},
"ConstValues": []
}

Some files were not shown because too many files have changed in this diff Show more