main
swift 589 lines 17 KB
Raw
1 import Foundation
2
3 struct AccountUser: Decodable, Equatable, Hashable, Sendable {
4 let id: Int
5 let email: String
6 let name: String?
7 let username: String?
8 let createdAt: String
9 let updatedAt: String
10 let passwordRulesSummary: String?
11
12 var displayName: String {
13 let trimmedName = name?.trimmingCharacters(in: .whitespacesAndNewlines)
14 if let trimmedName, !trimmedName.isEmpty {
15 return trimmedName
16 }
17
18 return email
19 }
20
21 var preferredTitle: String {
22 let trimmedName = name?.trimmingCharacters(in: .whitespacesAndNewlines)
23 if let trimmedName, !trimmedName.isEmpty {
24 return trimmedName
25 }
26
27 if let usernameDisplay {
28 return usernameDisplay
29 }
30
31 return email
32 }
33
34 var usernameDisplay: String? {
35 let trimmedUsername = username?.trimmingCharacters(in: .whitespacesAndNewlines)
36 guard let trimmedUsername, !trimmedUsername.isEmpty else { return nil }
37 return "@\(trimmedUsername)"
38 }
39 }
40
41 struct AccountSignupResult: Decodable, Equatable, Hashable, Sendable {
42 let code: Int?
43 let message: String
44 let userId: Int?
45 let userEmail: String?
46 let userCreatedAt: String?
47 }
48
49 private enum AccountLoginStatus: Decodable, Sendable {
50 case notFound
51 case ready(accessToken: String)
52 case incomplete(errorCode: UInt32)
53
54 private enum CodingKeys: String, CodingKey {
55 case status
56 case accessToken
57 case errorCode
58 }
59
60 init(from decoder: Decoder) throws {
61 let container = try decoder.container(keyedBy: CodingKeys.self)
62 let status = try container.decode(String.self, forKey: .status)
63
64 switch status {
65 case "notFound":
66 self = .notFound
67 case "ready":
68 self = .ready(accessToken: try container.decode(String.self, forKey: .accessToken))
69 case "incomplete":
70 self = .incomplete(errorCode: try container.decode(UInt32.self, forKey: .errorCode))
71 default:
72 throw DecodingError.dataCorruptedError(
73 forKey: .status,
74 in: container,
75 debugDescription: "Unsupported login status \(status)"
76 )
77 }
78 }
79 }
80
81 private struct AccountProxyError: Decodable {
82 let errorCode: Int?
83 let message: String?
84 let error: String?
85 }
86
87 private struct AccountActionMessage: Decodable {
88 let message: String
89 }
90
91 enum AccountProxyEnvironment: String, CaseIterable, Identifiable {
92 case development
93 case production
94
95 var id: String { rawValue }
96
97 var displayName: String {
98 switch self {
99 case .development: return "Development"
100 case .production: return "Production"
101 }
102 }
103
104 var baseURL: URL {
105 switch self {
106 case .development:
107 let host = Bundle.main.object(forInfoDictionaryKey: "OndeWebDevHost") as? String
108 let port = Bundle.main.object(forInfoDictionaryKey: "OndeWebDevPort") as? String
109 let resolvedHost = (host?.isEmpty == false) ? host! : "localhost"
110 let resolvedPort = (port?.isEmpty == false) ? port! : "3000"
111 return URL(string: "http://\(resolvedHost):\(resolvedPort)")!
112 case .production:
113 return URL(string: "https://ondeinference.com")!
114 }
115 }
116
117 var badgeColor: String {
118 switch self {
119 case .development: return "orange"
120 case .production: return "green"
121 }
122 }
123 }
124
125 @MainActor
126 final class AccountStore: ObservableObject {
127 @Published private(set) var currentUser: AccountUser?
128 @Published private(set) var accessToken: String?
129 @Published private(set) var isBusy = false
130 @Published var notice: String?
131 @Published var errorMessage: String?
132
133 @Published private(set) var proxyEnvironment: AccountProxyEnvironment
134
135 private let userDefaults: UserDefaults
136 private let session: URLSession
137 private let decoder: JSONDecoder
138 private var gatewayBaseURL: URL
139 private var hasAttemptedRestore = false
140
141 init(
142 userDefaults: UserDefaults = .standard,
143 session: URLSession = .shared
144 ) {
145 let savedEnvironmentRaw = userDefaults.string(forKey: Keys.proxyEnvironment)
146 let savedEnvironment =
147 savedEnvironmentRaw.flatMap(AccountProxyEnvironment.init(rawValue:)) ?? .production
148
149 self.userDefaults = userDefaults
150 self.session = session
151 self.proxyEnvironment = savedEnvironment
152 self.gatewayBaseURL = savedEnvironment.baseURL
153 self.accessToken = userDefaults.string(forKey: Keys.accessToken)
154 self.currentUser = nil
155
156 let decoder = JSONDecoder()
157 decoder.keyDecodingStrategy = .convertFromSnakeCase
158 self.decoder = decoder
159 }
160
161 /// Switches the active proxy environment, signs out, and persists the choice.
162 func switchEnvironment(_ environment: AccountProxyEnvironment) {
163 guard environment != proxyEnvironment else { return }
164 proxyEnvironment = environment
165 gatewayBaseURL = environment.baseURL
166 userDefaults.set(environment.rawValue, forKey: Keys.proxyEnvironment)
167 hasAttemptedRestore = false
168 signOutLocally()
169 notice = "Switched to \(environment.displayName). Sign in again."
170 }
171
172 var isSignedIn: Bool {
173 currentUser != nil && accessToken != nil
174 }
175
176 var statusTitle: String {
177 if let currentUser {
178 return currentUser.preferredTitle
179 }
180
181 return "Signed out"
182 }
183
184 var statusDetail: String {
185 if currentUser != nil {
186 return "Your account is ready on this device."
187 }
188
189 return "Sign in or create an account to keep your profile ready in Klepon."
190 }
191
192 func restoreSessionIfNeeded() async {
193 guard !hasAttemptedRestore else { return }
194 hasAttemptedRestore = true
195
196 guard accessToken != nil else { return }
197
198 await refreshProfile(showNoticeOnSuccess: false)
199 }
200
201 @discardableResult
202 func login(email: String, password: String) async -> Bool {
203 isBusy = true
204 clearTransientMessages()
205 defer { isBusy = false }
206
207 do {
208 let status: AccountLoginStatus = try await requestJSON(
209 path: "api/auth/sign-in",
210 method: "POST",
211 payload: [
212 "email": normalized(email),
213 "password": password,
214 ]
215 )
216
217 switch status {
218 case .ready(let accessToken):
219 self.accessToken = accessToken
220 userDefaults.set(accessToken, forKey: Keys.accessToken)
221 return await refreshProfile(showNoticeOnSuccess: true)
222
223 case .notFound:
224 errorMessage = "We could not find an account for that email address."
225 return false
226
227 case .incomplete:
228 errorMessage =
229 "Your account isn’t ready yet. Confirm your email first, or resend the confirmation email below."
230 return false
231 }
232 } catch {
233 errorMessage = describe(error)
234 return false
235 }
236 }
237
238 @discardableResult
239 func signUp(email: String, password: String) async -> AccountSignupResult? {
240 isBusy = true
241 clearTransientMessages()
242 defer { isBusy = false }
243
244 do {
245 let result: AccountSignupResult = try await requestJSON(
246 path: "api/auth/sign-up",
247 method: "POST",
248 payload: [
249 "email": normalized(email),
250 "password": password,
251 ]
252 )
253 notice = result.message
254 return result
255 } catch {
256 errorMessage = describe(error)
257 return nil
258 }
259 }
260
261 @discardableResult
262 func resendConfirmation(email: String) async -> Bool {
263 let normalizedEmail = normalized(email)
264 guard !normalizedEmail.isEmpty else {
265 errorMessage = "Enter your email first."
266 return false
267 }
268
269 isBusy = true
270 clearTransientMessages()
271 defer { isBusy = false }
272
273 do {
274 let response: AccountActionMessage = try await requestJSON(
275 path: "api/auth/resend-confirmation",
276 method: "POST",
277 payload: [
278 "email": normalizedEmail
279 ]
280 )
281 notice = response.message
282 return true
283 } catch {
284 errorMessage = describe(error)
285 return false
286 }
287 }
288
289 @discardableResult
290 func requestPasswordReset(email: String) async -> Bool {
291 let normalizedEmail = normalized(email)
292 guard !normalizedEmail.isEmpty else {
293 errorMessage = "Enter your email first."
294 return false
295 }
296
297 isBusy = true
298 clearTransientMessages()
299 defer { isBusy = false }
300
301 do {
302 let response: AccountActionMessage = try await requestJSON(
303 path: "api/auth/reset-password",
304 method: "POST",
305 payload: [
306 "email": normalizedEmail
307 ]
308 )
309 notice = response.message
310 return true
311 } catch {
312 errorMessage = describe(error)
313 return false
314 }
315 }
316
317 @discardableResult
318 func updateProfile(name: String, username: String) async -> Bool {
319 guard let accessToken else {
320 errorMessage = "Sign in before updating your profile."
321 return false
322 }
323
324 isBusy = true
325 clearTransientMessages()
326 defer { isBusy = false }
327
328 do {
329 currentUser = try await requestJSON(
330 path: "api/auth/me",
331 method: "PATCH",
332 accessToken: accessToken,
333 payload: [
334 "name": normalizedProfileField(name),
335 "username": normalizedUsername(username),
336 ]
337 )
338 notice = "Profile updated."
339 return true
340 } catch {
341 errorMessage = describe(error)
342 return false
343 }
344 }
345
346 @discardableResult
347 func updatePassword(
348 currentPassword: String,
349 newPassword: String,
350 passwordConfirmation: String
351 ) async -> Bool {
352 guard let accessToken else {
353 errorMessage = "Sign in before updating your password."
354 return false
355 }
356
357 isBusy = true
358 clearTransientMessages()
359 defer { isBusy = false }
360
361 do {
362 let response: AccountActionMessage = try await requestJSON(
363 path: "api/auth/password",
364 method: "PATCH",
365 accessToken: accessToken,
366 payload: [
367 "currentPassword": currentPassword,
368 "password": newPassword,
369 "passwordConfirmation": passwordConfirmation,
370 ]
371 )
372 notice = response.message
373 return true
374 } catch {
375 errorMessage = describe(error)
376 return false
377 }
378 }
379
380 @discardableResult
381 func refreshProfile(showNoticeOnSuccess: Bool = true) async -> Bool {
382 guard let accessToken else { return false }
383
384 isBusy = true
385 if !showNoticeOnSuccess {
386 errorMessage = nil
387 } else {
388 clearTransientMessages()
389 }
390 defer { isBusy = false }
391
392 do {
393 currentUser = try await requestJSON(
394 path: "api/auth/me",
395 method: "GET",
396 accessToken: accessToken
397 )
398 if showNoticeOnSuccess, let currentUser {
399 notice = "Signed in as \(currentUser.email)."
400 }
401 return true
402 } catch {
403 signOutLocally()
404 errorMessage = describe(error)
405 return false
406 }
407 }
408
409 func logout() async {
410 guard let accessToken else {
411 signOutLocally()
412 return
413 }
414
415 isBusy = true
416 clearTransientMessages()
417 defer { isBusy = false }
418
419 do {
420 try await requestVoid(
421 path: "api/auth/sign-out",
422 method: "POST",
423 accessToken: accessToken
424 )
425 signOutLocally()
426 notice = "Signed out."
427 } catch {
428 signOutLocally()
429 errorMessage = describe(error)
430 }
431 }
432
433 func removeAccount() async {
434 guard let accessToken else {
435 errorMessage = "Sign in before removing the account."
436 return
437 }
438
439 isBusy = true
440 clearTransientMessages()
441 defer { isBusy = false }
442
443 do {
444 try await requestVoid(
445 path: "api/auth/account",
446 method: "DELETE",
447 accessToken: accessToken
448 )
449 signOutLocally()
450 notice = "Account removed."
451 } catch {
452 errorMessage = describe(error)
453 }
454 }
455
456 private func requestVoid(
457 path: String,
458 method: String,
459 accessToken: String? = nil,
460 payload: [String: Any]? = nil
461 ) async throws {
462 let request = try makeRequest(
463 path: path,
464 method: method,
465 accessToken: accessToken,
466 payload: payload
467 )
468 let (data, response) = try await session.data(for: request)
469 try validate(response: response, data: data)
470 }
471
472 private func requestJSON<Response: Decodable>(
473 path: String,
474 method: String,
475 accessToken: String? = nil,
476 payload: [String: Any]? = nil
477 ) async throws -> Response {
478 let request = try makeRequest(
479 path: path,
480 method: method,
481 accessToken: accessToken,
482 payload: payload
483 )
484 let (data, response) = try await session.data(for: request)
485 try validate(response: response, data: data)
486 return try decoder.decode(Response.self, from: data)
487 }
488
489 private func makeRequest(
490 path: String,
491 method: String,
492 accessToken: String?,
493 payload: [String: Any]?
494 ) throws -> URLRequest {
495 var components = URLComponents(
496 url: gatewayBaseURL.appendingPathComponent(path),
497 resolvingAgainstBaseURL: false
498 )
499 components?.queryItems = [
500 URLQueryItem(name: "env", value: proxyEnvironment.rawValue)
501 ]
502
503 guard let url = components?.url else {
504 throw URLError(.badURL)
505 }
506
507 var request = URLRequest(url: url)
508 request.httpMethod = method
509 request.setValue("application/json", forHTTPHeaderField: "Content-Type")
510 request.setValue("application/json", forHTTPHeaderField: "Accept")
511
512 if let accessToken {
513 request.setValue(
514 "Bearer \(normalizedToken(accessToken))",
515 forHTTPHeaderField: "Authorization"
516 )
517 }
518
519 if let payload {
520 request.httpBody = try JSONSerialization.data(withJSONObject: payload)
521 }
522
523 return request
524 }
525
526 private func validate(response: URLResponse, data: Data) throws {
527 guard let response = response as? HTTPURLResponse else {
528 throw URLError(.badServerResponse)
529 }
530
531 guard (200..<300).contains(response.statusCode) else {
532 throw proxyError(from: data, statusCode: response.statusCode)
533 }
534 }
535
536 private func proxyError(from data: Data, statusCode: Int) -> NSError {
537 let proxyError = try? decoder.decode(AccountProxyError.self, from: data)
538 let message = proxyError?.message ?? proxyError?.error ?? "Request failed (\(statusCode))."
539
540 return NSError(
541 domain: "KleponAccountProxy",
542 code: proxyError?.errorCode ?? statusCode,
543 userInfo: [NSLocalizedDescriptionKey: message]
544 )
545 }
546
547 private func signOutLocally() {
548 currentUser = nil
549 accessToken = nil
550 userDefaults.removeObject(forKey: Keys.accessToken)
551 }
552
553 private func clearTransientMessages() {
554 notice = nil
555 errorMessage = nil
556 }
557
558 private func normalized(_ value: String) -> String {
559 value.trimmingCharacters(in: .whitespacesAndNewlines)
560 }
561
562 private func normalizedProfileField(_ value: String) -> String {
563 normalized(value)
564 }
565
566 private func normalizedUsername(_ value: String) -> String {
567 normalized(value).lowercased()
568 }
569
570 private func normalizedToken(_ token: String) -> String {
571 var normalized = token.trimmingCharacters(in: .whitespacesAndNewlines)
572
573 while normalized.lowercased().hasPrefix("bearer ") {
574 normalized = String(normalized.dropFirst(7)).trimmingCharacters(
575 in: .whitespacesAndNewlines)
576 }
577
578 return normalized
579 }
580
581 private func describe(_ error: Error) -> String {
582 error.localizedDescription
583 }
584 }
585
586 private enum Keys {
587 static let accessToken = "klepon.account.accessToken"
588 static let proxyEnvironment = "klepon.account.proxyEnvironment"
589 }