Files
Outpost/Outpost/Features/Auth/AuthView_macOS.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

91 lines
3.3 KiB
Swift

#if os(macOS)
import SwiftUI
struct AuthView_macOS: View {
@State private var viewModel = AuthViewModel()
@FocusState private var focusedField: Field?
@State private var isShowingServerHint = false
var onSigningIn: (AuthViewModel.AuthResult) -> Void
private enum Field {
case serverURL, apiToken
}
var body: some View {
VStack(spacing: 24) {
AuthHeaderView()
VStack(alignment: .leading, spacing: 14) {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 4) {
Text("Server")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
Button {
isShowingServerHint.toggle()
} label: {
Image(systemName: "info.circle")
.font(.caption)
.foregroundStyle(.tertiary)
}
.buttonStyle(.plain)
.help(AuthViewModel.httpsHint)
.popover(isPresented: $isShowingServerHint, arrowEdge: .bottom) {
Text(AuthViewModel.httpsHint)
.font(.callout)
.padding()
.frame(width: 240)
}
}
TextField("outline.example.com", text: $viewModel.serverURLString, prompt: Text("outline.example.com"))
.textFieldStyle(.roundedBorder)
.focused($focusedField, equals: .serverURL)
.onSubmit { focusedField = .apiToken }
}
VStack(alignment: .leading, spacing: 6) {
Text("API Token")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
SecureField("Personal API token", text: $viewModel.apiToken, prompt: Text("Personal API token"))
.textFieldStyle(.roundedBorder)
.focused($focusedField, equals: .apiToken)
.onSubmit { submit() }
}
if let errorMessage = viewModel.errorMessage {
Label(errorMessage, systemImage: "exclamationmark.triangle.fill")
.font(.callout)
.foregroundStyle(.red)
.transition(.opacity)
}
HStack {
Spacer()
if viewModel.isValidating {
ProgressView()
.controlSize(.small)
}
Button("Sign In", action: submit)
.keyboardShortcut(.defaultAction)
.disabled(!viewModel.canSubmit)
}
}
}
.padding(32)
.frame(width: 380)
.animation(.easeInOut(duration: 0.2), value: viewModel.errorMessage)
}
private func submit() {
focusedField = nil
Task {
guard let result = await viewModel.signIn() else { return }
onSigningIn(result)
}
}
}
#endif