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