feat: iOS auth flow - welcome screen, multi-step email login/register with OTP code field, native Sign in with Apple and Google buttons against the new token endpoints, org picker, and the post-signup onboarding questionnaire

This commit is contained in:
Matthew Meszaros
2026-07-07 05:57:31 +02:00
parent 5041c285ca
commit 4dc32e6ccb
5 changed files with 2678 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,55 @@
import SwiftUI
/// Six code boxes backed by one invisible text field, so iOS one-time-code
/// autofill (from Mail/Messages) fills the whole row in a single tap.
struct OTPCodeField: View {
@Binding var code: String
var boxCount: Int = 6
var onComplete: (String) -> Void = { _ in }
@FocusState private var focused: Bool
var body: some View {
ZStack {
TextField("", text: $code)
.keyboardType(.numberPad)
.textContentType(.oneTimeCode)
.focused($focused)
.opacity(0.02)
.allowsHitTesting(false)
.onChange(of: code) { _, newValue in
let digits = String(newValue.filter(\.isNumber).prefix(boxCount))
if digits != newValue { code = digits }
if digits.count == boxCount { onComplete(digits) }
}
HStack(spacing: 7) {
ForEach(0 ..< boxCount, id: \.self) { index in
box(at: index)
}
}
.frame(maxWidth: .infinity)
}
.contentShape(Rectangle())
.onTapGesture { focused = true }
.onAppear { focused = true }
}
private func box(at index: Int) -> some View {
let digits = Array(code)
let char = index < digits.count ? String(digits[index]) : ""
let isActive = focused && index == min(code.count, boxCount - 1)
return Text(char)
.font(.system(size: 27, weight: .semibold, design: .monospaced))
.frame(width: 50, height: 62)
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 14))
.overlay(
RoundedRectangle(cornerRadius: 14)
.strokeBorder(isActive ? WTheme.accent : Color(.separator).opacity(0.4), lineWidth: isActive ? 2 : 1)
)
.scaleEffect(char.isEmpty ? 1 : 1.02)
.animation(.easeOut(duration: 0.15), value: isActive)
.animation(.spring(response: 0.25, dampingFraction: 0.6), value: char)
}
}
@@ -0,0 +1,581 @@
import SwiftUI
/// Post-signup questionnaire (mirrors the web's /onboarding gate) wearing the
/// same air as the sign-in screen: the sky with its flight ambience and a
/// step badge up top, a full-bleed white sheet with the questions below.
/// Name and referral source are required, persona and team size optional.
/// Runs before org resolution so the profile is complete on first entry.
struct OnboardingFlowView: View {
private enum Page: Int, CaseIterable {
case name, referral, role, teamSize
var icon: String {
switch self {
case .name: "person.fill"
case .referral: "sparkles"
case .role: "briefcase.fill"
case .teamSize: "person.3.fill"
}
}
var skyLabel: String {
switch self {
case .name: "Introduce yourself"
case .referral: "How you found us"
case .role: "What you do"
case .teamSize: "Your team"
}
}
}
@Environment(AppEnvironment.self) private var env
@State private var page = Page.name
@State private var direction = 1.0
@State private var firstName = ""
@State private var lastName = ""
@State private var referralSource: String?
@State private var role: String?
@State private var teamSize: String?
@State private var busy = false
@State private var errorMessage: String?
@State private var errorPulse = 0
@State private var badgeAppeared = false
@FocusState private var focusedName: NameField?
private enum NameField { case first, last }
private static let referralOptions: [(value: String, label: String, icon: String)] = [
("google", "Google search", "magnifyingglass"),
("x", "X (Twitter)", "at"),
("reddit", "Reddit", "bubble.left.and.bubble.right"),
("facebook", "Facebook", "person.2"),
("other", "Somewhere else", "sparkles"),
]
private static let roleOptions: [(value: String, label: String, icon: String)] = [
("founder", "Founder", "flag"),
("sales", "Sales", "chart.line.uptrend.xyaxis"),
("marketing", "Marketing", "megaphone"),
("agency", "Agency", "building.2"),
("recruiter", "Recruiter", "person.badge.plus"),
("other", "Something else", "ellipsis.circle"),
]
private static let teamSizeOptions: [(value: String, label: String)] = [
("just_me", "Just me"),
("2-10", "2 to 10"),
("11-50", "11 to 50"),
("51-200", "51 to 200"),
("200+", "200+"),
]
var body: some View {
ZStack(alignment: .bottom) {
SkyBackdrop()
VStack(spacing: 0) {
topBar
skyArea
sheet
}
}
.sensoryFeedback(.impact(weight: .light), trigger: page)
.sensoryFeedback(.error, trigger: errorPulse)
.onAppear { prefillName() }
}
private var pageTransition: AnyTransition {
.asymmetric(
insertion: .move(edge: direction > 0 ? .trailing : .leading).combined(with: .opacity),
removal: .move(edge: direction > 0 ? .leading : .trailing).combined(with: .opacity)
)
}
// MARK: - Sky chrome
private var topBar: some View {
HStack(spacing: 12) {
if page != .name {
Button {
goBack()
} label: {
Image(systemName: "chevron.left")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 40, height: 40)
.background(.white.opacity(0.16), in: Circle())
}
.buttonStyle(PressableButtonStyle())
.accessibilityLabel("Back")
.transition(.opacity.combined(with: .scale(scale: 0.7)))
}
HStack(spacing: 8) {
WarmblyLogo()
.fill(.white)
.frame(width: 27, height: 28)
Text("Warmbly")
.font(.system(size: 20, weight: .heavy))
.tracking(-0.4)
.foregroundStyle(.white)
.fixedSize()
}
.shadow(color: Color(hex: 0x0C4A6E).opacity(0.25), radius: 8, y: 3)
Spacer()
Button("Sign out") {
Task { await env.session.logout() }
}
.font(.system(size: 13.5, weight: .semibold))
.foregroundStyle(.white.opacity(0.9))
.padding(.horizontal, 14)
.frame(height: 34)
.background(.white.opacity(0.16), in: Capsule())
.buttonStyle(PressableButtonStyle())
}
.padding(.horizontal, 16)
.padding(.top, 6)
.animation(.spring(response: 0.45, dampingFraction: 0.86), value: page)
}
/// The animated sky window: flight ambience plus the current page's badge
/// (icon, question name, position). Picks the tallest badge that fits so
/// the keyboard on the name page only compacts it, never kills it.
private var skyArea: some View {
ZStack {
Color.clear
.contentShape(Rectangle())
.onTapGesture { focusedName = nil }
HeroFlightScene()
pageBadge
.scaleEffect(badgeAppeared ? 1 : 0.9)
.opacity(badgeAppeared ? 1 : 0)
}
.frame(maxWidth: .infinity, minHeight: 40, maxHeight: .infinity)
.animation(.spring(response: 0.5, dampingFraction: 0.85), value: page)
.onAppear {
withAnimation(.spring(response: 0.7, dampingFraction: 0.75).delay(0.05)) {
badgeAppeared = true
}
}
}
private var pageBadge: some View {
ViewThatFits(in: .vertical) {
VStack(spacing: 12) {
ZStack {
Circle().fill(.white.opacity(0.16))
Circle().strokeBorder(.white.opacity(0.3), lineWidth: 1)
Image(systemName: page.icon)
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(.white)
.contentTransition(.symbolEffect(.replace))
}
.frame(width: 54, height: 54)
VStack(spacing: 4) {
Text(page.skyLabel)
.font(.system(size: 15.5, weight: .bold))
.foregroundStyle(.white)
.contentTransition(.opacity)
Text("Step \(page.rawValue + 1) of \(Page.allCases.count)")
.font(.system(size: 12))
.foregroundStyle(.white.opacity(0.7))
.contentTransition(.numericText())
}
pageSegments
}
.padding(.vertical, 16)
HStack(spacing: 10) {
ZStack {
Circle().fill(.white.opacity(0.16))
Circle().strokeBorder(.white.opacity(0.3), lineWidth: 1)
Image(systemName: page.icon)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.white)
.contentTransition(.symbolEffect(.replace))
}
.frame(width: 34, height: 34)
VStack(alignment: .leading, spacing: 4) {
Text(page.skyLabel)
.font(.system(size: 13.5, weight: .semibold))
.foregroundStyle(.white)
.contentTransition(.opacity)
HStack(spacing: 7) {
pageSegments
Text("Step \(page.rawValue + 1) of \(Page.allCases.count)")
.font(.system(size: 11))
.foregroundStyle(.white.opacity(0.7))
.contentTransition(.numericText())
}
}
}
.padding(.vertical, 8)
}
.shadow(color: Color(hex: 0x0C4A6E).opacity(0.25), radius: 6, y: 2)
}
private var pageSegments: some View {
HStack(spacing: 6) {
ForEach(Page.allCases, id: \.rawValue) { segment in
Capsule()
.fill(.white.opacity(segment.rawValue <= page.rawValue ? 0.95 : 0.3))
.frame(width: segment == page ? 26 : 14, height: 4)
}
}
}
// MARK: - The sheet
private var sheet: some View {
VStack(alignment: .leading, spacing: 0) {
// Hug the page's natural height so the sky keeps the rest; only
// fall back to scrolling when a page genuinely overflows.
ViewThatFits(in: .vertical) {
pageContent
ScrollView {
pageContent
}
.scrollBounceBehavior(.basedOnSize)
.scrollDismissesKeyboard(.interactively)
}
footer
}
.padding(.horizontal, 24)
.frame(maxWidth: .infinity, alignment: .leading)
.background {
// The tail below keeps the sheet sealed to the screen edge even
// mid-animation while the keyboard moves.
UnevenRoundedRectangle(cornerRadii: .init(topLeading: 36, topTrailing: 36))
.fill(Color(.systemBackground)
.shadow(.drop(color: Color(hex: 0x0F172A).opacity(0.28), radius: 34, y: -6)))
.padding(.bottom, -600)
.ignoresSafeArea()
}
.geometryGroup()
// The sheet wins the height negotiation: it takes what its page
// needs and the sky absorbs the rest, so options never hide behind
// the Continue button.
.layoutPriority(1)
}
private var pageContent: some View {
Group {
switch page {
case .name: namePage
case .referral: referralPage
case .role: rolePage
case .teamSize: teamSizePage
}
}
.padding(.top, 30)
.padding(.bottom, 8)
.transition(pageTransition)
}
private var footer: some View {
VStack(spacing: 12) {
if let errorMessage {
Text(errorMessage)
.font(.system(size: 13.5))
.foregroundStyle(WTheme.negative)
.frame(maxWidth: .infinity)
.multilineTextAlignment(.center)
.transition(.opacity)
}
Button {
Task { await advance() }
} label: {
Group {
if busy {
ProgressView().tint(.white)
} else {
HStack(spacing: 8) {
Text(page == .teamSize ? "Enter Warmbly" : "Continue")
if page == .teamSize {
Image(systemName: "paperplane.fill")
.font(.system(size: 14, weight: .semibold))
}
}
}
}
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.frame(height: 56)
.background(
LinearGradient(
colors: !canAdvance && !busy
? [WTheme.accent.opacity(0.35), WTheme.accent.opacity(0.35)]
: [Color(hex: 0x0EA5E9), Color(hex: 0x0284C7)],
startPoint: .top,
endPoint: .bottom
),
in: RoundedRectangle(cornerRadius: 17)
)
.shadow(color: !canAdvance ? .clear : Color(hex: 0x0284C7).opacity(0.32), radius: 12, y: 6)
}
.buttonStyle(PressableButtonStyle())
.disabled(busy || !canAdvance)
.animation(.easeOut(duration: 0.18), value: canAdvance)
if page == .role || page == .teamSize {
Button("Skip for now") {
if page == .role { role = nil } else { teamSize = nil }
Task { await advance(skipping: true) }
}
.font(.system(size: 14, weight: .medium))
.foregroundStyle(.secondary)
.disabled(busy)
.transition(.opacity)
}
}
.padding(.top, 10)
.padding(.bottom, 14)
.animation(.easeOut(duration: 0.2), value: page)
.animation(.easeOut(duration: 0.2), value: errorMessage != nil)
}
// MARK: - Pages
private func pageTitle(_ title: String, _ subtitle: String) -> some View {
VStack(alignment: .leading, spacing: 7) {
Text(title)
.font(.system(size: 30, weight: .bold))
.tracking(-0.6)
.foregroundStyle(.primary)
Text(subtitle)
.font(.system(size: 15.5))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.bottom, 22)
}
private var namePage: some View {
VStack(alignment: .leading, spacing: 0) {
pageTitle("What's your name?", "It shows up on your profile and in the emails you send.")
VStack(spacing: 12) {
nameField("First name", text: $firstName, contentType: .givenName, field: .first) {
focusedName = .last
}
nameField("Last name", text: $lastName, contentType: .familyName, field: .last) {
Task { await advance() }
}
}
}
.onAppear {
if firstName.isEmpty { focusedName = .first }
}
}
private func nameField(
_ placeholder: String,
text: Binding<String>,
contentType: UITextContentType,
field: NameField,
onSubmit: @escaping () -> Void
) -> some View {
TextField(placeholder, text: text)
.textContentType(contentType)
.focused($focusedName, equals: field)
.submitLabel(field == .first ? .next : .continue)
.onSubmit(onSubmit)
.font(.system(size: 16.5))
.padding(.horizontal, 16)
.frame(height: 56)
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 17))
.overlay(
RoundedRectangle(cornerRadius: 17)
.strokeBorder(focusedName == field ? WTheme.accent : .clear, lineWidth: 1.8)
)
.animation(.easeOut(duration: 0.18), value: focusedName)
}
private var referralPage: some View {
VStack(alignment: .leading, spacing: 0) {
pageTitle("How did you hear about us?", "One tap. It helps us know where to show up.")
optionGrid(Self.referralOptions, selection: $referralSource)
}
}
private var rolePage: some View {
VStack(alignment: .leading, spacing: 0) {
pageTitle("What best describes you?", "Optional. It tunes the tips you'll see.")
optionGrid(Self.roleOptions, selection: $role)
}
}
private var teamSizePage: some View {
VStack(alignment: .leading, spacing: 0) {
pageTitle("How big is your team?", "Optional. Helps us right-size recommendations.")
LazyVGrid(columns: [GridItem(.flexible(), spacing: 10), GridItem(.flexible(), spacing: 10), GridItem(.flexible(), spacing: 10)], spacing: 10) {
ForEach(Self.teamSizeOptions, id: \.value) { option in
sizeChip(option.label, selected: teamSize == option.value) {
withAnimation(.spring(response: 0.35, dampingFraction: 0.7)) {
teamSize = teamSize == option.value ? nil : option.value
}
}
}
}
}
}
/// Two-column icon tiles with a springy check badge; tapping again
/// deselects.
private func optionGrid(_ options: [(value: String, label: String, icon: String)], selection: Binding<String?>) -> some View {
LazyVGrid(columns: [GridItem(.flexible(), spacing: 12), GridItem(.flexible(), spacing: 12)], spacing: 12) {
ForEach(options, id: \.value) { option in
optionTile(option, selected: selection.wrappedValue == option.value) {
withAnimation(.spring(response: 0.35, dampingFraction: 0.7)) {
selection.wrappedValue = selection.wrappedValue == option.value ? nil : option.value
}
}
}
}
}
private func optionTile(
_ option: (value: String, label: String, icon: String),
selected: Bool,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
VStack(spacing: 10) {
Image(systemName: option.icon)
.font(.system(size: 19, weight: .semibold))
.foregroundStyle(selected ? .white : WTheme.accent)
.frame(width: 42, height: 42)
.background(
selected ? AnyShapeStyle(WTheme.accent) : AnyShapeStyle(WTheme.accent.opacity(0.12)),
in: Circle()
)
Text(option.label)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.primary)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.frame(maxWidth: .infinity)
.frame(height: 104)
.background(
selected ? AnyShapeStyle(WTheme.accent.opacity(0.08)) : AnyShapeStyle(Color(.secondarySystemBackground)),
in: RoundedRectangle(cornerRadius: 17)
)
.overlay(
RoundedRectangle(cornerRadius: 17)
.strokeBorder(selected ? WTheme.accent : .clear, lineWidth: 1.8)
)
.overlay(alignment: .topTrailing) {
if selected {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 19))
.foregroundStyle(.white, WTheme.accent)
.padding(7)
.transition(.scale(scale: 0.4).combined(with: .opacity))
}
}
}
.buttonStyle(PressableButtonStyle())
.sensoryFeedback(.selection, trigger: selected)
}
private func sizeChip(_ label: String, selected: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(label)
.font(.system(size: 14.5, weight: .semibold))
.foregroundStyle(selected ? .white : .primary)
.lineLimit(1)
.minimumScaleFactor(0.75)
.frame(maxWidth: .infinity)
.frame(height: 46)
.background(
selected ? AnyShapeStyle(WTheme.accent) : AnyShapeStyle(Color(.secondarySystemBackground)),
in: Capsule()
)
}
.buttonStyle(PressableButtonStyle())
.sensoryFeedback(.selection, trigger: selected)
}
// MARK: - Flow
private var canAdvance: Bool {
switch page {
case .name:
return !firstName.trimmingCharacters(in: .whitespaces).isEmpty
&& !lastName.trimmingCharacters(in: .whitespaces).isEmpty
case .referral:
return referralSource != nil
case .role, .teamSize:
return true
}
}
private func goBack() {
guard let previous = Page(rawValue: page.rawValue - 1) else { return }
errorMessage = nil
direction = -1
withAnimation(.spring(response: 0.45, dampingFraction: 0.86)) {
page = previous
}
}
private func advance(skipping: Bool = false) async {
guard canAdvance || skipping else { return }
errorMessage = nil
if page == .teamSize {
await submit()
return
}
focusedName = nil
direction = 1
withAnimation(.spring(response: 0.45, dampingFraction: 0.86)) {
page = Page(rawValue: page.rawValue + 1) ?? .teamSize
}
}
private func submit() async {
busy = true
do {
try await env.session.completeOnboarding(
firstName: firstName.trimmingCharacters(in: .whitespaces),
lastName: lastName.trimmingCharacters(in: .whitespaces),
referralSource: referralSource ?? "other",
role: role,
teamSize: teamSize
)
// The view unmounts as the session phase flips, so fire the
// arrival haptic directly instead of via sensoryFeedback.
UINotificationFeedbackGenerator().notificationOccurred(.success)
} catch {
withAnimation(.easeOut(duration: 0.2)) {
errorMessage = (error as? APIError)?.errorDescription ?? error.localizedDescription
}
errorPulse += 1
}
busy = false
}
private func prefillName() {
firstName = env.session.user?.firstName ?? ""
lastName = env.session.user?.lastName ?? ""
// CreateUser defaults the first name to the email local part;
// don't present that as if the user typed it.
if let email = env.session.user?.email, email.hasPrefix(firstName + "@") {
firstName = ""
}
}
}
@@ -0,0 +1,107 @@
import SwiftUI
/// Shown when the session has no auto-resolvable workspace: zero orgs
/// (create one) or several (pick one).
struct OrgPickerView: View {
@Environment(AppEnvironment.self) private var env
@State private var busyOrgID: String?
@State private var newOrgName = ""
@State private var creating = false
@State private var errorMessage: String?
var body: some View {
NavigationStack {
List {
if !env.session.memberships.isEmpty {
Section("Choose a workspace") {
ForEach(env.session.memberships) { membership in
Button {
Task { await select(membership.organizationID) }
} label: {
HStack(spacing: 12) {
WAvatar(
name: membership.organization?.name ?? "?",
imageURL: membership.organization?.avatarURL,
seed: membership.organizationID,
size: 34
)
VStack(alignment: .leading, spacing: 2) {
Text(membership.organization?.name ?? "Workspace")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(.primary)
if let role = membership.role {
Text(role.capitalized)
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
}
Spacer()
if busyOrgID == membership.organizationID {
ProgressView()
} else {
Image(systemName: "chevron.right")
.font(.system(size: 12))
.foregroundStyle(.tertiary)
}
}
}
.disabled(busyOrgID != nil)
}
}
}
Section(env.session.memberships.isEmpty ? "Create your workspace" : "Or create a new one") {
TextField("Workspace name", text: $newOrgName)
Button {
Task { await create() }
} label: {
if creating {
ProgressView()
} else {
Text("Create workspace")
}
}
.disabled(creating || newOrgName.trimmingCharacters(in: .whitespaces).isEmpty)
}
if let errorMessage {
Section {
Text(errorMessage)
.font(.system(size: 12))
.foregroundStyle(WTheme.negative)
}
}
}
.navigationTitle("Workspaces")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Log out") {
Task { await env.session.logout() }
}
}
}
}
}
private func select(_ orgID: String) async {
busyOrgID = orgID
errorMessage = nil
do {
try await env.session.selectOrganization(orgID)
} catch {
errorMessage = (error as? APIError)?.errorDescription ?? error.localizedDescription
}
busyOrgID = nil
}
private func create() async {
creating = true
errorMessage = nil
do {
try await env.session.createOrganization(name: newOrgName.trimmingCharacters(in: .whitespaces))
} catch {
errorMessage = (error as? APIError)?.errorDescription ?? error.localizedDescription
}
creating = false
}
}
@@ -0,0 +1,274 @@
import AuthenticationServices
import CryptoKit
import SwiftUI
/// Native Sign in with Apple plus Google via an in-app web ceremony. Rendered
/// optimistically: until `GET /auth/providers` answers, both buttons show (the
/// hosted service supports both); once a backend explicitly disables a
/// provider, its button disappears. One shipped binary, every deployment.
struct SocialSignInRow: View {
@Environment(AppEnvironment.self) private var env
@Environment(\.colorScheme) private var colorScheme
let providers: AuthProvidersInfo?
@Binding var busy: Bool
let onError: (String) -> Void
@State private var googleFlow = GoogleSignInFlow()
private var showApple: Bool { providers == nil || providers?.appleEnabled == true }
private var showGoogle: Bool { providers == nil || providers?.googleClientID != nil }
var body: some View {
VStack(spacing: 12) {
if showApple {
SignInWithAppleButton(.continue) { request in
request.requestedScopes = [.email, .fullName]
} onCompletion: { result in
handleApple(result)
}
.signInWithAppleButtonStyle(colorScheme == .dark ? .white : .black)
.frame(height: 56)
.clipShape(RoundedRectangle(cornerRadius: 17))
.disabled(busy)
}
if showGoogle {
Button {
if let clientID = providers?.googleClientID {
signInWithGoogle(clientID: clientID)
} else {
onError("Google sign-in isn't set up on this server yet.")
}
} label: {
HStack(spacing: 10) {
GoogleGlyph()
.frame(width: 19, height: 19)
Text("Continue with Google")
.font(.system(size: 17, weight: .medium))
}
.frame(maxWidth: .infinity)
.frame(height: 56)
.background(Color(.systemBackground), in: RoundedRectangle(cornerRadius: 17))
.overlay(RoundedRectangle(cornerRadius: 17).strokeBorder(Color(.separator).opacity(0.7), lineWidth: 1.2))
}
.buttonStyle(PressableButtonStyle())
.disabled(busy)
}
}
}
private func handleApple(_ result: Result<ASAuthorization, Error>) {
switch result {
case let .success(authorization):
guard
let credential = authorization.credential as? ASAuthorizationAppleIDCredential,
let tokenData = credential.identityToken,
let identityToken = String(data: tokenData, encoding: .utf8)
else {
onError("Apple didn't return a usable credential. Please try again.")
return
}
let first = credential.fullName?.givenName
let last = credential.fullName?.familyName
busy = true
Task {
defer { busy = false }
do {
try await env.session.signInWithApple(identityToken: identityToken, firstName: first, lastName: last)
} catch {
onError((error as? APIError)?.errorDescription ?? error.localizedDescription)
}
}
case let .failure(error):
// Dismissing the Apple sheet is not an error worth surfacing.
if let authError = error as? ASAuthorizationError, authError.code == .canceled { return }
onError(error.localizedDescription)
}
}
private func signInWithGoogle(clientID: String) {
busy = true
Task {
defer { busy = false }
do {
let idToken = try await googleFlow.signIn(clientID: clientID)
try await env.session.signInWithGoogle(idToken: idToken)
} catch GoogleSignInFlow.Failure.cancelled {
// User closed the sheet; stay quiet.
} catch {
onError((error as? APIError)?.errorDescription ?? error.localizedDescription)
}
}
}
}
/// The multicolor Google "G", drawn locally so no asset is needed. Segment
/// angles are lifted from the official 18x18 mark: the ring is open between
/// the red arm's diagonal cut (-50°) and the crossbar (12°).
struct GoogleGlyph: View {
var body: some View {
Canvas { context, size in
let side = min(size.width, size.height)
let center = CGPoint(x: size.width / 2, y: size.height / 2)
let lineWidth = side * (3.58 / 18)
let radius = side / 2 - lineWidth / 2
func arc(_ from: Double, _ to: Double, _ hex: UInt32) {
var path = Path()
path.addArc(center: center, radius: radius, startAngle: .degrees(from), endAngle: .degrees(to), clockwise: false)
context.stroke(path, with: .color(Color(hex: hex)), style: StrokeStyle(lineWidth: lineWidth))
}
arc(-161.3, -49.8, 0xEA4335) // red: top
arc(161.3, 198.7, 0xFBBC05) // yellow: left
arc(56.2, 161.3, 0x34A853) // green: bottom
arc(11.8, 56.2, 0x4285F4) // blue: lower right
// The crossbar of the G.
let bar = CGRect(
x: center.x,
y: center.y - lineWidth / 2,
width: side / 2 - side * (0.36 / 18),
height: lineWidth
)
context.fill(Path(bar), with: .color(Color(hex: 0x4285F4)))
}
}
}
/// Google Sign-In without the SDK: an `ASWebAuthenticationSession` OAuth
/// ceremony with PKCE against the iOS client ID the backend advertises. iOS
/// OAuth clients are public (no secret), so the code exchange happens on
/// device and only the resulting ID token is sent to our backend.
@MainActor
final class GoogleSignInFlow: NSObject, ASWebAuthenticationPresentationContextProviding {
enum Failure: Error {
case cancelled
case malformedResponse
}
private var activeSession: ASWebAuthenticationSession?
private var presentationWindow: UIWindow?
func signIn(clientID: String) async throws -> String {
// Captured up front (we're on the main actor) so the presentation
// callback never has to conjure a window of its own.
guard let window = Self.keyWindow else { throw Failure.malformedResponse }
presentationWindow = window
defer { presentationWindow = nil }
let verifier = Self.randomURLSafe(bytes: 32)
let challenge = Self.base64URL(SHA256.hash(data: Data(verifier.utf8)))
// Google's iOS redirect scheme is the reversed client ID.
let scheme = Self.reversedClientScheme(clientID)
let redirectURI = "\(scheme):/oauth2redirect"
var authorize = URLComponents(string: "https://accounts.google.com/o/oauth2/v2/auth")!
authorize.queryItems = [
URLQueryItem(name: "client_id", value: clientID),
URLQueryItem(name: "redirect_uri", value: redirectURI),
URLQueryItem(name: "response_type", value: "code"),
URLQueryItem(name: "scope", value: "openid email profile"),
URLQueryItem(name: "code_challenge", value: challenge),
URLQueryItem(name: "code_challenge_method", value: "S256"),
]
let callback = try await runWebAuth(url: authorize.url!, callbackScheme: scheme)
guard
let items = URLComponents(url: callback, resolvingAgainstBaseURL: false)?.queryItems,
let code = items.first(where: { $0.name == "code" })?.value
else {
throw Failure.malformedResponse
}
return try await exchange(code: code, clientID: clientID, redirectURI: redirectURI, verifier: verifier)
}
private func runWebAuth(url: URL, callbackScheme: String) async throws -> URL {
try await withCheckedThrowingContinuation { continuation in
let session = ASWebAuthenticationSession(url: url, callbackURLScheme: callbackScheme) { [weak self] callbackURL, error in
Task { @MainActor in self?.activeSession = nil }
if let error {
if let webError = error as? ASWebAuthenticationSessionError, webError.code == .canceledLogin {
continuation.resume(throwing: Failure.cancelled)
} else {
continuation.resume(throwing: error)
}
return
}
guard let callbackURL else {
continuation.resume(throwing: Failure.malformedResponse)
return
}
continuation.resume(returning: callbackURL)
}
session.presentationContextProvider = self
activeSession = session
if !session.start() {
activeSession = nil
continuation.resume(throwing: Failure.malformedResponse)
}
}
}
private func exchange(code: String, clientID: String, redirectURI: String, verifier: String) async throws -> String {
var request = URLRequest(url: URL(string: "https://oauth2.googleapis.com/token")!)
request.httpMethod = "POST"
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
let form = [
"code": code,
"client_id": clientID,
"redirect_uri": redirectURI,
"grant_type": "authorization_code",
"code_verifier": verifier,
]
request.httpBody = form
.map { "\($0.key)=\($0.value.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? $0.value)" }
.joined(separator: "&")
.data(using: .utf8)
let (data, response) = try await URLSession.shared.data(for: request)
guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw Failure.malformedResponse }
struct TokenResponse: Decodable {
let idToken: String
enum CodingKeys: String, CodingKey { case idToken = "id_token" }
}
return try JSONDecoder().decode(TokenResponse.self, from: data).idToken
}
nonisolated func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
MainActor.assumeIsolated {
// signIn guards that a window exists before the session starts.
presentationWindow ?? Self.keyWindow!
}
}
private static var keyWindow: UIWindow? {
let windows = UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.flatMap(\.windows)
return windows.first(where: \.isKeyWindow) ?? windows.first
}
// MARK: - Helpers
/// "1234-abc.apps.googleusercontent.com" -> "com.googleusercontent.apps.1234-abc"
static func reversedClientScheme(_ clientID: String) -> String {
clientID.split(separator: ".").reversed().joined(separator: ".")
}
static func randomURLSafe(bytes count: Int) -> String {
var bytes = [UInt8](repeating: 0, count: count)
_ = SecRandomCopyBytes(kSecRandomDefault, count, &bytes)
return base64URL(bytes)
}
static func base64URL(_ bytes: some Sequence<UInt8>) -> String {
Data(bytes).base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
}