update
This commit is contained in:
@@ -8,9 +8,14 @@ export async function initializeOIDC(): Promise<void> {
|
||||
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<string, string> = {
|
||||
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<TokenSet> {
|
||||
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<boolean> {
|
||||
} catch {
|
||||
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 };
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<void> {
|
||||
// 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<void> {
|
||||
email: user.email,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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