update
This commit is contained in:
@@ -81,6 +81,13 @@ final class AuthManager: ObservableObject {
|
||||
isAuthenticated = false
|
||||
}
|
||||
|
||||
func handleTokenResponse(_ response: TokenResponse) async {
|
||||
accessToken = response.accessToken
|
||||
idToken = response.idToken
|
||||
currentUser = response.user
|
||||
isAuthenticated = true
|
||||
}
|
||||
|
||||
var loginURL: URL {
|
||||
APIEndpoints.url(for: APIEndpoint.login)
|
||||
}
|
||||
|
||||
@@ -15,21 +15,13 @@ final class AuthService: NSObject {
|
||||
func login(presentationAnchor: ASPresentationAnchor?) async throws {
|
||||
self.presentationAnchor = presentationAnchor
|
||||
|
||||
let codeVerifier = generateCodeVerifier()
|
||||
let codeChallenge = generateCodeChallenge(from: codeVerifier)
|
||||
|
||||
let session = UUID().uuidString
|
||||
UserDefaults.standard.set(codeVerifier, forKey: "oidc_code_verifier_\(session)")
|
||||
|
||||
// Only the redirect_uri is needed — the backend owns PKCE generation.
|
||||
var components = URLComponents(
|
||||
url: AppConfig.apiBaseURL.appendingPathComponent(APIEndpoint.login),
|
||||
resolvingAgainstBaseURL: true
|
||||
)
|
||||
|
||||
components?.queryItems = [
|
||||
URLQueryItem(name: "session", value: session),
|
||||
URLQueryItem(name: "code_challenge", value: codeChallenge),
|
||||
URLQueryItem(name: "code_challenge_method", value: "S256"),
|
||||
URLQueryItem(name: "redirect_uri", value: AppConfig.authCallbackURL)
|
||||
]
|
||||
|
||||
@@ -39,6 +31,8 @@ final class AuthService: NSObject {
|
||||
|
||||
let callbackScheme = URL(string: AppConfig.authCallbackURL)?.scheme ?? "timetracker"
|
||||
|
||||
// Use a shared (non-ephemeral) session so the backend session cookie set during
|
||||
// /auth/login is automatically included in the /auth/token POST.
|
||||
let webAuthSession = ASWebAuthenticationSession(
|
||||
url: authURL,
|
||||
callbackURLScheme: callbackScheme
|
||||
@@ -71,10 +65,12 @@ final class AuthService: NSObject {
|
||||
return
|
||||
}
|
||||
|
||||
self?.handleCallback(url: callbackURL, session: session)
|
||||
self?.handleCallback(url: callbackURL)
|
||||
}
|
||||
|
||||
webAuthSession.presentationContextProvider = self
|
||||
// prefersEphemeralWebBrowserSession = false ensures the session cookie from
|
||||
// /auth/login is retained and sent with the subsequent /auth/token request.
|
||||
webAuthSession.prefersEphemeralWebBrowserSession = false
|
||||
|
||||
self.authSession = webAuthSession
|
||||
@@ -91,42 +87,83 @@ final class AuthService: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func handleCallback(url: URL, session: String) {
|
||||
private func handleCallback(url: URL) {
|
||||
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
|
||||
let code = components.queryItems?.first(where: { $0.name == "code" })?.value else {
|
||||
let code = components.queryItems?.first(where: { $0.name == "code" })?.value,
|
||||
let state = components.queryItems?.first(where: { $0.name == "state" })?.value
|
||||
else {
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(
|
||||
name: .authError,
|
||||
object: nil,
|
||||
userInfo: ["error": AuthError.noCallback]
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let codeVerifier = UserDefaults.standard.string(forKey: "oidc_code_verifier_\(session)")
|
||||
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(
|
||||
name: .authCallbackReceived,
|
||||
object: nil,
|
||||
userInfo: [
|
||||
"code": code,
|
||||
"codeVerifier": codeVerifier ?? ""
|
||||
]
|
||||
)
|
||||
Task {
|
||||
do {
|
||||
let tokenResponse = try await exchangeCodeForTokens(
|
||||
code: code,
|
||||
state: state,
|
||||
redirectUri: AppConfig.authCallbackURL
|
||||
)
|
||||
|
||||
await AuthManager.shared.handleTokenResponse(tokenResponse)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: .authCallbackReceived, object: nil)
|
||||
}
|
||||
} catch {
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(
|
||||
name: .authError,
|
||||
object: nil,
|
||||
userInfo: ["error": AuthError.failed(error.localizedDescription)]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func generateCodeVerifier() -> String {
|
||||
var buffer = [UInt8](repeating: 0, count: 32)
|
||||
_ = SecRandomCopyBytes(kSecRandomDefault, buffer.count, &buffer)
|
||||
return Data(buffer).base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
}
|
||||
|
||||
private func generateCodeChallenge(from verifier: String) -> String {
|
||||
guard let data = verifier.data(using: .ascii) else { return "" }
|
||||
let hash = SHA256.hash(data: data)
|
||||
return Data(hash).base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
private func exchangeCodeForTokens(
|
||||
code: String,
|
||||
state: String,
|
||||
redirectUri: String
|
||||
) async throws -> TokenResponse {
|
||||
let url = AppConfig.apiBaseURL.appendingPathComponent(APIEndpoint.token)
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
// code_verifier is intentionally omitted — the backend uses its own verifier
|
||||
// that was generated during /auth/login and stored in the server-side session.
|
||||
// state is sent so the backend can look up and validate the original session.
|
||||
let body: [String: Any] = [
|
||||
"code": code,
|
||||
"state": state,
|
||||
"code_verifier": "", // kept for API compatibility; backend ignores it
|
||||
"redirect_uri": redirectUri
|
||||
]
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw AuthError.failed("Invalid response")
|
||||
}
|
||||
|
||||
guard httpResponse.statusCode == 200 else {
|
||||
if let errorJson = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let errorMessage = errorJson["error"] as? String {
|
||||
throw AuthError.failed(errorMessage)
|
||||
}
|
||||
throw AuthError.failed("Token exchange failed with status \(httpResponse.statusCode)")
|
||||
}
|
||||
|
||||
return try JSONDecoder().decode(TokenResponse.self, from: data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,3 +197,19 @@ extension Notification.Name {
|
||||
static let authCallbackReceived = Notification.Name("authCallbackReceived")
|
||||
static let authError = Notification.Name("authError")
|
||||
}
|
||||
|
||||
struct TokenResponse: Codable {
|
||||
let accessToken: String
|
||||
let idToken: String
|
||||
let tokenType: String
|
||||
let expiresIn: Int?
|
||||
let user: User
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case accessToken = "access_token"
|
||||
case idToken = "id_token"
|
||||
case tokenType = "token_type"
|
||||
case expiresIn = "expires_in"
|
||||
case user
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,8 @@ actor APIClient {
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
|
||||
if authenticated {
|
||||
guard let token = AuthManager.shared.accessToken else {
|
||||
let token = await MainActor.run { AuthManager.shared.accessToken }
|
||||
guard let token = token else {
|
||||
throw NetworkError.unauthorized
|
||||
}
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
@@ -111,7 +112,8 @@ actor APIClient {
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
|
||||
if authenticated {
|
||||
guard let token = AuthManager.shared.accessToken else {
|
||||
let token = await MainActor.run { AuthManager.shared.accessToken }
|
||||
guard let token = token else {
|
||||
throw NetworkError.unauthorized
|
||||
}
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
|
||||
@@ -4,6 +4,7 @@ enum APIEndpoint {
|
||||
// Auth
|
||||
static let login = "/auth/login"
|
||||
static let callback = "/auth/callback"
|
||||
static let token = "/auth/token"
|
||||
static let logout = "/auth/logout"
|
||||
static let me = "/auth/me"
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ final class SyncManager: ObservableObject {
|
||||
DispatchQueue.main.async {
|
||||
self?.isOnline = path.status == .satisfied
|
||||
if path.status == .satisfied {
|
||||
self?.syncPendingChanges()
|
||||
Task { await self?.syncPendingChanges() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,11 +81,9 @@ struct LoginView: View {
|
||||
}
|
||||
|
||||
private func handleAuthCallback(_ userInfo: [AnyHashable: Any]?) {
|
||||
Task {
|
||||
await authManager.checkAuthState()
|
||||
await MainActor.run {
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
// AuthManager.handleTokenResponse() already set isAuthenticated = true
|
||||
// and populated currentUser during the token exchange in AuthService.
|
||||
// No further network call is needed here.
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ final class ClientsViewModel: ObservableObject {
|
||||
|
||||
do {
|
||||
let input = CreateClientInput(name: name, description: description)
|
||||
_ = try await apiClient.request(
|
||||
try await apiClient.requestVoid(
|
||||
endpoint: APIEndpoint.clients,
|
||||
method: .post,
|
||||
body: input,
|
||||
@@ -57,7 +57,7 @@ final class ClientsViewModel: ObservableObject {
|
||||
|
||||
do {
|
||||
let input = UpdateClientInput(name: name, description: description)
|
||||
_ = try await apiClient.request(
|
||||
try await apiClient.requestVoid(
|
||||
endpoint: APIEndpoint.client(id: id),
|
||||
method: .put,
|
||||
body: input,
|
||||
|
||||
@@ -50,7 +50,7 @@ final class ProjectsViewModel: ObservableObject {
|
||||
color: color,
|
||||
clientId: clientId
|
||||
)
|
||||
_ = try await apiClient.request(
|
||||
try await apiClient.requestVoid(
|
||||
endpoint: APIEndpoint.projects,
|
||||
method: .post,
|
||||
body: input,
|
||||
@@ -74,7 +74,7 @@ final class ProjectsViewModel: ObservableObject {
|
||||
color: color,
|
||||
clientId: clientId
|
||||
)
|
||||
_ = try await apiClient.request(
|
||||
try await apiClient.requestVoid(
|
||||
endpoint: APIEndpoint.project(id: id),
|
||||
method: .put,
|
||||
body: input,
|
||||
|
||||
@@ -79,7 +79,7 @@ struct TimeEntryRow: View {
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text(formatDateRange(entry.startTime, entry.endTime))
|
||||
Text(formatDateRange(start: entry.startTime, end: entry.endTime))
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
|
||||
@@ -119,11 +119,11 @@ struct TimeEntryFormView: View {
|
||||
let startDateTime = calendar.date(bySettingHour: calendar.component(.hour, from: startTime),
|
||||
minute: calendar.component(.minute, from: startTime),
|
||||
second: 0,
|
||||
on: startDate) ?? startDate
|
||||
of: startDate) ?? startDate
|
||||
let endDateTime = calendar.date(bySettingHour: calendar.component(.hour, from: endTime),
|
||||
minute: calendar.component(.minute, from: endTime),
|
||||
second: 0,
|
||||
on: endDate) ?? endDate
|
||||
of: endDate) ?? endDate
|
||||
|
||||
Task {
|
||||
do {
|
||||
@@ -134,7 +134,7 @@ struct TimeEntryFormView: View {
|
||||
description: description.isEmpty ? nil : description,
|
||||
projectId: project.id
|
||||
)
|
||||
_ = try await apiClient.request(
|
||||
try await apiClient.requestVoid(
|
||||
endpoint: APIEndpoint.timeEntry(id: existingEntry.id),
|
||||
method: .put,
|
||||
body: input,
|
||||
@@ -147,7 +147,7 @@ struct TimeEntryFormView: View {
|
||||
description: description.isEmpty ? nil : description,
|
||||
projectId: project.id
|
||||
)
|
||||
_ = try await apiClient.request(
|
||||
try await apiClient.requestVoid(
|
||||
endpoint: APIEndpoint.timeEntries,
|
||||
method: .post,
|
||||
body: input,
|
||||
@@ -161,9 +161,10 @@ struct TimeEntryFormView: View {
|
||||
onSave()
|
||||
}
|
||||
} catch {
|
||||
let errorMessage = error.localizedDescription
|
||||
await MainActor.run {
|
||||
isLoading = false
|
||||
error = error.localizedDescription
|
||||
self.error = errorMessage
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,11 +44,16 @@ struct TimerView: View {
|
||||
.font(.system(size: 64, weight: .light, design: .monospaced))
|
||||
.foregroundStyle(viewModel.activeTimer != nil ? .primary : .secondary)
|
||||
|
||||
if let project = viewModel.selectedProject ?? viewModel.activeTimer?.project {
|
||||
if let project = viewModel.selectedProject {
|
||||
ProjectColorBadge(
|
||||
color: project.color,
|
||||
name: project.name
|
||||
)
|
||||
} else if let timerProject = viewModel.activeTimer?.project {
|
||||
ProjectColorBadge(
|
||||
color: timerProject.color,
|
||||
name: timerProject.name
|
||||
)
|
||||
} else {
|
||||
Text("No project selected")
|
||||
.font(.subheadline)
|
||||
@@ -137,7 +142,7 @@ struct ProjectPickerSheet: View {
|
||||
.foregroundStyle(.primary)
|
||||
if selectedProject == nil {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundStyle(.accent)
|
||||
.foregroundStyle(Color.accentColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -155,7 +160,7 @@ struct ProjectPickerSheet: View {
|
||||
.foregroundStyle(.secondary)
|
||||
if selectedProject?.id == project.id {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundStyle(.accent)
|
||||
.foregroundStyle(Color.accentColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ final class TimerViewModel: ObservableObject {
|
||||
|
||||
do {
|
||||
let input = StopTimerInput(projectId: projectId)
|
||||
_ = try await apiClient.request(
|
||||
try await apiClient.requestVoid(
|
||||
endpoint: APIEndpoint.timerStop,
|
||||
method: .post,
|
||||
body: input,
|
||||
|
||||
@@ -2,65 +2,65 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
</dict>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict>
|
||||
<key>UIColorName</key>
|
||||
<string>LaunchBackground</string>
|
||||
</dict>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>armv7</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.timetracker.app</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>timetracker</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>API_BASE_URL</key>
|
||||
<string>$(API_BASE_URL)</string>
|
||||
<key>API_BASE_URL</key>
|
||||
<string>https://timetracker.simon-franken.de/api</string>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.timetracker.app</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>timetracker</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
</dict>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict>
|
||||
<key>UIColorName</key>
|
||||
<string>LaunchBackground</string>
|
||||
</dict>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>armv7</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
struct Client: Codable, Identifiable, Equatable {
|
||||
struct Client: Codable, Identifiable, Equatable, Hashable {
|
||||
let id: String
|
||||
let name: String
|
||||
let description: String?
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
struct Project: Codable, Identifiable, Equatable {
|
||||
struct Project: Codable, Identifiable, Equatable, Hashable {
|
||||
let id: String
|
||||
let name: String
|
||||
let description: String?
|
||||
@@ -11,7 +11,7 @@ struct Project: Codable, Identifiable, Equatable {
|
||||
let updatedAt: String
|
||||
}
|
||||
|
||||
struct ClientReference: Codable, Equatable {
|
||||
struct ClientReference: Codable, Equatable, Hashable {
|
||||
let id: String
|
||||
let name: String
|
||||
}
|
||||
|
||||
@@ -39,12 +39,12 @@ struct Pagination: Codable, Equatable {
|
||||
}
|
||||
|
||||
struct TimeEntryFilters: Codable {
|
||||
let startDate: String?
|
||||
let endDate: String?
|
||||
let projectId: String?
|
||||
let clientId: String?
|
||||
let page: Int?
|
||||
let limit: Int?
|
||||
var startDate: String?
|
||||
var endDate: String?
|
||||
var projectId: String?
|
||||
var clientId: String?
|
||||
var page: Int?
|
||||
var limit: Int?
|
||||
|
||||
init(
|
||||
startDate: Date? = nil,
|
||||
|
||||
Reference in New Issue
Block a user