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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#if os(macOS)
|
||||
typealias AuthView = AuthView_macOS
|
||||
#else
|
||||
typealias AuthView = AuthView_iOS
|
||||
#endif
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user