From 66b650105dee96893be8e739e05a07f69f8b36e2 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 13 Mar 2025 10:49:59 +0100 Subject: [PATCH 1/6] feat(auth): add missing auth admin methods --- Sources/Auth/AuthAdmin.swift | 92 ++++++++++++++++++++++- Sources/Auth/Types.swift | 139 ++++++++++++++++++++++++++++++++++- 2 files changed, 225 insertions(+), 6 deletions(-) diff --git a/Sources/Auth/AuthAdmin.swift b/Sources/Auth/AuthAdmin.swift index 1a31aee8..32bfecd0 100644 --- a/Sources/Auth/AuthAdmin.swift +++ b/Sources/Auth/AuthAdmin.swift @@ -6,8 +6,8 @@ // import Foundation -import Helpers import HTTPTypes +import Helpers public struct AuthAdmin: Sendable { let clientID: AuthClientID @@ -16,6 +16,89 @@ public struct AuthAdmin: Sendable { var api: APIClient { Dependencies[clientID].api } var encoder: JSONEncoder { Dependencies[clientID].encoder } + /// Get user by id. + /// - Parameter uid: The user's unique identifier. + /// - Note: This function should only be called on a server. Never expose your `service_role` key in the browser. + public func getUserById(_ uid: String) async throws -> User { + try await api.execute( + HTTPRequest( + url: configuration.url.appendingPathComponent("admin/users/\(uid)"), + method: .get + ) + ).decoded(decoder: configuration.decoder) + } + + /// Updates the user data. + /// - Parameters: + /// - uid: The user id you want to update. + /// - attributes: The data you want to update. + @discardableResult + public func updateUserById(_ uid: String, attributes: AdminUserAttributes) async throws -> User { + try await api.execute( + HTTPRequest( + url: configuration.url.appendingPathComponent("admin/users/\(uid)"), + method: .put, + body: configuration.encoder.encode(attributes) + ) + ).decoded(decoder: configuration.decoder) + } + + /// Creates a new user. + /// + /// - To confirm the user's email address or phone number, set ``AdminUserAttributes/emailConfirm`` or ``AdminUserAttributes/phoneConfirm`` to `true`. Both arguments default to `false`. + /// - ``createUser(attributes:)`` will not send a confirmation email to the user. You can use ``inviteUserByEmail(_:data:redirectTo:)`` if you want to send them an email invite instead. + /// - If you are sure that the created user's email or phone number is legitimate and verified, you can set the ``AdminUserAttributes/emailConfirm`` or ``AdminUserAttributes/phoneConfirm`` param to true. + /// - Warning: Never expose your `service_role` key on the client. + @discardableResult + public func createUser(attributes: AdminUserAttributes) async throws -> User { + try await api.execute( + HTTPRequest( + url: configuration.url.appendingPathComponent("admin/users"), + method: .post, + body: encoder.encode(attributes) + ) + ) + .decoded(decoder: configuration.decoder) + } + + /// Sends an invite link to an email address. + /// + /// - Sends an invite link to the user's email address. + /// - The ``inviteUserByEmail(_:data:redirectTo:)`` method is typically used by administrators to invite users to join the application. + /// - Parameters: + /// - email: The email address of the user. + /// - data: A custom data object to store additional metadata about the user. This maps to the `auth.users.user_metadata` column. + /// - redirectTo: The URL which will be appended to the email link sent to the user's email address. Once clicked the user will end up on this URL. + /// - Note: that PKCE is not supported when using ``inviteUserByEmail(_:data:redirectTo:)``. This is because the browser initiating the invite is often different from the browser accepting the invite which makes it difficult to provide the security guarantees required of the PKCE flow. + @discardableResult + public func inviteUserByEmail( + _ email: String, + data: [String: AnyJSON]? = nil, + redirectTo: URL? = nil + ) async throws -> User { + try await api.execute( + HTTPRequest( + url: configuration.url.appendingPathComponent("admin/invite"), + method: .post, + query: [ + (redirectTo ?? configuration.redirectToURL).map { + URLQueryItem( + name: "redirect_to", + value: $0.absoluteString + ) + } + ].compactMap { $0 }, + body: encoder.encode( + [ + "email": .string(email), + "data": data.map({ AnyJSON.object($0) }) ?? .null, + ] + ) + ) + ) + .decoded(decoder: configuration.decoder) + } + /// Delete a user. Requires `service_role` key. /// - Parameter id: The id of the user you want to delete. /// - Parameter shouldSoftDelete: If true, then the user will be soft-deleted (setting @@ -69,7 +152,8 @@ public struct AuthAdmin: Sendable { let links = httpResponse.headers[.link]?.components(separatedBy: ",") ?? [] if !links.isEmpty { for link in links { - let page = link.components(separatedBy: ";")[0].components(separatedBy: "=")[1].prefix(while: \.isNumber) + let page = link.components(separatedBy: ";")[0].components(separatedBy: "=")[1].prefix( + while: \.isNumber) let rel = link.components(separatedBy: ";")[1].components(separatedBy: "=")[1] if rel == "\"last\"", let lastPage = Int(page) { @@ -82,6 +166,10 @@ public struct AuthAdmin: Sendable { return pagination } + + public func generateLink() { + + } } extension HTTPField.Name { diff --git a/Sources/Auth/Types.swift b/Sources/Auth/Types.swift index 6e51684b..74dc2abf 100644 --- a/Sources/Auth/Types.swift +++ b/Sources/Auth/Types.swift @@ -195,7 +195,8 @@ public struct User: Codable, Hashable, Identifiable, Sendable { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decode(UUID.self, forKey: .id) appMetadata = try container.decodeIfPresent([String: AnyJSON].self, forKey: .appMetadata) ?? [:] - userMetadata = try container.decodeIfPresent([String: AnyJSON].self, forKey: .userMetadata) ?? [:] + userMetadata = + try container.decodeIfPresent([String: AnyJSON].self, forKey: .userMetadata) ?? [:] aud = try container.decode(String.self, forKey: .aud) confirmationSentAt = try container.decodeIfPresent(Date.self, forKey: .confirmationSentAt) recoverySentAt = try container.decodeIfPresent(Date.self, forKey: .recoverySentAt) @@ -263,7 +264,8 @@ public struct UserIdentity: Codable, Hashable, Identifiable, Sendable { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decode(String.self, forKey: .id) - identityId = try container.decodeIfPresent(UUID.self, forKey: .identityId) + identityId = + try container.decodeIfPresent(UUID.self, forKey: .identityId) ?? UUID(uuidString: "00000000-0000-0000-0000-000000000000")! userId = try container.decode(UUID.self, forKey: .userId) identityData = try container.decodeIfPresent([String: AnyJSON].self, forKey: .identityData) @@ -507,6 +509,73 @@ public struct UserAttributes: Codable, Hashable, Sendable { } } +public struct AdminUserAttributes: Encodable, Hashable, Sendable { + + /// A custom data object to store the user's application specific metadata. This maps to the `auth.users.app_metadata` column. + public var appMetadata: [String: AnyJSON]? + + /// Determines how long a user is banned for. + public var banDuration: String? + + /// The user's email. + public var email: String? + + /// Confirms the user's email address if set to true. + public var emailConfirm: Bool? + + /// The `id` for the user. + public var id: String? + + /// The nonce sent for reauthentication if the user's password is to be updated. + public var nonce: String? + + /// The user's password. + public var password: String? + + /// The `password_hash` for the user's password. + public var passwordHash: String? + + /// The user's phone. + public var phone: String? + + /// Confirms the user's phone number if set to true. + public var phoneConfirm: Bool? + + /// The role claim set in the user's access token JWT. + public var role: String? + + /// A custom data object to store the user's metadata. This maps to the `auth.users.raw_user_meta_data` column. + public var userMetadata: [String: AnyJSON]? + + public init( + appMetadata: [String: AnyJSON]? = nil, + banDuration: String? = nil, + email: String? = nil, + emailConfirm: Bool? = nil, + id: String? = nil, + nonce: String? = nil, + password: String? = nil, + passwordHash: String? = nil, + phone: String? = nil, + phoneConfirm: Bool? = nil, + role: String? = nil, + userMetadata: [String: AnyJSON]? = nil + ) { + self.appMetadata = appMetadata + self.banDuration = banDuration + self.email = email + self.emailConfirm = emailConfirm + self.id = id + self.nonce = nonce + self.password = password + self.passwordHash = passwordHash + self.phone = phone + self.phoneConfirm = phoneConfirm + self.role = role + self.userMetadata = userMetadata + } +} + struct RecoverParams: Codable, Hashable, Sendable { var email: String var gotrueMetaSecurity: AuthMetaSecurity? @@ -719,8 +788,8 @@ public struct AMREntry: Decodable, Hashable, Sendable { extension AMREntry { init?(value: Any) { guard let dict = value as? [String: Any], - let method = dict["method"] as? Method, - let timestamp = dict["timestamp"] as? TimeInterval + let method = dict["method"] as? Method, + let timestamp = dict["timestamp"] as? TimeInterval else { return nil } @@ -839,3 +908,65 @@ public struct ListUsersPaginatedResponse: Hashable, Sendable { public var lastPage: Int public var total: Int } + +public struct GenerateLinkParams { + var type: String + var email: String + var password: String? + var newEmail: String? + var data: [String: AnyJSON]? + var redirectTo: URL? + + public static func signUp( + email: String, + password: String, + data: [String: AnyJSON]? = nil, + redirectTo: URL? = nil + ) -> GenerateLinkParams { + GenerateLinkParams( + type: "signup", + email: email, + password: password, + data: data, + redirectTo: redirectTo + ) + } + + public static func invite( + email: String, + data: [String: AnyJSON]?, + redirectTo: URL? + ) -> GenerateLinkParams { + GenerateLinkParams( + type: "invite", + email: email, + data: data, + redirectTo: redirectTo + ) + } + + public static func magicLink( + email: String, + data: [String: AnyJSON]?, + redirectTo: URL? + ) -> GenerateLinkParams { + GenerateLinkParams( + type: "magiclink", + email: email, + data: data, + redirectTo: redirectTo + ) + } + + public static func recovery( + email: String, + redirectTo: URL? + ) -> GenerateLinkParams { + GenerateLinkParams( + type: "recovery", + email: email, + redirectTo: redirectTo + ) + } + +} From a43d47eada98aace1af32511e7b13e1cb2195235 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Fri, 9 May 2025 08:10:17 -0300 Subject: [PATCH 2/6] add tests for admin features --- Sources/Auth/AuthAdmin.swift | 29 +++- Sources/Auth/Internal/APIClient.swift | 29 ++-- Sources/Auth/Types.swift | 155 +++++++++++++---- Tests/AuthTests/AuthClientTests.swift | 161 ++++++++++++++++++ .../IntegrationTests/.vscode/extensions.json | 4 +- Tests/IntegrationTests/.vscode/settings.json | 8 +- .../AuthClientIntegrationTests.swift | 51 +++++- Tests/IntegrationTests/DotEnv.swift | 4 +- 8 files changed, 385 insertions(+), 56 deletions(-) diff --git a/Sources/Auth/AuthAdmin.swift b/Sources/Auth/AuthAdmin.swift index 32bfecd0..5c6ed0fe 100644 --- a/Sources/Auth/AuthAdmin.swift +++ b/Sources/Auth/AuthAdmin.swift @@ -167,8 +167,33 @@ public struct AuthAdmin: Sendable { return pagination } - public func generateLink() { - + /// Generates email links and OTPs to be sent via a custom email provider. + /// + /// - Parameter params: The parameters for the link generation. + /// - Throws: An error if the link generation fails. + /// - Returns: The generated link. + public func generateLink(params: GenerateLinkParams) async throws -> GenerateLinkResponse { + let response = try await api.execute( + HTTPRequest( + url: configuration.url.appendingPathComponent("admin/generate_link").appendingQueryItems( + [ + (params.redirectTo ?? configuration.redirectToURL).map { + URLQueryItem( + name: "redirect_to", + value: $0.absoluteString + ) + } + ].compactMap { $0 } + ), + method: .post, + body: encoder.encode(params.body) + ) + ).decoded(as: AnyJSON.self, decoder: configuration.decoder) + + let properties = try response.decode(as: GenerateLinkProperties.self) + let user = try response.decode(as: User.self) + + return GenerateLinkResponse(properties: properties, user: user) } } diff --git a/Sources/Auth/Internal/APIClient.swift b/Sources/Auth/Internal/APIClient.swift index fc2d8521..af7eece5 100644 --- a/Sources/Auth/Internal/APIClient.swift +++ b/Sources/Auth/Internal/APIClient.swift @@ -1,6 +1,6 @@ import Foundation -import Helpers import HTTPTypes +import Helpers extension HTTPClient { init(configuration: AuthClient.Configuration) { @@ -12,7 +12,7 @@ extension HTTPClient { interceptors.append( RetryRequestInterceptor( retryableHTTPMethods: RetryRequestInterceptor.defaultRetryableHTTPMethods.union( - [.post] // Add POST method so refresh token are also retried. + [.post] // Add POST method so refresh token are also retried. ) ) ) @@ -42,7 +42,7 @@ struct APIClient: Sendable { let response = try await http.send(request) - guard 200 ..< 300 ~= response.statusCode else { + guard 200..<300 ~= response.statusCode else { throw handleError(response: response) } @@ -64,10 +64,12 @@ struct APIClient: Sendable { } func handleError(response: Helpers.HTTPResponse) -> AuthError { - guard let error = try? response.decoded( - as: _RawAPIErrorResponse.self, - decoder: configuration.decoder - ) else { + guard + let error = try? response.decoded( + as: _RawAPIErrorResponse.self, + decoder: configuration.decoder + ) + else { return .api( message: "Unexpected error", errorCode: .unexpectedFailure, @@ -78,11 +80,14 @@ struct APIClient: Sendable { let responseAPIVersion = parseResponseAPIVersion(response) - let errorCode: ErrorCode? = if let responseAPIVersion, responseAPIVersion >= apiVersions[._20240101]!.timestamp, let code = error.code { - ErrorCode(code) - } else { - error.errorCode - } + let errorCode: ErrorCode? = + if let responseAPIVersion, responseAPIVersion >= apiVersions[._20240101]!.timestamp, + let code = error.code + { + ErrorCode(code) + } else { + error.errorCode + } if errorCode == nil, let weakPassword = error.weakPassword { return .weakPassword( diff --git a/Sources/Auth/Types.swift b/Sources/Auth/Types.swift index 74dc2abf..4bf4dda6 100644 --- a/Sources/Auth/Types.swift +++ b/Sources/Auth/Types.swift @@ -143,6 +143,31 @@ public struct User: Codable, Hashable, Identifiable, Sendable { public var isAnonymous: Bool public var factors: [Factor]? + enum CodingKeys: String, CodingKey { + case id + case appMetadata = "app_metadata" + case userMetadata = "user_metadata" + case aud + case confirmationSentAt = "confirmation_sent_at" + case recoverySentAt = "recovery_sent_at" + case emailChangeSentAt = "email_change_sent_at" + case newEmail = "new_email" + case invitedAt = "invited_at" + case actionLink = "action_link" + case email + case phone + case createdAt = "created_at" + case confirmedAt = "confirmed_at" + case emailConfirmedAt = "email_confirmed_at" + case phoneConfirmedAt = "phone_confirmed_at" + case lastSignInAt = "last_sign_in_at" + case role + case updatedAt = "updated_at" + case identities + case isAnonymous = "is_anonymous" + case factors + } + public init( id: UUID, appMetadata: [String: AnyJSON], @@ -229,6 +254,17 @@ public struct UserIdentity: Codable, Hashable, Identifiable, Sendable { public var lastSignInAt: Date? public var updatedAt: Date? + enum CodingKeys: String, CodingKey { + case id + case identityId = "identity_id" + case userId = "user_id" + case identityData = "identity_data" + case provider + case createdAt = "created_at" + case lastSignInAt = "last_sign_in_at" + case updatedAt = "updated_at" + } + public init( id: String, identityId: UUID, @@ -249,17 +285,6 @@ public struct UserIdentity: Codable, Hashable, Identifiable, Sendable { self.updatedAt = updatedAt } - private enum CodingKeys: CodingKey { - case id - case identityId - case userId - case identityData - case provider - case createdAt - case lastSignInAt - case updatedAt - } - public init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -909,14 +934,18 @@ public struct ListUsersPaginatedResponse: Hashable, Sendable { public var total: Int } -public struct GenerateLinkParams { - var type: String - var email: String - var password: String? - var newEmail: String? - var data: [String: AnyJSON]? +public struct GenerateLinkParams: Sendable { + struct Body: Encodable { + var type: GenerateLinkType + var email: String + var password: String? + var newEmail: String? + var data: [String: AnyJSON]? + } + var body: Body var redirectTo: URL? + /// Generates a signup link. public static func signUp( email: String, password: String, @@ -924,49 +953,107 @@ public struct GenerateLinkParams { redirectTo: URL? = nil ) -> GenerateLinkParams { GenerateLinkParams( - type: "signup", - email: email, - password: password, - data: data, + body: .init( + type: .signup, + email: email, + password: password, + data: data + ), redirectTo: redirectTo ) } + /// Generates an invite link. public static func invite( email: String, - data: [String: AnyJSON]?, - redirectTo: URL? + data: [String: AnyJSON]? = nil, + redirectTo: URL? = nil ) -> GenerateLinkParams { GenerateLinkParams( - type: "invite", - email: email, - data: data, + body: .init( + type: .invite, + email: email, + data: data + ), redirectTo: redirectTo ) } + /// Generates a magic link. public static func magicLink( email: String, - data: [String: AnyJSON]?, - redirectTo: URL? + data: [String: AnyJSON]? = nil, + redirectTo: URL? = nil ) -> GenerateLinkParams { GenerateLinkParams( - type: "magiclink", - email: email, - data: data, + body: .init( + type: .magiclink, + email: email, + data: data + ), redirectTo: redirectTo ) } + /// Generates a recovery link. public static func recovery( email: String, - redirectTo: URL? + redirectTo: URL? = nil ) -> GenerateLinkParams { GenerateLinkParams( - type: "recovery", - email: email, + body: .init( + type: .recovery, + email: email + ), redirectTo: redirectTo ) } } + +/// The response from the `generateLink` function. +public struct GenerateLinkResponse: Hashable, Sendable { + /// The properties related to the email link generated. + public let properties: GenerateLinkProperties + /// The user that the email link is associated to. + public let user: User +} + +/// The properties related to the email link generated. +public struct GenerateLinkProperties: Decodable, Hashable, Sendable { + /// The email link to send to the users. + /// The action link follows the following format: auth/v1/verify?type={verification_type}&token={hashed_token}&redirect_to={redirect_to} + public let actionLink: URL + /// The raw ramil OTP. + /// You should send this in the email if you want your users to verify using an OTP instead of the action link. + public let emailOTP: String + /// The hashed token appended to the action link. + public let hashedToken: String + /// The URL appended to the action link. + public let redirectTo: URL + /// The verification type that the emaillink is associated to. + public let verificationType: GenerateLinkType + + enum CodingKeys: String, CodingKey { + case actionLink = "action_link" + case emailOTP = "email_otp" + case hashedToken = "hashed_token" + case redirectTo = "redirect_to" + case verificationType = "verification_type" + } +} + +public struct GenerateLinkType: RawRepresentable, Codable, Hashable, Sendable { + public let rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + public static let signup = GenerateLinkType(rawValue: "signup") + public static let invite = GenerateLinkType(rawValue: "invite") + public static let magiclink = GenerateLinkType(rawValue: "magiclink") + public static let recovery = GenerateLinkType(rawValue: "recovery") + public static let emailChangeCurrent = GenerateLinkType(rawValue: "email_change_current") + public static let emailChangeNew = GenerateLinkType(rawValue: "email_change_new") +} diff --git a/Tests/AuthTests/AuthClientTests.swift b/Tests/AuthTests/AuthClientTests.swift index 259ee839..d29fa188 100644 --- a/Tests/AuthTests/AuthClientTests.swift +++ b/Tests/AuthTests/AuthClientTests.swift @@ -1940,6 +1940,167 @@ final class AuthClientTests: XCTestCase { ) } + func testgetUserById() async throws { + let id = "859f402d-b3de-4105-a1b9-932836d9193b" + let sut = makeSUT() + + Mock( + url: clientURL.appendingPathComponent("admin/users/\(id)"), + statusCode: 200, + data: [.get: MockData.user] + ) + .snapshotRequest { + #""" + curl \ + --header "X-Client-Info: auth-swift/0.0.0" \ + --header "X-Supabase-Api-Version: 2024-01-01" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/auth/v1/admin/users/859f402d-b3de-4105-a1b9-932836d9193b" + """# + } + .register() + + let user = try await sut.admin.getUserById(id) + + expectNoDifference(user.id, UUID(uuidString: id)) + } + + func testUpdateUserById() async throws { + let id = "859f402d-b3de-4105-a1b9-932836d9193b" + let sut = makeSUT() + + Mock( + url: clientURL.appendingPathComponent("admin/users/\(id)"), + statusCode: 200, + data: [.put: MockData.user] + ) + .snapshotRequest { + #""" + curl \ + --request PUT \ + --header "Content-Length: 63" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: auth-swift/0.0.0" \ + --header "X-Supabase-Api-Version: 2024-01-01" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + --data "{\"phone\":\"1234567890\",\"user_metadata\":{\"full_name\":\"John Doe\"}}" \ + "http://localhost:54321/auth/v1/admin/users/859f402d-b3de-4105-a1b9-932836d9193b" + """# + } + .register() + + let attributes = AdminUserAttributes( + phone: "1234567890", + userMetadata: [ + "full_name": "John Doe" + ] + ) + + let user = try await sut.admin.updateUserById(id, attributes: attributes) + + expectNoDifference(user.id, UUID(uuidString: id)) + } + + func testCreateUser() async throws { + let sut = makeSUT() + + Mock( + url: clientURL.appendingPathComponent("admin/users"), + statusCode: 200, + data: [.post: MockData.user] + ) + .snapshotRequest { + #""" + curl \ + --request POST \ + --header "Content-Length: 98" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: auth-swift/0.0.0" \ + --header "X-Supabase-Api-Version: 2024-01-01" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + --data "{\"email\":\"test@example.com\",\"password\":\"password\",\"password_hash\":\"password\",\"phone\":\"1234567890\"}" \ + "http://localhost:54321/auth/v1/admin/users" + """# + } + .register() + + let attributes = AdminUserAttributes( + email: "test@example.com", + password: "password", + passwordHash: "password", + phone: "1234567890" + ) + + _ = try await sut.admin.createUser(attributes: attributes) + } + + func testGenerateLink_signUp() async throws { + let sut = makeSUT() + + Mock( + url: clientURL.appendingPathComponent("admin/generate_link"), + statusCode: 200, + data: [ + .post: try! AuthClient.Configuration.jsonEncoder.encode([ + "properties": [ + "action_link": + "https://example.com/auth/v1/verify?type=signup&token={hashed_token}&redirect_to=https://example.com", + "email_otp": "123456", + "hashed_token": "hashed_token", + "redirect_to": "https://example.com", + "verification_type": "signup", + ], + "user": AnyJSON(User(fromMockNamed: "user")), + ]) + ] + ) + .register() + + let link = try await sut.admin.generateLink( + params: .signUp( + email: "test@example.com", + password: "password", + data: ["full_name": "John Doe"] + ) + ) + + expectNoDifference( + link.properties.actionLink.absoluteString, + "https://example.com/auth/v1/verify?type=signup&token={hashed_token}&redirect_to=https://example.com" + ) + } + + func testInviteUserByEmail() async throws { + let sut = makeSUT() + + Mock( + url: clientURL.appendingPathComponent("admin/invite"), + ignoreQuery: true, + statusCode: 200, + data: [.post: MockData.user] + ) + .snapshotRequest { + #""" + curl \ + --request POST \ + --header "Content-Length: 60" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: auth-swift/0.0.0" \ + --header "X-Supabase-Api-Version: 2024-01-01" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + --data "{\"data\":{\"full_name\":\"John Doe\"},\"email\":\"test@example.com\"}" \ + "http://localhost:54321/auth/v1/admin/invite?redirect_to=https://example.com" + """# + } + .register() + + _ = try await sut.admin.inviteUserByEmail( + "test@example.com", + data: ["full_name": "John Doe"], + redirectTo: URL(string: "https://example.com") + ) + } + private func makeSUT(flowType: AuthFlowType = .pkce) -> AuthClient { let sessionConfiguration = URLSessionConfiguration.default sessionConfiguration.protocolClasses = [MockingURLProtocol.self] diff --git a/Tests/IntegrationTests/.vscode/extensions.json b/Tests/IntegrationTests/.vscode/extensions.json index 74baffcc..09cf720d 100644 --- a/Tests/IntegrationTests/.vscode/extensions.json +++ b/Tests/IntegrationTests/.vscode/extensions.json @@ -1,3 +1,5 @@ { - "recommendations": ["denoland.vscode-deno"] + "recommendations": [ + "denoland.vscode-deno" + ] } diff --git a/Tests/IntegrationTests/.vscode/settings.json b/Tests/IntegrationTests/.vscode/settings.json index af62c23f..35b884cd 100644 --- a/Tests/IntegrationTests/.vscode/settings.json +++ b/Tests/IntegrationTests/.vscode/settings.json @@ -1,4 +1,7 @@ { + "[typescript]": { + "editor.defaultFormatter": "denoland.vscode-deno" + }, "deno.enablePaths": [ "supabase/functions" ], @@ -17,8 +20,5 @@ "fs", "http", "net" - ], - "[typescript]": { - "editor.defaultFormatter": "denoland.vscode-deno" - } + ] } diff --git a/Tests/IntegrationTests/AuthClientIntegrationTests.swift b/Tests/IntegrationTests/AuthClientIntegrationTests.swift index ad62d350..d2a55046 100644 --- a/Tests/IntegrationTests/AuthClientIntegrationTests.swift +++ b/Tests/IntegrationTests/AuthClientIntegrationTests.swift @@ -172,7 +172,8 @@ final class AuthClientIntegrationTests: XCTestCase { func testUserIdentities() async throws { let session = try await signUpIfNeededOrSignIn(email: mockEmail(), password: mockPassword()) let identities = try await authClient.userIdentities() - expectNoDifference(session.user.identities?.map(\.identityId) ?? [], identities.map(\.identityId)) + expectNoDifference( + session.user.identities?.map(\.identityId) ?? [], identities.map(\.identityId)) } func testUnlinkIdentity_withOnlyOneIdentity() async throws { @@ -273,6 +274,54 @@ final class AuthClientIntegrationTests: XCTestCase { } } + func testGenerateLink_signUp() async throws { + let client = Self.makeClient(serviceRole: true) + let email = mockEmail() + let password = mockPassword() + + let link = try await client.admin.generateLink( + params: .signUp( + email: email, + password: password, + data: ["full_name": "John Doe"] + ) + ) + + expectNoDifference(link.properties.actionLink.path, "/auth/v1/verify") + expectNoDifference(link.properties.verificationType, .signup) + expectNoDifference(link.user.email, email) + } + + func testGenerateLink_magicLink() async throws { + let client = Self.makeClient(serviceRole: true) + let email = mockEmail() + + let link = try await client.admin.generateLink(params: .magicLink(email: email)) + + expectNoDifference(link.properties.verificationType, .magiclink) + } + + // func testGenerateLink_recovery() async throws { + // let client = Self.makeClient(serviceRole: true) + // let email = mockEmail() + // let password = mockPassword() + + // _ = try await client.signUp(email: email, password: password) + + // let link = try await client.admin.generateLink(params: .recovery(email: email)) + + // expectNoDifference(link.properties.verificationType, .recovery) + // } + + func testGenerateLink_invite() async throws { + let client = Self.makeClient(serviceRole: true) + let email = mockEmail() + + let link = try await client.admin.generateLink(params: .invite(email: email)) + + expectNoDifference(link.properties.verificationType, .invite) + } + @discardableResult private func signUpIfNeededOrSignIn( email: String, diff --git a/Tests/IntegrationTests/DotEnv.swift b/Tests/IntegrationTests/DotEnv.swift index c7b179a5..678b89b3 100644 --- a/Tests/IntegrationTests/DotEnv.swift +++ b/Tests/IntegrationTests/DotEnv.swift @@ -1,7 +1,7 @@ enum DotEnv { static let SUPABASE_URL = "http://localhost:54321" static let SUPABASE_ANON_KEY = - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im91dGxvb2stZGV2Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3MTM3MzYwMjgsImV4cCI6MjAyOTMxMjAyOH0.6Y900000000000000000000000000000000000000000000000000000000000000" + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" static let SUPABASE_SERVICE_ROLE_KEY = - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im91dGxvb2stZGV2Iiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTcxMzczNjAyOCwiZXhwIjoyMDI5MzEyMDI4fQ.0000000000000000000000000000000000000000000000000000000000000000" + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU" } From ebc2f672e3770a8abb9dd6a21c8ee61c44df3b6c Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Mon, 12 May 2025 11:34:55 -0300 Subject: [PATCH 3/6] make ids an uuid --- Sources/Auth/AuthAdmin.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/Auth/AuthAdmin.swift b/Sources/Auth/AuthAdmin.swift index 5c6ed0fe..6aa83841 100644 --- a/Sources/Auth/AuthAdmin.swift +++ b/Sources/Auth/AuthAdmin.swift @@ -19,7 +19,7 @@ public struct AuthAdmin: Sendable { /// Get user by id. /// - Parameter uid: The user's unique identifier. /// - Note: This function should only be called on a server. Never expose your `service_role` key in the browser. - public func getUserById(_ uid: String) async throws -> User { + public func getUserById(_ uid: UUID) async throws -> User { try await api.execute( HTTPRequest( url: configuration.url.appendingPathComponent("admin/users/\(uid)"), @@ -33,7 +33,7 @@ public struct AuthAdmin: Sendable { /// - uid: The user id you want to update. /// - attributes: The data you want to update. @discardableResult - public func updateUserById(_ uid: String, attributes: AdminUserAttributes) async throws -> User { + public func updateUserById(_ uid: UUID, attributes: AdminUserAttributes) async throws -> User { try await api.execute( HTTPRequest( url: configuration.url.appendingPathComponent("admin/users/\(uid)"), @@ -106,7 +106,7 @@ public struct AuthAdmin: Sendable { /// from the auth schema. /// /// - Warning: Never expose your `service_role` key on the client. - public func deleteUser(id: String, shouldSoftDelete: Bool = false) async throws { + public func deleteUser(id: UUID, shouldSoftDelete: Bool = false) async throws { _ = try await api.execute( HTTPRequest( url: configuration.url.appendingPathComponent("admin/users/\(id)"), From fa478c1eef5d51ed5f802df62aa1f3bc8066321a Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Mon, 12 May 2025 17:05:09 -0300 Subject: [PATCH 4/6] deprecate deleteUser with id string --- Examples/UserManagement/ProfileView.swift | 2 +- Sources/Auth/Deprecated.swift | 28 +++++++++++++++++++---- Tests/AuthTests/AuthClientTests.swift | 2 +- Tests/AuthTests/RequestsTests.swift | 2 +- 4 files changed, 27 insertions(+), 7 deletions(-) diff --git a/Examples/UserManagement/ProfileView.swift b/Examples/UserManagement/ProfileView.swift index 15771a51..caf70caf 100644 --- a/Examples/UserManagement/ProfileView.swift +++ b/Examples/UserManagement/ProfileView.swift @@ -185,7 +185,7 @@ struct ProfileView: View { do { let currentUserId = try await supabase.auth.session.user.id try await supabase.auth.admin.deleteUser( - id: currentUserId.uuidString, + id: currentUserId, shouldSoftDelete: true ) } catch { diff --git a/Sources/Auth/Deprecated.swift b/Sources/Auth/Deprecated.swift index 3f1eba1d..b4f99a3c 100644 --- a/Sources/Auth/Deprecated.swift +++ b/Sources/Auth/Deprecated.swift @@ -32,7 +32,8 @@ extension JSONEncoder { *, deprecated, renamed: "AuthClient.Configuration.jsonEncoder", - message: "Access to the default JSONEncoder instance moved to AuthClient.Configuration.jsonEncoder" + message: + "Access to the default JSONEncoder instance moved to AuthClient.Configuration.jsonEncoder" ) public static var goTrue: JSONEncoder { AuthClient.Configuration.jsonEncoder @@ -44,7 +45,8 @@ extension JSONDecoder { *, deprecated, renamed: "AuthClient.Configuration.jsonDecoder", - message: "Access to the default JSONDecoder instance moved to AuthClient.Configuration.jsonDecoder" + message: + "Access to the default JSONDecoder instance moved to AuthClient.Configuration.jsonDecoder" ) public static var goTrue: JSONDecoder { AuthClient.Configuration.jsonDecoder @@ -65,7 +67,8 @@ extension AuthClient.Configuration { @available( *, deprecated, - message: "Replace usages of this initializer with new init(url:headers:flowType:localStorage:logger:encoder:decoder:fetch)" + message: + "Replace usages of this initializer with new init(url:headers:flowType:localStorage:logger:encoder:decoder:fetch)" ) public init( url: URL, @@ -103,7 +106,8 @@ extension AuthClient { @available( *, deprecated, - message: "Replace usages of this initializer with new init(url:headers:flowType:localStorage:logger:encoder:decoder:fetch)" + message: + "Replace usages of this initializer with new init(url:headers:flowType:localStorage:logger:encoder:decoder:fetch)" ) public init( url: URL, @@ -129,3 +133,19 @@ extension AuthClient { @available(*, deprecated, message: "Use MFATotpEnrollParams or MFAPhoneEnrollParams instead.") public typealias MFAEnrollParams = MFATotpEnrollParams + +extension AuthAdmin { + @available( + *, + deprecated, + renamed: "deleteUser(id:shouldSoftDelete:)", + message: "Use deleteUser with UUID instead of string." + ) + public func deleteUser(id: String, shouldSoftDelete: Bool = false) async throws { + guard let id = UUID(uuidString: id) else { + fatalError("id should be a valid UUID") + } + + try await self.deleteUser(id: id, shouldSoftDelete: shouldSoftDelete) + } +} diff --git a/Tests/AuthTests/AuthClientTests.swift b/Tests/AuthTests/AuthClientTests.swift index d29fa188..f1bd3090 100644 --- a/Tests/AuthTests/AuthClientTests.swift +++ b/Tests/AuthTests/AuthClientTests.swift @@ -1330,7 +1330,7 @@ final class AuthClientTests: XCTestCase { } func testDeleteUser() async throws { - let id = "E621E1F8-C36C-495A-93FC-0C247A3E6E5F" + let id = UUID(uuidString: "E621E1F8-C36C-495A-93FC-0C247A3E6E5F")! Mock( url: clientURL.appendingPathComponent("admin/users/\(id)"), diff --git a/Tests/AuthTests/RequestsTests.swift b/Tests/AuthTests/RequestsTests.swift index b81b71bc..0db09eb2 100644 --- a/Tests/AuthTests/RequestsTests.swift +++ b/Tests/AuthTests/RequestsTests.swift @@ -338,7 +338,7 @@ final class RequestsTests: XCTestCase { func testDeleteUser() async { let sut = makeSUT() - let id = "E621E1F8-C36C-495A-93FC-0C247A3E6E5F" + let id = UUID(uuidString: "E621E1F8-C36C-495A-93FC-0C247A3E6E5F")! await assert { try await sut.admin.deleteUser(id: id) } From aa9e48ba7dc1cfc4e79a82a888087a78a73285ce Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Mon, 12 May 2025 17:07:23 -0300 Subject: [PATCH 5/6] remove renamed --- Sources/Auth/Deprecated.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/Auth/Deprecated.swift b/Sources/Auth/Deprecated.swift index b4f99a3c..5a77e208 100644 --- a/Sources/Auth/Deprecated.swift +++ b/Sources/Auth/Deprecated.swift @@ -138,7 +138,6 @@ extension AuthAdmin { @available( *, deprecated, - renamed: "deleteUser(id:shouldSoftDelete:)", message: "Use deleteUser with UUID instead of string." ) public func deleteUser(id: String, shouldSoftDelete: Bool = false) async throws { From b3c852051df3eb808c6e805a79f95967993e6270 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Mon, 12 May 2025 17:19:59 -0300 Subject: [PATCH 6/6] remove CodingKeys --- Sources/Auth/Types.swift | 44 --------------------------- Tests/AuthTests/AuthClientTests.swift | 8 ++--- 2 files changed, 4 insertions(+), 48 deletions(-) diff --git a/Sources/Auth/Types.swift b/Sources/Auth/Types.swift index 4bf4dda6..a14f2e68 100644 --- a/Sources/Auth/Types.swift +++ b/Sources/Auth/Types.swift @@ -143,31 +143,6 @@ public struct User: Codable, Hashable, Identifiable, Sendable { public var isAnonymous: Bool public var factors: [Factor]? - enum CodingKeys: String, CodingKey { - case id - case appMetadata = "app_metadata" - case userMetadata = "user_metadata" - case aud - case confirmationSentAt = "confirmation_sent_at" - case recoverySentAt = "recovery_sent_at" - case emailChangeSentAt = "email_change_sent_at" - case newEmail = "new_email" - case invitedAt = "invited_at" - case actionLink = "action_link" - case email - case phone - case createdAt = "created_at" - case confirmedAt = "confirmed_at" - case emailConfirmedAt = "email_confirmed_at" - case phoneConfirmedAt = "phone_confirmed_at" - case lastSignInAt = "last_sign_in_at" - case role - case updatedAt = "updated_at" - case identities - case isAnonymous = "is_anonymous" - case factors - } - public init( id: UUID, appMetadata: [String: AnyJSON], @@ -254,17 +229,6 @@ public struct UserIdentity: Codable, Hashable, Identifiable, Sendable { public var lastSignInAt: Date? public var updatedAt: Date? - enum CodingKeys: String, CodingKey { - case id - case identityId = "identity_id" - case userId = "user_id" - case identityData = "identity_data" - case provider - case createdAt = "created_at" - case lastSignInAt = "last_sign_in_at" - case updatedAt = "updated_at" - } - public init( id: String, identityId: UUID, @@ -1033,14 +997,6 @@ public struct GenerateLinkProperties: Decodable, Hashable, Sendable { public let redirectTo: URL /// The verification type that the emaillink is associated to. public let verificationType: GenerateLinkType - - enum CodingKeys: String, CodingKey { - case actionLink = "action_link" - case emailOTP = "email_otp" - case hashedToken = "hashed_token" - case redirectTo = "redirect_to" - case verificationType = "verification_type" - } } public struct GenerateLinkType: RawRepresentable, Codable, Hashable, Sendable { diff --git a/Tests/AuthTests/AuthClientTests.swift b/Tests/AuthTests/AuthClientTests.swift index f1bd3090..cc57b70b 100644 --- a/Tests/AuthTests/AuthClientTests.swift +++ b/Tests/AuthTests/AuthClientTests.swift @@ -1941,7 +1941,7 @@ final class AuthClientTests: XCTestCase { } func testgetUserById() async throws { - let id = "859f402d-b3de-4105-a1b9-932836d9193b" + let id = UUID(uuidString: "859f402d-b3de-4105-a1b9-932836d9193b")! let sut = makeSUT() Mock( @@ -1962,11 +1962,11 @@ final class AuthClientTests: XCTestCase { let user = try await sut.admin.getUserById(id) - expectNoDifference(user.id, UUID(uuidString: id)) + expectNoDifference(user.id, id) } func testUpdateUserById() async throws { - let id = "859f402d-b3de-4105-a1b9-932836d9193b" + let id = UUID(uuidString:"859f402d-b3de-4105-a1b9-932836d9193b")! let sut = makeSUT() Mock( @@ -1998,7 +1998,7 @@ final class AuthClientTests: XCTestCase { let user = try await sut.admin.updateUserById(id, attributes: attributes) - expectNoDifference(user.id, UUID(uuidString: id)) + expectNoDifference(user.id, id) } func testCreateUser() async throws {