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.
109 lines
4.1 KiB
Swift
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
|