Add complete OAuth 2.0 Authorization Code Flow with PKCE as alternative to API token authentication, with automatic server detection and graceful fallback to classic login. **OAuth Core (RFC 7636 PKCE):** - PKCEGenerator: S256 challenge generation for secure code exchange - OAuth DTOs: Client registration, token request/response models - OAuthClient, OAuthToken, AuthenticationMethod domain models - API.swift: registerOAuthClient() and exchangeOAuthToken() endpoints - OAuthRepository + POAuthRepository protocol **Browser Integration (ASWebAuthenticationSession):** - OAuthSession: Wraps native authentication session - OAuthFlowCoordinator: Orchestrates 5-phase OAuth flow - readeck:// URL scheme for OAuth callback handling - State verification for CSRF protection - User cancellation handling **Token Management:** - KeychainHelper: OAuth token storage alongside API tokens - TokenProvider: getOAuthToken(), setOAuthToken(), getAuthMethod() - AuthenticationMethod enum to distinguish token types - AuthRepository: loginWithOAuth(), getAuthenticationMethod() - Endpoint persistence in both Keychain and Settings **Server Feature Detection:** - ServerInfo extended with features array and supportsOAuth flag - GET /api/info endpoint integration (backward compatible) - GetServerInfoUseCase with optional endpoint parameter **User Profile Integration:** - ProfileApiClient: Fetch user data via GET /api/profile - UserProfileDto with username, email, provider information - GetUserProfileUseCase: Extract username from profile - Username saved and displayed for OAuth users (like classic auth) **Automatic OAuth Flow (No User Selection):** - OnboardingServerView: 2-phase flow (endpoint → auto-OAuth or classic) - OAuth attempted automatically if server supports it - Fallback to username/password on OAuth failure or unsupported - SettingsServerViewModel: checkServerOAuthSupport(), loginWithOAuth() **Cleanup & Refactoring:** - Remove all #if os(iOS) && !APP_EXTENSION conditionals - Remove LoginMethodSelectionView (no longer needed) - Remove switchToClassicLogin() method - Factories updated with OAuth dependencies **Testing:** - PKCEGeneratorTests: Verify RFC 7636 compliance - ServerInfoTests: Feature detection and backward compatibility - Mock implementations for all OAuth components **Documentation:** - docs/OAuth2-Implementation-Plan.md: Complete implementation guide - openapi.json: Readeck API specification **Scopes Requested:** - bookmarks:read, bookmarks:write, profile:read OAuth users now have full feature parity with classic authentication. Server auto-detects OAuth support via /info endpoint. Seamless UX with browser-based login and automatic fallback.
228 lines
8.9 KiB
Swift
228 lines
8.9 KiB
Swift
//
|
|
// OnboardingServerView.swift
|
|
// readeck
|
|
//
|
|
// Created by Ilyas Hallak on 31.10.25.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
struct OnboardingServerView: View {
|
|
@State private var viewModel = SettingsServerViewModel()
|
|
@State private var showLoginFields = false
|
|
|
|
var body: some View {
|
|
classicLoginForm
|
|
}
|
|
|
|
private var buttonEnabled: Bool {
|
|
if showLoginFields {
|
|
// Phase 2: Need endpoint, username, and password
|
|
return !viewModel.endpoint.isEmpty && !viewModel.username.isEmpty && !viewModel.password.isEmpty
|
|
} else {
|
|
// Phase 1: Only need endpoint
|
|
return !viewModel.endpoint.isEmpty
|
|
}
|
|
}
|
|
|
|
private var classicLoginForm: some View {
|
|
VStack(spacing: 20) {
|
|
// Readeck Logo with green background
|
|
ZStack {
|
|
Circle()
|
|
.fill(Color("green"))
|
|
.frame(width: 80, height: 80)
|
|
|
|
Image("readeck")
|
|
.resizable()
|
|
.scaledToFit()
|
|
.frame(width: 50, height: 50)
|
|
.foregroundColor(.white)
|
|
}
|
|
.padding(.top, 20)
|
|
.padding(.bottom, 8)
|
|
|
|
Text(showLoginFields ? "Enter your credentials" : "Enter your Readeck server")
|
|
.font(.title2)
|
|
.fontWeight(.bold)
|
|
.padding(.bottom, 4)
|
|
|
|
Text(showLoginFields ? "Please provide your username and password." : "Enter your server endpoint to get started.")
|
|
.font(.body)
|
|
.foregroundColor(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
.padding(.bottom, 8)
|
|
|
|
// Form
|
|
VStack(spacing: 16) {
|
|
// Server Endpoint
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
TextField("",
|
|
text: $viewModel.endpoint,
|
|
prompt: Text("Server Endpoint").foregroundColor(.secondary))
|
|
.textFieldStyle(.roundedBorder)
|
|
.keyboardType(.URL)
|
|
.autocapitalization(.none)
|
|
.disableAutocorrection(true)
|
|
.onChange(of: viewModel.endpoint) {
|
|
viewModel.clearMessages()
|
|
}
|
|
|
|
// Quick Input Chips
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 8) {
|
|
QuickInputChip(text: "http://", action: {
|
|
if !viewModel.endpoint.starts(with: "http") {
|
|
viewModel.endpoint = "http://" + viewModel.endpoint
|
|
}
|
|
})
|
|
QuickInputChip(text: "https://", action: {
|
|
if !viewModel.endpoint.starts(with: "http") {
|
|
viewModel.endpoint = "https://" + viewModel.endpoint
|
|
}
|
|
})
|
|
QuickInputChip(text: "192.168.", action: {
|
|
if viewModel.endpoint.isEmpty || viewModel.endpoint == "http://" || viewModel.endpoint == "https://" {
|
|
if viewModel.endpoint.starts(with: "http") {
|
|
viewModel.endpoint += "192.168."
|
|
} else {
|
|
viewModel.endpoint = "http://192.168."
|
|
}
|
|
}
|
|
})
|
|
QuickInputChip(text: ":8000", action: {
|
|
if !viewModel.endpoint.contains(":") || viewModel.endpoint.hasSuffix("://") {
|
|
viewModel.endpoint += ":8000"
|
|
}
|
|
})
|
|
}
|
|
.padding(.horizontal, 1)
|
|
}
|
|
|
|
Text("HTTP/HTTPS supported. HTTP only for local networks. Port optional. No trailing slash needed.")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
|
|
// Username & Password - only show when showLoginFields is true
|
|
if showLoginFields {
|
|
// Username
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
TextField("",
|
|
text: $viewModel.username,
|
|
prompt: Text("Username").foregroundColor(.secondary))
|
|
.textFieldStyle(.roundedBorder)
|
|
.autocapitalization(.none)
|
|
.disableAutocorrection(true)
|
|
.onChange(of: viewModel.username) {
|
|
viewModel.clearMessages()
|
|
}
|
|
}
|
|
|
|
// Password
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
SecureField("",
|
|
text: $viewModel.password,
|
|
prompt: Text("Password").foregroundColor(.secondary))
|
|
.textFieldStyle(.roundedBorder)
|
|
.onChange(of: viewModel.password) {
|
|
viewModel.clearMessages()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Messages
|
|
if let errorMessage = viewModel.errorMessage {
|
|
HStack {
|
|
Image(systemName: "exclamationmark.triangle.fill")
|
|
.foregroundColor(.red)
|
|
Text(errorMessage)
|
|
.foregroundColor(.red)
|
|
.font(.caption)
|
|
}
|
|
}
|
|
|
|
if let successMessage = viewModel.successMessage {
|
|
HStack {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.foregroundColor(.green)
|
|
Text(successMessage)
|
|
.foregroundColor(.green)
|
|
.font(.caption)
|
|
}
|
|
}
|
|
|
|
VStack(spacing: 10) {
|
|
Button(action: {
|
|
Task {
|
|
if !showLoginFields {
|
|
// Phase 1: Check server for OAuth support
|
|
await viewModel.checkServerOAuthSupport()
|
|
if viewModel.serverSupportsOAuth {
|
|
// Try OAuth login
|
|
await viewModel.loginWithOAuth()
|
|
// If OAuth fails, error message is shown, user can fallback to classic
|
|
if viewModel.errorMessage != nil {
|
|
showLoginFields = true
|
|
}
|
|
} else {
|
|
// No OAuth → show login fields for classic auth
|
|
showLoginFields = true
|
|
}
|
|
} else {
|
|
// Phase 2: Classic login
|
|
await viewModel.saveServerSettings()
|
|
}
|
|
}
|
|
}) {
|
|
HStack {
|
|
if viewModel.isLoading {
|
|
ProgressView()
|
|
.scaleEffect(0.8)
|
|
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
|
}
|
|
Text(viewModel.isLoading ? (showLoginFields ? "Logging in..." : "Checking...") : (showLoginFields ? "Login & Save" : "Continue"))
|
|
.fontWeight(.semibold)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding()
|
|
.background(buttonEnabled ? Color.accentColor : Color.gray)
|
|
.foregroundColor(.white)
|
|
.cornerRadius(10)
|
|
}
|
|
.disabled(!buttonEnabled || viewModel.isLoading)
|
|
}
|
|
}
|
|
.task {
|
|
await viewModel.loadServerSettings()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Quick Input Chip Component
|
|
|
|
struct QuickInputChip: View {
|
|
let text: String
|
|
let action: () -> Void
|
|
|
|
var body: some View {
|
|
Button(action: action) {
|
|
Text(text)
|
|
.font(.caption)
|
|
.fontWeight(.medium)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 6)
|
|
.background(Color(.systemGray5))
|
|
.foregroundColor(.secondary)
|
|
.cornerRadius(12)
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
OnboardingServerView()
|
|
.padding()
|
|
}
|