mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-06 16:01:28 +00:00
335 lines
12 KiB
Swift
335 lines
12 KiB
Swift
import Foundation
|
|
|
|
enum SessionPhase: Equatable {
|
|
case launching
|
|
case loggedOut
|
|
/// A stored session exists but the backend can't be reached: keep the
|
|
/// login and retry instead of bouncing the user to sign-in.
|
|
case unreachable
|
|
case onboarding
|
|
case selectOrg
|
|
case ready
|
|
}
|
|
|
|
enum LoginOutcome {
|
|
case loggedIn
|
|
case needsTwoFA(pendingToken: String)
|
|
/// A code was emailed and must be confirmed with `confirmLogin`. Not every
|
|
/// deployment sends one: self-hosted installs default to signing in without
|
|
/// it, so a login can resolve on the first call.
|
|
case needsCode(session: String)
|
|
}
|
|
|
|
/// Owns the auth lifecycle and the active-organization context.
|
|
///
|
|
/// The active org is server-side session state (`sessions.current_organization_id`),
|
|
/// so this store re-POSTs `/organization/switch/:id` once per launch before
|
|
/// entering `.ready`, mirroring the web's OrgGate.
|
|
@MainActor
|
|
@Observable
|
|
final class SessionStore {
|
|
let api: APIClient
|
|
|
|
private(set) var phase: SessionPhase = .launching
|
|
/// True when the last sign-out was forced by an expired/revoked session
|
|
/// (not a manual logout); the auth screen shows a notice explaining it.
|
|
private(set) var wasSignedOutBySession = false
|
|
private(set) var user: User?
|
|
private(set) var memberships: [OrganizationMember] = []
|
|
private(set) var currentOrgID: String?
|
|
private(set) var currentOrg: OrganizationCurrent?
|
|
private(set) var permissions: OrgPermissions = []
|
|
private(set) var role: String?
|
|
|
|
var onReady: ((_ userID: String, _ orgID: String) -> Void)?
|
|
var onLoggedOut: (() -> Void)?
|
|
|
|
init(api: APIClient) {
|
|
self.api = api
|
|
// The outer task is one-shot registration (strong self is fine); the
|
|
// stored handler captures weakly to avoid an APIClient<->store cycle.
|
|
Task { [api, self] in
|
|
await api.onAuthFailure { [weak self] in
|
|
guard let self else { return }
|
|
Task { @MainActor in self.handleAuthFailure() }
|
|
}
|
|
}
|
|
}
|
|
|
|
var isOwner: Bool {
|
|
guard let user, let currentOrg else { return false }
|
|
return currentOrg.ownerUserID == user.id
|
|
}
|
|
|
|
func can(_ permission: OrgPermissions) -> Bool {
|
|
isOwner || permissions.contains(permission)
|
|
}
|
|
|
|
// MARK: - Launch
|
|
|
|
func bootstrap() async {
|
|
guard await api.hasToken else {
|
|
phase = .loggedOut
|
|
return
|
|
}
|
|
do {
|
|
try await loadIdentity()
|
|
await enterApp()
|
|
} catch let error as APIError {
|
|
if case .unauthorized = error {
|
|
// The token really is dead; only then drop to sign-in. The
|
|
// user believed they were signed in, so say why they aren't.
|
|
wasSignedOutBySession = true
|
|
await clearSession()
|
|
} else {
|
|
phase = .unreachable
|
|
}
|
|
} catch {
|
|
phase = .unreachable
|
|
}
|
|
}
|
|
|
|
private func loadIdentity() async throws {
|
|
user = try await api.get("auth/me")
|
|
let orgs: ListResponse<OrganizationMember> = try await api.get("organization")
|
|
memberships = orgs.data.filter { $0.organization != nil }
|
|
}
|
|
|
|
/// Reloads `/auth/me` (profile edits, label group changes).
|
|
func refreshUser() async {
|
|
user = (try? await api.get("auth/me")) ?? user
|
|
}
|
|
|
|
/// Reloads `/organization/current` (limits, counts, presence flags).
|
|
func refreshCurrentOrg() async {
|
|
guard phase == .ready else { return }
|
|
currentOrg = (try? await api.get("organization/current")) ?? currentOrg
|
|
}
|
|
|
|
/// Post-identity routing: the onboarding questionnaire gates the app
|
|
/// (mirrors the web's /onboarding redirect), then org resolution.
|
|
private func enterApp() async {
|
|
if user?.onboardingCompletedAt == nil {
|
|
phase = .onboarding
|
|
return
|
|
}
|
|
await resolveOrganization()
|
|
}
|
|
|
|
/// Submits the onboarding questionnaire, then continues into the app.
|
|
func completeOnboarding(firstName: String, lastName: String, referralSource: String, role: String?, teamSize: String?) async throws {
|
|
var body: [String: String] = [
|
|
"first_name": firstName,
|
|
"last_name": lastName,
|
|
"referral_source": referralSource,
|
|
]
|
|
if let role { body["role"] = role }
|
|
if let teamSize { body["team_size"] = teamSize }
|
|
let _: EmptyBody = try await api.patch("auth/me/onboarding", body: body)
|
|
await refreshUser()
|
|
await resolveOrganization()
|
|
}
|
|
|
|
private func resolveOrganization() async {
|
|
let persisted = AppConfig.lastOrganizationID
|
|
let candidate = memberships.first { $0.organizationID == persisted }
|
|
?? (memberships.count == 1 ? memberships.first : nil)
|
|
if let candidate {
|
|
do {
|
|
try await selectOrganization(candidate.organizationID)
|
|
return
|
|
} catch {
|
|
// fall through to the picker
|
|
}
|
|
}
|
|
// Zero orgs -> create screen; several -> picker. Both render as .selectOrg.
|
|
phase = .selectOrg
|
|
}
|
|
|
|
// MARK: - Org switching
|
|
|
|
func selectOrganization(_ orgID: String) async throws {
|
|
let _: SwitchOrgResponse = try await api.post("organization/switch/\(orgID)")
|
|
currentOrgID = orgID
|
|
AppConfig.lastOrganizationID = orgID
|
|
if let membership = memberships.first(where: { $0.organizationID == orgID }) {
|
|
permissions = membership.orgPermissions
|
|
role = membership.role
|
|
}
|
|
currentOrg = try? await api.get("organization/current")
|
|
phase = .ready
|
|
if let user {
|
|
onReady?(user.id, orgID)
|
|
}
|
|
}
|
|
|
|
/// Switch initiated from inside the app (workspace switcher).
|
|
func switchOrganization(_ orgID: String) async throws {
|
|
guard orgID != currentOrgID else { return }
|
|
try await selectOrganization(orgID)
|
|
}
|
|
|
|
func createOrganization(name: String) async throws {
|
|
let org: Organization = try await api.post("organization", body: ["name": name])
|
|
try await loadIdentity()
|
|
try await selectOrganization(org.id)
|
|
}
|
|
|
|
// MARK: - Login flow
|
|
|
|
func startLogin(email: String, password: String) async throws -> LoginOutcome {
|
|
let body = [
|
|
"email": email,
|
|
"password": password,
|
|
"turnstile": AppConfig.turnstileToken,
|
|
]
|
|
let result: AuthSession = try await api.post("auth/login", body: body, authorized: false)
|
|
return try await resolve(result)
|
|
}
|
|
|
|
/// Maps a start-of-login response onto an outcome, completing the sign-in
|
|
/// when the backend already resolved it.
|
|
private func resolve(_ result: AuthSession) async throws -> LoginOutcome {
|
|
if result.needsCode {
|
|
guard let session = result.session else {
|
|
throw APIError.decoding(URLError(.cannotParseResponse))
|
|
}
|
|
return .needsCode(session: session)
|
|
}
|
|
if result.twoFARequired == true, let pending = result.pendingToken {
|
|
return .needsTwoFA(pendingToken: pending)
|
|
}
|
|
guard let token = result.token else {
|
|
throw APIError.decoding(URLError(.cannotParseResponse))
|
|
}
|
|
try await completeLogin(with: token)
|
|
return .loggedIn
|
|
}
|
|
|
|
func confirmLogin(session: String, code: String) async throws -> LoginOutcome {
|
|
let body = [
|
|
"session": session,
|
|
"code": code,
|
|
"turnstile": AppConfig.turnstileToken,
|
|
]
|
|
let result: LoginResult = try await api.post("auth/login/confirm", body: body, authorized: false)
|
|
if result.twoFARequired == true, let pending = result.pendingToken {
|
|
return .needsTwoFA(pendingToken: pending)
|
|
}
|
|
guard let token = result.token else {
|
|
throw APIError.decoding(URLError(.cannotParseResponse))
|
|
}
|
|
try await completeLogin(with: token)
|
|
return .loggedIn
|
|
}
|
|
|
|
func verifyTwoFA(pendingToken: String, code: String) async throws {
|
|
let body = ["pending_token": pendingToken, "code": code]
|
|
let token: AuthToken = try await api.post("auth/2fa/verify", body: body, authorized: false)
|
|
try await completeLogin(with: token)
|
|
}
|
|
|
|
private func completeLogin(with token: AuthToken) async throws {
|
|
await api.setToken(token)
|
|
wasSignedOutBySession = false
|
|
try await loadIdentity()
|
|
await enterApp()
|
|
}
|
|
|
|
// MARK: - Social sign-in
|
|
|
|
/// Which social providers this backend supports (drives button visibility).
|
|
func fetchAuthProviders() async -> AuthProvidersInfo? {
|
|
try? await api.get("auth/providers", authorized: false)
|
|
}
|
|
|
|
/// Apple shares the user's name only with the app, never inside the
|
|
/// identity token, so it rides along for first-sign-in profile prefill.
|
|
func signInWithApple(identityToken: String, firstName: String?, lastName: String?) async throws -> LoginOutcome {
|
|
var body = ["identity_token": identityToken]
|
|
if let firstName, !firstName.isEmpty { body["first_name"] = firstName }
|
|
if let lastName, !lastName.isEmpty { body["last_name"] = lastName }
|
|
let result: LoginResult = try await api.post("auth/apple", body: body, authorized: false)
|
|
return try await resolve(social: result)
|
|
}
|
|
|
|
func signInWithGoogle(idToken: String) async throws -> LoginOutcome {
|
|
let result: LoginResult = try await api.post("auth/google", body: ["id_token": idToken], authorized: false)
|
|
return try await resolve(social: result)
|
|
}
|
|
|
|
/// Social sign-in runs the same 2FA gate as password login, so it can
|
|
/// return a challenge instead of a token.
|
|
private func resolve(social result: LoginResult) async throws -> LoginOutcome {
|
|
if result.twoFARequired == true, let pending = result.pendingToken {
|
|
return .needsTwoFA(pendingToken: pending)
|
|
}
|
|
guard let token = result.token else {
|
|
throw APIError.decoding(URLError(.cannotParseResponse))
|
|
}
|
|
try await completeLogin(with: token)
|
|
return .loggedIn
|
|
}
|
|
|
|
// MARK: - Registration
|
|
|
|
/// Returns the session to confirm, or nil when the deployment has email
|
|
/// verification off and the account already exists.
|
|
func startRegister(email: String, password: String) async throws -> String? {
|
|
let body = [
|
|
"email": email,
|
|
"password": password,
|
|
"turnstile": AppConfig.turnstileToken,
|
|
]
|
|
let result: AuthSession = try await api.post("auth/register", body: body, authorized: false)
|
|
return result.needsCode ? result.session : nil
|
|
}
|
|
|
|
/// Registration confirm returns 204 (no tokens); chain into the login flow after.
|
|
func confirmRegister(session: String, code: String) async throws {
|
|
let body = [
|
|
"session": session,
|
|
"code": code,
|
|
"turnstile": AppConfig.turnstileToken,
|
|
]
|
|
let _: EmptyBody = try await api.post("auth/register/confirm", body: body, authorized: false)
|
|
}
|
|
|
|
func requestPasswordReset(email: String) async throws {
|
|
let body = ["email": email, "turnstile": AppConfig.turnstileToken]
|
|
let _: EmptyBody = try await api.post("auth/reset-password", body: body, authorized: false)
|
|
}
|
|
|
|
// MARK: - Logout
|
|
|
|
func logout() async {
|
|
// Treat a 401 on logout as already logged out.
|
|
let _: EmptyBody? = try? await api.post("auth/logout")
|
|
wasSignedOutBySession = false
|
|
await clearSession()
|
|
}
|
|
|
|
private func handleAuthFailure() {
|
|
Task {
|
|
// Only flag it when the user was actually inside the app; a failed
|
|
// bootstrap already lands on sign-in without needing a banner.
|
|
if phase == .ready || phase == .selectOrg || phase == .onboarding {
|
|
wasSignedOutBySession = true
|
|
}
|
|
await clearSession()
|
|
}
|
|
}
|
|
|
|
private func clearSession() async {
|
|
await api.setToken(nil)
|
|
user = nil
|
|
memberships = []
|
|
currentOrgID = nil
|
|
currentOrg = nil
|
|
permissions = []
|
|
role = nil
|
|
phase = .loggedOut
|
|
onLoggedOut?()
|
|
}
|
|
}
|