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:
parent
0a79083b58
commit
fe607c15d2
397 changed files with 2876 additions and 0 deletions
19
CHANGELOG.md
19
CHANGELOG.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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?
|
||||||
|
}
|
||||||
|
|
|
||||||
77
Tests/ManaCoreTests/AuthClientProfileTests.swift
Normal file
77
Tests/ManaCoreTests/AuthClientProfileTests.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4
build/GeneratedModuleMaps/ManaCore.modulemap
Normal file
4
build/GeneratedModuleMaps/ManaCore.modulemap
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
module ManaCore {
|
||||||
|
header "ManaCore-Swift.h"
|
||||||
|
export *
|
||||||
|
}
|
||||||
4
build/GeneratedModuleMaps/ManaTokens.modulemap
Normal file
4
build/GeneratedModuleMaps/ManaTokens.modulemap
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
module ManaTokens {
|
||||||
|
header "ManaTokens-Swift.h"
|
||||||
|
export *
|
||||||
|
}
|
||||||
Binary file not shown.
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"ABIRoot": {
|
||||||
|
"kind": "Root",
|
||||||
|
"name": "NO_MODULE",
|
||||||
|
"printedName": "NO_MODULE",
|
||||||
|
"json_format_version": 8
|
||||||
|
},
|
||||||
|
"ConstValues": []
|
||||||
|
}
|
||||||
BIN
build/Release/ManaTokens.swiftmodule/x86_64-apple-macos.swiftdoc
Normal file
BIN
build/Release/ManaTokens.swiftmodule/x86_64-apple-macos.swiftdoc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue