Files
Outpost/Outpost/Features/Auth/AuthView_iOS.swift
T
Puranjay Savar Mattas 9203897ccd feat(auth): add sign-in flow with keychain-backed token storage
Per-platform login screens (iOS/macOS share a view model) that validate
against auth.info and store the API token via OutlineKit's Keychain
wrapper — never UserDefaults, per CLAUDE.md.
2026-08-14 00:23:47 +01:00

109 lines
4.1 KiB
Swift

#if os(iOS)
import SwiftUI
struct AuthView_iOS: View {
@State private var viewModel = AuthViewModel()
@FocusState private var focusedField: Field?
var onSigningIn: (AuthViewModel.AuthResult) -> Void
private enum Field {
case serverURL, apiToken
}
var body: some View {
ScrollView {
VStack(spacing: 28) {
AuthHeaderView()
.padding(.top, 48)
VStack(spacing: 16) {
VStack(alignment: .leading, spacing: 6) {
fieldLabel("Server", systemImage: "globe")
TextField("outline.example.com", text: $viewModel.serverURLString)
.textFieldStyle(.plain)
.padding(14)
.background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 12))
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.URL)
.focused($focusedField, equals: .serverURL)
.submitLabel(.next)
.onSubmit { focusedField = .apiToken }
Label(AuthViewModel.httpsHint, systemImage: "lock.fill")
.font(.caption)
.foregroundStyle(.secondary)
}
VStack(alignment: .leading, spacing: 6) {
fieldLabel("API Token", systemImage: "key.fill")
SecureField("Personal API token", text: $viewModel.apiToken)
.textFieldStyle(.plain)
.padding(14)
.background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 12))
.focused($focusedField, equals: .apiToken)
.submitLabel(.go)
.onSubmit { submit() }
}
if let errorMessage = viewModel.errorMessage {
Label(errorMessage, systemImage: "exclamationmark.triangle.fill")
.font(.footnote)
.foregroundStyle(.red)
.frame(maxWidth: .infinity, alignment: .leading)
.transition(.opacity.combined(with: .move(edge: .top)))
}
Button(action: submit) {
HStack {
if viewModel.isValidating {
ProgressView()
.tint(.white)
} else {
Text("Sign In")
.fontWeight(.semibold)
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.buttonStyle(.borderedProminent)
.disabled(!viewModel.canSubmit)
}
.padding(20)
.background(.background.secondary, in: RoundedRectangle(cornerRadius: 24))
.padding(.horizontal, 20)
Spacer(minLength: 24)
}
}
.background(
LinearGradient(
colors: [Color.accentColor.opacity(0.12), Color(.systemBackground)],
startPoint: .top,
endPoint: .bottom
)
.ignoresSafeArea()
)
.animation(.easeInOut(duration: 0.2), value: viewModel.errorMessage)
.animation(.easeInOut(duration: 0.2), value: viewModel.isValidating)
}
private func fieldLabel(_ text: String, systemImage: String) -> some View {
Label(text, systemImage: systemImage)
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
}
private func submit() {
focusedField = nil
Task {
guard let result = await viewModel.signIn() else { return }
onSigningIn(result)
}
}
}
#endif