This commit is contained in:
2026-02-18 22:37:49 +01:00
parent ed180500a6
commit 7e8e220e3b
20 changed files with 320 additions and 148 deletions

View File

@@ -8,9 +8,14 @@ export async function initializeOIDC(): Promise<void> {
try { try {
const issuer = await Issuer.discover(config.oidc.issuerUrl); 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({ oidcClient = new issuer.Client({
client_id: config.oidc.clientId, client_id: config.oidc.clientId,
redirect_uris: [config.oidc.redirectUri], redirect_uris: redirectUris,
response_types: ['code'], response_types: ['code'],
token_endpoint_auth_method: 'none', // PKCE flow - no client secret token_endpoint_auth_method: 'none', // PKCE flow - no client secret
}); });
@@ -33,27 +38,35 @@ export interface AuthSession {
codeVerifier: string; codeVerifier: string;
state: string; state: string;
nonce: string; nonce: string;
redirectUri?: string;
} }
export function createAuthSession(): AuthSession { export function createAuthSession(redirectUri?: string): AuthSession {
return { return {
codeVerifier: generators.codeVerifier(), codeVerifier: generators.codeVerifier(),
state: generators.state(), state: generators.state(),
nonce: generators.nonce(), nonce: generators.nonce(),
redirectUri,
}; };
} }
export function getAuthorizationUrl(session: AuthSession): string { export function getAuthorizationUrl(session: AuthSession, redirectUri?: string): string {
const client = getOIDCClient(); const client = getOIDCClient();
const codeChallenge = generators.codeChallenge(session.codeVerifier); const codeChallenge = generators.codeChallenge(session.codeVerifier);
return client.authorizationUrl({ const params: Record<string, string> = {
scope: 'openid profile email', scope: 'openid profile email',
state: session.state, state: session.state,
nonce: session.nonce, nonce: session.nonce,
code_challenge: codeChallenge, code_challenge: codeChallenge,
code_challenge_method: 'S256', code_challenge_method: 'S256',
}); };
if (redirectUri) {
params.redirect_uri = redirectUri;
}
return client.authorizationUrl(params);
} }
export async function handleCallback( export async function handleCallback(
@@ -62,8 +75,10 @@ export async function handleCallback(
): Promise<TokenSet> { ): Promise<TokenSet> {
const client = getOIDCClient(); const client = getOIDCClient();
const redirectUri = session.redirectUri || config.oidc.redirectUri;
const tokenSet = await client.callback( const tokenSet = await client.callback(
config.oidc.redirectUri, redirectUri,
params, params,
{ {
code_verifier: session.codeVerifier, code_verifier: session.codeVerifier,
@@ -115,3 +130,20 @@ export async function verifyToken(tokenSet: TokenSet): Promise<boolean> {
return false; return false;
} }
} }
export async function verifyBearerToken(accessToken: string): Promise<AuthenticatedUser> {
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 };
}

View File

@@ -17,6 +17,7 @@ export const config = {
redirectUri: redirectUri:
process.env.OIDC_REDIRECT_URI || process.env.OIDC_REDIRECT_URI ||
"http://localhost:3001/api/auth/callback", "http://localhost:3001/api/auth/callback",
iosRedirectUri: process.env.OIDC_IOS_REDIRECT_URI || "timetracker://oauth/callback",
}, },
session: { session: {

View File

@@ -1,19 +1,34 @@
import { Request, Response, NextFunction } from 'express'; import { Request, Response, NextFunction } from 'express';
import { prisma } from '../prisma/client'; import { prisma } from '../prisma/client';
import type { AuthenticatedRequest, AuthenticatedUser } from '../types'; import type { AuthenticatedRequest, AuthenticatedUser } from '../types';
import { getOIDCClient, verifyBearerToken } from '../auth/oidc';
export function requireAuth( export async function requireAuth(
req: AuthenticatedRequest, req: AuthenticatedRequest,
res: Response, res: Response,
next: NextFunction next: NextFunction
): void { ): Promise<void> {
if (!req.session?.user) { // 1. Session-based auth (web frontend)
res.status(401).json({ error: 'Unauthorized' }); if (req.session?.user) {
return; req.user = req.session.user as AuthenticatedUser;
return next();
} }
req.user = req.session.user as AuthenticatedUser; // 2. Bearer token auth (iOS / native clients)
next(); 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( export function optionalAuth(

View File

@@ -26,10 +26,11 @@ router.get("/login", async (req, res) => {
try { try {
await ensureOIDC(); await ensureOIDC();
const session = createAuthSession(); const redirectUri = req.query.redirect_uri as string | undefined;
const session = createAuthSession(redirectUri);
req.session.oidc = session; req.session.oidc = session;
const authorizationUrl = getAuthorizationUrl(session); const authorizationUrl = getAuthorizationUrl(session, redirectUri);
res.redirect(authorizationUrl); res.redirect(authorizationUrl);
} catch (error) { } catch (error) {
console.error("Login error:", error); console.error("Login error:", error);
@@ -88,4 +89,60 @@ router.get("/me", requireAuth, (req: AuthenticatedRequest, res) => {
res.json(req.user); 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; export default router;

View File

@@ -81,6 +81,13 @@ final class AuthManager: ObservableObject {
isAuthenticated = false isAuthenticated = false
} }
func handleTokenResponse(_ response: TokenResponse) async {
accessToken = response.accessToken
idToken = response.idToken
currentUser = response.user
isAuthenticated = true
}
var loginURL: URL { var loginURL: URL {
APIEndpoints.url(for: APIEndpoint.login) APIEndpoints.url(for: APIEndpoint.login)
} }

View File

@@ -15,21 +15,13 @@ final class AuthService: NSObject {
func login(presentationAnchor: ASPresentationAnchor?) async throws { func login(presentationAnchor: ASPresentationAnchor?) async throws {
self.presentationAnchor = presentationAnchor self.presentationAnchor = presentationAnchor
let codeVerifier = generateCodeVerifier() // Only the redirect_uri is needed the backend owns PKCE generation.
let codeChallenge = generateCodeChallenge(from: codeVerifier)
let session = UUID().uuidString
UserDefaults.standard.set(codeVerifier, forKey: "oidc_code_verifier_\(session)")
var components = URLComponents( var components = URLComponents(
url: AppConfig.apiBaseURL.appendingPathComponent(APIEndpoint.login), url: AppConfig.apiBaseURL.appendingPathComponent(APIEndpoint.login),
resolvingAgainstBaseURL: true resolvingAgainstBaseURL: true
) )
components?.queryItems = [ 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) URLQueryItem(name: "redirect_uri", value: AppConfig.authCallbackURL)
] ]
@@ -39,6 +31,8 @@ final class AuthService: NSObject {
let callbackScheme = URL(string: AppConfig.authCallbackURL)?.scheme ?? "timetracker" 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( let webAuthSession = ASWebAuthenticationSession(
url: authURL, url: authURL,
callbackURLScheme: callbackScheme callbackURLScheme: callbackScheme
@@ -71,10 +65,12 @@ final class AuthService: NSObject {
return return
} }
self?.handleCallback(url: callbackURL, session: session) self?.handleCallback(url: callbackURL)
} }
webAuthSession.presentationContextProvider = self 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 webAuthSession.prefersEphemeralWebBrowserSession = false
self.authSession = webAuthSession 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), 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 return
} }
let codeVerifier = UserDefaults.standard.string(forKey: "oidc_code_verifier_\(session)") Task {
do {
let tokenResponse = try await exchangeCodeForTokens(
code: code,
state: state,
redirectUri: AppConfig.authCallbackURL
)
DispatchQueue.main.async { await AuthManager.shared.handleTokenResponse(tokenResponse)
NotificationCenter.default.post(
name: .authCallbackReceived, DispatchQueue.main.async {
object: nil, NotificationCenter.default.post(name: .authCallbackReceived, object: nil)
userInfo: [ }
"code": code, } catch {
"codeVerifier": codeVerifier ?? "" DispatchQueue.main.async {
] NotificationCenter.default.post(
) name: .authError,
object: nil,
userInfo: ["error": AuthError.failed(error.localizedDescription)]
)
}
}
} }
} }
private func generateCodeVerifier() -> String { private func exchangeCodeForTokens(
var buffer = [UInt8](repeating: 0, count: 32) code: String,
_ = SecRandomCopyBytes(kSecRandomDefault, buffer.count, &buffer) state: String,
return Data(buffer).base64EncodedString() redirectUri: String
.replacingOccurrences(of: "+", with: "-") ) async throws -> TokenResponse {
.replacingOccurrences(of: "/", with: "_") let url = AppConfig.apiBaseURL.appendingPathComponent(APIEndpoint.token)
.replacingOccurrences(of: "=", with: "")
}
private func generateCodeChallenge(from verifier: String) -> String { var request = URLRequest(url: url)
guard let data = verifier.data(using: .ascii) else { return "" } request.httpMethod = "POST"
let hash = SHA256.hash(data: data) request.setValue("application/json", forHTTPHeaderField: "Content-Type")
return Data(hash).base64EncodedString()
.replacingOccurrences(of: "+", with: "-") // code_verifier is intentionally omitted the backend uses its own verifier
.replacingOccurrences(of: "/", with: "_") // that was generated during /auth/login and stored in the server-side session.
.replacingOccurrences(of: "=", with: "") // 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 authCallbackReceived = Notification.Name("authCallbackReceived")
static let authError = Notification.Name("authError") 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
}
}

View File

@@ -41,7 +41,8 @@ actor APIClient {
request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue("application/json", forHTTPHeaderField: "Accept")
if authenticated { 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 throw NetworkError.unauthorized
} }
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
@@ -111,7 +112,8 @@ actor APIClient {
request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue("application/json", forHTTPHeaderField: "Accept")
if authenticated { 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 throw NetworkError.unauthorized
} }
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")

View File

@@ -4,6 +4,7 @@ enum APIEndpoint {
// Auth // Auth
static let login = "/auth/login" static let login = "/auth/login"
static let callback = "/auth/callback" static let callback = "/auth/callback"
static let token = "/auth/token"
static let logout = "/auth/logout" static let logout = "/auth/logout"
static let me = "/auth/me" static let me = "/auth/me"

View File

@@ -22,7 +22,7 @@ final class SyncManager: ObservableObject {
DispatchQueue.main.async { DispatchQueue.main.async {
self?.isOnline = path.status == .satisfied self?.isOnline = path.status == .satisfied
if path.status == .satisfied { if path.status == .satisfied {
self?.syncPendingChanges() Task { await self?.syncPendingChanges() }
} }
} }
} }

View File

@@ -81,11 +81,9 @@ struct LoginView: View {
} }
private func handleAuthCallback(_ userInfo: [AnyHashable: Any]?) { private func handleAuthCallback(_ userInfo: [AnyHashable: Any]?) {
Task { // AuthManager.handleTokenResponse() already set isAuthenticated = true
await authManager.checkAuthState() // and populated currentUser during the token exchange in AuthService.
await MainActor.run { // No further network call is needed here.
isLoading = false isLoading = false
}
}
} }
} }

View File

@@ -38,7 +38,7 @@ final class ClientsViewModel: ObservableObject {
do { do {
let input = CreateClientInput(name: name, description: description) let input = CreateClientInput(name: name, description: description)
_ = try await apiClient.request( try await apiClient.requestVoid(
endpoint: APIEndpoint.clients, endpoint: APIEndpoint.clients,
method: .post, method: .post,
body: input, body: input,
@@ -57,7 +57,7 @@ final class ClientsViewModel: ObservableObject {
do { do {
let input = UpdateClientInput(name: name, description: description) let input = UpdateClientInput(name: name, description: description)
_ = try await apiClient.request( try await apiClient.requestVoid(
endpoint: APIEndpoint.client(id: id), endpoint: APIEndpoint.client(id: id),
method: .put, method: .put,
body: input, body: input,

View File

@@ -50,7 +50,7 @@ final class ProjectsViewModel: ObservableObject {
color: color, color: color,
clientId: clientId clientId: clientId
) )
_ = try await apiClient.request( try await apiClient.requestVoid(
endpoint: APIEndpoint.projects, endpoint: APIEndpoint.projects,
method: .post, method: .post,
body: input, body: input,
@@ -74,7 +74,7 @@ final class ProjectsViewModel: ObservableObject {
color: color, color: color,
clientId: clientId clientId: clientId
) )
_ = try await apiClient.request( try await apiClient.requestVoid(
endpoint: APIEndpoint.project(id: id), endpoint: APIEndpoint.project(id: id),
method: .put, method: .put,
body: input, body: input,

View File

@@ -79,7 +79,7 @@ struct TimeEntryRow: View {
} }
HStack { HStack {
Text(formatDateRange(entry.startTime, entry.endTime)) Text(formatDateRange(start: entry.startTime, end: entry.endTime))
.font(.subheadline) .font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Spacer() Spacer()

View File

@@ -119,11 +119,11 @@ struct TimeEntryFormView: View {
let startDateTime = calendar.date(bySettingHour: calendar.component(.hour, from: startTime), let startDateTime = calendar.date(bySettingHour: calendar.component(.hour, from: startTime),
minute: calendar.component(.minute, from: startTime), minute: calendar.component(.minute, from: startTime),
second: 0, second: 0,
on: startDate) ?? startDate of: startDate) ?? startDate
let endDateTime = calendar.date(bySettingHour: calendar.component(.hour, from: endTime), let endDateTime = calendar.date(bySettingHour: calendar.component(.hour, from: endTime),
minute: calendar.component(.minute, from: endTime), minute: calendar.component(.minute, from: endTime),
second: 0, second: 0,
on: endDate) ?? endDate of: endDate) ?? endDate
Task { Task {
do { do {
@@ -134,7 +134,7 @@ struct TimeEntryFormView: View {
description: description.isEmpty ? nil : description, description: description.isEmpty ? nil : description,
projectId: project.id projectId: project.id
) )
_ = try await apiClient.request( try await apiClient.requestVoid(
endpoint: APIEndpoint.timeEntry(id: existingEntry.id), endpoint: APIEndpoint.timeEntry(id: existingEntry.id),
method: .put, method: .put,
body: input, body: input,
@@ -147,7 +147,7 @@ struct TimeEntryFormView: View {
description: description.isEmpty ? nil : description, description: description.isEmpty ? nil : description,
projectId: project.id projectId: project.id
) )
_ = try await apiClient.request( try await apiClient.requestVoid(
endpoint: APIEndpoint.timeEntries, endpoint: APIEndpoint.timeEntries,
method: .post, method: .post,
body: input, body: input,
@@ -161,9 +161,10 @@ struct TimeEntryFormView: View {
onSave() onSave()
} }
} catch { } catch {
let errorMessage = error.localizedDescription
await MainActor.run { await MainActor.run {
isLoading = false isLoading = false
error = error.localizedDescription self.error = errorMessage
} }
} }
} }

View File

@@ -44,11 +44,16 @@ struct TimerView: View {
.font(.system(size: 64, weight: .light, design: .monospaced)) .font(.system(size: 64, weight: .light, design: .monospaced))
.foregroundStyle(viewModel.activeTimer != nil ? .primary : .secondary) .foregroundStyle(viewModel.activeTimer != nil ? .primary : .secondary)
if let project = viewModel.selectedProject ?? viewModel.activeTimer?.project { if let project = viewModel.selectedProject {
ProjectColorBadge( ProjectColorBadge(
color: project.color, color: project.color,
name: project.name name: project.name
) )
} else if let timerProject = viewModel.activeTimer?.project {
ProjectColorBadge(
color: timerProject.color,
name: timerProject.name
)
} else { } else {
Text("No project selected") Text("No project selected")
.font(.subheadline) .font(.subheadline)
@@ -137,7 +142,7 @@ struct ProjectPickerSheet: View {
.foregroundStyle(.primary) .foregroundStyle(.primary)
if selectedProject == nil { if selectedProject == nil {
Image(systemName: "checkmark") Image(systemName: "checkmark")
.foregroundStyle(.accent) .foregroundStyle(Color.accentColor)
} }
} }
} }
@@ -155,7 +160,7 @@ struct ProjectPickerSheet: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
if selectedProject?.id == project.id { if selectedProject?.id == project.id {
Image(systemName: "checkmark") Image(systemName: "checkmark")
.foregroundStyle(.accent) .foregroundStyle(Color.accentColor)
} }
} }
} }

View File

@@ -102,7 +102,7 @@ final class TimerViewModel: ObservableObject {
do { do {
let input = StopTimerInput(projectId: projectId) let input = StopTimerInput(projectId: projectId)
_ = try await apiClient.request( try await apiClient.requestVoid(
endpoint: APIEndpoint.timerStop, endpoint: APIEndpoint.timerStop,
method: .post, method: .post,
body: input, body: input,

View File

@@ -2,65 +2,65 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>CFBundleDevelopmentRegion</key> <key>API_BASE_URL</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>https://timetracker.simon-franken.de/api</string>
<key>CFBundleExecutable</key> <key>CFBundleDevelopmentRegion</key>
<string>$(EXECUTABLE_NAME)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleIdentifier</key> <key>CFBundleExecutable</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundleInfoDictionaryVersion</key> <key>CFBundleIdentifier</key>
<string>6.0</string> <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleName</key> <key>CFBundleInfoDictionaryVersion</key>
<string>$(PRODUCT_NAME)</string> <string>6.0</string>
<key>CFBundlePackageType</key> <key>CFBundleName</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string> <string>$(PRODUCT_NAME)</string>
<key>CFBundleShortVersionString</key> <key>CFBundlePackageType</key>
<string>$(MARKETING_VERSION)</string> <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleVersion</key> <key>CFBundleShortVersionString</key>
<string>$(CURRENT_PROJECT_VERSION)</string> <string>$(MARKETING_VERSION)</string>
<key>LSRequiresIPhoneOS</key> <key>CFBundleURLTypes</key>
<true/> <array>
<key>UIApplicationSceneManifest</key> <dict>
<dict> <key>CFBundleTypeRole</key>
<key>UIApplicationSupportsMultipleScenes</key> <string>Editor</string>
<false/> <key>CFBundleURLName</key>
</dict> <string>com.timetracker.app</string>
<key>UILaunchScreen</key> <key>CFBundleURLSchemes</key>
<dict> <array>
<key>UIColorName</key> <string>timetracker</string>
<string>LaunchBackground</string> </array>
</dict> </dict>
<key>UIRequiredDeviceCapabilities</key> </array>
<array> <key>CFBundleVersion</key>
<string>armv7</string> <string>$(CURRENT_PROJECT_VERSION)</string>
</array> <key>LSRequiresIPhoneOS</key>
<key>UISupportedInterfaceOrientations</key> <true/>
<array> <key>UIApplicationSceneManifest</key>
<string>UIInterfaceOrientationPortrait</string> <dict>
<string>UIInterfaceOrientationLandscapeLeft</string> <key>UIApplicationSupportsMultipleScenes</key>
<string>UIInterfaceOrientationLandscapeRight</string> <false/>
</array> </dict>
<key>UISupportedInterfaceOrientations~ipad</key> <key>UILaunchScreen</key>
<array> <dict>
<string>UIInterfaceOrientationPortrait</string> <key>UIColorName</key>
<string>UIInterfaceOrientationPortraitUpsideDown</string> <string>LaunchBackground</string>
<string>UIInterfaceOrientationLandscapeLeft</string> </dict>
<string>UIInterfaceOrientationLandscapeRight</string> <key>UIRequiredDeviceCapabilities</key>
</array> <array>
<key>CFBundleURLTypes</key> <string>armv7</string>
<array> </array>
<dict> <key>UISupportedInterfaceOrientations</key>
<key>CFBundleTypeRole</key> <array>
<string>Editor</string> <string>UIInterfaceOrientationPortrait</string>
<key>CFBundleURLName</key> <string>UIInterfaceOrientationLandscapeLeft</string>
<string>com.timetracker.app</string> <string>UIInterfaceOrientationLandscapeRight</string>
<key>CFBundleURLSchemes</key> </array>
<array> <key>UISupportedInterfaceOrientations~ipad</key>
<string>timetracker</string> <array>
</array> <string>UIInterfaceOrientationPortrait</string>
</dict> <string>UIInterfaceOrientationPortraitUpsideDown</string>
</array> <string>UIInterfaceOrientationLandscapeLeft</string>
<key>API_BASE_URL</key> <string>UIInterfaceOrientationLandscapeRight</string>
<string>$(API_BASE_URL)</string> </array>
</dict> </dict>
</plist> </plist>

View File

@@ -1,6 +1,6 @@
import Foundation import Foundation
struct Client: Codable, Identifiable, Equatable { struct Client: Codable, Identifiable, Equatable, Hashable {
let id: String let id: String
let name: String let name: String
let description: String? let description: String?

View File

@@ -1,6 +1,6 @@
import Foundation import Foundation
struct Project: Codable, Identifiable, Equatable { struct Project: Codable, Identifiable, Equatable, Hashable {
let id: String let id: String
let name: String let name: String
let description: String? let description: String?
@@ -11,7 +11,7 @@ struct Project: Codable, Identifiable, Equatable {
let updatedAt: String let updatedAt: String
} }
struct ClientReference: Codable, Equatable { struct ClientReference: Codable, Equatable, Hashable {
let id: String let id: String
let name: String let name: String
} }

View File

@@ -39,12 +39,12 @@ struct Pagination: Codable, Equatable {
} }
struct TimeEntryFilters: Codable { struct TimeEntryFilters: Codable {
let startDate: String? var startDate: String?
let endDate: String? var endDate: String?
let projectId: String? var projectId: String?
let clientId: String? var clientId: String?
let page: Int? var page: Int?
let limit: Int? var limit: Int?
init( init(
startDate: Date? = nil, startDate: Date? = nil,