mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
- Add AuthenticationError enum for proper error handling in authentication domain - Add attemptAutoLogin(profile:) method to AuthenticationService that: - Checks for valid existing tokens first - Retrieves stored credentials from Keychain using profile ID - Attempts authentication with stored username/password - Throws AuthenticationError for graceful fallback to manual login - Update ServerProfilesViewModel.connectToProfile() to: - Remove early return that immediately showed login modal - Attempt auto-login first when authentication required - Only show login modal when auto-login fails (graceful fallback) - Maintain existing connection flow for no-auth servers - Enhance server setup UI to capture username during profile creation: - Add username field to ConnectionViewModel - Add username input to ServerConfigForm - Update profile creation to store actual username instead of hardcoded "admin" - Default to "admin" if username field is empty for backward compatibility - Update EnhancedConnectionView to pass username binding to form - Build verification: All changes compile successfully This implements a "Terminus-like experience" where users configure credentials once during server setup and enjoy automatic authentication thereafter, with graceful fallback to manual login when needed.
463 lines
17 KiB
Swift
463 lines
17 KiB
Swift
import SwiftUI
|
|
|
|
/// Enhanced connection view with server profiles support
|
|
struct EnhancedConnectionView: View {
|
|
@Environment(ConnectionManager.self)
|
|
var connectionManager
|
|
@State private var networkMonitor = NetworkMonitor.shared
|
|
@State private var viewModel = ConnectionViewModel()
|
|
@State private var profilesViewModel = ServerProfilesViewModel()
|
|
@State private var logoScale: CGFloat = 0.8
|
|
@State private var contentOpacity: Double = 0
|
|
@State private var showingNewServerForm = false
|
|
@State private var selectedProfile: ServerProfile?
|
|
@State private var showingProfileEditor = false
|
|
|
|
#if targetEnvironment(macCatalyst)
|
|
@StateObject private var windowManager = MacCatalystWindowManager.shared
|
|
#endif
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ZStack {
|
|
ScrollView {
|
|
VStack(spacing: Theme.Spacing.extraLarge) {
|
|
// Logo and Title
|
|
headerView
|
|
.padding(.top, {
|
|
#if targetEnvironment(macCatalyst)
|
|
return windowManager.windowStyle == .inline ? 60 : 40
|
|
#else
|
|
return 40
|
|
#endif
|
|
}())
|
|
|
|
// Quick Connect Section
|
|
if !profilesViewModel.profiles.isEmpty && !showingNewServerForm {
|
|
quickConnectSection
|
|
.opacity(contentOpacity)
|
|
.onAppear {
|
|
withAnimation(Theme.Animation.smooth.delay(0.3)) {
|
|
contentOpacity = 1.0
|
|
}
|
|
}
|
|
}
|
|
|
|
// New Connection Form
|
|
if showingNewServerForm || profilesViewModel.profiles.isEmpty {
|
|
newConnectionSection
|
|
.opacity(contentOpacity)
|
|
.onAppear {
|
|
withAnimation(Theme.Animation.smooth.delay(0.3)) {
|
|
contentOpacity = 1.0
|
|
}
|
|
}
|
|
}
|
|
|
|
Spacer(minLength: 50)
|
|
}
|
|
.padding()
|
|
}
|
|
.scrollBounceBehavior(.basedOnSize)
|
|
}
|
|
.toolbar(.hidden, for: .navigationBar)
|
|
.background(Theme.Colors.terminalBackground.ignoresSafeArea())
|
|
.sheet(item: $selectedProfile) { profile in
|
|
ServerProfileEditView(
|
|
profile: profile,
|
|
onSave: { updatedProfile, password in
|
|
Task {
|
|
try await profilesViewModel.updateProfile(updatedProfile, password: password)
|
|
selectedProfile = nil
|
|
}
|
|
},
|
|
onDelete: {
|
|
Task {
|
|
try await profilesViewModel.deleteProfile(profile)
|
|
selectedProfile = nil
|
|
}
|
|
}
|
|
)
|
|
}
|
|
.sheet(isPresented: $viewModel.showLoginView) {
|
|
if let config = connectionManager.serverConfig,
|
|
let authService = connectionManager.authenticationService
|
|
{
|
|
LoginView(
|
|
isPresented: $viewModel.showLoginView,
|
|
serverConfig: config,
|
|
authenticationService: authService
|
|
) {
|
|
// Authentication successful, mark as connected
|
|
connectionManager.isConnected = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.navigationViewStyle(StackNavigationViewStyle())
|
|
.onAppear {
|
|
profilesViewModel.loadProfiles()
|
|
}
|
|
}
|
|
|
|
// MARK: - Header View
|
|
|
|
private var headerView: some View {
|
|
VStack(spacing: Theme.Spacing.large) {
|
|
ZStack {
|
|
// Glow effect
|
|
Image(systemName: "terminal.fill")
|
|
.font(.system(size: 80))
|
|
.foregroundColor(Theme.Colors.primaryAccent)
|
|
.blur(radius: 20)
|
|
.opacity(0.5)
|
|
|
|
// Main icon
|
|
Image(systemName: "terminal.fill")
|
|
.font(.system(size: 80))
|
|
.foregroundColor(Theme.Colors.primaryAccent)
|
|
.glowEffect()
|
|
}
|
|
.scaleEffect(logoScale)
|
|
.onAppear {
|
|
withAnimation(Theme.Animation.smooth.delay(0.1)) {
|
|
logoScale = 1.0
|
|
}
|
|
}
|
|
|
|
VStack(spacing: Theme.Spacing.small) {
|
|
Text("VibeTunnel")
|
|
.font(.system(size: 42, weight: .bold, design: .rounded))
|
|
.foregroundColor(Theme.Colors.terminalForeground)
|
|
|
|
Text("Terminal Multiplexer")
|
|
.font(Theme.Typography.terminalSystem(size: 16))
|
|
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
|
|
.tracking(2)
|
|
|
|
// Network status
|
|
ConnectionStatusView()
|
|
.padding(.top, Theme.Spacing.small)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Quick Connect Section
|
|
|
|
private var quickConnectSection: some View {
|
|
VStack(alignment: .leading, spacing: Theme.Spacing.medium) {
|
|
HStack {
|
|
Text("Saved Servers")
|
|
.font(Theme.Typography.terminalSystem(size: 18, weight: .semibold))
|
|
.foregroundColor(Theme.Colors.terminalForeground)
|
|
|
|
Spacer()
|
|
|
|
Button(action: {
|
|
withAnimation {
|
|
showingNewServerForm.toggle()
|
|
}
|
|
}) {
|
|
Image(systemName: showingNewServerForm ? "minus.circle" : "plus.circle")
|
|
.font(.system(size: 20))
|
|
.foregroundColor(Theme.Colors.primaryAccent)
|
|
}
|
|
}
|
|
|
|
VStack(spacing: Theme.Spacing.small) {
|
|
ForEach(profilesViewModel.profiles) { profile in
|
|
ServerProfileCard(
|
|
profile: profile,
|
|
isLoading: profilesViewModel.isLoading,
|
|
onConnect: {
|
|
connectToProfile(profile)
|
|
},
|
|
onEdit: {
|
|
selectedProfile = profile
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - New Connection Section
|
|
|
|
private var newConnectionSection: some View {
|
|
VStack(spacing: Theme.Spacing.large) {
|
|
if !profilesViewModel.profiles.isEmpty {
|
|
HStack {
|
|
Text("New Server Connection")
|
|
.font(Theme.Typography.terminalSystem(size: 18, weight: .semibold))
|
|
.foregroundColor(Theme.Colors.terminalForeground)
|
|
|
|
Spacer()
|
|
}
|
|
}
|
|
|
|
ServerConfigForm(
|
|
host: $viewModel.host,
|
|
port: $viewModel.port,
|
|
name: $viewModel.name,
|
|
username: $viewModel.username,
|
|
password: $viewModel.password,
|
|
isConnecting: viewModel.isConnecting,
|
|
errorMessage: viewModel.errorMessage,
|
|
onConnect: saveAndConnect
|
|
)
|
|
|
|
if !profilesViewModel.profiles.isEmpty {
|
|
Button(action: {
|
|
withAnimation {
|
|
showingNewServerForm = false
|
|
}
|
|
}) {
|
|
Text("Cancel")
|
|
.font(Theme.Typography.terminalSystem(size: 16))
|
|
.foregroundColor(Theme.Colors.secondaryText)
|
|
}
|
|
.padding(.top, Theme.Spacing.small)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Actions
|
|
|
|
private func connectToProfile(_ profile: ServerProfile) {
|
|
guard networkMonitor.isConnected else {
|
|
viewModel.errorMessage = "No internet connection available"
|
|
return
|
|
}
|
|
|
|
Task {
|
|
do {
|
|
try await profilesViewModel.connectToProfile(profile, connectionManager: connectionManager)
|
|
|
|
// Check if we need to show login view for authentication
|
|
// This happens when auto-login failed and manual login is required
|
|
if let authService = connectionManager.authenticationService,
|
|
!authService.isAuthenticated {
|
|
viewModel.showLoginView = true
|
|
}
|
|
} catch {
|
|
viewModel.errorMessage = "Failed to connect: \(error.localizedDescription)"
|
|
}
|
|
}
|
|
}
|
|
|
|
private func saveAndConnect() {
|
|
guard networkMonitor.isConnected else {
|
|
viewModel.errorMessage = "No internet connection available"
|
|
return
|
|
}
|
|
|
|
// Create profile from form data
|
|
let urlString = viewModel.port.isEmpty ? viewModel.host : "\(viewModel.host):\(viewModel.port)"
|
|
guard let profile = profilesViewModel.createProfileFromURL(urlString) else {
|
|
viewModel.errorMessage = "Invalid server URL"
|
|
return
|
|
}
|
|
|
|
var updatedProfile = profile
|
|
updatedProfile.name = viewModel.name.isEmpty ? profile.name : viewModel.name
|
|
updatedProfile.requiresAuth = !viewModel.password.isEmpty
|
|
updatedProfile.username = updatedProfile.requiresAuth ? (viewModel.username.isEmpty ? "admin" : viewModel.username) : nil
|
|
|
|
// Save profile and password
|
|
Task {
|
|
try await profilesViewModel.addProfile(updatedProfile, password: viewModel.password)
|
|
|
|
// Connect
|
|
connectToProfile(updatedProfile)
|
|
}
|
|
|
|
// Reset form
|
|
viewModel = ConnectionViewModel()
|
|
showingNewServerForm = false
|
|
}
|
|
}
|
|
|
|
// MARK: - Server Profile Card
|
|
|
|
struct ServerProfileCard: View {
|
|
let profile: ServerProfile
|
|
let isLoading: Bool
|
|
let onConnect: () -> Void
|
|
let onEdit: () -> Void
|
|
|
|
@State private var isPressed = false
|
|
|
|
var body: some View {
|
|
HStack(spacing: Theme.Spacing.medium) {
|
|
// Icon
|
|
Image(systemName: profile.iconSymbol)
|
|
.font(.system(size: 24))
|
|
.foregroundColor(Theme.Colors.primaryAccent)
|
|
.frame(width: 40, height: 40)
|
|
.background(Theme.Colors.primaryAccent.opacity(0.1))
|
|
.cornerRadius(Theme.CornerRadius.small)
|
|
|
|
// Server Info
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(profile.name)
|
|
.font(Theme.Typography.terminalSystem(size: 16, weight: .medium))
|
|
.foregroundColor(Theme.Colors.terminalForeground)
|
|
|
|
HStack(spacing: 4) {
|
|
Text(profile.url)
|
|
.font(Theme.Typography.terminalSystem(size: 12))
|
|
.foregroundColor(Theme.Colors.secondaryText)
|
|
|
|
if profile.requiresAuth {
|
|
Image(systemName: "lock.fill")
|
|
.font(.system(size: 10))
|
|
.foregroundColor(Theme.Colors.warningAccent)
|
|
}
|
|
}
|
|
|
|
if let lastConnected = profile.lastConnected {
|
|
Text(RelativeDateTimeFormatter().localizedString(for: lastConnected, relativeTo: Date()))
|
|
.font(Theme.Typography.terminalSystem(size: 11))
|
|
.foregroundColor(Theme.Colors.secondaryText.opacity(0.7))
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// Action Buttons
|
|
HStack(spacing: Theme.Spacing.small) {
|
|
Button(action: onEdit) {
|
|
Image(systemName: "ellipsis.circle")
|
|
.font(.system(size: 20))
|
|
.foregroundColor(Theme.Colors.secondaryText)
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
Button(action: onConnect) {
|
|
HStack(spacing: 4) {
|
|
if isLoading {
|
|
ProgressView()
|
|
.scaleEffect(0.8)
|
|
} else {
|
|
Image(systemName: "arrow.right.circle.fill")
|
|
.font(.system(size: 24))
|
|
}
|
|
}
|
|
.foregroundColor(Theme.Colors.primaryAccent)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.disabled(isLoading)
|
|
}
|
|
}
|
|
.padding(Theme.Spacing.medium)
|
|
.background(Theme.Colors.cardBackground)
|
|
.cornerRadius(Theme.CornerRadius.card)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: Theme.CornerRadius.card)
|
|
.stroke(Theme.Colors.cardBorder, lineWidth: 1)
|
|
)
|
|
.scaleEffect(isPressed ? 0.98 : 1.0)
|
|
.animation(.easeInOut(duration: 0.1), value: isPressed)
|
|
.onTapGesture {
|
|
onConnect()
|
|
}
|
|
.simultaneousGesture(
|
|
DragGesture(minimumDistance: 0)
|
|
.onChanged { _ in isPressed = true }
|
|
.onEnded { _ in isPressed = false }
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Server Profile Edit View
|
|
|
|
struct ServerProfileEditView: View {
|
|
@State var profile: ServerProfile
|
|
let onSave: (ServerProfile, String?) -> Void
|
|
let onDelete: () -> Void
|
|
|
|
@State private var password: String = ""
|
|
@State private var showingDeleteConfirmation = false
|
|
@Environment(\.dismiss)
|
|
private var dismiss
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Form {
|
|
Section("Server Details") {
|
|
HStack {
|
|
Text("Icon")
|
|
Spacer()
|
|
Image(systemName: profile.iconSymbol)
|
|
.font(.system(size: 24))
|
|
.foregroundColor(Theme.Colors.primaryAccent)
|
|
}
|
|
|
|
TextField("Name", text: $profile.name)
|
|
TextField("URL", text: $profile.url)
|
|
|
|
Toggle("Requires Authentication", isOn: $profile.requiresAuth)
|
|
|
|
if profile.requiresAuth {
|
|
TextField("Username", text: Binding(
|
|
get: { profile.username ?? "admin" },
|
|
set: { profile.username = $0 }
|
|
))
|
|
SecureField("Password", text: $password)
|
|
.textContentType(.password)
|
|
}
|
|
}
|
|
|
|
Section {
|
|
Button(role: .destructive, action: {
|
|
showingDeleteConfirmation = true
|
|
}) {
|
|
Label("Delete Server", systemImage: "trash")
|
|
.foregroundColor(.red)
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("Edit Server")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarLeading) {
|
|
Button("Cancel") {
|
|
dismiss()
|
|
}
|
|
}
|
|
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
Button("Save") {
|
|
onSave(profile, profile.requiresAuth ? password : nil)
|
|
dismiss()
|
|
}
|
|
.fontWeight(.semibold)
|
|
}
|
|
}
|
|
.alert("Delete Server?", isPresented: $showingDeleteConfirmation) {
|
|
Button("Delete", role: .destructive) {
|
|
onDelete()
|
|
dismiss()
|
|
}
|
|
Button("Cancel", role: .cancel) {}
|
|
} message: {
|
|
Text("Are you sure you want to delete \"\(profile.name)\"? This action cannot be undone.")
|
|
}
|
|
}
|
|
.task {
|
|
// Load existing password from keychain
|
|
if profile.requiresAuth,
|
|
let existingPassword = try? KeychainService.getPassword(for: profile.id)
|
|
{
|
|
password = existingPassword
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Preview
|
|
|
|
#Preview {
|
|
EnhancedConnectionView()
|
|
.environment(ConnectionManager())
|
|
}
|