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.
This commit is contained in:
2026-08-14 00:23:47 +01:00
parent a394b1b629
commit 9203897ccd
5 changed files with 338 additions and 0 deletions
@@ -0,0 +1,31 @@
import SwiftUI
struct AuthHeaderView: View {
var body: some View {
VStack(spacing: 12) {
ZStack {
Circle()
.fill(
LinearGradient(
colors: [Color.accentColor, Color.accentColor.opacity(0.6)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 64, height: 64)
Image(systemName: "text.book.closed.fill")
.font(.system(size: 26, weight: .semibold))
.foregroundStyle(.white)
}
.shadow(color: Color.accentColor.opacity(0.35), radius: 12, y: 6)
VStack(spacing: 4) {
Text("Welcome to Outpost")
.font(.title2.bold())
Text("Sign in to your Outline workspace")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
}
}
+5
View File
@@ -0,0 +1,5 @@
#if os(macOS)
typealias AuthView = AuthView_macOS
#else
typealias AuthView = AuthView_iOS
#endif
+104
View File
@@ -0,0 +1,104 @@
import Foundation
import Observation
import OutlineKit
enum AuthValidationError: Error {
case emptyURL
case httpNotAllowed
case invalidURL
}
@MainActor
@Observable
final class AuthViewModel {
struct AuthResult {
let serverURL: URL
let user: OutlineUser
let team: OutlineTeam
}
static let httpsHint = "Outline requires HTTPS. We'll add it automatically if you don't include a scheme."
var serverURLString = ""
var apiToken = ""
var isValidating = false
var errorMessage: String?
private let tokenStore: TokenStoring
init(tokenStore: TokenStoring = KeychainTokenStore()) {
self.tokenStore = tokenStore
}
var canSubmit: Bool {
!serverURLString.trimmingCharacters(in: .whitespaces).isEmpty
&& !apiToken.trimmingCharacters(in: .whitespaces).isEmpty
&& !isValidating
}
func signIn() async -> AuthResult? {
errorMessage = nil
let url: URL
do {
url = try Self.resolvedURL(from: serverURLString)
} catch {
errorMessage = Self.message(for: error)
return nil
}
isValidating = true
defer { isValidating = false }
do {
try tokenStore.store(apiToken)
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: url),
tokenStore: tokenStore
)
let auth = try await client.authInfo()
return AuthResult(serverURL: url, user: auth.user, team: auth.team)
} catch {
try? tokenStore.clear()
errorMessage = Self.message(for: error)
return nil
}
}
private static func resolvedURL(from raw: String) throws -> URL {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
throw AuthValidationError.emptyURL
}
if trimmed.lowercased().hasPrefix("http://") {
throw AuthValidationError.httpNotAllowed
}
let withScheme = trimmed.lowercased().hasPrefix("https://") ? trimmed : "https://\(trimmed)"
guard let url = URL(string: withScheme), let host = url.host, !host.isEmpty else {
throw AuthValidationError.invalidURL
}
return url
}
private static func message(for error: Error) -> String {
switch error {
case AuthValidationError.httpNotAllowed:
return "HTTP is not supported. Outline requires HTTPS to protect your API token."
case AuthValidationError.emptyURL:
return "Enter your Outline server address."
case AuthValidationError.invalidURL:
return "That doesn't look like a valid server address."
case OutlineAPIError.unauthorized:
return "Invalid API token."
case OutlineAPIError.tokenUnavailable:
return "Could not access Keychain."
case OutlineAPIError.transport:
return "Could not reach server. Check the address and try again."
default:
return "Sign in failed. Please try again."
}
}
}
+108
View File
@@ -0,0 +1,108 @@
#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
@@ -0,0 +1,90 @@
#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