diff --git a/backend/src/auth/oidc.ts b/backend/src/auth/oidc.ts index e81bd96..0d82526 100644 --- a/backend/src/auth/oidc.ts +++ b/backend/src/auth/oidc.ts @@ -8,9 +8,14 @@ export async function initializeOIDC(): Promise { try { const issuer = await Issuer.discover(config.oidc.issuerUrl); + const redirectUris = [config.oidc.redirectUri]; + if (config.oidc.iosRedirectUri) { + redirectUris.push(config.oidc.iosRedirectUri); + } + oidcClient = new issuer.Client({ client_id: config.oidc.clientId, - redirect_uris: [config.oidc.redirectUri], + redirect_uris: redirectUris, response_types: ['code'], token_endpoint_auth_method: 'none', // PKCE flow - no client secret }); @@ -33,27 +38,35 @@ export interface AuthSession { codeVerifier: string; state: string; nonce: string; + redirectUri?: string; } -export function createAuthSession(): AuthSession { +export function createAuthSession(redirectUri?: string): AuthSession { return { codeVerifier: generators.codeVerifier(), state: generators.state(), nonce: generators.nonce(), + redirectUri, }; } -export function getAuthorizationUrl(session: AuthSession): string { +export function getAuthorizationUrl(session: AuthSession, redirectUri?: string): string { const client = getOIDCClient(); const codeChallenge = generators.codeChallenge(session.codeVerifier); - return client.authorizationUrl({ + const params: Record = { scope: 'openid profile email', state: session.state, nonce: session.nonce, code_challenge: codeChallenge, code_challenge_method: 'S256', - }); + }; + + if (redirectUri) { + params.redirect_uri = redirectUri; + } + + return client.authorizationUrl(params); } export async function handleCallback( @@ -62,8 +75,10 @@ export async function handleCallback( ): Promise { const client = getOIDCClient(); + const redirectUri = session.redirectUri || config.oidc.redirectUri; + const tokenSet = await client.callback( - config.oidc.redirectUri, + redirectUri, params, { code_verifier: session.codeVerifier, @@ -114,4 +129,21 @@ export async function verifyToken(tokenSet: TokenSet): Promise { } catch { return false; } +} + +export async function verifyBearerToken(accessToken: string): Promise { + const client = getOIDCClient(); + + const userInfo = await client.userinfo(accessToken); + + const id = String(userInfo.sub); + const username = String(userInfo.preferred_username || userInfo.name || id); + const email = String(userInfo.email || ''); + const fullName = String(userInfo.name || '') || null; + + if (!email) { + throw new Error('Email not provided by OIDC provider'); + } + + return { id, username, fullName, email }; } \ No newline at end of file diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 78b4d62..8361038 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -17,6 +17,7 @@ export const config = { redirectUri: process.env.OIDC_REDIRECT_URI || "http://localhost:3001/api/auth/callback", + iosRedirectUri: process.env.OIDC_IOS_REDIRECT_URI || "timetracker://oauth/callback", }, session: { diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index 70ba54c..309bee2 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -1,19 +1,34 @@ import { Request, Response, NextFunction } from 'express'; import { prisma } from '../prisma/client'; import type { AuthenticatedRequest, AuthenticatedUser } from '../types'; +import { getOIDCClient, verifyBearerToken } from '../auth/oidc'; -export function requireAuth( +export async function requireAuth( req: AuthenticatedRequest, res: Response, next: NextFunction -): void { - if (!req.session?.user) { - res.status(401).json({ error: 'Unauthorized' }); - return; +): Promise { + // 1. Session-based auth (web frontend) + if (req.session?.user) { + req.user = req.session.user as AuthenticatedUser; + return next(); } - - req.user = req.session.user as AuthenticatedUser; - next(); + + // 2. Bearer token auth (iOS / native clients) + const authHeader = req.headers.authorization; + if (authHeader?.startsWith('Bearer ')) { + const accessToken = authHeader.slice(7); + try { + const user = await verifyBearerToken(accessToken); + req.user = user; + return next(); + } catch { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + } + + res.status(401).json({ error: 'Unauthorized' }); } export function optionalAuth( @@ -42,4 +57,4 @@ export async function syncUser(user: AuthenticatedUser): Promise { email: user.email, }, }); -} \ No newline at end of file +} diff --git a/backend/src/routes/auth.routes.ts b/backend/src/routes/auth.routes.ts index c5ac858..584ade1 100644 --- a/backend/src/routes/auth.routes.ts +++ b/backend/src/routes/auth.routes.ts @@ -26,10 +26,11 @@ router.get("/login", async (req, res) => { try { await ensureOIDC(); - const session = createAuthSession(); + const redirectUri = req.query.redirect_uri as string | undefined; + const session = createAuthSession(redirectUri); req.session.oidc = session; - const authorizationUrl = getAuthorizationUrl(session); + const authorizationUrl = getAuthorizationUrl(session, redirectUri); res.redirect(authorizationUrl); } catch (error) { console.error("Login error:", error); @@ -88,4 +89,60 @@ router.get("/me", requireAuth, (req: AuthenticatedRequest, res) => { res.json(req.user); }); +// POST /auth/token - Exchange authorization code for tokens (for native apps) +// The session cookie set during /auth/login is sent automatically by ASWebAuthenticationSession, +// so req.session.oidc contains the original state/nonce for validation. +router.post("/token", async (req, res) => { + try { + await ensureOIDC(); + + const { code, state, code_verifier, redirect_uri } = req.body; + + if (!code || !state || !code_verifier || !redirect_uri) { + res.status(400).json({ error: "Missing required parameters: code, state, code_verifier, redirect_uri" }); + return; + } + + const oidcSession = req.session.oidc; + if (!oidcSession) { + res.status(400).json({ error: "No active OIDC session. Initiate login first." }); + return; + } + + // Validate that the returned state matches what was stored in the session + if (oidcSession.state !== state) { + res.status(400).json({ error: "State mismatch" }); + return; + } + + // Use the session's own codeVerifier (generated by the backend during /auth/login) + // rather than a client-supplied one, to prevent verifier substitution attacks. + const session = { + codeVerifier: oidcSession.codeVerifier, + state: oidcSession.state, + nonce: oidcSession.nonce, + redirectUri: redirect_uri, + }; + + const tokenSet = await handleCallback({ code, state }, session); + + const user = await getUserInfo(tokenSet); + await syncUser(user); + + // Clear OIDC session state now that it has been consumed + delete req.session.oidc; + + res.json({ + access_token: tokenSet.access_token, + id_token: tokenSet.id_token, + token_type: tokenSet.token_type, + expires_in: tokenSet.expires_in, + user, + }); + } catch (error) { + console.error("Token exchange error:", error); + res.status(500).json({ error: "Failed to exchange token" }); + } +}); + export default router; diff --git a/ios/TimeTracker/TimeTracker/Core/Auth/AuthManager.swift b/ios/TimeTracker/TimeTracker/Core/Auth/AuthManager.swift index fe0bdbb..2739349 100644 --- a/ios/TimeTracker/TimeTracker/Core/Auth/AuthManager.swift +++ b/ios/TimeTracker/TimeTracker/Core/Auth/AuthManager.swift @@ -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) } diff --git a/ios/TimeTracker/TimeTracker/Core/Auth/AuthService.swift b/ios/TimeTracker/TimeTracker/Core/Auth/AuthService.swift index f77bdfa..74d03ce 100644 --- a/ios/TimeTracker/TimeTracker/Core/Auth/AuthService.swift +++ b/ios/TimeTracker/TimeTracker/Core/Auth/AuthService.swift @@ -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 + } +} diff --git a/ios/TimeTracker/TimeTracker/Core/Network/APIClient.swift b/ios/TimeTracker/TimeTracker/Core/Network/APIClient.swift index c342917..514043a 100644 --- a/ios/TimeTracker/TimeTracker/Core/Network/APIClient.swift +++ b/ios/TimeTracker/TimeTracker/Core/Network/APIClient.swift @@ -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") diff --git a/ios/TimeTracker/TimeTracker/Core/Network/APIEndpoints.swift b/ios/TimeTracker/TimeTracker/Core/Network/APIEndpoints.swift index c4d80bb..f36d8ee 100644 --- a/ios/TimeTracker/TimeTracker/Core/Network/APIEndpoints.swift +++ b/ios/TimeTracker/TimeTracker/Core/Network/APIEndpoints.swift @@ -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" diff --git a/ios/TimeTracker/TimeTracker/Core/Persistence/SyncManager.swift b/ios/TimeTracker/TimeTracker/Core/Persistence/SyncManager.swift index b5e4762..53d9820 100644 --- a/ios/TimeTracker/TimeTracker/Core/Persistence/SyncManager.swift +++ b/ios/TimeTracker/TimeTracker/Core/Persistence/SyncManager.swift @@ -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() } } } } diff --git a/ios/TimeTracker/TimeTracker/Features/Auth/LoginView.swift b/ios/TimeTracker/TimeTracker/Features/Auth/LoginView.swift index bac2ce2..369d7cc 100644 --- a/ios/TimeTracker/TimeTracker/Features/Auth/LoginView.swift +++ b/ios/TimeTracker/TimeTracker/Features/Auth/LoginView.swift @@ -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 } } diff --git a/ios/TimeTracker/TimeTracker/Features/Clients/ClientsViewModel.swift b/ios/TimeTracker/TimeTracker/Features/Clients/ClientsViewModel.swift index d8f7510..1bf9a94 100644 --- a/ios/TimeTracker/TimeTracker/Features/Clients/ClientsViewModel.swift +++ b/ios/TimeTracker/TimeTracker/Features/Clients/ClientsViewModel.swift @@ -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, diff --git a/ios/TimeTracker/TimeTracker/Features/Projects/ProjectsViewModel.swift b/ios/TimeTracker/TimeTracker/Features/Projects/ProjectsViewModel.swift index cf59d55..f47af6d 100644 --- a/ios/TimeTracker/TimeTracker/Features/Projects/ProjectsViewModel.swift +++ b/ios/TimeTracker/TimeTracker/Features/Projects/ProjectsViewModel.swift @@ -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, diff --git a/ios/TimeTracker/TimeTracker/Features/TimeEntries/TimeEntriesView.swift b/ios/TimeTracker/TimeTracker/Features/TimeEntries/TimeEntriesView.swift index 56623e5..36ed16a 100644 --- a/ios/TimeTracker/TimeTracker/Features/TimeEntries/TimeEntriesView.swift +++ b/ios/TimeTracker/TimeTracker/Features/TimeEntries/TimeEntriesView.swift @@ -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() diff --git a/ios/TimeTracker/TimeTracker/Features/TimeEntries/TimeEntryFormView.swift b/ios/TimeTracker/TimeTracker/Features/TimeEntries/TimeEntryFormView.swift index 81394ca..bf7998e 100644 --- a/ios/TimeTracker/TimeTracker/Features/TimeEntries/TimeEntryFormView.swift +++ b/ios/TimeTracker/TimeTracker/Features/TimeEntries/TimeEntryFormView.swift @@ -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 } } } diff --git a/ios/TimeTracker/TimeTracker/Features/Timer/TimerView.swift b/ios/TimeTracker/TimeTracker/Features/Timer/TimerView.swift index a00b469..2b59d03 100644 --- a/ios/TimeTracker/TimeTracker/Features/Timer/TimerView.swift +++ b/ios/TimeTracker/TimeTracker/Features/Timer/TimerView.swift @@ -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) } } } diff --git a/ios/TimeTracker/TimeTracker/Features/Timer/TimerViewModel.swift b/ios/TimeTracker/TimeTracker/Features/Timer/TimerViewModel.swift index f2bcc93..de1dd9c 100644 --- a/ios/TimeTracker/TimeTracker/Features/Timer/TimerViewModel.swift +++ b/ios/TimeTracker/TimeTracker/Features/Timer/TimerViewModel.swift @@ -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, diff --git a/ios/TimeTracker/TimeTracker/Info.plist b/ios/TimeTracker/TimeTracker/Info.plist index 8105d63..0ec5689 100644 --- a/ios/TimeTracker/TimeTracker/Info.plist +++ b/ios/TimeTracker/TimeTracker/Info.plist @@ -2,65 +2,65 @@ - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) - CFBundleShortVersionString - $(MARKETING_VERSION) - CFBundleVersion - $(CURRENT_PROJECT_VERSION) - LSRequiresIPhoneOS - - UIApplicationSceneManifest - - UIApplicationSupportsMultipleScenes - - - UILaunchScreen - - UIColorName - LaunchBackground - - UIRequiredDeviceCapabilities - - armv7 - - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - CFBundleURLTypes - - - CFBundleTypeRole - Editor - CFBundleURLName - com.timetracker.app - CFBundleURLSchemes - - timetracker - - - - API_BASE_URL - $(API_BASE_URL) + API_BASE_URL + https://timetracker.simon-franken.de/api + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + com.timetracker.app + CFBundleURLSchemes + + timetracker + + + + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UILaunchScreen + + UIColorName + LaunchBackground + + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + diff --git a/ios/TimeTracker/TimeTracker/Models/Client.swift b/ios/TimeTracker/TimeTracker/Models/Client.swift index 9d683ba..9ed6d81 100644 --- a/ios/TimeTracker/TimeTracker/Models/Client.swift +++ b/ios/TimeTracker/TimeTracker/Models/Client.swift @@ -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? diff --git a/ios/TimeTracker/TimeTracker/Models/Project.swift b/ios/TimeTracker/TimeTracker/Models/Project.swift index 4079baf..be34552 100644 --- a/ios/TimeTracker/TimeTracker/Models/Project.swift +++ b/ios/TimeTracker/TimeTracker/Models/Project.swift @@ -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 } diff --git a/ios/TimeTracker/TimeTracker/Models/TimeEntry.swift b/ios/TimeTracker/TimeTracker/Models/TimeEntry.swift index bf553f5..38cd007 100644 --- a/ios/TimeTracker/TimeTracker/Models/TimeEntry.swift +++ b/ios/TimeTracker/TimeTracker/Models/TimeEntry.swift @@ -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,