import SwiftUI // MARK: - API Infrastructure enum APIError: LocalizedError { case invalidURL case unauthorized case serverError(Int, String?) case networkError(Error) case decodingError var errorDescription: String? { switch self { case .invalidURL: return "Ogiltig URL" case .unauthorized: return "Felaktiga inloggningsuppgifter" case .serverError(let c, let m): return m ?? "Serverfel (\(c))" case .networkError(let e): return "Nätverksfel: \(e.localizedDescription)" case .decodingError: return "Dataformatfel från servern" } } } final class APIClient { static let shared = APIClient() var baseURL = "https://api.aamos.io/v1" private var token: String? private let session: URLSession private init() { let cfg = URLSessionConfiguration.default cfg.timeoutIntervalForRequest = 30 cfg.timeoutIntervalForResource = 60 session = URLSession(configuration: cfg) } func setToken(_ token: String?) { self.token = token } func get(_ path: String) async throws -> T { try await request(path, method: "GET") } func post(_ path: String, body: B) async throws -> T { try await request(path, method: "POST", body: try JSONEncoder().encode(body)) } func patch(_ path: String, body: B) async throws -> T { try await request(path, method: "PATCH", body: try JSONEncoder().encode(body)) } func delete(_ path: String) async throws { let _: EmptyResponse = try await request(path, method: "DELETE") } private struct EmptyResponse: Decodable {} private struct ServerError: Decodable { let message: String? } private func request(_ path: String, method: String, body: Data? = nil) async throws -> T { guard let url = URL(string: baseURL + path) else { throw APIError.invalidURL } var req = URLRequest(url: url) req.httpMethod = method req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.setValue("application/json", forHTTPHeaderField: "Accept") if let token { req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } req.httpBody = body let data: Data let response: URLResponse do { (data, response) = try await session.data(for: req) } catch { throw APIError.networkError(error) } if let http = response as? HTTPURLResponse { if http.statusCode == 401 { throw APIError.unauthorized } if http.statusCode >= 400 { let msg = try? JSONDecoder().decode(ServerError.self, from: data) throw APIError.serverError(http.statusCode, msg?.message) } } let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 decoder.keyDecodingStrategy = .convertFromSnakeCase guard let result = try? decoder.decode(T.self, from: data) else { throw APIError.decodingError } return result } } // MARK: - Auth Model struct AdminUser: Codable, Identifiable { let id: String let name: String let email: String let role: String } // MARK: - Auth ViewModel @MainActor final class AuthViewModel: ObservableObject { @Published var isAuthenticated = false @Published var isLoading = false @Published var errorMessage: String? @Published var currentUser: AdminUser? private let api = APIClient.shared private struct LoginRequest: Encodable { let email: String let password: String } private struct LoginResponse: Decodable { let token: String let user: AdminUser } func login(email: String, password: String) async { guard !email.trimmingCharacters(in: .whitespaces).isEmpty, !password.isEmpty else { errorMessage = "Fyll i e-post och lösenord" return } isLoading = true errorMessage = nil defer { isLoading = false } do { let response: LoginResponse = try await api.post( "/auth/login", body: LoginRequest(email: email, password: password) ) api.setToken(response.token) currentUser = response.user isAuthenticated = true } catch { errorMessage = error.localizedDescription } } func logout() { api.setToken(nil) currentUser = nil isAuthenticated = false } } // MARK: - Login View struct LoginView: View { @EnvironmentObject private var auth: AuthViewModel @State private var email = "" @State private var password = "" @FocusState private var focused: LoginField? private enum LoginField { case email, password } var body: some View { ZStack { Color(.systemBackground).ignoresSafeArea() ScrollView { VStack(spacing: 0) { Spacer(minLength: 72) logoSection Spacer(minLength: 48) formSection Spacer(minLength: 40) Text("AAMOS Admin v1.0") .font(.caption2) .foregroundStyle(.tertiary) .padding(.bottom, 8) } .padding(.horizontal, 24) } } } private var logoSection: some View { VStack(spacing: 14) { ZStack { RoundedRectangle(cornerRadius: 22, style: .continuous) .fill(Color(.systemBlue).opacity(0.12)) .frame(width: 88, height: 88) Image(systemName: "shield.checkered") .font(.system(size: 44, weight: .semibold)) .foregroundStyle(Color(.systemBlue)) } Text("AAMOS Admin") .font(.system(size: 28, weight: .bold)) .foregroundStyle(.primary) Text("Administrationsportal") .font(.subheadline) .foregroundStyle(.secondary) } } private var formSection: some View { VStack(spacing: 16) { VStack(spacing: 8) { TextField("E-postadress", text: $email) .textContentType(.emailAddress) .keyboardType(.emailAddress) .autocapitalization(.none) .autocorrectionDisabled() .focused($focused, equals: .email) .submitLabel(.next) .onSubmit { focused = .password } .padding(16) .background(Color(.secondarySystemBackground)) .cornerRadius(12) SecureField("Lösenord", text: $password) .textContentType(.password) .focused($focused, equals: .password) .submitLabel(.go) .onSubmit { focused = nil Task { await auth.login(email: email, password: password) } } .padding(16) .background(Color(.secondarySystemBackground)) .cornerRadius(12) } if let error = auth.errorMessage { HStack(spacing: 8) { Image(systemName: "exclamationmark.circle.fill") .font(.callout) .foregroundStyle(Color(.systemRed)) Text(error) .font(.callout) .foregroundStyle(Color(.systemRed)) } .padding(12) .frame(maxWidth: .infinity, alignment: .leading) .background(Color(.systemRed).opacity(0.08)) .cornerRadius(12) .transition(.opacity.combined(with: .move(edge: .top))) } Button { focused = nil Task { await auth.login(email: email, password: password) } } label: { Group { if auth.isLoading { ProgressView().tint(.white).scaleEffect(0.9) } else { Text("Logga in").font(.headline) } } .frame(maxWidth: .infinity) .frame(height: 52) .background(auth.isLoading ? Color(.systemBlue).opacity(0.7) : Color(.systemBlue)) .foregroundStyle(.white) .cornerRadius(12) } .disabled(auth.isLoading) .animation(.easeInOut(duration: 0.15), value: auth.isLoading) } } } // MARK: - App Entry Point @main struct AamosAdminApp: App { @StateObject private var auth = AuthViewModel() var body: some Scene { WindowGroup { Group { if auth.isAuthenticated { ContentView() .environmentObject(auth) .transition(.opacity) } else { LoginView() .environmentObject(auth) .transition(.opacity) } } .animation(.easeInOut(duration: 0.25), value: auth.isAuthenticated) } } }