update
This commit is contained in:
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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?
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user