diff --git a/PDSMan.xcodeproj/project.pbxproj b/PDSMan.xcodeproj/project.pbxproj index 23b88ad..4ddab58 100644 --- a/PDSMan.xcodeproj/project.pbxproj +++ b/PDSMan.xcodeproj/project.pbxproj @@ -393,17 +393,21 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_ASSET_PATHS = "\"PDSMan/Preview Content\""; DEVELOPMENT_TEAM = 4BC9Y2LL4B; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = PDSMan; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -411,9 +415,12 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.PDSMan; PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = 1; }; name = Debug; }; @@ -422,17 +429,21 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_ASSET_PATHS = "\"PDSMan/Preview Content\""; DEVELOPMENT_TEAM = 4BC9Y2LL4B; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = PDSMan; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -440,9 +451,12 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.PDSMan; PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = 1; }; name = Release; }; diff --git a/PDSMan/ContentView.swift b/PDSMan/ContentView.swift index 76e4aba..5607b9c 100644 --- a/PDSMan/ContentView.swift +++ b/PDSMan/ContentView.swift @@ -9,13 +9,8 @@ import SwiftUI struct ContentView: View { var body: some View { - VStack { - Image(systemName: "globe") - .imageScale(.large) - .foregroundStyle(.tint) - Text("Hello, world!") - } - .padding() + MainAppView() + .accentColor(.blue) } } diff --git a/PDSMan/Models/PDSModels.swift b/PDSMan/Models/PDSModels.swift new file mode 100644 index 0000000..1bd895c --- /dev/null +++ b/PDSMan/Models/PDSModels.swift @@ -0,0 +1,68 @@ +import Foundation + +// Credentials for connecting to the PDS +struct PDSCredentials: Sendable { + var serverURL: String + var username: String + var password: String + + init?(serverURL: String, username: String, password: String) { + // Validate inputs + guard !serverURL.isEmpty, !username.isEmpty, !password.isEmpty else { + return nil + } + + self.serverURL = serverURL + self.username = username + self.password = password + } +} + +// Invite code model +struct InviteCode: Identifiable, Sendable { + var id: String + var code: String { id } // For compatibility with the view + var uses: [CodeUse]? + var createdAt: Date + var disabled: Bool + var isDisabled: Bool { disabled } // For backwards compatibility +} + +// Invite use model +struct CodeUse: Codable, Identifiable, Sendable { + var id: String { usedBy } + var usedBy: String + var usedAt: String +} + +// User model +struct PDSUser: Identifiable, Hashable, Sendable { + var id: String // DID + var handle: String + var displayName: String + var description: String + var joinedAt: Date + var avatar: URL? + var isActive: Bool = true + + // Add Hashable conformance + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + static func == (lhs: PDSUser, rhs: PDSUser) -> Bool { + lhs.id == rhs.id + } +} + +// Auth state +enum AuthState: Sendable { + case loggedOut + case loggedIn +} + +// View state +enum ViewTab: Sendable { + case inviteCodes + case userList +} \ No newline at end of file diff --git a/PDSMan/PDSApp.swift b/PDSMan/PDSApp.swift new file mode 100644 index 0000000..7f8d5ad --- /dev/null +++ b/PDSMan/PDSApp.swift @@ -0,0 +1,20 @@ +import SwiftUI + +@main +struct PDSApp: App { + @StateObject private var viewModel = PDSViewModel() + + init() { + print("PDSApp: initializing") + } + + var body: some Scene { + WindowGroup { + MainAppView() + .environmentObject(viewModel) + .onAppear { + print("PDSApp: root view appeared") + } + } + } +} \ No newline at end of file diff --git a/PDSMan/PDSManApp.swift b/PDSMan/PDSManApp.swift index ae03f54..aefd6af 100644 --- a/PDSMan/PDSManApp.swift +++ b/PDSMan/PDSManApp.swift @@ -7,11 +7,11 @@ import SwiftUI -@main +// This app entry point is replaced by PDSApp.swift struct PDSManApp: App { var body: some Scene { WindowGroup { - ContentView() + Text("This app entry point is not used.") } } } diff --git a/PDSMan/Services/PDSService.swift b/PDSMan/Services/PDSService.swift new file mode 100644 index 0000000..2f1ecf7 --- /dev/null +++ b/PDSMan/Services/PDSService.swift @@ -0,0 +1,1052 @@ +import Foundation +import Combine +import Security + +@MainActor +class PDSService: ObservableObject { + private var credentials: PDSCredentials? + private var baseURL: URL? + private var authHeader: String? + private var session = URLSession.shared + + // Published properties for UI to observe + @Published var isAuthenticated = false + @Published var inviteCodes: [InviteCode] = [] + @Published var users: [PDSUser] = [] + @Published var isLoading = false + @Published var errorMessage: String? + + // MARK: - Keychain Constants + private struct KeychainConstants { + static let service = "com.atproto.pdsmanager" + static let usernameKey = "username" + static let passwordKey = "password" + static let serverURLKey = "serverURL" + static let authHeaderKey = "authHeader" + } + + init() { + // Try to restore previous session on launch + loadCredentialsFromKeychain() + } + + // MARK: - Keychain Access + + private func saveToKeychain(key: String, value: String) -> Bool { + if let data = value.data(using: .utf8) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: KeychainConstants.service, + kSecAttrAccount as String: key, + kSecValueData as String: data + ] + + // First delete any existing item + SecItemDelete(query as CFDictionary) + + // Then add the new item + let status = SecItemAdd(query as CFDictionary, nil) + return status == errSecSuccess + } + return false + } + + private func loadFromKeychain(key: String) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: KeychainConstants.service, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + if status == errSecSuccess, let data = result as? Data, let value = String(data: data, encoding: .utf8) { + return value + } + return nil + } + + private func deleteFromKeychain(key: String) -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: KeychainConstants.service, + kSecAttrAccount as String: key + ] + + let status = SecItemDelete(query as CFDictionary) + return status == errSecSuccess || status == errSecItemNotFound + } + + private func saveCredentialsToKeychain() { + guard let credentials = credentials else { return } + + _ = saveToKeychain(key: KeychainConstants.usernameKey, value: credentials.username) + _ = saveToKeychain(key: KeychainConstants.passwordKey, value: credentials.password) + _ = saveToKeychain(key: KeychainConstants.serverURLKey, value: credentials.serverURL) + + if let authHeader = authHeader { + _ = saveToKeychain(key: KeychainConstants.authHeaderKey, value: authHeader) + } + + print("Credentials saved to keychain") + } + + private func loadCredentialsFromKeychain() { + guard let username = loadFromKeychain(key: KeychainConstants.usernameKey), + let password = loadFromKeychain(key: KeychainConstants.passwordKey), + let serverURL = loadFromKeychain(key: KeychainConstants.serverURLKey), + let authHeader = loadFromKeychain(key: KeychainConstants.authHeaderKey) else { + print("No credentials found in keychain") + return + } + + print("Credentials loaded from keychain") + + // Restore the credentials + self.credentials = PDSCredentials(serverURL: serverURL, username: username, password: password) + self.baseURL = URL(string: serverURL) + self.authHeader = authHeader + self.isAuthenticated = true + + // Refresh data + Task { + await fetchInviteCodes() + await fetchUsers() + } + } + + private func clearCredentialsFromKeychain() { + _ = deleteFromKeychain(key: KeychainConstants.usernameKey) + _ = deleteFromKeychain(key: KeychainConstants.passwordKey) + _ = deleteFromKeychain(key: KeychainConstants.serverURLKey) + _ = deleteFromKeychain(key: KeychainConstants.authHeaderKey) + print("Credentials cleared from keychain") + } + + // MARK: - Response Structures + + // Invite Code structures + struct InviteCodesResponse: Codable { + var codes: [InviteCodeResponse] + } + + struct InviteCodeResponse: Codable { + var code: String + var available: Int + var disabled: Bool + var createdAt: String + var createdBy: String + var uses: [CodeUse]? + } + + struct CodeUse: Codable { + var usedBy: String + var usedAt: String + } + + struct CreateCodeResponse: Codable { + var code: String + } + + // User structures + struct RepoResponse: Codable { + var cursor: String? + var repos: [Repo] + } + + struct Repo: Codable { + var did: String + var head: String + var rev: String + var active: Bool + } + + struct AccountInfo: Codable { + var did: String + var handle: String + var email: String? + var emailConfirmedAt: String? + var indexedAt: String + var invitedBy: InviteInfo? + var invites: [String]? + var invitesDisabled: Bool? + var createdAt: String { + return indexedAt + } + } + + struct InviteInfo: Codable { + var code: String + var available: Int + var disabled: Bool + var forAccount: String + var createdBy: String + var createdAt: String + var uses: [CodeUse]? + } + + struct ProfileResponse: Codable { + var value: ProfileValue + } + + struct ProfileValue: Codable { + var displayName: String? + var description: String? + var avatar: Avatar? + } + + struct Avatar: Codable { + var ref: AvatarRef + } + + struct AvatarRef: Codable { + var link: String + + enum CodingKeys: String, CodingKey { + case link = "$link" + } + } + + // MARK: - Authentication + + private func setAuthenticated(_ value: Bool) { + print("PDSService: setting isAuthenticated to \(value)") + self.isAuthenticated = value + } + + func login(credentials: PDSCredentials) async -> Bool { + print("Attempting to connect to server: \(credentials.serverURL)") + self.credentials = credentials + self.isLoading = true + defer { self.isLoading = false } + + // Clear any previous errors + self.errorMessage = nil + + // Validate the URL format + guard let components = URLComponents(string: credentials.serverURL) else { + setError("Invalid URL format: The server URL is not properly formatted") + return false + } + + // Ensure scheme is either http or https + guard let scheme = components.scheme, (scheme == "http" || scheme == "https") else { + setError("Invalid URL scheme: URL must begin with http:// or https://") + return false + } + + // Ensure there's a host + guard let host = components.host, !host.isEmpty else { + setError("Invalid URL: Missing or empty host") + return false + } + + guard let url = URL(string: credentials.serverURL) else { + setError("Invalid URL format") + return false + } + + // Test if we can connect to a known reliable server + if await !testInternetConnectivity() { + setError("Network connectivity issue: Cannot reach the internet. Please check your network connection.") + return false + } + + self.baseURL = url + + // Create Basic Auth header from credentials + let authString = "\(credentials.username):\(credentials.password)" + guard let authData = authString.data(using: .utf8) else { + setError("Invalid authentication data") + return false + } + + let base64Auth = authData.base64EncodedString() + self.authHeader = "Basic \(base64Auth)" + + // Just check if the server exists and is running, no auth needed for this endpoint + guard let testURL = URL(string: "\(credentials.serverURL)/xrpc/com.atproto.server.describeServer") else { + setError("Invalid server URL") + return false + } + + print("Testing connectivity to: \(testURL.absoluteString)") + + // Create a URLSession with shorter timeouts + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 10 // 10 seconds timeout + config.timeoutIntervalForResource = 10 + let quickTimeoutSession = URLSession(configuration: config) + + var request = URLRequest(url: testURL) + request.httpMethod = "GET" + + do { + print("Sending request...") + let (data, response) = try await quickTimeoutSession.data(for: request) + print("Got response: \(response)") + + // Debug: print response data as string + if let dataString = String(data: data, encoding: .utf8) { + print("Response data: \(dataString)") + } + + guard let httpResponse = response as? HTTPURLResponse else { + setError("Invalid response from server") + return false + } + + // If the server responds with 200, we consider it a success + // verify auth credentials when user accesses protected endpoints + if httpResponse.statusCode == 200 { + setAuthenticated(true) + + // Save credentials securely + saveCredentialsToKeychain() + + self.errorMessage = nil + print("Login successful!") + return true + } else { + let errorMessage = "Server error: \(httpResponse.statusCode)" + print(errorMessage) + setError(errorMessage) + return false + } + } catch let error as NSError { + // Handle specific network errors with better messages + print("Connection error: \(error.domain) code: \(error.code) - \(error.localizedDescription)") + + if error.domain == NSURLErrorDomain { + switch error.code { + case NSURLErrorCannotFindHost: + setError("Cannot find host: The server URL appears to be invalid or the server is unreachable") + case NSURLErrorCannotConnectToHost: + setError("Cannot connect to host: The server exists but isn't responding") + case NSURLErrorNetworkConnectionLost: + setError("Connection lost: The network connection was lost during the request") + case NSURLErrorNotConnectedToInternet: + setError("No internet connection: Please check your network settings") + case NSURLErrorTimedOut: + setError("Connection timed out: The server took too long to respond") + default: + setError("Network error (\(error.code)): \(error.localizedDescription)") + } + } else { + setError("Connection error: \(error.localizedDescription)") + } + return false + } + } + + // Test basic internet connectivity by trying to reach a reliable server + private func testInternetConnectivity() async -> Bool { + print("Testing general internet connectivity...") + + // Use Apple's captive portal detection as a reliable server to test connectivity + guard let connectivityURL = URL(string: "https://www.apple.com/library/test/success.html") else { + return false + } + + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 5 + config.waitsForConnectivity = false + let session = URLSession(configuration: config) + + do { + let (_, response) = try await session.data(for: URLRequest(url: connectivityURL)) + guard let httpResponse = response as? HTTPURLResponse else { + return false + } + + print("Internet connectivity test: status code \(httpResponse.statusCode)") + return httpResponse.statusCode == 200 + } catch { + print("Internet connectivity test failed: \(error.localizedDescription)") + return false + } + } + + func logout() { + // Clear authentication state + setAuthenticated(false) + self.authHeader = nil + self.credentials = nil + self.baseURL = nil + self.users = [] + self.inviteCodes = [] + self.errorMessage = nil + + // Clear saved credentials + clearCredentialsFromKeychain() + } + + // MARK: - Invite Codes + + func fetchInviteCodes() async { + guard isAuthenticated, let baseURL = baseURL, let authHeader = authHeader else { return } + + self.isLoading = true + + defer { + self.isLoading = false + } + + // Construct the URL for the invite codes endpoint + guard let inviteCodesURL = URL(string: "\(baseURL)/xrpc/com.atproto.admin.getInviteCodes") else { + setError("Invalid invite codes URL") + return + } + + // Add query parameters + var components = URLComponents(url: inviteCodesURL, resolvingAgainstBaseURL: true) + components?.queryItems = [ + URLQueryItem(name: "sort", value: "recent"), + URLQueryItem(name: "limit", value: "100"), + URLQueryItem(name: "includeDisabled", value: "true") // Always include disabled codes + ] + + guard let finalURL = components?.url else { + setError("Invalid invite codes URL with parameters") + return + } + + var request = URLRequest(url: finalURL) + request.httpMethod = "GET" + request.addValue(authHeader, forHTTPHeaderField: "Authorization") + + do { + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + setError("Invalid response from server") + return + } + + if httpResponse.statusCode == 200 { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + + let codesResponse = try decoder.decode(InviteCodesResponse.self, from: data) + + let dateFormatter = ISO8601DateFormatter() + + let parsedCodes = codesResponse.codes.map { codeResp -> InviteCode in + let createdDate = dateFormatter.date(from: codeResp.createdAt) ?? Date() + + // Convert the uses array + let inviteUses = codeResp.uses?.map { use -> PDSMan.CodeUse in + return PDSMan.CodeUse(usedBy: use.usedBy, usedAt: use.usedAt) + } + + return InviteCode( + id: codeResp.code, + uses: inviteUses, + createdAt: createdDate, + disabled: codeResp.disabled + ) + } + + self.inviteCodes = parsedCodes + } else { + let responseString = String(data: data, encoding: .utf8) ?? "Unknown error" + setError("Failed to fetch invite codes: \(httpResponse.statusCode) - \(responseString)") + } + } catch { + setError("Failed to fetch invite codes: \(error.localizedDescription)") + } + } + + func createInviteCode(maxUses: Int = 1) async -> InviteCode? { + guard isAuthenticated, let baseURL = baseURL, let authHeader = authHeader else { return nil } + + // Construct the URL for creating an invite code + guard let createURL = URL(string: "\(baseURL)/xrpc/com.atproto.server.createInviteCode") else { + setError("Invalid create invite code URL") + return nil + } + + var request = URLRequest(url: createURL) + request.httpMethod = "POST" + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + request.addValue(authHeader, forHTTPHeaderField: "Authorization") + + let createBody = ["useCount": maxUses] + + do { + let jsonData = try JSONSerialization.data(withJSONObject: createBody) + request.httpBody = jsonData + + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + setError("Invalid response from server") + return nil + } + + if httpResponse.statusCode == 200 || httpResponse.statusCode == 201 { + // Parse the response to get the new code + let decoder = JSONDecoder() + let codeResponse = try decoder.decode(CreateCodeResponse.self, from: data) + + // Create a new InviteCode object + let newCode = InviteCode( + id: codeResponse.code, + uses: [] as [PDSMan.CodeUse]?, + createdAt: Date(), + disabled: false + ) + + // Update the local list + self.inviteCodes.append(newCode) + + return newCode + } else { + let responseString = String(data: data, encoding: .utf8) ?? "Unknown error" + setError("Failed to create invite code: \(httpResponse.statusCode) - \(responseString)") + return nil + } + } catch { + setError("Failed to create invite code: \(error.localizedDescription)") + return nil + } + } + + func disableInviteCode(_ code: String) async -> Bool { + guard isAuthenticated, let baseURL = baseURL, let authHeader = authHeader else { return false } + + guard let disableURL = URL(string: "\(baseURL)/xrpc/com.atproto.admin.disableInviteCodes") else { + setError("Invalid disable invite code URL") + return false + } + + var request = URLRequest(url: disableURL) + request.httpMethod = "POST" + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + request.addValue(authHeader, forHTTPHeaderField: "Authorization") + + // Create the request body with an array of codes + let disableBody = ["codes": [code]] + + do { + let jsonData = try JSONSerialization.data(withJSONObject: disableBody) + request.httpBody = jsonData + + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + setError("Invalid response from server") + return false + } + + if httpResponse.statusCode == 200 { + // Refresh the invite codes + await fetchInviteCodes() + return true + } else { + let responseString = String(data: data, encoding: .utf8) ?? "Unknown error" + setError("Failed to disable invite code: \(httpResponse.statusCode) - \(responseString)") + return false + } + } catch { + setError("Failed to disable invite code: \(error.localizedDescription)") + return false + } + } + + // MARK: - Users + + func fetchUsers() async { + guard isAuthenticated, let baseURL = baseURL, let authHeader = authHeader else { return } + + self.isLoading = true + + defer { + self.isLoading = false + } + + // Construct the URL for the repos endpoint + guard let reposURL = URL(string: "\(baseURL)/xrpc/com.atproto.sync.listRepos") else { + setError("Invalid list repos URL") + return + } + + // Add query parameters + var components = URLComponents(url: reposURL, resolvingAgainstBaseURL: true) + components?.queryItems = [ + URLQueryItem(name: "limit", value: "100") + ] + + guard let finalURL = components?.url else { + setError("Invalid repos URL with parameters") + return + } + + var request = URLRequest(url: finalURL) + request.httpMethod = "GET" + request.addValue(authHeader, forHTTPHeaderField: "Authorization") + + do { + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + setError("Invalid response from server") + return + } + + if httpResponse.statusCode == 200 { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + + let reposResponse = try decoder.decode(RepoResponse.self, from: data) + + // Fetch details for each user + var fetchedUsers: [PDSUser] = [] + + for repo in reposResponse.repos { + if let user = await fetchUserProfile(did: repo.did, isActive: repo.active) { + fetchedUsers.append(user) + } + } + + // Sort users by join date (newest first) + fetchedUsers.sort { $0.joinedAt > $1.joinedAt } + + self.users = fetchedUsers + } else { + let responseString = String(data: data, encoding: .utf8) ?? "Unknown error" + setError("Failed to fetch users: \(httpResponse.statusCode) - \(responseString)") + } + } catch { + setError("Failed to fetch users: \(error.localizedDescription)") + } + } + + private func fetchUserProfile(did: String, isActive: Bool = true) async -> PDSUser? { + guard let baseURL = baseURL, let authHeader = authHeader else { + print("Cannot fetch user profile: Missing baseURL or authHeader") + return nil + } + + print("Fetching profile for user: \(did)") + + // First, fetch account info + do { + // 1. Fetch account info + guard let accountURL = URL(string: "\(baseURL)/xrpc/com.atproto.admin.getAccountInfo?did=\(did)") else { + print("Invalid account info URL for did: \(did)") + return nil + } + + print("Requesting account info from: \(accountURL.absoluteString)") + + var accountRequest = URLRequest(url: accountURL) + accountRequest.httpMethod = "GET" + accountRequest.addValue(authHeader, forHTTPHeaderField: "Authorization") + + let (accountData, accountResponse) = try await session.data(for: accountRequest) + + guard let httpResponse = accountResponse as? HTTPURLResponse else { + print("Invalid HTTP response for account info") + return nil + } + + // Print response status and data for debugging + print("Account info response status: \(httpResponse.statusCode)") + if let responseString = String(data: accountData, encoding: .utf8) { + print("Account info response: \(responseString)") + } + + guard httpResponse.statusCode == 200 else { + let statusCode = httpResponse.statusCode + let responseText = String(data: accountData, encoding: .utf8) ?? "Unknown error" + print("Account info failed with status \(statusCode): \(responseText)") + return createBasicUser(did: did, handle: did, isActive: isActive) + } + + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + + // Parse the account info + let accountInfo: AccountInfo + do { + accountInfo = try decoder.decode(AccountInfo.self, from: accountData) + print("Successfully decoded account info for \(accountInfo.handle)") + } catch { + print("Error decoding account info: \(error)") + // If we can't decode the account info, create a basic user with the DID + return createBasicUser(did: did, handle: did, isActive: isActive) + } + + // 2. Try to fetch profile data (optional) + var displayName = accountInfo.handle + var description = "" + var avatarURL: URL? = nil + + // Try to fetch profile record + let profileURLString = "\(baseURL)/xrpc/com.atproto.repo.getRecord?collection=app.bsky.actor.profile&repo=\(did)&rkey=self" + guard let profileURL = URL(string: profileURLString) else { + print("Invalid profile URL: \(profileURLString)") + // Still return user with account info + let dateFormatter = ISO8601DateFormatter() + let joinedDate = dateFormatter.date(from: accountInfo.indexedAt) ?? Date() + + return PDSUser( + id: accountInfo.did, + handle: accountInfo.handle, + displayName: displayName, + description: description, + joinedAt: joinedDate, + avatar: avatarURL, + isActive: isActive + ) + } + + print("Fetching profile from: \(profileURL.absoluteString)") + + var profileRequest = URLRequest(url: profileURL) + profileRequest.httpMethod = "GET" + profileRequest.addValue(authHeader, forHTTPHeaderField: "Authorization") + + do { + let (profileData, profileResponse) = try await session.data(for: profileRequest) + + if let httpResponse = profileResponse as? HTTPURLResponse { + print("Profile response status: \(httpResponse.statusCode)") + + if let responseString = String(data: profileData, encoding: .utf8) { + print("Profile response: \(responseString)") + } + + if httpResponse.statusCode == 200 { + // Define the structures to match the expected response + do { + // Try to decode the profile data + let profileRecord = try decoder.decode(ProfileResponse.self, from: profileData) + + if let name = profileRecord.value.displayName, !name.isEmpty { + displayName = name + } + + if let desc = profileRecord.value.description, !desc.isEmpty { + description = desc + } + + if let avatar = profileRecord.value.avatar { + // Construct avatar URL + avatarURL = URL(string: "\(baseURL)/xrpc/com.atproto.sync.getBlob?did=\(did)&cid=\(avatar.ref.link)") + print("Avatar URL: \(avatarURL?.absoluteString ?? "nil")") + } + } catch { + print("Error decoding profile: \(error)") + // Continue with basic info if profile decoding fails + } + } else { + // Profile fetch failed, but we can still continue with account info + print("Profile fetch failed with status \(httpResponse.statusCode)") + } + } + } catch { + // Continue without profile data + print("Error fetching profile: \(error.localizedDescription)") + } + + let dateFormatter = ISO8601DateFormatter() + let joinedDate = dateFormatter.date(from: accountInfo.indexedAt) ?? Date() + + return PDSUser( + id: accountInfo.did, + handle: accountInfo.handle, + displayName: displayName, + description: description, + joinedAt: joinedDate, + avatar: avatarURL, + isActive: isActive + ) + } catch { + print("Error fetching account info: \(error.localizedDescription)") + return createBasicUser(did: did, handle: did, isActive: isActive) + } + } + + // Helper method to create a basic user with minimal info + private func createBasicUser(did: String, handle: String, isActive: Bool) -> PDSUser { + print("Creating basic user for did: \(did)") + return PDSUser( + id: did, + handle: handle, + displayName: handle, + description: "", + joinedAt: Date(), + avatar: nil, + isActive: isActive + ) + } + + func editUserHandle(userId: String, newHandle: String) async -> Bool { + guard isAuthenticated, let baseURL = baseURL, let authHeader = authHeader else { return false } + + guard let updateURL = URL(string: "\(baseURL)/xrpc/com.atproto.admin.updateAccountHandle") else { + setError("Invalid update handle URL") + return false + } + + var request = URLRequest(url: updateURL) + request.httpMethod = "POST" + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + request.addValue(authHeader, forHTTPHeaderField: "Authorization") + + // Create the request body + let updateBody: [String: String] = [ + "did": userId, + "handle": newHandle + ] + + do { + let jsonData = try JSONSerialization.data(withJSONObject: updateBody) + request.httpBody = jsonData + + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + setError("Invalid response") + return false + } + + if httpResponse.statusCode == 200 { + // Refresh the users list to show the updated handle + await fetchUsers() + return true + } else { + let responseString = String(data: data, encoding: .utf8) ?? "Unknown error" + setError("Failed to update handle: \(httpResponse.statusCode) - \(responseString)") + return false + } + } catch { + setError("Failed to update handle: \(error.localizedDescription)") + return false + } + } + + func resetUserPassword(userId: String) async -> Bool { + guard isAuthenticated, let baseURL = baseURL else { return false } + + // Construct the URL for resetting a user's password + guard let resetURL = URL(string: "\(baseURL)/xrpc/com.atproto.admin.resetPassword") else { + setError("Invalid reset password URL") + return false + } + + var request = URLRequest(url: resetURL) + request.httpMethod = "POST" + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + + let resetBody = ["did": userId] + + do { + let jsonData = try JSONSerialization.data(withJSONObject: resetBody) + request.httpBody = jsonData + + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + setError("Invalid response from server") + return false + } + + if httpResponse.statusCode == 200 || httpResponse.statusCode == 204 { + return true + } else { + let responseString = String(data: data, encoding: .utf8) ?? "Unknown error" + setError("Failed to reset password: \(httpResponse.statusCode) - \(responseString)") + return false + } + } catch { + setError("Failed to reset password: \(error.localizedDescription)") + return false + } + } + + func sendResetEmail(userId: String) async -> Bool { + guard isAuthenticated, let baseURL = baseURL else { return false } + + // Construct the URL for sending a reset email + guard let emailURL = URL(string: "\(baseURL)/xrpc/com.atproto.admin.sendEmail") else { + setError("Invalid send email URL") + return false + } + + var request = URLRequest(url: emailURL) + request.httpMethod = "POST" + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + + let emailBody = [ + "recipientDid": userId, + "subject": "Password Reset", + "body": "Click the link to reset your password." + ] + + do { + let jsonData = try JSONSerialization.data(withJSONObject: emailBody) + request.httpBody = jsonData + + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + setError("Invalid response from server") + return false + } + + if httpResponse.statusCode == 200 || httpResponse.statusCode == 204 { + return true + } else { + let responseString = String(data: data, encoding: .utf8) ?? "Unknown error" + setError("Failed to send reset email: \(httpResponse.statusCode) - \(responseString)") + return false + } + } catch { + setError("Failed to send reset email: \(error.localizedDescription)") + return false + } + } + + func deleteUser(userId: String) async -> Bool { + guard isAuthenticated, let baseURL = baseURL else { return false } + + // Construct the URL for deleting a user + guard let deleteURL = URL(string: "\(baseURL)/xrpc/com.atproto.admin.deleteAccount") else { + setError("Invalid delete user URL") + return false + } + + var request = URLRequest(url: deleteURL) + request.httpMethod = "POST" + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + + let deleteBody = ["did": userId] + + do { + let jsonData = try JSONSerialization.data(withJSONObject: deleteBody) + request.httpBody = jsonData + + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + setError("Invalid response from server") + return false + } + + if httpResponse.statusCode == 200 || httpResponse.statusCode == 204 { + // Update the local list + self.users.removeAll { $0.id == userId } + return true + } else { + let responseString = String(data: data, encoding: .utf8) ?? "Unknown error" + setError("Failed to delete user: \(httpResponse.statusCode) - \(responseString)") + return false + } + } catch { + setError("Failed to delete user: \(error.localizedDescription)") + return false + } + } + + func suspendUser(userId: String, reason: String) async -> Bool { + guard isAuthenticated, let baseURL = baseURL, let authHeader = authHeader else { return false } + + guard let suspendURL = URL(string: "\(baseURL)/xrpc/com.atproto.admin.disableAccountByDid") else { + setError("Invalid suspend user URL") + return false + } + + var request = URLRequest(url: suspendURL) + request.httpMethod = "POST" + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + request.addValue(authHeader, forHTTPHeaderField: "Authorization") + + // Create the request body + let suspendBody: [String: Any] = [ + "did": userId, + "reason": reason + ] + + do { + let jsonData = try JSONSerialization.data(withJSONObject: suspendBody) + request.httpBody = jsonData + + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + setError("Invalid response") + return false + } + + if httpResponse.statusCode == 200 { + // Refresh the users list to show the updated status + await fetchUsers() + return true + } else { + let responseString = String(data: data, encoding: .utf8) ?? "Unknown error" + setError("Failed to suspend user: \(httpResponse.statusCode) - \(responseString)") + return false + } + } catch { + setError("Failed to suspend user: \(error.localizedDescription)") + return false + } + } + + func reactivateUser(userId: String) async -> Bool { + guard isAuthenticated, let baseURL = baseURL, let authHeader = authHeader else { return false } + + guard let reactivateURL = URL(string: "\(baseURL)/xrpc/com.atproto.admin.enableAccountByDid") else { + setError("Invalid reactivate user URL") + return false + } + + var request = URLRequest(url: reactivateURL) + request.httpMethod = "POST" + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + request.addValue(authHeader, forHTTPHeaderField: "Authorization") + + // Create the request body + let reactivateBody: [String: String] = [ + "did": userId + ] + + do { + let jsonData = try JSONSerialization.data(withJSONObject: reactivateBody) + request.httpBody = jsonData + + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + setError("Invalid response") + return false + } + + if httpResponse.statusCode == 200 { + // Refresh the users list to show the updated status + await fetchUsers() + return true + } else { + let responseString = String(data: data, encoding: .utf8) ?? "Unknown error" + setError("Failed to reactivate user: \(httpResponse.statusCode) - \(responseString)") + return false + } + } catch { + setError("Failed to reactivate user: \(error.localizedDescription)") + return false + } + } + + // MARK: - Helper Methods + + private func setError(_ message: String) { + self.errorMessage = message + print("Error: \(message)") + } +} diff --git a/PDSMan/ViewModels/PDSViewModel.swift b/PDSMan/ViewModels/PDSViewModel.swift new file mode 100644 index 0000000..213371c --- /dev/null +++ b/PDSMan/ViewModels/PDSViewModel.swift @@ -0,0 +1,58 @@ +import Foundation +import SwiftUI +import Combine + +@MainActor +class PDSViewModel: ObservableObject { + @Published var pdsService = PDSService() + @Published var alertItem: AlertItem? + + var errorMessage: String? { + pdsService.errorMessage + } + + var isLoading: Bool { + pdsService.isLoading + } + + var isAuthenticated: Bool { + get { + let value = pdsService.isAuthenticated + print("PDSViewModel: isAuthenticated getter called, value = \(value)") + return value + } + } + + var users: [PDSUser] { + pdsService.users + } + + var inviteCodes: [InviteCode] { + pdsService.inviteCodes + } + + func login(serverURL: String, username: String, password: String) async { + print("PDSViewModel: login called") + if let credentials = PDSCredentials(serverURL: serverURL, username: username, password: password) { + let success = await pdsService.login(credentials: credentials) + print("PDSViewModel: login completed, success = \(success), isAuthenticated = \(pdsService.isAuthenticated)") + + // Force update the view by triggering objectWillChange + objectWillChange.send() + } else { + pdsService.errorMessage = "Invalid credentials" + } + } + + func logout() { + print("PDSViewModel: logout called") + pdsService.logout() + objectWillChange.send() + } +} + +struct AlertItem: Identifiable { + let id = UUID() + let title: String + let message: String +} \ No newline at end of file diff --git a/PDSMan/Views/InviteCodesView.swift b/PDSMan/Views/InviteCodesView.swift new file mode 100644 index 0000000..6c8993b --- /dev/null +++ b/PDSMan/Views/InviteCodesView.swift @@ -0,0 +1,227 @@ +import SwiftUI + +struct InviteCodesView: View { + @EnvironmentObject var viewModel: PDSViewModel + @State private var isCreatingCode = false + @State private var isRefreshing = false + @State private var showDisabledCodes = false + + // Split codes into active and disabled + var activeCodes: [InviteCode] { + return viewModel.inviteCodes.filter { !$0.disabled } + } + + var disabledCodes: [InviteCode] { + return viewModel.inviteCodes.filter { $0.disabled } + } + + var filteredCodes: [InviteCode] { + if showDisabledCodes { + return viewModel.inviteCodes + } else { + return viewModel.inviteCodes.filter { !$0.disabled } + } + } + + var body: some View { + NavigationView { + Group { + if viewModel.isLoading && viewModel.inviteCodes.isEmpty { + ProgressView("Loading invite codes...") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if filteredCodes.isEmpty { + VStack { + Text("No invite codes found") + .foregroundColor(.secondary) + Button("Refresh") { + refreshInviteCodes() + } + .padding() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + VStack(spacing: 0) { + List { + // Active codes section + Section(header: Text("Active Codes")) { + ForEach(activeCodes) { code in + InviteCodeRow(code: code) + .contextMenu { + Button(role: .destructive) { + Task { + await viewModel.pdsService.disableInviteCode(code.id) + } + } label: { + Label("Disable Code", systemImage: "xmark.circle") + } + } + } + } + + // Disabled codes section (only shown if toggle is on) + if showDisabledCodes && !disabledCodes.isEmpty { + Section(header: Text("Disabled Codes")) { + ForEach(disabledCodes) { code in + InviteCodeRow(code: code) + } + } + } + } + + Toggle("Show Disabled Codes (\(disabledCodes.count))", isOn: $showDisabledCodes) + .padding() + .background(Color(.systemBackground)) + } + } + } + .navigationTitle("Invite Codes") + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + isCreatingCode = true + Task { + await viewModel.pdsService.createInviteCode() + isCreatingCode = false + } + } label: { + if isCreatingCode { + ProgressView() + } else { + Label("Create Code", systemImage: "plus") + } + } + .disabled(isCreatingCode) + } + + ToolbarItem(placement: .navigationBarLeading) { + Button { + refreshInviteCodes() + } label: { + if isRefreshing { + ProgressView() + } else { + Label("Refresh", systemImage: "arrow.clockwise") + } + } + .disabled(isRefreshing) + } + } + .refreshable { + await viewModel.pdsService.fetchInviteCodes() + } + } + .task { + // Only fetch if we don't already have data + if viewModel.inviteCodes.isEmpty && !viewModel.isLoading { + await viewModel.pdsService.fetchInviteCodes() + } + } + .onAppear { + // Always attempt to refresh when view appears + if !viewModel.isLoading { + refreshInviteCodes() + } + } + } + + private func refreshInviteCodes() { + isRefreshing = true + Task { + await viewModel.pdsService.fetchInviteCodes() + isRefreshing = false + } + } +} + +struct InviteCodeRow: View { + let code: InviteCode + @State private var copySuccess = false + + var formattedDate: String { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter.string(from: code.createdAt) + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(code.id) + .font(.system(.headline, design: .monospaced)) + .foregroundColor(code.disabled ? .gray : .primary) + + Spacer() + + if code.disabled { + Text("DISABLED") + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.red.opacity(0.2)) + .foregroundColor(.red) + .cornerRadius(4) + } else { + Button { + UIPasteboard.general.string = code.id + withAnimation { + copySuccess = true + } + // Reset the success message after a delay + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + withAnimation { + copySuccess = false + } + } + } label: { + HStack(spacing: 4) { + Image(systemName: copySuccess ? "checkmark" : "doc.on.doc") + if copySuccess { + Text("Copied!") + } + } + .foregroundColor(copySuccess ? .green : .blue) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + (copySuccess ? Color.green : Color.blue) + .opacity(0.1) + .cornerRadius(4) + ) + } + .buttonStyle(BorderlessButtonStyle()) + } + } + + HStack { + Text("Created: \(formattedDate)") + .font(.caption) + .foregroundColor(.secondary) + + Spacer() + + if let uses = code.uses { + if uses.isEmpty { + Text("Unused") + .font(.caption) + .foregroundColor(.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.green.opacity(0.1)) + .cornerRadius(4) + } else { + Text("Uses: \(uses.count)") + .font(.caption) + .foregroundColor(.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.blue.opacity(0.1)) + .cornerRadius(4) + } + } + } + } + .padding(.vertical, 6) + .opacity(code.disabled ? 0.7 : 1.0) + } +} \ No newline at end of file diff --git a/PDSMan/Views/LoginView.swift b/PDSMan/Views/LoginView.swift new file mode 100644 index 0000000..40ebf46 --- /dev/null +++ b/PDSMan/Views/LoginView.swift @@ -0,0 +1,134 @@ +import SwiftUI + +struct LoginView: View { + @EnvironmentObject var viewModel: PDSViewModel + + @State private var serverURL = "" + @State private var password = "" + @State private var isLoggingIn = false + @State private var showingDebugInfo = false + @State private var debugLogs: [String] = [] + + var body: some View { + VStack(spacing: 20) { + Image(systemName: "server.rack") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 100, height: 100) + .foregroundColor(.blue) + + Text("PDS Manager") + .font(.largeTitle) + .fontWeight(.bold) + + VStack(alignment: .leading, spacing: 20) { + TextField("Server URL", text: $serverURL) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .autocapitalization(.none) + .disableAutocorrection(true) + .keyboardType(.URL) + + SecureField("Admin Password", text: $password) + .textFieldStyle(RoundedBorderTextFieldStyle()) + } + .padding(.horizontal, 40) + + Button { + login() + } label: { + if isLoggingIn { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .frame(maxWidth: .infinity) + .padding() + } else { + Text("Login") + .frame(maxWidth: .infinity) + .padding() + } + } + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(10) + .padding(.horizontal, 40) + .disabled(serverURL.isEmpty || password.isEmpty || isLoggingIn) + + if let errorMessage = viewModel.errorMessage { + Text(errorMessage) + .foregroundColor(.red) + .font(.callout) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + .padding(.vertical, 10) + .background(Color.red.opacity(0.1)) + .cornerRadius(8) + } + + Button("Toggle Debug Info") { + showingDebugInfo.toggle() + } + .font(.caption) + .padding(.top, 20) + + if showingDebugInfo { + ScrollView { + VStack(alignment: .leading, spacing: 8) { + ForEach(debugLogs, id: \.self) { log in + Text(log) + .font(.system(.caption, design: .monospaced)) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + } + .frame(height: 200) + .background(Color.black.opacity(0.05)) + .cornerRadius(8) + .padding(.horizontal, 40) + } + + Spacer() + } + .padding(.top, 50) + .onAppear { + // Add some example connection info + debugLogs.append("Example URLs:") + debugLogs.append("https://bsky.social - Main Bluesky server") + debugLogs.append("https://bsky.atri.dad - Your local server") + debugLogs.append("http://localhost:3000 - Local development PDS") + } + } + + private func login() { + isLoggingIn = true + + // Add debug info + debugLogs.append("Attempting login to: \(serverURL)") + + // Sanitize the URL to ensure it has the correct format + var cleanURL = serverURL.trimmingCharacters(in: .whitespacesAndNewlines) + if !cleanURL.hasPrefix("http://") && !cleanURL.hasPrefix("https://") { + cleanURL = "http://\(cleanURL)" + debugLogs.append("Added http:// prefix: \(cleanURL)") + } + + // Remove trailing slash if present + if cleanURL.hasSuffix("/") { + cleanURL.removeLast() + debugLogs.append("Removed trailing slash: \(cleanURL)") + } + + debugLogs.append("Using final URL: \(cleanURL)") + + Task { + await viewModel.login(serverURL: cleanURL, username: "admin", password: password) + isLoggingIn = false + + if let error = viewModel.errorMessage { + debugLogs.append("Login failed: \(error)") + } else { + debugLogs.append("Login successful!") + } + } + } +} \ No newline at end of file diff --git a/PDSMan/Views/MainAppView.swift b/PDSMan/Views/MainAppView.swift new file mode 100644 index 0000000..2b7f36d --- /dev/null +++ b/PDSMan/Views/MainAppView.swift @@ -0,0 +1,50 @@ +import SwiftUI + +struct MainAppView: View { + @EnvironmentObject var viewModel: PDSViewModel + @State private var selectedTab = 0 + + var body: some View { + ZStack { + if viewModel.isAuthenticated { + authenticatedView + } else { + LoginView() + } + } + .alert(item: $viewModel.alertItem) { alert in + Alert( + title: Text(alert.title), + message: Text(alert.message), + dismissButton: .default(Text("OK")) + ) + } + } + + var authenticatedView: some View { + NavigationView { + TabView(selection: $selectedTab) { + InviteCodesView() + .tabItem { + Image(systemName: "ticket") + Text("Invite Codes") + } + .tag(0) + + UserListView() + .tabItem { + Image(systemName: "person.3") + Text("Users") + } + .tag(1) + } + .navigationTitle("PDS Manager") + .navigationBarItems(trailing: Button("Logout") { + viewModel.logout() + }) + } + .onAppear { + print("Authenticated view appeared") + } + } +} \ No newline at end of file diff --git a/PDSMan/Views/UserListView.swift b/PDSMan/Views/UserListView.swift new file mode 100644 index 0000000..29716a2 --- /dev/null +++ b/PDSMan/Views/UserListView.swift @@ -0,0 +1,260 @@ +import SwiftUI + +struct UserListView: View { + @EnvironmentObject var viewModel: PDSViewModel + @State private var editingUser: PDSUser? = nil + @State private var newHandle: String = "" + @State private var showingEditSheet = false + @State private var showingSuspendAlert = false + @State private var showingReactivateAlert = false + @State private var suspensionReason: String = "" + @State private var selectedUser: PDSUser? = nil + @State private var isRefreshing = false + + var body: some View { + NavigationView { + Group { + if viewModel.isLoading && viewModel.users.isEmpty { + ProgressView("Loading users...") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if viewModel.users.isEmpty { + VStack { + Text("No users found") + .foregroundColor(.secondary) + Button("Refresh") { + refreshUsers() + } + .padding() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List { + ForEach(viewModel.users) { user in + UserRow(user: user) + .contentShape(Rectangle()) + .onTapGesture { + selectedUser = user + editingUser = user + newHandle = user.handle + showingEditSheet = true + } + .contextMenu { + Button(action: { + selectedUser = user + editingUser = user + newHandle = user.handle + showingEditSheet = true + }) { + Label("Edit Handle", systemImage: "pencil") + } + + if user.isActive { + Button(action: { + selectedUser = user + suspensionReason = "" + showingSuspendAlert = true + }) { + Label("Suspend Account", systemImage: "xmark.circle") + } + } else { + Button(action: { + selectedUser = user + showingReactivateAlert = true + }) { + Label("Reactivate Account", systemImage: "checkmark.circle") + } + } + } + } + } + } + } + .navigationTitle("Users") + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + refreshUsers() + } label: { + if isRefreshing { + ProgressView() + } else { + Label("Refresh", systemImage: "arrow.clockwise") + } + } + .disabled(isRefreshing) + } + } + .refreshable { + await viewModel.pdsService.fetchUsers() + } + .sheet(isPresented: $showingEditSheet) { + if let user = editingUser { + NavigationView { + Form { + Section(header: Text("Edit User Handle")) { + TextField("Handle", text: $newHandle) + + HStack { + Button("Cancel") { + showingEditSheet = false + } + + Spacer() + + Button("Save") { + Task { + if await viewModel.pdsService.editUserHandle(userId: user.id, newHandle: newHandle) { + showingEditSheet = false + } + } + } + .disabled(newHandle.isEmpty || newHandle == user.handle) + } + } + } + .navigationTitle("Edit \(user.displayName)") + } + } + } + .alert("Suspend User", isPresented: $showingSuspendAlert) { + TextField("Reason for suspension", text: $suspensionReason) + Button("Cancel", role: .cancel) { } + Button("Suspend", role: .destructive) { + if let user = selectedUser { + Task { + await viewModel.pdsService.suspendUser(userId: user.id, reason: suspensionReason) + await viewModel.pdsService.fetchUsers() // Refresh after suspension + } + } + } + } message: { + if let user = selectedUser { + Text("Are you sure you want to suspend \(user.displayName)'s account? This will prevent them from using the service.") + } + } + .alert("Reactivate User", isPresented: $showingReactivateAlert) { + Button("Cancel", role: .cancel) { } + Button("Reactivate") { + if let user = selectedUser { + Task { + await viewModel.pdsService.reactivateUser(userId: user.id) + await viewModel.pdsService.fetchUsers() // Refresh after reactivation + } + } + } + } message: { + if let user = selectedUser { + Text("Are you sure you want to reactivate \(user.displayName)'s account?") + } + } + } + .task { + // Only fetch if we don't already have data + if viewModel.users.isEmpty && !viewModel.isLoading { + await viewModel.pdsService.fetchUsers() + } + } + .onAppear { + // Always attempt to refresh when view appears + if !viewModel.isLoading { + refreshUsers() + } + } + } + + private func refreshUsers() { + isRefreshing = true + Task { + await viewModel.pdsService.fetchUsers() + isRefreshing = false + } + } +} + +struct UserRow: View { + let user: PDSUser + + var formattedDate: String { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .none + return "Joined: \(formatter.string(from: user.joinedAt))" + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 12) { + if let avatarURL = user.avatar { + AsyncImage(url: avatarURL) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + } placeholder: { + ProgressView() + } + .frame(width: 60, height: 60) + .clipShape(Circle()) + .overlay( + Circle() + .stroke(Color.gray.opacity(0.2), lineWidth: 1) + ) + } else { + Image(systemName: "person.circle.fill") + .resizable() + .foregroundColor(.gray.opacity(0.7)) + .frame(width: 60, height: 60) + } + + VStack(alignment: .leading, spacing: 3) { + HStack { + Text(user.displayName) + .font(.title3) + .fontWeight(.semibold) + .lineLimit(1) + + if !user.isActive { + Text("SUSPENDED") + .font(.caption) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background(Color.red) + .foregroundColor(.white) + .cornerRadius(4) + } + } + + Text("@\(user.handle)") + .font(.subheadline) + .foregroundColor(.blue) + .lineLimit(1) + } + } + + if !user.description.isEmpty { + Text(user.description) + .font(.subheadline) + .foregroundColor(.primary) + .lineLimit(3) + .padding(.top, 2) + } + + HStack(spacing: 12) { + Text(formattedDate) + .font(.caption) + .foregroundColor(.secondary) + + Spacer() + + Text(user.id) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .frame(maxWidth: 160) + .opacity(0.7) + } + } + .padding(.vertical, 10) + .opacity(user.isActive ? 1.0 : 0.7) + } +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..cc245eb --- /dev/null +++ b/README.md @@ -0,0 +1,6 @@ +# PDSMan + +Privacy Policy + +This app does not collect any personal information. All data processing is performed locally on your device. The only data transmitted is crash logs sent to Apple for the purpose of improving the app. +