Managers and Utils

AuthManager

AuthManager is the one place every authentication decision flows through. Register, sign in with email, Sign in with Apple, send a magic link, restore a session on launch, sign out, delete an account — all of it lives behind a single @MainActor @Observable singleton so your UI never has to reason about tokens, sessions, or providers directly.

It wraps Supabase Auth and exposes a small, app-shaped API (AppUser, not raw Supabase types), so the rest of the app stays decoupled from the backend.

See the full flow

This page documents the manager itself — the class, its public methods, and how it's wired in. For the end-to-end picture (the sign-in screens, the magic-link deep-link handshake, and SignInViewModel validation), read Authentication Flow.

Where it lives

ShipThatApp/Services/Auth/AuthManager.swift

Why it's a singleton

Authentication is genuinely app-wide shared state: one user, one session, observed from the sign-in screen, the landing view, settings, and account deletion all at once. AuthManager is therefore a @MainActor-isolated @Observable singleton:

@MainActor
@Observable
final class AuthManager {
    static let shared = AuthManager()

    private init() {}

    var currentSession: AppUser?
    var errorMessage: Error?
}

Because it's @Observable, any view that reads currentSession or errorMessage re-renders automatically when they change — no Combine, no @Published, no manual notification.

The client is configured from your gitignored config

The SupabaseClient is built lazily from SUPABASE_URL and SUPABASE_KEY read out of Info.plist — which are populated from your gitignored Config.xcconfig. Your Supabase credentials never get hardcoded into a tracked source file.

let client: SupabaseClient = {
    guard let supabaseURLString = Bundle.main.infoDictionary?["SUPABASE_URL"] as? String,
          let supabaseURL = URL(string: supabaseURLString),
          let supabaseKey = Bundle.main.infoDictionary?["SUPABASE_KEY"] as? String else {
        fatalError("Supabase configuration not found in Info.plist")
    }
    return SupabaseClient(supabaseURL: supabaseURL, supabaseKey: supabaseKey)
}()

Wire up your keys first

The initializer deliberately fatalErrors if SUPABASE_URL / SUPABASE_KEY are missing — a loud failure at launch beats a silent, broken auth flow in production. Add them to Config.xcconfig before your first run.

The AppUser model

Callers never touch a raw Supabase User. Every method returns a tiny, app-owned value type:

struct AppUser {
    let uid: String
    let email: String?
}

This is the seam that keeps your app independent of the backend — swap providers and only AuthManager changes.

Public API

Every method is async throws (errors propagate so callers can react), and most are also @discardableResult so you can fire-and-forget when you only care about the side effect on currentSession. On success they each present a confirmation Toast; on failure they log via OSLog and surface a toast before rethrowing.

Email & password

@discardableResult
func registerNewUserWithEmail(email: String, password: String) async throws -> AppUser

@discardableResult
func signInWithEmail(email: String, password: String) async throws -> AppUser

Registration calls client.auth.signUp(...) with a shipthatapp://login-callback redirect and throws if no session comes back. Sign-in calls client.auth.signIn(...); on failure it clears currentSession, stores the error on errorMessage, and rethrows rather than returning a sentinel user — so a failed sign-in is impossible to mistake for a success.

Sign in with Apple

@discardableResult
func signInWithApple(idToken: String, nonce: String) async throws -> AppUser

Takes both the Apple identity token and the raw nonce. The raw nonce is matched against the hashed nonce embedded in the Apple token by Supabase, preventing token replay. The AppleButton view generates the nonce and hands both values here.

func sendMagicLink(email: String) async throws

Calls client.auth.signInWithOTP(...) with the shipthatapp://login-callback redirect, then toasts "Magic Link sent". The actual sign-in completes later, when the user taps the emailed link and the app receives the deep link (see getSessionFromUrl below).

Session lifecycle

func getCurrentSession() async throws -> AppUser     // refresh from the stored Supabase session
func getSessionFromUrl(url: URL) async throws -> AppUser  // complete a magic-link deep link
func updateCurrentSession() async                    // refresh + toast/log on failure, never throws

getCurrentSession() is the "am I still signed in?" check you run on launch. getSessionFromUrl(url:) completes the magic-link handshake — LandingView.handleIncomingURL(_:) calls it when a shipthatapp://login-callback URL arrives via .onOpenURL.

Sign out

func signOut() async throws

Invalidates the Supabase session and resets currentSession to nil.

Account deletion (App Store requirement)

func deleteAccount() async throws

App Store Guideline 5.1.1(v) requires in-app account deletion for any app that offers account creation — and this is built in. Deleting an auth user needs the service-role key, which must never ship in the client, so the work runs in a Supabase Edge Function:

func deleteAccount() async throws {
    try await client.functions.invoke(functionName: "delete-account")
    try? await client.auth.signOut()
    SettingsManager.shared.clearUserProfile()
    KeychainSwift().delete(Config.Keychain.tokenKey)
    currentSession = nil
    // ...toast "Account deleted"
}

The Edge Function authenticates the caller by their JWT and deletes them with admin rights; on success the client signs out, wipes the cached profile, and clears the Keychain token. SettingsView exposes this behind a destructive confirmation.

How it's used

Because it's app-wide state, the singleton is injected into the SwiftUI environment once — in ContentView — and read from there:

// ContentView.swift
@State private var authManager = AuthManager.shared

LandingView()
    .environment(authManager)

Downstream views pull it out of the environment instead of reaching for .shared directly:

// LandingView.swift, AppleButton.swift, SignInOptions.swift, ...
@Environment(AuthManager.self) private var authManager

if authManager.currentSession != nil {
    // signed-in UI
}

A typical call site just awaits a method and lets the toast/log machinery handle feedback:

// AppleButton.swift
try await authManager.signInWithApple(idToken: idToken, nonce: nonce)
// LandingView.swift — completing a magic-link sign-in
.onOpenURL { url in
    if url.scheme == "shipthatapp" && url.host == "login-callback" {
        Task { _ = try await authManager.getSessionFromUrl(url: url) }
    }
}

Read errorMessage in the UI

SignInOptions shows inline error text by reading authManager.errorMessage?.localizedDescription. Because the manager is @Observable, the message appears automatically the moment a sign-in fails — you don't have to plumb the error back yourself.

Customize & extend

AuthManager is intentionally small and self-contained, so changes stay local:

  • Add an OAuth provider (Google, GitHub, …). Add a method that calls the matching client.auth.* API and returns an AppUser, following the existing toast-on-success / log-and-rethrow pattern. Nothing else in the app needs to change.
  • Change the redirect scheme. The shipthatapp://login-callback URL is referenced in registerNewUserWithEmail, sendMagicLink, and the .onOpenURL matcher in LandingView. Update all three together and register the new scheme under CFBundleURLTypes in Info.plist.
  • Carry more user fields. Extend AppUser (e.g. displayName, avatarURL) and populate it from the Supabase session.user inside each method.
  • Swap the backend. Replace the client and the bodies of each method; keep the method signatures and AppUser shape, and the entire UI keeps working unchanged.
Previous
SignInViewModel