1
0
Fork 0
pdsman-ios/PDSMan/Services/PDSService.swift
2025-03-19 01:55:58 -06:00

1080 lines
42 KiB
Swift

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 {
print("⏳ PDSService: Starting to fetch invite codes")
self.isLoading = true
defer { self.isLoading = false }
guard let baseURL = baseURL, let authHeader = authHeader else {
print("❌ PDSService: Cannot fetch invite codes - missing authentication")
return
}
// Set up URL components for the request with any needed query parameters
guard var components = URLComponents(string: "\(baseURL)/xrpc/com.atproto.admin.getInviteCodes") else {
print("❌ PDSService: Invalid invite codes URL")
return
}
// Add query parameters
components.queryItems = [
URLQueryItem(name: "sort", value: "recent"),
URLQueryItem(name: "limit", value: "100"),
URLQueryItem(name: "includeDisabled", value: "true")
]
guard let url = components.url else {
print("❌ PDSService: Failed to construct URL with query parameters")
return
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.addValue(authHeader, forHTTPHeaderField: "Authorization")
do {
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0
print("❌ PDSService: Invite codes fetch failed with status \(statusCode)")
return
}
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
// Debug: Print raw response
if let responseString = String(data: data, encoding: .utf8) {
print("👀 PDSService: Raw invite codes response: \(responseString)")
}
let codesResponse = try decoder.decode(InviteCodesResponse.self, from: data)
// Map the response to our model
let parsedCodes = codesResponse.codes.map { codeResp -> InviteCode in
let dateFormatter = ISO8601DateFormatter()
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
)
}
// Update the inviteCodes property
DispatchQueue.main.async {
self.inviteCodes = parsedCodes
self.objectWillChange.send()
print("✅ PDSService: Successfully fetched \(parsedCodes.count) invite codes")
print("✅ PDSService: Including \(parsedCodes.filter { !$0.disabled }.count) active codes")
}
} catch {
print("❌ PDSService: Error fetching 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
DispatchQueue.main.async {
self.inviteCodes.append(newCode)
self.objectWillChange.send()
}
// Also refresh the full list to ensure we have the most up-to-date data
await fetchInviteCodes()
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 {
print("⏳ PDSService: Attempting to disable invite code: \(code)")
self.isLoading = true
defer { self.isLoading = false }
guard let baseURL = baseURL, let authHeader = authHeader else {
print("❌ PDSService: Cannot disable code - missing authentication")
return false
}
guard let disableURL = URL(string: "\(baseURL)/xrpc/com.atproto.admin.disableInviteCodes") else {
print("❌ PDSService: Invalid disable 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
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 {
print("❌ PDSService: Invalid response from server")
return false
}
// Debug: Print response details
if let responseString = String(data: data, encoding: .utf8) {
print("👀 PDSService: Disable code response: \(responseString)")
}
if httpResponse.statusCode == 200 {
print("✅ PDSService: Successfully disabled code: \(code)")
// Refresh the invite codes list
await fetchInviteCodes()
return true
} else {
let responseString = String(data: data, encoding: .utf8) ?? "Unknown error"
print("❌ PDSService: Failed to disable code: \(httpResponse.statusCode) - \(responseString)")
return false
}
} catch {
print("❌ PDSService: Error disabling code: \(error.localizedDescription)")
return false
}
}
// MARK: - Users
func fetchUsers() async {
print("⏳ PDSService: Starting to fetch users")
self.isLoading = true
defer { self.isLoading = false }
guard let baseURL = baseURL, let authHeader = authHeader else {
print("❌ PDSService: Cannot fetch users - missing authentication")
return
}
// First, get a list of all repos (users) on the server
guard let repoURL = URL(string: "\(baseURL)/xrpc/com.atproto.sync.listRepos") else {
print("❌ PDSService: Invalid list repos URL")
return
}
var repoRequest = URLRequest(url: repoURL)
repoRequest.httpMethod = "GET"
repoRequest.addValue(authHeader, forHTTPHeaderField: "Authorization")
do {
let (repoData, repoResponse) = try await session.data(for: repoRequest)
guard let httpResponse = repoResponse as? HTTPURLResponse, httpResponse.statusCode == 200 else {
let statusCode = (repoResponse as? HTTPURLResponse)?.statusCode ?? 0
print("❌ PDSService: Repos fetch failed with status \(statusCode)")
return
}
// Debug: Print raw response
if let responseString = String(data: repoData, encoding: .utf8) {
print("👀 PDSService: Raw repos response: \(responseString)")
}
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
let reposResult = try decoder.decode(RepoResponse.self, from: repoData)
print("📊 PDSService: Found \(reposResult.repos.count) repos")
// Fetch individual user profiles
var fetchedUsers: [PDSUser] = []
for repo in reposResult.repos {
print("🔍 PDSService: Fetching profile for \(repo.did)")
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 }
// Update the users property
DispatchQueue.main.async {
self.users = fetchedUsers
self.objectWillChange.send()
print("✅ PDSService: Successfully fetched \(fetchedUsers.count) user profiles")
}
} catch {
print("❌ PDSService: Error fetching repos: \(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)")
}
}