mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-05 00:01:49 +00:00
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:
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: "")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user