From 9203897ccdeb6824256dd6a6fa4ca1123dd5d3b0 Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Fri, 14 Aug 2026 00:23:47 +0100 Subject: [PATCH] feat(auth): add sign-in flow with keychain-backed token storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Outpost/Features/Auth/AuthHeaderView.swift | 31 ++++++ Outpost/Features/Auth/AuthView.swift | 5 + Outpost/Features/Auth/AuthViewModel.swift | 104 ++++++++++++++++++++ Outpost/Features/Auth/AuthView_iOS.swift | 108 +++++++++++++++++++++ Outpost/Features/Auth/AuthView_macOS.swift | 90 +++++++++++++++++ 5 files changed, 338 insertions(+) create mode 100644 Outpost/Features/Auth/AuthHeaderView.swift create mode 100644 Outpost/Features/Auth/AuthView.swift create mode 100644 Outpost/Features/Auth/AuthViewModel.swift create mode 100644 Outpost/Features/Auth/AuthView_iOS.swift create mode 100644 Outpost/Features/Auth/AuthView_macOS.swift diff --git a/Outpost/Features/Auth/AuthHeaderView.swift b/Outpost/Features/Auth/AuthHeaderView.swift new file mode 100644 index 0000000..efa4249 --- /dev/null +++ b/Outpost/Features/Auth/AuthHeaderView.swift @@ -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) + } + } + } +} diff --git a/Outpost/Features/Auth/AuthView.swift b/Outpost/Features/Auth/AuthView.swift new file mode 100644 index 0000000..5700d7d --- /dev/null +++ b/Outpost/Features/Auth/AuthView.swift @@ -0,0 +1,5 @@ +#if os(macOS) +typealias AuthView = AuthView_macOS +#else +typealias AuthView = AuthView_iOS +#endif diff --git a/Outpost/Features/Auth/AuthViewModel.swift b/Outpost/Features/Auth/AuthViewModel.swift new file mode 100644 index 0000000..a2e9dc8 --- /dev/null +++ b/Outpost/Features/Auth/AuthViewModel.swift @@ -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." + } + } +} diff --git a/Outpost/Features/Auth/AuthView_iOS.swift b/Outpost/Features/Auth/AuthView_iOS.swift new file mode 100644 index 0000000..42e2684 --- /dev/null +++ b/Outpost/Features/Auth/AuthView_iOS.swift @@ -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 diff --git a/Outpost/Features/Auth/AuthView_macOS.swift b/Outpost/Features/Auth/AuthView_macOS.swift new file mode 100644 index 0000000..294591d --- /dev/null +++ b/Outpost/Features/Auth/AuthView_macOS.swift @@ -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