-
- Clerk helps developers build user management. We provide streamlined user experiences for your users to sign up, sign in, and manage their profile.
-
-
+
[](https://swiftpackageindex.com/clerk/clerk-ios)
[](https://swiftpackageindex.com/clerk/clerk-ios)
@@ -22,13 +18,7 @@
[](https://clerk.com/docs)
[](https://twitter.com/intent/follow?screen_name=ClerkDev)
----
-
-**Clerk is Hiring!**
-
-Would you like to work on Open Source software and help maintain this repository? [Apply today!](https://jobs.ashbyhq.com/clerk)
-
----
+The Clerk iOS SDK gives you access to prebuilt SwiftUI components, observable state management, and helpers to make user management easier.
## 🚀 Get Started with Clerk
@@ -55,184 +45,12 @@ dependencies: [
]
```
-## 🛠️ Usage
-
-First, configure Clerk in your app's entry point:
-
-```swift
-import SwiftUI
-import Clerk
-
-@main
-struct ClerkQuickstartApp: App {
- @State private var clerk = Clerk.shared
-
- var body: some Scene {
- WindowGroup {
- ContentView()
- .environment(clerk)
- .task {
- clerk.configure(publishableKey: "your_publishable_key")
- try? await clerk.load()
- }
- }
- }
-}
-```
-
-Now in your views, you can conditionally render content based on the user's session:
-
-```swift
-import SwiftUI
-import Clerk
-
-struct ContentView: View {
- @Environment(Clerk.self) private var clerk
-
- var body: some View {
- VStack {
- if let user = clerk.user {
- Text("Hello, \(user.id)")
- } else {
- Text("You are signed out")
- }
- }
- }
-}
-```
-
-### Authentication
-
-#### Sign Up with Email and Perform Verification
-```swift
-// Create a sign up
-var signUp = try await SignUp.create(
- strategy: .standard(emailAddress: "user@example.com", password: "••••••••••••")
-)
-
-// Check if the SignUp needs the email address verified and send an OTP code via email.
-if signUp.unverifiedFields.contains("email_address") {
- signUp = try await signUp.prepareVerification(strategy: .emailCode)
-}
-
-// After collecting the OTP code from the user, attempt verification
-signUp = try await signUp.attemptVerification(strategy: .emailCode(code: "12345"))
-```
-
-#### Passwordless Sign In
-```swift
-// Create the sign in
-var signIn = try await SignIn.create(
- strategy: .identifier("user@example.com", strategy: .emailCode())
-)
-
-// After collecting the OTP code from the user, attempt verification
-signIn = try await signIn.attemptFirstFactor(strategy: .emailCode(code: "12345"))
-```
-
-#### Sign In with OAuth (e.g. Google, Github, etc.)
-```swift
-try await SignIn.authenticateWithRedirect(strategy: .oauth(provider: .google))
-```
-
-#### Native Sign in with Apple
-```swift
-// Use the Clerk SignInWithAppleHelper class to get your Apple credential
-let credential = try await SignInWithAppleHelper.getAppleIdCredential()
-
-// Convert the identityToken data to String format
-guard let idToken = credential.identityToken.flatMap({ String(data: $0, encoding: .utf8) }) else { return }
-
-// Authenticate with Clerk
-try await SignIn.authenticateWithIdToken(provider: .apple, idToken: idToken)
-```
-
-#### Forgot Password
-```swift
-// Create a sign in and send an OTP code to verify the user owns the email.
-var signIn = try await SignIn.create(
- strategy: .identifier("user@example.com", strategy: .resetPasswordEmailCode())
-)
-
-// After collecting the OTP code from the user, attempt verification.
-signIn = try await signIn.attemptFirstFactor(strategy: .resetPasswordEmailCode(code: "12345"))
-
-// Set a new password to complete the process.
-signIn = try await signIn.resetPassword(.init(password: "••••••••••••", signOutOfOtherSessions: true))
-```
-
-#### Sign Out
-```swift
-try await clerk.signOut()
-```
-
-### User Management
-
-#### Update User Profile
-```swift
-try await user.update(.init(firstName: "John", lastName: "Appleseed"))
-```
-
-#### Update User Profile Image
-```swift
-let imageData = try await photosPickerItem.loadTransferable(type: Data.self)
-try await user.setProfileImage(imageData: imageData)
-```
-
-#### Add Phone Number
-```swift
-// Create a new phone number on the user's account
-var newPhoneNumber = try await user.createPhoneNumber("5555550100")
-
-// Send an OTP verification code via SMS to the phone number
-newPhoneNumber = try await newPhoneNumber.prepareVerification()
-
-// After collecting the OTP code from the user, attempt verification.
-newPhoneNumber = try await newPhoneNumber.attemptVerification(code: "12345")
-```
-
-#### Link an External Account
-```swift
-let externalAccount = try await user.createExternalAccount(provider: .github)
-try await externalAccount.reauthorize()
-```
-
-### Session Tokens
-```swift
-if let token = try await Clerk.shared.session?.getToken()?.jwt {
- headers["Authorization"] = "Bearer \(token)"
-}
-```
-
-For a full set of features and functionality, please see our docs!
## 🎓 Docs
- [iOS SDK Reference](https://swiftpackageindex.com/clerk/clerk-ios/main/documentation/clerk)
-- [Clerk iOS](https://clerk.com/docs/references/ios/overview)
+- [Quickstart](https://clerk.com/docs/quickstarts/ios)
- [Custom Flows](https://clerk.com/docs/custom-flows/overview)
-## ✅ Supported Features
-
-| Feature | iOS Support
---- | :---:
-Email/Phone/Username Authentication | ✅
-Email Code Verification | ✅
-SMS Code Verification | ✅
-Multi-Factor Authentication (TOTP / SMS) | ✅
-Sign in / Sign up with OAuth | ✅
-Native Sign in with Apple | ✅
-Session Management | ✅
-Multi-Session Applications | ✅
-Forgot Password | ✅
-User Management | ✅
-Passkeys | ✅
-Enterprise SSO (SAML) | ✅
-Device Attestation | ✅
-Organizations | ✅
-Prebuilt UI Components | 🚧
-Magic Links | ❌
-Web3 Wallet | ❌
-
## 🚢 Release Notes
Curious what we shipped recently? Check out our [changelog](https://clerk.com/changelog)!
diff --git a/Sources/Clerk/APIClient/ClerkAPIClient.swift b/Sources/Clerk/APIClient/ClerkAPIClient.swift
index 4890419cf..45217b880 100644
--- a/Sources/Clerk/APIClient/ClerkAPIClient.swift
+++ b/Sources/Clerk/APIClient/ClerkAPIClient.swift
@@ -5,48 +5,15 @@
// Created by Mike Pitre on 10/2/23.
//
-import Factory
+import FactoryKit
import Foundation
-import Get
-
-final class ClerkAPIClientDelegate: APIClientDelegate, Sendable {
-
- func client(_ client: APIClient, willSendRequest request: inout URLRequest) async throws {
- await HeaderMiddleware.process(&request)
- QueryItemMiddleware.process(&request)
- try URLEncodedFormMiddleware.process(&request)
- }
-
- func client(_ client: APIClient, validateResponse response: HTTPURLResponse, data: Data, task: URLSessionTask) throws {
- DeviceTokenSavingMiddleware.process(response)
- ClientSyncingMiddleware.process(data)
- EventEmitterMiddleware.process(data)
- try ErrorThrowingMiddleware.process(response, data: data)
- }
-
- func client(_ client: APIClient, shouldRetry task: URLSessionTask, error: any Error, attempts: Int) async throws -> Bool {
- guard attempts == 1 else {
- return false
- }
-
- if try await DeviceAssertionMiddleware.process(task: task, error: error) {
- return true
- }
-
- if try await InvalidAuthMiddleware.process(task: task, error: error) {
- return true
- }
-
- return false
- }
-
-}
+import RequestBuilder
extension Container {
- var apiClient: Factory {
- self { APIClient(baseURL: URL(string: "")) }
- .cached
- }
+ var apiClient: Factory {
+ self { BaseSessionManager(base: URL(string: ""), session: .shared) }
+ .cached
+ }
}
diff --git a/Sources/Clerk/APIClient/Extensions/URLRequestBuilder+Ext.swift b/Sources/Clerk/APIClient/Extensions/URLRequestBuilder+Ext.swift
new file mode 100644
index 000000000..e64a530d8
--- /dev/null
+++ b/Sources/Clerk/APIClient/Extensions/URLRequestBuilder+Ext.swift
@@ -0,0 +1,36 @@
+//
+// URLRequestBuilder+Ext.swift
+// Clerk
+//
+// Created by Mike Pitre on 1/8/25.
+//
+
+import FactoryKit
+import Foundation
+import RequestBuilder
+
+extension URLRequestBuilder {
+
+ /// Adds the current Clerk session id to request URL.
+ @discardableResult @MainActor
+ func addClerkSessionId() -> Self {
+ map {
+ if let url = request.url, var components = URLComponents(url: url, resolvingAgainstBaseURL: false) {
+ components.queryItems = (components.queryItems ?? []) + [.init(name: "_clerk_session_id", value: Clerk.shared.session?.id)]
+ $0.request.url = components.url
+ }
+ }
+ }
+
+ /// Given an encodable data type, sets the request body to x-www-form-urlencoded data .
+ @discardableResult
+ public func body(formEncode data: DataType) -> Self {
+ return map {
+ if let data: Data = try? URLEncodedFormEncoder(keyEncoding: .convertToSnakeCase).encode(data) {
+ $0.add(value: "application/x-www-form-urlencoded", forHeader: "Content-Type")
+ $0.request.httpBody = data
+ }
+ }
+ }
+
+}
diff --git a/Sources/Clerk/APIClient/Interceptors/URLRequestInterceptorClerkErrorThrowing.swift b/Sources/Clerk/APIClient/Interceptors/URLRequestInterceptorClerkErrorThrowing.swift
new file mode 100644
index 000000000..d2a838af3
--- /dev/null
+++ b/Sources/Clerk/APIClient/Interceptors/URLRequestInterceptorClerkErrorThrowing.swift
@@ -0,0 +1,47 @@
+//
+// URLRequestInterceptorClerkErrorThrowing.swift
+// Clerk
+//
+// Created by Mike Pitre on 1/8/25.
+//
+
+import Foundation
+import RequestBuilder
+
+final class URLRequestInterceptorClerkErrorThrowing: URLRequestInterceptor, @unchecked Sendable {
+
+ var parent: URLSessionManager!
+
+ func data(for request: URLRequest) async throws -> (Data?, HTTPURLResponse?) {
+ let (data, response) = try await parent.data(for: request)
+
+ if let response, response.isError {
+
+ // ...and the response has a ClerkError body throw a custom clerk error
+ if let data,
+ let clerkErrorResponse = try? JSONDecoder.clerkDecoder.decode(ClerkErrorResponse.self, from: data),
+ var clerkAPIError = clerkErrorResponse.errors.first
+ {
+ clerkAPIError.clerkTraceId = clerkErrorResponse.clerkTraceId
+ ClerkLogger.logNetworkError(
+ clerkAPIError,
+ endpoint: response.url?.absoluteString ?? "unknown",
+ statusCode: response.statusCode
+ )
+ throw clerkAPIError
+ }
+
+ // ...else throw a generic api error
+ let error = URLError(.badServerResponse)
+ ClerkLogger.logNetworkError(
+ error,
+ endpoint: response.url?.absoluteString ?? "unknown",
+ statusCode: response.statusCode
+ )
+ throw error
+ }
+
+ // fallback
+ return (data, response)
+ }
+}
diff --git a/Sources/Clerk/APIClient/Interceptors/URLRequestInterceptorClerkHeaders.swift b/Sources/Clerk/APIClient/Interceptors/URLRequestInterceptorClerkHeaders.swift
new file mode 100644
index 000000000..04e86e7e7
--- /dev/null
+++ b/Sources/Clerk/APIClient/Interceptors/URLRequestInterceptorClerkHeaders.swift
@@ -0,0 +1,39 @@
+//
+// URLRequestInterceptorClerkHeaders.swift
+// Clerk
+//
+// Created by Mike Pitre on 1/8/25.
+//
+
+import FactoryKit
+import Foundation
+import RequestBuilder
+
+@MainActor
+final class URLRequestInterceptorClerkHeaders: @preconcurrency URLRequestInterceptor, @unchecked Sendable {
+
+ var parent: URLSessionManager!
+
+ func request(forURL url: URL?) -> URLRequestBuilder {
+ var headers = [
+ "Content-Type": "application/x-www-form-urlencoded",
+ "clerk-api-version": "2024-10-01",
+ "x-ios-sdk-version": Clerk.version,
+ "x-mobile": "1",
+ "x-native-device-id": deviceID
+ ]
+
+ // Set the device token on every request
+ if let deviceToken = try? Container.shared.keychain().string(forKey: "clerkDeviceToken") {
+ headers["Authorization"] = deviceToken
+ }
+
+ if Clerk.shared.settings.debugMode, let client = Clerk.shared.client {
+ headers["x-clerk-client-id"] = client.id
+ }
+
+ return URLRequestBuilder(manager: self, builder: parent.request(forURL: url))
+ .add(headers: headers)
+ }
+
+}
diff --git a/Sources/Clerk/APIClient/Interceptors/URLRequestInterceptorClientSync.swift b/Sources/Clerk/APIClient/Interceptors/URLRequestInterceptorClientSync.swift
new file mode 100644
index 000000000..5426879c6
--- /dev/null
+++ b/Sources/Clerk/APIClient/Interceptors/URLRequestInterceptorClientSync.swift
@@ -0,0 +1,59 @@
+//
+// ClientSyncingMiddleware.swift
+// Clerk
+//
+// Created by Mike Pitre on 1/31/25.
+//
+
+import Foundation
+import RequestBuilder
+
+final class URLRequestInterceptorClientSync: URLRequestInterceptor, @unchecked Sendable {
+
+ var parent: URLSessionManager!
+
+ func data(for request: URLRequest) async throws -> (Data?, HTTPURLResponse?) {
+ let (data, response) = try await parent.data(for: request)
+ if let data, let client = decodeClient(from: data) {
+ Task { @MainActor in
+ Clerk.shared.client = client
+ }
+ }
+ return (data, response)
+ }
+
+ private func decodeClient(from jsonData: Data) -> Client? {
+ struct ClientWrapper: Decodable {
+ let client: Client?
+
+ enum CodingKeys: String, CodingKey {
+ case response, client
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try? decoder.container(keyedBy: CodingKeys.self)
+
+ if let responseClient = try? container?.decode(Client.self, forKey: .response) {
+ self.client = responseClient
+ return
+ }
+
+ if let clientClient = try? container?.decode(Client.self, forKey: .client) {
+ self.client = clientClient
+ return
+ }
+
+ // If `Client` is the top-level object, attempt direct decoding (least common)
+ if let topLevelClient = try? Client(from: decoder) {
+ self.client = topLevelClient
+ return
+ }
+
+ self.client = nil
+ }
+ }
+
+ return (try? JSONDecoder.clerkDecoder.decode(ClientWrapper.self, from: jsonData))?.client
+ }
+
+}
diff --git a/Sources/Clerk/APIClient/Interceptors/URLRequestInterceptorDeviceAssertion.swift b/Sources/Clerk/APIClient/Interceptors/URLRequestInterceptorDeviceAssertion.swift
new file mode 100644
index 000000000..2444cc7ff
--- /dev/null
+++ b/Sources/Clerk/APIClient/Interceptors/URLRequestInterceptorDeviceAssertion.swift
@@ -0,0 +1,68 @@
+//
+// DeviceAssertionMiddleware.swift
+// Clerk
+//
+// Created by Mike Pitre on 1/30/25.
+//
+
+import Foundation
+import RequestBuilder
+
+final class URLRequestInterceptorDeviceAssertion: URLRequestInterceptor, @unchecked Sendable {
+
+ private static let assertionManager = AssertionManager()
+ var parent: URLSessionManager!
+
+ func data(for request: URLRequest) async throws -> (Data?, HTTPURLResponse?) {
+ let (data, response) = try await parent.data(for: request)
+
+ if let response,
+ response.isError,
+ let data,
+ let clerkErrorResponse = try? JSONDecoder.clerkDecoder.decode(ClerkErrorResponse.self, from: data),
+ let clerkAPIError = clerkErrorResponse.errors.first,
+ clerkAPIError.code == "requires_assertion"
+ {
+ do {
+ try await Self.assertionManager.performDeviceAssertion(request: request, error: clerkAPIError)
+ } catch {
+ ClerkLogger.logError(error, message: "Device assertion interceptor failed for request: \(request)")
+ return (data, response)
+ }
+
+ // retry
+ return try await parent.data(for: request)
+ }
+
+ // fallback
+ return (data, response)
+ }
+
+ private actor AssertionManager {
+ private var inFlightTask: Task?
+
+ func performDeviceAssertion(request: URLRequest, error: any Error) async throws {
+ if let inFlightTask {
+ try await inFlightTask.value
+ }
+
+ let newTask = Task {
+ defer {
+ inFlightTask = nil
+ }
+
+ do {
+ try await AppAttestHelper.performAssertion()
+ } catch let error as ClerkAPIError where error.code == "requires_device_attestation" {
+ try await AppAttestHelper.performDeviceAttestation()
+ try await AppAttestHelper.performAssertion()
+ }
+ }
+
+ inFlightTask = newTask
+
+ try await newTask.value
+ }
+ }
+
+}
diff --git a/Sources/Clerk/APIClient/Interceptors/URLRequestInterceptorDeviceTokenSaving.swift b/Sources/Clerk/APIClient/Interceptors/URLRequestInterceptorDeviceTokenSaving.swift
new file mode 100644
index 000000000..5bb327069
--- /dev/null
+++ b/Sources/Clerk/APIClient/Interceptors/URLRequestInterceptorDeviceTokenSaving.swift
@@ -0,0 +1,26 @@
+//
+// URLRequestInterceptorDeviceTokenSaving.swift
+// Clerk
+//
+// Created by Mike Pitre on 1/8/25.
+//
+
+import FactoryKit
+import Foundation
+import RequestBuilder
+
+final class URLRequestInterceptorDeviceTokenSaving: URLRequestInterceptor, @unchecked Sendable {
+
+ var parent: URLSessionManager!
+
+ func data(for request: URLRequest) async throws -> (Data?, HTTPURLResponse?) {
+ let (data, response) = try await parent.data(for: request)
+
+ // Set the device token from the response headers whenever received
+ if let response, let deviceToken = response.value(forHTTPHeaderField: "Authorization") {
+ try? Container.shared.keychain().set(deviceToken, forKey: "clerkDeviceToken")
+ }
+
+ return (data, response)
+ }
+}
diff --git a/Sources/Clerk/APIClient/Interceptors/URLRequestInterceptorEventEmitter.swift b/Sources/Clerk/APIClient/Interceptors/URLRequestInterceptorEventEmitter.swift
new file mode 100644
index 000000000..55e69033d
--- /dev/null
+++ b/Sources/Clerk/APIClient/Interceptors/URLRequestInterceptorEventEmitter.swift
@@ -0,0 +1,29 @@
+//
+// URLRequestInterceptorEventEmitter.swift
+// Clerk
+//
+// Created by Mike Pitre on 1/8/25.
+//
+
+import Foundation
+import RequestBuilder
+
+final class URLRequestInterceptorEventEmitter: URLRequestInterceptor, @unchecked Sendable {
+
+ var parent: URLSessionManager!
+
+ func data(for request: URLRequest) async throws -> (Data?, HTTPURLResponse?) {
+ let (data, response) = try await parent.data(for: request)
+
+ if let data, let signIn = try? JSONDecoder.clerkDecoder.decode(ClientResponse.self, from: data).response, signIn.status == .complete {
+ await Clerk.shared.authEventEmitter.send(.signInCompleted(signIn: signIn))
+ }
+
+ if let data, let signUp = try? JSONDecoder.clerkDecoder.decode(ClientResponse.self, from: data).response, signUp.status == .complete {
+ await Clerk.shared.authEventEmitter.send(.signUpCompleted(signUp: signUp))
+ }
+
+ return (data, response)
+ }
+
+}
diff --git a/Sources/Clerk/APIClient/Interceptors/URLRequestInterceptorInvalidAuth.swift b/Sources/Clerk/APIClient/Interceptors/URLRequestInterceptorInvalidAuth.swift
new file mode 100644
index 000000000..dccb56d86
--- /dev/null
+++ b/Sources/Clerk/APIClient/Interceptors/URLRequestInterceptorInvalidAuth.swift
@@ -0,0 +1,37 @@
+//
+// URLRequestInterceptorInvalidAuth.swift
+// Clerk
+//
+// Created by Mike Pitre on 2/14/25.
+//
+
+import Foundation
+import RequestBuilder
+
+final class URLRequestInterceptorInvalidAuth: URLRequestInterceptor, @unchecked Sendable {
+
+ var parent: URLSessionManager!
+
+ func data(for request: URLRequest) async throws -> (Data?, HTTPURLResponse?) {
+ let (data, response) = try await parent.data(for: request)
+
+ if let response,
+ response.isError,
+ let data,
+ let clerkErrorResponse = try? JSONDecoder.clerkDecoder.decode(ClerkErrorResponse.self, from: data),
+ let clerkAPIError = clerkErrorResponse.errors.first,
+ ["authentication_invalid", "resource_not_found"].contains(clerkAPIError.code)
+ {
+ // If the original request was also a GET client, return so we don't end up in a loop of failed GET Clients.
+ if request.url?.lastPathComponent == "client", request.httpMethod == "GET" {
+ return (data, response)
+ }
+
+ // Try to get the client in sync.
+ // If the client doesn't have a session on the server, this will set the local session to nil.
+ try await Client.get()
+ }
+
+ return (data, response)
+ }
+}
diff --git a/Sources/Clerk/APIClient/Interceptors/URLRequestInterceptorQueryItems.swift b/Sources/Clerk/APIClient/Interceptors/URLRequestInterceptorQueryItems.swift
new file mode 100644
index 000000000..5dfe5a7e5
--- /dev/null
+++ b/Sources/Clerk/APIClient/Interceptors/URLRequestInterceptorQueryItems.swift
@@ -0,0 +1,20 @@
+//
+// URLRequestInterceptorQueryItems.swift
+// Clerk
+//
+// Created by Mike Pitre on 1/8/25.
+//
+
+import Foundation
+import RequestBuilder
+
+final class URLRequestInterceptorQueryItems: URLRequestInterceptor, @unchecked Sendable {
+
+ var parent: URLSessionManager!
+
+ func request(forURL url: URL?) -> URLRequestBuilder {
+ URLRequestBuilder(manager: self, builder: parent.request(forURL: url))
+ .add(queryItems: [.init(name: "_is_native", value: "true")])
+ }
+
+}
diff --git a/Sources/Clerk/APIClient/Interceptors/URLRequestInterceptorUrlFormEncoding.swift b/Sources/Clerk/APIClient/Interceptors/URLRequestInterceptorUrlFormEncoding.swift
new file mode 100644
index 000000000..8449593bf
--- /dev/null
+++ b/Sources/Clerk/APIClient/Interceptors/URLRequestInterceptorUrlFormEncoding.swift
@@ -0,0 +1,25 @@
+//
+// URLRequestInterceptorUrlFormEncoding.swift
+// Clerk
+//
+// Created by Mike Pitre on 7/25/25.
+//
+
+import Foundation
+import RequestBuilder
+
+final class URLRequestInterceptorUrlFormEncoding: URLRequestInterceptor, @unchecked Sendable {
+
+ var parent: URLSessionManager!
+
+ func request(forURL url: URL?) -> URLRequestBuilder {
+ URLRequestBuilder(manager: self, builder: parent.request(forURL: url))
+ .with { request in
+ if let data = request.httpBody {
+ let json = try? JSONDecoder.clerkDecoder.decode(JSON.self, from: data)
+ request.httpBody = try? URLEncodedFormEncoder(keyEncoding: .convertToSnakeCase).encode(json)
+ }
+ }
+ }
+
+}
diff --git a/Sources/Clerk/APIClient/Middleware/Incoming/ClientSyncingMiddleware.swift b/Sources/Clerk/APIClient/Middleware/Incoming/ClientSyncingMiddleware.swift
deleted file mode 100644
index be0ab5ada..000000000
--- a/Sources/Clerk/APIClient/Middleware/Incoming/ClientSyncingMiddleware.swift
+++ /dev/null
@@ -1,55 +0,0 @@
-//
-// ClientSyncingMiddleware.swift
-// Clerk
-//
-// Created by Mike Pitre on 1/31/25.
-//
-
-import Foundation
-import Get
-
-struct ClientSyncingMiddleware {
-
- static func process(_ data: Data) {
- Task { @MainActor in
- if let client = decodeClient(from: data) {
- Clerk.shared.client = client
- }
- }
- }
-
- private static func decodeClient(from jsonData: Data) -> Client? {
- struct ClientWrapper: Decodable {
- let client: Client?
-
- enum CodingKeys: String, CodingKey {
- case response, client
- }
-
- init(from decoder: Decoder) throws {
- let container = try? decoder.container(keyedBy: CodingKeys.self)
-
- if let responseClient = try? container?.decode(Client.self, forKey: .response) {
- self.client = responseClient
- return
- }
-
- if let clientClient = try? container?.decode(Client.self, forKey: .client) {
- self.client = clientClient
- return
- }
-
- // If `Client` is the top-level object, attempt direct decoding (least common)
- if let topLevelClient = try? Client(from: decoder) {
- self.client = topLevelClient
- return
- }
-
- self.client = nil
- }
- }
-
- return (try? JSONDecoder.clerkDecoder.decode(ClientWrapper.self, from: jsonData))?.client
- }
-
-}
diff --git a/Sources/Clerk/APIClient/Middleware/Incoming/DeviceTokenSavingMiddleware.swift b/Sources/Clerk/APIClient/Middleware/Incoming/DeviceTokenSavingMiddleware.swift
deleted file mode 100644
index 03bfcfd84..000000000
--- a/Sources/Clerk/APIClient/Middleware/Incoming/DeviceTokenSavingMiddleware.swift
+++ /dev/null
@@ -1,22 +0,0 @@
-//
-// DeviceTokenSavingMiddleware.swift
-// Clerk
-//
-// Created by Mike Pitre on 1/8/25.
-//
-
-import Factory
-import Foundation
-
-struct DeviceTokenSavingMiddleware {
-
- static func process(_ response: HTTPURLResponse) {
-
- // Set the device token from the response headers whenever received
- if let deviceToken = response.value(forHTTPHeaderField: "Authorization") {
- try? Container.shared.keychain().set(deviceToken, forKey: "clerkDeviceToken")
- }
-
- }
-
-}
diff --git a/Sources/Clerk/APIClient/Middleware/Incoming/ErrorThrowingMiddleware.swift b/Sources/Clerk/APIClient/Middleware/Incoming/ErrorThrowingMiddleware.swift
deleted file mode 100644
index 281031696..000000000
--- a/Sources/Clerk/APIClient/Middleware/Incoming/ErrorThrowingMiddleware.swift
+++ /dev/null
@@ -1,32 +0,0 @@
-//
-// ErrorThrowingMiddleware.swift
-// Clerk
-//
-// Created by Mike Pitre on 1/8/25.
-//
-
-import Foundation
-import Get
-
-struct ErrorThrowingMiddleware {
-
- static func process(_ response: HTTPURLResponse, data: Data) throws {
-
- // If our response is an error status code...
- guard (200..<300).contains(response.statusCode) else {
-
- // ...and the response has a ClerkError body throw a custom clerk error
- if let clerkErrorResponse = try? JSONDecoder.clerkDecoder.decode(ClerkErrorResponse.self, from: data),
- var clerkAPIError = clerkErrorResponse.errors.first
- {
- clerkAPIError.clerkTraceId = clerkErrorResponse.clerkTraceId
- throw clerkAPIError
- }
-
- // ...else throw a generic api error
- throw APIError.unacceptableStatusCode(response.statusCode)
- }
-
- }
-
-}
diff --git a/Sources/Clerk/APIClient/Middleware/Incoming/EventEmitterMiddleware.swift b/Sources/Clerk/APIClient/Middleware/Incoming/EventEmitterMiddleware.swift
deleted file mode 100644
index ea9838c4c..000000000
--- a/Sources/Clerk/APIClient/Middleware/Incoming/EventEmitterMiddleware.swift
+++ /dev/null
@@ -1,24 +0,0 @@
-//
-// EventEmitterMiddleware.swift
-// Clerk
-//
-// Created by Mike Pitre on 1/8/25.
-//
-
-import Foundation
-
-struct EventEmitterMiddleware {
-
- static func process(_ data: Data) {
- Task {
- if let signIn = try? JSONDecoder.clerkDecoder.decode(ClientResponse.self, from: data).response, signIn.status == .complete {
- await Clerk.shared.authEventEmitter.send(.signInCompleted(signIn: signIn))
- }
-
- if let signUp = try? JSONDecoder.clerkDecoder.decode(ClientResponse.self, from: data).response, signUp.status == .complete {
- await Clerk.shared.authEventEmitter.send(.signUpCompleted(signUp: signUp))
- }
- }
- }
-
-}
diff --git a/Sources/Clerk/APIClient/Middleware/Outgoing/HeaderMiddleware.swift b/Sources/Clerk/APIClient/Middleware/Outgoing/HeaderMiddleware.swift
deleted file mode 100644
index 3fbe0f72d..000000000
--- a/Sources/Clerk/APIClient/Middleware/Outgoing/HeaderMiddleware.swift
+++ /dev/null
@@ -1,28 +0,0 @@
-//
-// HeaderMiddleware.swift
-// Clerk
-//
-// Created by Mike Pitre on 1/8/25.
-//
-
-import Factory
-import Foundation
-
-struct HeaderMiddleware {
-
- @MainActor
- static func process(_ request: inout URLRequest) async {
-
- // Set the device token on every request
- if let deviceToken = try? Container.shared.keychain().string(forKey: "clerkDeviceToken") {
- request.setValue(deviceToken, forHTTPHeaderField: "Authorization")
- }
-
- if Clerk.shared.debugMode, let client = Clerk.shared.client {
- request.setValue(client.id, forHTTPHeaderField: "x-clerk-client-id")
- }
-
- request.setValue(deviceID, forHTTPHeaderField: "x-native-device-id")
- }
-
-}
diff --git a/Sources/Clerk/APIClient/Middleware/Outgoing/QueryItemMiddleware.swift b/Sources/Clerk/APIClient/Middleware/Outgoing/QueryItemMiddleware.swift
deleted file mode 100644
index 0d9a9f6ee..000000000
--- a/Sources/Clerk/APIClient/Middleware/Outgoing/QueryItemMiddleware.swift
+++ /dev/null
@@ -1,16 +0,0 @@
-//
-// QueryItemMiddleware.swift
-// Clerk
-//
-// Created by Mike Pitre on 1/8/25.
-//
-
-import Foundation
-
-struct QueryItemMiddleware {
-
- static func process(_ request: inout URLRequest) {
- request.url?.append(queryItems: [.init(name: "_is_native", value: "true")])
- }
-
-}
diff --git a/Sources/Clerk/APIClient/Middleware/Outgoing/URLEncodedFormMiddleware.swift b/Sources/Clerk/APIClient/Middleware/Outgoing/URLEncodedFormMiddleware.swift
deleted file mode 100644
index d123e4a22..000000000
--- a/Sources/Clerk/APIClient/Middleware/Outgoing/URLEncodedFormMiddleware.swift
+++ /dev/null
@@ -1,20 +0,0 @@
-//
-// URLEncodedFormMiddleware.swift
-// Clerk
-//
-// Created by Mike Pitre on 1/8/25.
-//
-
-import Foundation
-
-struct URLEncodedFormMiddleware {
-
- static func process(_ request: inout URLRequest) throws {
- // Encode body with url-encoded form
- if let data = request.httpBody {
- let json = try JSONDecoder.clerkDecoder.decode(JSON.self, from: data)
- request.httpBody = try URLEncodedFormEncoder(keyEncoding: .convertToSnakeCase).encode(json)
- }
- }
-
-}
diff --git a/Sources/Clerk/APIClient/Middleware/Retry/DeviceAssertionMiddleware.swift b/Sources/Clerk/APIClient/Middleware/Retry/DeviceAssertionMiddleware.swift
deleted file mode 100644
index 66992e48a..000000000
--- a/Sources/Clerk/APIClient/Middleware/Retry/DeviceAssertionMiddleware.swift
+++ /dev/null
@@ -1,73 +0,0 @@
-//
-// DeviceAssertionMiddleware.swift
-// Clerk
-//
-// Created by Mike Pitre on 1/30/25.
-//
-
-import Foundation
-import Get
-
-struct DeviceAssertionMiddleware {
- private static let manager = Manager()
-
- static func process(task: URLSessionTask, error: any Error) async throws -> Bool {
- return try await manager.performDeviceAssertion(task: task, error: error)
- }
-
- private actor Manager {
- private var inFlightTask: Task?
-
- func performDeviceAssertion(task: URLSessionTask, error: any Error) async throws -> Bool {
- guard let clerkAPIError = error as? ClerkAPIError, clerkAPIError.code == "requires_assertion" else {
- return false
- }
-
- if let inFlightTask {
- return try await inFlightTask.value
- }
-
- let newTask = Task {
- defer {
- inFlightTask = nil
- }
-
- switch clerkAPIError.code {
- case "requires_assertion":
- do {
- return try await handleRequiresAssertionError()
- } catch let error as ClerkAPIError where error.code == "requires_device_attestation" {
- return try await handleRequiresDeviceAttestationError(task: task)
- } catch {
- throw error
- }
-
- default:
- return false
- }
- }
-
- inFlightTask = newTask
-
- return try await newTask.value
- }
-
- private func handleRequiresAssertionError() async throws -> Bool {
- try await AppAttestHelper.performAssertion()
- return true
- }
-
- private func handleRequiresDeviceAttestationError(task: URLSessionTask) async throws -> Bool {
- try await AppAttestHelper.performDeviceAttestation()
- try await AppAttestHelper.performAssertion()
-
- // if the original request was a client/verify, we dont need to retry it.
- // The above `performAssertion()` uses the new attestation to verify.
- if let url = task.originalRequest?.url, url.path().hasSuffix("client/verify") {
- return false
- }
-
- return true
- }
- }
-}
diff --git a/Sources/Clerk/APIClient/Middleware/Retry/InvalidAuthMiddleware.swift b/Sources/Clerk/APIClient/Middleware/Retry/InvalidAuthMiddleware.swift
deleted file mode 100644
index f9d99e23f..000000000
--- a/Sources/Clerk/APIClient/Middleware/Retry/InvalidAuthMiddleware.swift
+++ /dev/null
@@ -1,30 +0,0 @@
-//
-// UnauthenticatedMiddleware.swift
-// Clerk
-//
-// Created by Mike Pitre on 2/14/25.
-//
-
-import Foundation
-import Get
-
-struct InvalidAuthMiddleware {
-
- static func process(task: URLSessionTask, error: any Error) async throws -> Bool {
-
- if let clerkAPIError = error as? ClerkAPIError, clerkAPIError.code == "authentication_invalid" {
-
- // If the original request was also a GET client, return false so we don't end up in a loop of failed GET Clients.
- if task.originalRequest?.url?.lastPathComponent == "client", task.originalRequest?.httpMethod == "GET" {
- return false
- }
-
- // Try to get the client in sync.
- // If the client doesn't have a session on the server, this will set the local session to nil.
- try await Client.get()
- }
-
- return false
- }
-
-}
diff --git a/Sources/Clerk/ClerkUI/Common/AppLogoView.swift b/Sources/Clerk/ClerkUI/Common/AppLogoView.swift
new file mode 100644
index 000000000..7237a6b69
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Common/AppLogoView.swift
@@ -0,0 +1,35 @@
+//
+// AppLogoView.swift
+// Clerk
+//
+// Created by Mike Pitre on 4/16/25.
+//
+
+#if os(iOS)
+
+import Kingfisher
+import SwiftUI
+
+struct AppLogoView: View {
+ @Environment(\.clerk) private var clerk
+
+ var body: some View {
+ KFImage(URL(string: clerk.environment.displayConfig?.logoImageUrl ?? ""))
+ .resizable()
+ // .placeholder {
+ // #if DEBUG
+ // Image(systemName: "circle.square.fill")
+ // .resizable()
+ // .scaledToFit()
+ // #endif
+ // }
+ .scaledToFit()
+ }
+}
+
+#Preview {
+ AppLogoView()
+ .padding()
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Common/AsyncButton.swift b/Sources/Clerk/ClerkUI/Common/AsyncButton.swift
new file mode 100644
index 000000000..54cb8535f
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Common/AsyncButton.swift
@@ -0,0 +1,93 @@
+//
+// AsyncButton.swift
+// Clerk
+//
+// Created by Mike Pitre on 4/15/25.
+//
+
+#if os(iOS)
+
+import SwiftUI
+
+struct AsyncButton: View {
+ @State private var isRunning = false
+
+ let role: ButtonRole?
+ let action: () async -> Void
+ let label: (_ isRunning: Bool) -> Label
+
+ var onIsRunningChanged: ((Bool) -> Void)?
+
+ init(
+ role: ButtonRole? = nil,
+ action: @escaping () async -> Void,
+ @ViewBuilder label: @escaping (_ isRunning: Bool) -> Label
+ ) {
+ self.role = role
+ self.action = action
+ self.label = label
+ }
+
+ var body: some View {
+ Button(role: role) {
+ Task {
+ if isRunning { return }
+ isRunning = true
+ defer { isRunning = false }
+ await action()
+ }
+ } label: {
+ label(isRunning)
+ }
+ .animation(.default, value: isRunning)
+ .onChange(of: isRunning) {
+ onIsRunningChanged?($1)
+ }
+ }
+}
+
+extension AsyncButton {
+
+ func onIsRunningChanged(_ action: @escaping (Bool) -> Void) -> Self {
+ var view = self
+ view.onIsRunningChanged = action
+ return view
+ }
+
+}
+
+#Preview {
+ VStack(spacing: 20) {
+ AsyncButton {
+ do {
+ try await Task.sleep(for: .seconds(2))
+ } catch {
+ dump(error)
+ }
+ } label: { isRunning in
+ Text("Button")
+ .overlayProgressView(isActive: isRunning)
+ }
+ .buttonStyle(.primary())
+
+ AsyncButton {
+ do {
+ try await Task.sleep(for: .seconds(2))
+ } catch {
+ dump(error)
+ }
+ } label: { isRunning in
+ Text("Button")
+ .padding(12)
+ .frame(maxWidth: .infinity)
+ .overlayProgressView(isActive: isRunning)
+ .overlay {
+ RoundedRectangle(cornerRadius: 6)
+ .stroke(.secondary, lineWidth: 1)
+ }
+ }
+ }
+ .padding()
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Common/Badge.swift b/Sources/Clerk/ClerkUI/Common/Badge.swift
new file mode 100644
index 000000000..39b93fc66
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Common/Badge.swift
@@ -0,0 +1,113 @@
+//
+// Badge.swift
+// Clerk
+//
+// Created by Mike Pitre on 5/13/25.
+//
+
+import SwiftUI
+
+struct Badge: View {
+ @Environment(\.clerkTheme) private var theme
+
+ enum Style: CaseIterable {
+ case primary
+ case secondary
+ case positive
+ case negative
+ case warning
+ }
+
+ var foregroundColor: Color {
+ switch style {
+ case .primary:
+ theme.colors.textOnPrimaryBackground
+ case .secondary:
+ theme.colors.textSecondary
+ case .positive:
+ theme.colors.success
+ case .negative:
+ theme.colors.danger
+ case .warning:
+ theme.colors.warning
+ }
+ }
+
+ var backgroundColor: Color {
+ switch style {
+ case .primary:
+ theme.colors.primary
+ case .secondary:
+ theme.colors.backgroundSecondary
+ case .positive:
+ theme.colors.backgroundSuccess
+ case .negative:
+ theme.colors.backgroundDanger
+ case .warning:
+ theme.colors.backgroundWarning
+ }
+ }
+
+ var borderColor: Color {
+ switch style {
+ case .primary:
+ .clear
+ case .secondary:
+ theme.colors.backgroundSecondary
+ case .positive:
+ theme.colors.success
+ case .negative:
+ theme.colors.danger
+ case .warning:
+ theme.colors.warning
+ }
+ }
+
+ private let text: Text
+ private let style: Style
+
+ init(key: LocalizedStringKey, style: Style = .primary) {
+ self.text = Text(key, bundle: .module)
+ self.style = style
+ }
+
+ init(string: String, style: Style = .primary) {
+ self.text = Text(string)
+ self.style = style
+ }
+
+ var body: some View {
+ text
+ .font(theme.fonts.footnote)
+ .fontWeight(.semibold)
+ .frame(minHeight: 18)
+ .multilineTextAlignment(.center)
+ .padding(.horizontal, 6)
+ .foregroundStyle(foregroundColor)
+ .background {
+ if style == .secondary {
+ LinearGradient(
+ colors: [
+ theme.colors.background,
+ theme.colors.backgroundTransparent
+ ],
+ startPoint: .top,
+ endPoint: .bottom
+ )
+ }
+ }
+ .background(theme.colors.backgroundSecondary)
+ .background(backgroundColor)
+ .clipShape(.rect(cornerRadius: theme.design.borderRadius))
+ .overlay {
+ RoundedRectangle(cornerRadius: theme.design.borderRadius)
+ .strokeBorder(borderColor, lineWidth: 1)
+ }
+ }
+}
+
+#Preview {
+ ForEach(Badge.Style.allCases, id: \.self) { style in
+ Badge(key: "Badge Label", style: style)
+ }
+}
diff --git a/Sources/Clerk/ClerkUI/Common/ClerkFocusedBorder.swift b/Sources/Clerk/ClerkUI/Common/ClerkFocusedBorder.swift
new file mode 100644
index 000000000..db11cde3b
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Common/ClerkFocusedBorder.swift
@@ -0,0 +1,94 @@
+//
+// ClerkFocusedBorder.swift
+// Clerk
+//
+// Created by Mike Pitre on 4/18/25.
+//
+
+#if os(iOS)
+
+import SwiftUI
+
+struct ClerkFocusedBorder: ViewModifier {
+ @Environment(\.clerkTheme) private var theme
+
+ enum BorderState {
+ case `default`
+ case error
+ }
+
+ var innerBorderColor: Color {
+ switch state {
+ case .default:
+ isFocused ? theme.colors.inputBorderFocused : theme.colors.inputBorder
+ case .error:
+ theme.colors.dangerInputBorder
+ }
+ }
+
+ var outerBorderColor: Color {
+ switch state {
+ case .default:
+ theme.colors.inputBorder
+ case .error:
+ theme.colors.dangerInputBorderFocused
+ }
+ }
+
+ let isFocused: Bool
+ var state: BorderState = .default
+
+ func body(content: Content) -> some View {
+ content
+ .animation(
+ .default,
+ body: { content in
+ content
+ .overlay {
+ RoundedRectangle(cornerRadius: theme.design.borderRadius)
+ .strokeBorder(innerBorderColor, lineWidth: 1)
+ }
+ .background {
+ RoundedRectangle(cornerRadius: theme.design.borderRadius)
+ .stroke(outerBorderColor, lineWidth: isFocused ? 4 : 0)
+ }
+ })
+ }
+}
+
+extension View {
+ func clerkFocusedBorder(isFocused: Bool = true, state: ClerkFocusedBorder.BorderState = .default) -> some View {
+ modifier(ClerkFocusedBorder(isFocused: isFocused, state: state))
+ }
+}
+
+#Preview {
+ @Previewable @Environment(\.clerkTheme) var theme
+
+ VStack(spacing: 20) {
+
+ RoundedRectangle(cornerRadius: theme.design.borderRadius)
+ .fill(theme.colors.background)
+ .frame(maxWidth: .infinity, maxHeight: 48)
+ .clerkFocusedBorder(isFocused: false)
+
+ RoundedRectangle(cornerRadius: theme.design.borderRadius)
+ .fill(theme.colors.background)
+ .frame(maxWidth: .infinity, maxHeight: 48)
+ .clerkFocusedBorder()
+
+ RoundedRectangle(cornerRadius: theme.design.borderRadius)
+ .fill(theme.colors.background)
+ .frame(maxWidth: .infinity, maxHeight: 48)
+ .clerkFocusedBorder(isFocused: false, state: .error)
+
+ RoundedRectangle(cornerRadius: theme.design.borderRadius)
+ .fill(theme.colors.background)
+ .frame(maxWidth: .infinity, maxHeight: 48)
+ .clerkFocusedBorder(state: .error)
+
+ }
+ .padding()
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Common/ClerkPhoneNumberField.swift b/Sources/Clerk/ClerkUI/Common/ClerkPhoneNumberField.swift
new file mode 100644
index 000000000..98cce1260
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Common/ClerkPhoneNumberField.swift
@@ -0,0 +1,250 @@
+//
+// ClerkPhoneNumberField.swift
+// Clerk
+//
+// Created by Mike Pitre on 4/10/25.
+//
+
+#if os(iOS)
+
+import FactoryKit
+import PhoneNumberKit
+import SwiftUI
+
+extension ClerkPhoneNumberField {
+ @Observable
+ @MainActor
+ final class PhoneNumberModel {
+
+ private let utility = Container.shared.phoneNumberUtility()
+ let textField: PhoneNumberTextField
+ let partialFormatter: PartialFormatter
+
+ let defaultCountry: CountryCodePickerViewController.Country
+ var currentCountry: CountryCodePickerViewController.Country {
+ didSet {
+ partialFormatter.defaultRegion = currentCountry.code
+ }
+ }
+
+ init() {
+ self.textField = .init(utility: utility)
+ self.defaultCountry = .init(for: textField.defaultRegion, with: utility)!
+ self.currentCountry = .init(for: textField.defaultRegion, with: utility)!
+ self.partialFormatter = .init(
+ utility: utility,
+ defaultRegion: defaultCountry.code,
+ withPrefix: false
+ )
+ }
+
+ var allCountriesExceptDefault: [CountryCodePickerViewController.Country] {
+ utility.allCountries.filter { country in
+ country.code != defaultCountry.code
+ }
+ }
+
+ func stringForCountry(_ country: CountryCodePickerViewController.Country) -> String {
+ "\(country.flag) \(country.name) \(country.prefix)"
+ }
+
+ var exampleNumber: String {
+ utility.getFormattedExampleNumber(
+ forCountry: textField.currentRegion,
+ withFormat: .national,
+ withPrefix: false
+ ) ?? ""
+ }
+
+ func phoneNumberFormattedForDisplay(text: String) -> String {
+ if let phoneNumber = try? utility.parse(text, withRegion: currentCountry.code) {
+ return utility.format(phoneNumber, toType: .national)
+ } else {
+ return partialFormatter.formatPartial(text)
+ }
+ }
+
+ func phoneNumberFormattedForData(text: String) -> String {
+ if let phoneNumber = try? utility.parse(text, withRegion: currentCountry.code) {
+ return utility.format(phoneNumber, toType: .e164)
+ } else {
+ return text
+ }
+ }
+ }
+}
+
+struct ClerkPhoneNumberField: View {
+ @Environment(\.clerkTheme) private var theme
+ @State private var phoneNumberModel = PhoneNumberModel()
+ @State private var reservedHeight: CGFloat?
+ @State var displayText = ""
+ @FocusState private var isFocused: Bool
+
+ enum FieldState {
+ case `default`, error
+ }
+
+ let titleKey: LocalizedStringKey
+ @Binding var text: String
+ let fieldState: FieldState
+
+ init(
+ _ titleKey: LocalizedStringKey,
+ text: Binding,
+ fieldState: FieldState = .default
+ ) {
+ self.titleKey = titleKey
+ self._text = text
+ self.fieldState = fieldState
+ }
+
+ var isFocusedOrFilled: Bool {
+ isFocused || !text.isEmpty
+ }
+
+ var offsetAmount: CGFloat {
+ guard let reservedHeight else { return 0 }
+ return reservedHeight * 0.333
+ }
+
+ private func textDidUpdate(text: String) {
+ let rawText = text.filter(\.isWholeNumber)
+ self.displayText = phoneNumberModel.phoneNumberFormattedForDisplay(text: rawText)
+ self.text = phoneNumberModel.phoneNumberFormattedForData(text: rawText)
+ }
+
+ @ViewBuilder
+ var countrySelector: some View {
+ Menu {
+ Section("Default") {
+ Button {
+ phoneNumberModel.currentCountry = phoneNumberModel.defaultCountry
+ textDidUpdate(text: displayText)
+ } label: {
+ Text(phoneNumberModel.stringForCountry(phoneNumberModel.defaultCountry))
+ .lineLimit(1)
+ }
+ }
+
+ Section("International") {
+ ForEach(phoneNumberModel.allCountriesExceptDefault, id: \.code) { country in
+ Button {
+ phoneNumberModel.currentCountry = country
+ textDidUpdate(text: displayText)
+ } label: {
+ Text(phoneNumberModel.stringForCountry(country))
+ .lineLimit(1)
+ }
+ }
+ }
+ } label: {
+ HStack(spacing: 4) {
+ Text(verbatim: "\(phoneNumberModel.currentCountry.flag) \(phoneNumberModel.currentCountry.code)")
+ .font(theme.fonts.footnote)
+ .foregroundStyle(theme.colors.text)
+ .monospaced()
+
+ Image("icon-up-down", bundle: .module)
+ .foregroundStyle(theme.colors.textSecondary)
+ }
+ .padding(.horizontal, 10)
+ .padding(.vertical, 13)
+ .background(theme.colors.backgroundSecondary)
+ .clipShape(.rect(cornerRadius: theme.design.borderRadius))
+ .contentShape(.rect(cornerRadius: theme.design.borderRadius))
+ }
+ }
+
+ var body: some View {
+ HStack(spacing: 8) {
+ countrySelector
+
+ ZStack(alignment: .leading) {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(titleKey, bundle: .module)
+ .lineLimit(1)
+ .font(theme.fonts.caption)
+ .foregroundStyle(theme.colors.text)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .opacity(0)
+
+ HStack(spacing: 4) {
+ if isFocusedOrFilled {
+ Text(phoneNumberModel.currentCountry.prefix)
+ .transition(
+ .asymmetric(
+ insertion: .opacity.animation(.default.delay(0.1)),
+ removal: .opacity.animation(nil)
+ )
+ )
+ }
+
+ TextField("", text: $displayText)
+ .focused($isFocused)
+ .textContentType(.telephoneNumber)
+ .keyboardType(.numberPad)
+ .tint(theme.colors.primary)
+ .animation(.default.delay(0.2)) {
+ $0.opacity(isFocusedOrFilled ? 1 : 0)
+ }
+ .onChange(of: displayText) { _, newValue in
+ textDidUpdate(text: newValue)
+ }
+ }
+ .lineLimit(1)
+ .font(theme.fonts.body)
+ .foregroundStyle(theme.colors.inputText)
+ .frame(minHeight: 22)
+ }
+ .onGeometryChange(for: CGFloat.self) { geometry in
+ geometry.size.height
+ } action: { newValue in
+ reservedHeight = newValue
+ }
+
+ Text(titleKey, bundle: .module)
+ .lineLimit(1)
+ .minimumScaleFactor(0.75)
+ .font(theme.fonts.body)
+ .foregroundStyle(theme.colors.textSecondary)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .allowsHitTesting(false)
+ .offset(y: isFocusedOrFilled ? -offsetAmount : 0)
+ .scaleEffect(isFocusedOrFilled ? (12 / 17) : 1, anchor: .topLeading)
+ .animation(.default, value: isFocusedOrFilled)
+ }
+ }
+ .padding(.horizontal, 6)
+ .padding(.vertical, 6)
+ .frame(minHeight: 56)
+ .contentShape(.rect)
+ .onTapGesture {
+ isFocused = true
+ }
+ .background(
+ theme.colors.inputBackground,
+ in: .rect(cornerRadius: theme.design.borderRadius)
+ )
+ .clerkFocusedBorder(
+ isFocused: isFocused,
+ state: fieldState == .error ? .error : .default
+ )
+ .onAppear {
+ textDidUpdate(text: text)
+ }
+ }
+}
+
+#Preview {
+ @Previewable @State var emptyEmail: String = ""
+ @Previewable @State var filledEmail: String = "5555550100"
+
+ VStack(spacing: 20) {
+ ClerkPhoneNumberField("Enter your phone number", text: $emptyEmail)
+ ClerkPhoneNumberField("Enter your phone number", text: $filledEmail)
+ }
+ .padding()
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Common/ClerkTextField.swift b/Sources/Clerk/ClerkUI/Common/ClerkTextField.swift
new file mode 100644
index 000000000..16397865a
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Common/ClerkTextField.swift
@@ -0,0 +1,167 @@
+//
+// ClerkTextField.swift
+// Clerk
+//
+// Created by Mike Pitre on 4/10/25.
+//
+
+#if os(iOS)
+
+import SwiftUI
+
+struct ClerkTextField: View {
+ @Environment(\.clerkTheme) private var theme
+ @State private var reservedHeight: CGFloat?
+ @State private var revealText = false
+ @FocusState private var focused: Field?
+
+ enum Field {
+ case regular, secure
+ }
+
+ enum FieldState {
+ case `default`, error
+ }
+
+ let titleKey: LocalizedStringKey
+ @Binding var text: String
+ let isSecure: Bool
+ let fieldState: FieldState
+
+ init(
+ _ titleKey: LocalizedStringKey,
+ text: Binding,
+ isSecure: Bool = false,
+ fieldState: FieldState = .default
+ ) {
+ self.titleKey = titleKey
+ self._text = text
+ self.isSecure = isSecure
+ self.fieldState = fieldState
+ }
+
+ var isFocusedOrFilled: Bool {
+ focused != nil || !text.isEmpty
+ }
+
+ var offsetAmount: CGFloat {
+ guard let reservedHeight else { return 0 }
+ return reservedHeight * 0.333
+ }
+
+ var body: some View {
+ HStack(spacing: 8) {
+ ZStack(alignment: .leading) {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(titleKey, bundle: .module)
+ .lineLimit(1)
+ .font(theme.fonts.caption)
+ .foregroundStyle(theme.colors.text)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .opacity(0)
+
+ ZStack {
+ TextField("", text: $text)
+ .zIndex(revealText ? 1 : 0)
+ .focused($focused, equals: .regular)
+ .animation(.default) {
+ $0.opacity(!isSecure || revealText ? 1 : 0)
+ }
+
+ if isSecure {
+ SecureField("", text: $text)
+ .zIndex(revealText ? 0 : 1)
+ .focused($focused, equals: .secure)
+ .animation(.default) {
+ $0.opacity(isSecure && !revealText ? 1 : 0)
+ }
+ }
+ }
+ .lineLimit(1)
+ .font(theme.fonts.body)
+ .foregroundStyle(theme.colors.inputText)
+ .frame(minHeight: 22)
+ .tint(theme.colors.primary)
+ .animation(.default.delay(0.2)) {
+ $0.opacity(isFocusedOrFilled ? 1 : 0.0001)
+ }
+ .onChange(of: focused) { oldValue, newValue in
+ if newValue != nil {
+ focused = revealText ? .regular : .secure
+ }
+ }
+ .onChange(of: revealText) { oldValue, newValue in
+ if focused == .regular {
+ focused = .secure
+ } else if focused == .secure {
+ focused = .regular
+ }
+ }
+ }
+ .onGeometryChange(for: CGFloat.self) { geometry in
+ geometry.size.height
+ } action: { newValue in
+ reservedHeight = newValue
+ }
+
+ Text(titleKey, bundle: .module)
+ .lineLimit(1)
+ .minimumScaleFactor(0.75)
+ .font(theme.fonts.body)
+ .foregroundStyle(theme.colors.textSecondary)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .allowsHitTesting(false)
+ .offset(y: isFocusedOrFilled ? -offsetAmount : 0)
+ .scaleEffect(isFocusedOrFilled ? (12 / 17) : 1, anchor: .topLeading)
+ .animation(.default, value: isFocusedOrFilled)
+ }
+
+ if isSecure {
+ Button {
+ revealText.toggle()
+ } label: {
+ Image(systemName: revealText ? "eye.fill" : "eye.slash.fill")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 18)
+ .contentTransition(.symbolEffect(.replace))
+ .foregroundStyle(theme.colors.textSecondary)
+ }
+ .frame(width: 24)
+ }
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 6)
+ .frame(minHeight: 56)
+ .contentShape(.rect)
+ .onTapGesture {
+ if isSecure {
+ focused = revealText ? .regular : .secure
+ } else {
+ focused = .regular
+ }
+ }
+ .background(
+ theme.colors.inputBackground,
+ in: .rect(cornerRadius: theme.design.borderRadius)
+ )
+ .clerkFocusedBorder(
+ isFocused: focused != nil,
+ state: fieldState == .error ? .error : .default
+ )
+ }
+}
+
+#Preview {
+ @Previewable @State var emptyEmail: String = ""
+ @Previewable @State var filledEmail: String = "user@example.com"
+
+ VStack(spacing: 20) {
+ ClerkTextField("Enter your email", text: $emptyEmail)
+ ClerkTextField("Enter your email", text: $filledEmail)
+ ClerkTextField("Enter your password", text: $filledEmail, isSecure: true)
+ }
+ .padding()
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Common/DismissButton.swift b/Sources/Clerk/ClerkUI/Common/DismissButton.swift
new file mode 100644
index 000000000..e9b5deb62
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Common/DismissButton.swift
@@ -0,0 +1,50 @@
+//
+// DismissButton.swift
+// Clerk
+//
+// Created by Mike Pitre on 5/1/25.
+//
+
+#if os(iOS)
+
+import SwiftUI
+
+struct DismissButton: View {
+ @Environment(\.dismiss) private var dismiss
+ @Environment(\.clerkTheme) private var theme
+ @Environment(\.colorScheme) private var colorScheme
+
+ var action: (() -> Void)?
+
+ var secondaryPaletteStyle: AnyShapeStyle {
+ if #available(iOS 26.0, *) {
+ AnyShapeStyle(Color.clear)
+ } else {
+ AnyShapeStyle(Material.ultraThinMaterial)
+ }
+ }
+
+ var body: some View {
+ Button {
+ if let action {
+ action()
+ } else {
+ dismiss()
+ }
+ } label: {
+ Image(systemName: "xmark.circle.fill")
+ .resizable()
+ .scaledToFit()
+ .symbolRenderingMode(.palette)
+ .foregroundStyle(theme.colors.textSecondary, secondaryPaletteStyle)
+ .frame(width: 30, height: 30)
+ .brightness(colorScheme == .light ? -0.05 : 0.05)
+ }
+ }
+}
+
+#Preview {
+ DismissButton()
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Common/ErrorText.swift b/Sources/Clerk/ClerkUI/Common/ErrorText.swift
new file mode 100644
index 000000000..881421b7f
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Common/ErrorText.swift
@@ -0,0 +1,38 @@
+//
+// ErrorText.swift
+// Clerk
+//
+// Created by Mike Pitre on 5/7/25.
+//
+
+#if os(iOS)
+
+import SwiftUI
+
+struct ErrorText: View {
+ @Environment(\.clerkTheme) private var theme
+
+ let error: Error
+ var alignment: Alignment = .center
+
+ var body: some View {
+ HStack(alignment: .firstTextBaseline, spacing: 4) {
+ Image("icon-warning", bundle: .module)
+ .resizable()
+ .frame(width: 16, height: 16)
+ .scaledToFit()
+ .offset(y: 3)
+ Text(error.localizedDescription)
+ .multilineTextAlignment(.leading)
+ }
+ .foregroundStyle(theme.colors.danger)
+ .frame(maxWidth: .infinity, alignment: alignment)
+ }
+}
+
+#Preview {
+ ErrorText(error: ClerkClientError(message: "Password is incorrect. Try again, or use another method."))
+ .padding()
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Common/ErrorView.swift b/Sources/Clerk/ClerkUI/Common/ErrorView.swift
new file mode 100644
index 000000000..0c1d62665
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Common/ErrorView.swift
@@ -0,0 +1,90 @@
+//
+// ErrorView.swift
+// Clerk
+//
+// Created by Mike Pitre on 5/8/25.
+//
+
+#if os(iOS)
+
+import SwiftUI
+
+struct ErrorView: View {
+ @Environment(\.clerkTheme) private var theme
+ @Environment(\.dismiss) private var dismiss
+
+ struct ActionConfig {
+ let text: LocalizedStringKey
+ let action: () async -> Void
+ }
+
+ let error: Error
+ var action: ActionConfig?
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 24) {
+ Image("icon-warning", bundle: .module)
+ .resizable()
+ .scaledToFit()
+ .frame(width: 24, height: 24)
+ .foregroundStyle(theme.colors.danger)
+ .padding(12)
+ .background(theme.colors.backgroundDanger, in: .circle)
+
+ VStack(alignment: .leading, spacing: 12) {
+ Text("Whoops, something is wrong", bundle: .module)
+ .font(theme.fonts.title2)
+ .fontWeight(.bold)
+ .foregroundStyle(theme.colors.text)
+ .frame(minHeight: 28)
+
+ Text(error.localizedDescription)
+ .font(theme.fonts.body)
+ .foregroundStyle(theme.colors.textSecondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+
+ VStack(spacing: 12) {
+ if let action {
+ AsyncButton {
+ dismiss()
+ await action.action()
+ } label: { isRunning in
+ Text(action.text, bundle: .module)
+ .frame(maxWidth: .infinity)
+ .overlayProgressView(isActive: isRunning) {
+ SpinnerView(color: theme.colors.textOnPrimaryBackground)
+ }
+ }
+ .buttonStyle(.primary())
+ .simultaneousGesture(TapGesture())
+ }
+
+ Button {
+ dismiss()
+ } label: {
+ Text("Close", bundle: .module)
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.secondary())
+ .simultaneousGesture(TapGesture())
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+}
+
+#Preview {
+ ErrorView(
+ error: ClerkClientError(message: "Similique qui enim placeat tempore. Labore voluptates aliquam est quaerat aut perferendis similique."),
+ action: .init(
+ text: "Call to action",
+ action: {
+ try! await Task.sleep(for: .seconds(2))
+ })
+ )
+ .padding()
+ .environment(\.clerkTheme, .clerk)
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Common/HeaderView.swift b/Sources/Clerk/ClerkUI/Common/HeaderView.swift
new file mode 100644
index 000000000..bd9ab76e1
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Common/HeaderView.swift
@@ -0,0 +1,74 @@
+//
+// HeaderView.swift
+// Clerk
+//
+// Created by Mike Pitre on 4/18/25.
+//
+
+#if os(iOS)
+
+import SwiftUI
+
+struct HeaderView: View {
+ @Environment(\.clerkTheme) private var theme
+
+ let style: Style
+ let text: LocalizedStringKey
+
+ enum Style {
+ case title
+ case subtitle
+ }
+
+ var font: Font {
+ switch style {
+ case .title:
+ theme.fonts.title2
+ case .subtitle:
+ theme.fonts.subheadline
+ }
+ }
+
+ var fontWeight: Font.Weight {
+ switch style {
+ case .title:
+ .bold
+ case .subtitle:
+ .regular
+ }
+ }
+
+ var minHeight: CGFloat {
+ switch style {
+ case .title:
+ 28
+ case .subtitle:
+ 20
+ }
+ }
+
+ var foregroundStyle: Color {
+ switch style {
+ case .title:
+ theme.colors.text
+ case .subtitle:
+ theme.colors.textSecondary
+ }
+ }
+
+ var body: some View {
+ Text(text, bundle: .module)
+ .font(font)
+ .fontWeight(fontWeight)
+ .foregroundStyle(foregroundStyle)
+ .multilineTextAlignment(.center)
+ .frame(minHeight: minHeight)
+ }
+}
+
+#Preview {
+ HeaderView(style: .title, text: "Hello, World!")
+ HeaderView(style: .subtitle, text: "Hello, World!")
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Common/IdentityPreviewView.swift b/Sources/Clerk/ClerkUI/Common/IdentityPreviewView.swift
new file mode 100644
index 000000000..d0ae4aed7
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Common/IdentityPreviewView.swift
@@ -0,0 +1,36 @@
+//
+// IdentityPreviewView.swift
+// Clerk
+//
+// Created by Mike Pitre on 4/17/25.
+//
+
+#if os(iOS)
+
+import SwiftUI
+
+struct IdentityPreviewView: View {
+ @Environment(\.clerkTheme) private var theme
+
+ let label: String
+
+ var body: some View {
+ HStack(spacing: 4) {
+ Text(label)
+ .font(theme.fonts.subheadline)
+ .foregroundStyle(theme.colors.text)
+ .frame(minHeight: 20)
+ Image("icon-edit", bundle: .module)
+ .resizable()
+ .frame(width: 16, height: 16)
+ .scaledToFit()
+ .foregroundStyle(theme.colors.textSecondary)
+ }
+ }
+}
+
+#Preview {
+ IdentityPreviewView(label: "example@email.com")
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Common/OTPField.swift b/Sources/Clerk/ClerkUI/Common/OTPField.swift
new file mode 100644
index 000000000..97ddb8e65
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Common/OTPField.swift
@@ -0,0 +1,162 @@
+//
+// OTPField.swift
+// Clerk
+//
+// Created by Mike Pitre on 4/21/25.
+//
+
+#if os(iOS)
+
+import SwiftUI
+
+struct OTPField: View {
+ @Environment(\.clerkTheme) private var theme
+
+ @Binding var code: String
+ var numberOfInputs: Int = 6
+ @Binding var fieldState: FieldState
+ @FocusState.Binding var isFocused: Bool
+ var onCodeEntry: ((String) async -> Void)
+
+ enum FieldState {
+ case `default`
+ case error
+ }
+
+ @State private var cursorAnimating = false
+ @State private var inputSize = CGSize.zero
+ @State private var errorTrigger = false
+
+ var body: some View {
+ HStack(spacing: 12) {
+ ForEach(0.. some View {
+ var isSelected: Bool {
+ isFocused && code.count == index
+ }
+
+ ZStack {
+ if code.count > index {
+ let startIndex = code.startIndex
+ let charIndex = code.index(startIndex, offsetBy: index)
+ let charToString = String(code[charIndex])
+ Text(charToString)
+ } else {
+ Text(verbatim: " ")
+ }
+ }
+ .monospacedDigit()
+ .padding(.vertical)
+ .frame(maxWidth: .infinity, minHeight: 56)
+ .onGeometryChange(
+ for: CGSize.self,
+ of: { geometry in
+ geometry.size
+ },
+ action: { newValue in
+ inputSize = newValue
+ }
+ )
+ .overlay {
+ if isSelected {
+ Rectangle()
+ .frame(maxWidth: 2, maxHeight: 0.35 * inputSize.height)
+ .foregroundStyle(theme.colors.primary)
+ .animation(
+ .easeInOut.speed(0.75).repeatForever(),
+ body: { content in
+ content
+ .opacity(cursorAnimating ? 1 : 0)
+ }
+ )
+ .onAppear {
+ cursorAnimating.toggle()
+ }
+ }
+ }
+ .font(theme.fonts.body)
+ .foregroundStyle(theme.colors.text)
+ .background(theme.colors.inputBackground)
+ .clipShape(.rect(cornerRadius: theme.design.borderRadius))
+ .overlay {
+ RoundedRectangle(cornerRadius: theme.design.borderRadius)
+ .strokeBorder(theme.colors.inputBackground)
+ }
+ .clerkFocusedBorder(
+ isFocused: fieldState == .error || isSelected,
+ state: fieldState == .error ? .error : .default
+ )
+ }
+}
+
+#Preview {
+ @Previewable @State var code = ""
+ @Previewable @State var fieldState1 = OTPField.FieldState.default
+ @Previewable @State var fieldState2 = OTPField.FieldState.default
+ @Previewable @FocusState var isFocused: Bool
+
+ VStack(spacing: 20) {
+ OTPField(code: $code, fieldState: $fieldState1, isFocused: $isFocused) { code in
+ fieldState1 = .default
+ }
+
+ OTPField(code: $code, fieldState: $fieldState2, isFocused: $isFocused) { code in
+ fieldState2 = .error
+ }
+ }
+ .padding()
+ // .environment(\.dynamicTypeSize, .accessibility5)
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Common/OverlayProgressView.swift b/Sources/Clerk/ClerkUI/Common/OverlayProgressView.swift
new file mode 100644
index 000000000..403268c72
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Common/OverlayProgressView.swift
@@ -0,0 +1,72 @@
+//
+// SwiftUIView.swift
+// Clerk
+//
+// Created by Mike Pitre on 4/15/25.
+//
+
+#if os(iOS)
+
+import SwiftUI
+
+struct OverlayProgressModifier: ViewModifier {
+ let isActive: Bool
+ let progressView: () -> ProgressView
+
+ func body(content: Content) -> some View {
+ content
+ .opacity(isActive ? 0 : 1)
+ .overlay {
+ if isActive {
+ progressView()
+ }
+ }
+ }
+}
+
+extension View {
+ func overlayProgressView(
+ isActive: Bool,
+ progressView: @escaping () -> ProgressView
+ ) -> some View {
+ modifier(OverlayProgressModifier(isActive: isActive, progressView: progressView))
+ }
+
+ func overlayProgressView(isActive: Bool) -> some View {
+ modifier(
+ OverlayProgressModifier(
+ isActive: isActive,
+ progressView: {
+ SpinnerView()
+ .frame(width: 24, height: 24)
+ }
+ )
+ )
+ }
+}
+
+#Preview {
+ AsyncButton {
+ try! await Task.sleep(for: .seconds(3))
+ } label: { isRunning in
+ Text("Button")
+ .overlayProgressView(isActive: isRunning)
+ }
+ .buttonStyle(.secondary())
+ .padding()
+}
+
+#Preview("Custom Progress View") {
+ AsyncButton {
+ try! await Task.sleep(for: .seconds(3))
+ } label: { isRunning in
+ Text("Button")
+ .overlayProgressView(isActive: isRunning) {
+ ProgressView()
+ }
+ }
+ .buttonStyle(.secondary())
+ .padding()
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Common/SecuredByClerkView.swift b/Sources/Clerk/ClerkUI/Common/SecuredByClerkView.swift
new file mode 100644
index 000000000..b5102711f
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Common/SecuredByClerkView.swift
@@ -0,0 +1,72 @@
+//
+// SecuredByClerkView.swift
+// Clerk
+//
+// Created by Mike Pitre on 4/15/25.
+//
+
+#if os(iOS)
+
+import SwiftUI
+
+struct SecuredByClerkView: View {
+ @Environment(\.clerk) private var clerk
+ @Environment(\.clerkTheme) private var theme
+
+ var body: some View {
+ if clerk.environment.displayConfig?.branded == true {
+ HStack(spacing: 6) {
+ Text("Secured by", bundle: .module)
+ Image("clerk-logo", bundle: .module)
+ }
+ .font(theme.fonts.footnote.weight(.medium))
+ .foregroundStyle(theme.colors.textSecondary)
+ .transition(.blurReplace.animation(.default))
+ } else {
+ EmptyView()
+ }
+ }
+}
+
+struct SecuredByClerkFooter: View {
+ @Environment(\.clerk) private var clerk
+ @Environment(\.clerkTheme) private var theme
+
+ var body: some View {
+ if clerk.environment.displayConfig?.branded == true {
+ SecuredByClerkView()
+ .padding(16)
+ .frame(maxWidth: .infinity)
+ .background(theme.colors.backgroundSecondary)
+ .overlay(
+ alignment: .top,
+ content: {
+ Rectangle()
+ .fill(theme.colors.border)
+ .frame(height: 1)
+ }
+ )
+ .transition(.blurReplace.animation(.default))
+ } else {
+ EmptyView()
+ }
+ }
+}
+
+#Preview {
+ SecuredByClerkView()
+}
+
+#Preview {
+ @Previewable @Environment(\.clerkTheme) var theme
+
+ VStack(spacing: 0) {
+ ScrollView {
+ theme.colors.backgroundSecondary
+ .containerRelativeFrame(.vertical)
+ }
+ SecuredByClerkFooter()
+ }
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Common/SocialButton.swift b/Sources/Clerk/ClerkUI/Common/SocialButton.swift
new file mode 100644
index 000000000..a97e58c52
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Common/SocialButton.swift
@@ -0,0 +1,124 @@
+//
+// SocialButton.swift
+// Clerk
+//
+// Created by Mike Pitre on 4/11/25.
+//
+
+#if os(iOS)
+
+import Kingfisher
+import SwiftUI
+
+struct SocialButton: View {
+ @Environment(\.clerkTheme) private var theme
+ @Environment(\.colorScheme) private var colorScheme
+
+ let provider: OAuthProvider
+ var action: (() async -> Void)? = nil
+ var result: Result?
+ var onSuccess: ((TransferFlowResult) -> Void)? = nil
+ var onError: ((Error) -> Void)? = nil
+
+ private var iconImage: some View {
+ KFImage(provider.iconImageUrl(darkMode: colorScheme == .dark))
+ .resizable()
+ .placeholder {
+ Image(systemName: "globe")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 21, height: 21)
+ }
+ .fade(duration: 0.25)
+ .scaledToFit()
+ .frame(width: 21, height: 21)
+ }
+
+ init(
+ provider: OAuthProvider
+ ) {
+ self.provider = provider
+ }
+
+ init(
+ provider: OAuthProvider,
+ action: (() async -> Void)? = nil
+ ) {
+ self.provider = provider
+ self.action = action
+ }
+
+ init(
+ provider: OAuthProvider,
+ onSuccess: ((TransferFlowResult) -> Void)? = nil,
+ onError: ((Error) -> Void)? = nil
+ ) {
+ self.provider = provider
+ self.onSuccess = onSuccess
+ self.onError = onError
+ }
+
+ var body: some View {
+ AsyncButton {
+ do {
+ if let action = action {
+ await action()
+ } else {
+ try await defaultAction()
+ }
+ } catch {
+ if error.isUserCancelledError {
+ return
+ } else {
+ onError?(error)
+ }
+ }
+ } label: { isRunning in
+ ViewThatFits(in: .horizontal) {
+ HStack(spacing: 12) {
+ iconImage
+ Text("Continue with \(provider.name)", bundle: .module)
+ .font(theme.fonts.body)
+ .foregroundStyle(theme.colors.text)
+ }
+
+ iconImage
+ }
+ .frame(maxWidth: .infinity)
+ .overlayProgressView(isActive: isRunning)
+ }
+ .buttonStyle(.secondary())
+ }
+}
+
+extension SocialButton {
+
+ func defaultAction() async throws {
+ let result: TransferFlowResult
+
+ if provider == .apple {
+ result = try await SignInWithAppleUtils.signIn()
+ } else {
+ result = try await SignIn.authenticateWithRedirect(
+ strategy: .oauth(provider: provider)
+ )
+ }
+ onSuccess?(result)
+ }
+}
+
+#Preview {
+
+ VStack {
+ SocialButton(provider: .google)
+
+ HStack {
+ SocialButton(provider: .apple)
+ SocialButton(provider: .google)
+ SocialButton(provider: .slack)
+ }
+ }
+ .padding()
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Common/SocialButtonLayout.swift b/Sources/Clerk/ClerkUI/Common/SocialButtonLayout.swift
new file mode 100644
index 000000000..5bcdedd30
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Common/SocialButtonLayout.swift
@@ -0,0 +1,115 @@
+//
+// SocialButtonStack.swift
+// Clerk
+//
+// Created by Mike Pitre on 4/23/25.
+//
+
+#if os(iOS)
+
+import SwiftUI
+
+struct SocialButtonLayout: Layout {
+ enum Alignment {
+ case leading, center, trailing
+ }
+
+ var alignment: Alignment = .center
+ var minItemWidth: CGFloat = 112
+ var spacing: CGFloat = 8
+
+ func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
+ let containerWidth = proposal.width ?? 0
+ let itemsPerRow = maxFittingItemCount(containerWidth: containerWidth)
+ let rowCount = Int(ceil(Double(subviews.count) / Double(itemsPerRow)))
+ let rowHeight = subviews.first?.sizeThatFits(.unspecified).height ?? 0
+ let totalHeight = CGFloat(rowCount) * rowHeight + CGFloat(rowCount - 1) * spacing
+ return CGSize(width: containerWidth, height: totalHeight)
+ }
+
+ func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
+ let containerWidth = bounds.width
+ let itemsPerRow = maxFittingItemCount(containerWidth: containerWidth)
+ let rowHeight = subviews.first?.sizeThatFits(.unspecified).height ?? 0
+ let rowCount = Int(ceil(Double(subviews.count) / Double(itemsPerRow)))
+ let useMaxWidth = subviews.count <= itemsPerRow
+
+ for row in 0.. Int {
+ guard containerWidth >= minItemWidth else { return 1 }
+ let count = (containerWidth + spacing) / (minItemWidth + spacing)
+ return max(1, Int(count.rounded(.down)))
+ }
+}
+
+#Preview {
+ ScrollView {
+ VStack(spacing: 50) {
+ SocialButtonLayout {
+ SocialButton(provider: .google)
+ }
+
+ SocialButtonLayout {
+ SocialButton(provider: .google)
+ SocialButton(provider: .apple)
+ }
+
+ SocialButtonLayout {
+ SocialButton(provider: .google)
+ SocialButton(provider: .apple)
+ SocialButton(provider: .github)
+ }
+
+ SocialButtonLayout {
+ SocialButton(provider: .google)
+ SocialButton(provider: .apple)
+ SocialButton(provider: .github)
+ SocialButton(provider: .slack)
+ }
+
+ SocialButtonLayout {
+ SocialButton(provider: .google)
+ SocialButton(provider: .apple)
+ SocialButton(provider: .github)
+ SocialButton(provider: .slack)
+ SocialButton(provider: .facebook)
+ }
+ }
+ .frame(maxWidth: .infinity)
+ .padding()
+ }
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Common/SpinnerView.swift b/Sources/Clerk/ClerkUI/Common/SpinnerView.swift
new file mode 100644
index 000000000..35a2fca43
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Common/SpinnerView.swift
@@ -0,0 +1,43 @@
+//
+// SpinnerView.swift
+// Clerk
+//
+// Created by Mike Pitre on 4/15/25.
+//
+
+#if os(iOS)
+
+import SwiftUI
+
+struct SpinnerView: View {
+ @Environment(\.clerkTheme) private var theme
+ @State private var rotation = 0.0
+
+ var color: Color?
+
+ var body: some View {
+ Image("icon-spinner", bundle: .module)
+ .resizable()
+ .foregroundStyle(color ?? theme.colors.textSecondary)
+ .scaledToFit()
+ .rotationEffect(.degrees(rotation))
+ .onAppear {
+ withAnimation(
+ .linear(duration: 1.0)
+ .repeatForever(autoreverses: false)
+ ) {
+ rotation = 360
+ }
+ }
+ }
+}
+
+#Preview {
+ SpinnerView()
+ .frame(width: 24, height: 24)
+
+ SpinnerView()
+ .frame(width: 16, height: 16)
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Common/TextDivider.swift b/Sources/Clerk/ClerkUI/Common/TextDivider.swift
new file mode 100644
index 000000000..df3376e52
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Common/TextDivider.swift
@@ -0,0 +1,45 @@
+//
+// TextDivider.swift
+// Clerk
+//
+// Created by Mike Pitre on 4/11/25.
+//
+
+#if os(iOS)
+
+import SwiftUI
+
+struct TextDivider: View {
+ @Environment(\.clerkTheme) private var theme
+
+ let string: LocalizedStringKey
+
+ var divider: some View {
+ Rectangle()
+ .foregroundStyle(theme.colors.border)
+ .frame(height: 1)
+ }
+
+ var body: some View {
+ HStack(spacing: 16) {
+ divider
+ Text(string, bundle: .module)
+ .font(theme.fonts.footnote)
+ .foregroundStyle(theme.colors.textSecondary)
+ .multilineTextAlignment(.center)
+ .layoutPriority(1)
+ divider
+ }
+ }
+}
+
+#Preview {
+ VStack {
+ TextDivider(string: "or")
+ TextDivider(string: "Or, sign in with another method")
+ TextDivider(string: "Or, sign in with another method. This is some really long text.")
+ }
+ .padding()
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Common/WrappingHStack.swift b/Sources/Clerk/ClerkUI/Common/WrappingHStack.swift
new file mode 100644
index 000000000..402c3232a
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Common/WrappingHStack.swift
@@ -0,0 +1,143 @@
+//
+// WrappingHStack.swift
+// Clerk
+//
+// Created by Mike Pitre on 5/29/25.
+//
+
+#if os(iOS)
+
+import SwiftUI
+
+struct WrappingHStack: Layout {
+ var alignment: Alignment
+ var spacing: CGFloat
+ var lineSpacing: CGFloat
+
+ init(alignment: Alignment = .center, spacing: CGFloat = 8, lineSpacing: CGFloat = 8) {
+ self.alignment = alignment
+ self.spacing = spacing
+ self.lineSpacing = lineSpacing
+ }
+
+ func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
+ let rows = calculateRows(proposal: proposal, subviews: subviews)
+
+ let maxWidth = rows.map { $0.width }.max() ?? 0
+ let totalHeight = rows.enumerated().reduce(0) { result, row in
+ let (index, rowData) = row
+ return result + rowData.height + (index > 0 ? lineSpacing : 0)
+ }
+
+ return CGSize(width: maxWidth, height: totalHeight)
+ }
+
+ func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
+ let rows = calculateRows(proposal: proposal, subviews: subviews)
+ var currentY = bounds.minY
+
+ for row in rows {
+ let rowBounds = CGRect(
+ x: bounds.minX,
+ y: currentY,
+ width: bounds.width,
+ height: row.height
+ )
+
+ placeRow(row: row, in: rowBounds, alignment: alignment)
+ currentY += row.height + lineSpacing
+ }
+ }
+
+ private func calculateRows(proposal: ProposedViewSize, subviews: Subviews) -> [RowData] {
+ let availableWidth = proposal.width ?? .infinity
+ var rows: [RowData] = []
+ var currentRow: [SubviewData] = []
+ var currentRowWidth: CGFloat = 0
+
+ for subview in subviews {
+ let subviewSize = subview.sizeThatFits(.unspecified)
+ let subviewWidth = subviewSize.width
+ let requiredWidth = currentRowWidth + (currentRow.isEmpty ? 0 : spacing) + subviewWidth
+
+ if requiredWidth <= availableWidth || currentRow.isEmpty {
+ // Add to current row
+ if !currentRow.isEmpty {
+ currentRowWidth += spacing
+ }
+ currentRow.append(SubviewData(subview: subview, size: subviewSize))
+ currentRowWidth += subviewWidth
+ } else {
+ // Start new row
+ if !currentRow.isEmpty {
+ rows.append(RowData(subviews: currentRow, width: currentRowWidth))
+ }
+ currentRow = [SubviewData(subview: subview, size: subviewSize)]
+ currentRowWidth = subviewWidth
+ }
+ }
+
+ // Add the last row
+ if !currentRow.isEmpty {
+ rows.append(RowData(subviews: currentRow, width: currentRowWidth))
+ }
+
+ return rows
+ }
+
+ private func placeRow(row: RowData, in bounds: CGRect, alignment: Alignment) {
+ let rowHeight = row.height
+
+ // Calculate horizontal alignment
+ let totalRowWidth = row.width
+ let startX: CGFloat
+ switch alignment.horizontal {
+ case .leading:
+ startX = bounds.minX
+ case .trailing:
+ startX = bounds.maxX - totalRowWidth
+ default: // center
+ startX = bounds.minX + (bounds.width - totalRowWidth) / 2
+ }
+
+ var currentX = startX
+
+ for subviewData in row.subviews {
+ let subview = subviewData.subview
+ let size = subviewData.size
+
+ // Calculate vertical alignment
+ let yPosition: CGFloat
+ switch alignment.vertical {
+ case .top:
+ yPosition = bounds.minY
+ case .bottom:
+ yPosition = bounds.minY + rowHeight - size.height
+ default: // center
+ yPosition = bounds.minY + (rowHeight - size.height) / 2
+ }
+
+ let position = CGPoint(x: currentX, y: yPosition)
+ subview.place(at: position, proposal: ProposedViewSize(size))
+
+ currentX += size.width + spacing
+ }
+ }
+}
+
+// Helper structures
+private struct SubviewData {
+ let subview: LayoutSubview
+ let size: CGSize
+}
+
+private struct RowData {
+ let subviews: [SubviewData]
+ let width: CGFloat
+
+ var height: CGFloat {
+ subviews.map { $0.size.height }.max() ?? 0
+ }
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Components/Auth/AuthStartView.swift b/Sources/Clerk/ClerkUI/Components/Auth/AuthStartView.swift
new file mode 100644
index 000000000..6379595b2
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Components/Auth/AuthStartView.swift
@@ -0,0 +1,335 @@
+//
+// AuthStartView.swift
+// Clerk
+//
+// Created by Mike Pitre on 4/9/25.
+//
+
+#if os(iOS)
+
+import FactoryKit
+import SwiftUI
+
+struct AuthStartView: View {
+ @Environment(\.clerk) private var clerk
+ @Environment(\.clerkTheme) private var theme
+ @Environment(\.authState) private var authState
+ @Environment(\.dismissKeyboard) private var dismissKeyboard
+
+ @SceneStorage("phoneNumberFieldIsActive") private var phoneNumberFieldIsActive = false
+
+ @State private var fieldError: Error?
+ @State private var generalError: Error?
+
+ var titleString: LocalizedStringKey {
+ switch authState.mode {
+ case .signIn, .signInOrUp:
+ if let appName = clerk.environment.displayConfig?.applicationName {
+ return "Continue to \(appName)"
+ } else {
+ return "Continue"
+ }
+ case .signUp:
+ return "Create your account"
+ }
+ }
+
+ var subtitleString: LocalizedStringKey {
+ switch authState.mode {
+ case .signIn, .signInOrUp:
+ "Welcome! Sign in to continue"
+ case .signUp:
+ "Welcome! Please fill in the details to get started."
+ }
+ }
+
+ var emailIsEnabled: Bool {
+ clerk.environment.enabledFirstFactorAttributes
+ .contains("email_address")
+ }
+
+ var usernameIsEnabled: Bool {
+ clerk.environment.enabledFirstFactorAttributes
+ .contains("username")
+ }
+
+ var phoneNumberIsEnabled: Bool {
+ clerk.environment.enabledFirstFactorAttributes
+ .contains("phone_number")
+ }
+
+ var showIdentifierField: Bool {
+ emailIsEnabled || usernameIsEnabled || phoneNumberIsEnabled
+ }
+
+ var showIdentifierSwitcher: Bool {
+ (emailIsEnabled || usernameIsEnabled) && phoneNumberIsEnabled
+ }
+
+ var identifierSwitcherString: LocalizedStringKey {
+ if phoneNumberFieldIsActive {
+ if emailIsEnabled && usernameIsEnabled {
+ "Use email address or username"
+ } else if emailIsEnabled {
+ "Use email address"
+ } else if usernameIsEnabled {
+ "Use username"
+ } else {
+ ""
+ }
+ } else {
+ "Use phone number"
+ }
+ }
+
+ var shouldStartOnPhoneNumber: Bool {
+ guard phoneNumberIsEnabled else { return false }
+
+ if !(emailIsEnabled || usernameIsEnabled) {
+ return true
+ }
+
+ if !authState.authStartPhoneNumber.isEmpty && authState.authStartIdentifier.isEmpty {
+ return true
+ }
+
+ return false
+ }
+
+ var emailOrUsernamePlaceholder: LocalizedStringKey {
+ switch (emailIsEnabled, usernameIsEnabled) {
+ case (true, false):
+ "Enter your email"
+ case (false, true):
+ "Enter your username"
+ default:
+ "Enter your email or username"
+ }
+ }
+
+ var showOrDivider: Bool {
+ !clerk.environment.authenticatableSocialProviders.isEmpty && showIdentifierField
+ }
+
+ var continueIsDisabled: Bool {
+ if phoneNumberFieldIsActive {
+ authState.authStartPhoneNumber.isEmpty
+ } else {
+ authState.authStartIdentifier.isEmpty
+ }
+ }
+
+ var body: some View {
+ @Bindable var authState = authState
+
+ ScrollView {
+ VStack(spacing: 0) {
+ AppLogoView()
+ .frame(maxHeight: 44)
+ .padding(.bottom, 24)
+
+ VStack(spacing: 8) {
+ HeaderView(style: .title, text: titleString)
+ HeaderView(style: .subtitle, text: subtitleString)
+ }
+ .padding(.bottom, 32)
+
+ VStack(spacing: 24) {
+ if showIdentifierField {
+ VStack(spacing: 4) {
+ if phoneNumberFieldIsActive && phoneNumberIsEnabled {
+ ClerkPhoneNumberField(
+ "Enter your phone number",
+ text: $authState.authStartPhoneNumber,
+ fieldState: fieldError != nil ? .error : .default
+ )
+ .transition(.blurReplace)
+ } else {
+ ClerkTextField(
+ emailOrUsernamePlaceholder,
+ text: $authState.authStartIdentifier,
+ fieldState: fieldError != nil ? .error : .default
+ )
+ .textContentType(.username)
+ .keyboardType(.emailAddress)
+ .textInputAutocapitalization(.never)
+ .transition(.blurReplace)
+ }
+
+ if let fieldError {
+ ErrorText(error: fieldError, alignment: .leading)
+ .font(theme.fonts.subheadline)
+ .transition(.blurReplace.animation(.default.speed(2)))
+ .id(fieldError.localizedDescription)
+ }
+ }
+ }
+
+ AsyncButton {
+ await startAuth()
+ } label: { isRunning in
+ HStack(spacing: 4) {
+ Text("Continue", bundle: .module)
+ Image("icon-triangle-right", bundle: .module)
+ .foregroundStyle(theme.colors.textOnPrimaryBackground)
+ .opacity(0.6)
+ }
+ .frame(maxWidth: .infinity)
+ .overlayProgressView(isActive: isRunning) {
+ SpinnerView(color: theme.colors.textOnPrimaryBackground)
+ }
+ }
+ .buttonStyle(.primary())
+ .disabled(continueIsDisabled)
+ .simultaneousGesture(TapGesture())
+
+ if showIdentifierSwitcher {
+ Button {
+ withAnimation(.default.speed(2)) {
+ phoneNumberFieldIsActive.toggle()
+ }
+ } label: {
+ Text(identifierSwitcherString, bundle: .module)
+ .id(phoneNumberFieldIsActive)
+ }
+ .buttonStyle(.primary(config: .init(emphasis: .none, size: .small)))
+ .simultaneousGesture(TapGesture())
+ }
+
+ if showOrDivider {
+ TextDivider(string: "or")
+ }
+
+ SocialButtonLayout {
+ ForEach(clerk.environment.authenticatableSocialProviders) { provider in
+ SocialButton(provider: provider) { result in
+ switch result {
+ case .signIn(let signIn):
+ authState.setToStepForStatus(signIn: signIn)
+ case .signUp(let signUp):
+ authState.setToStepForStatus(signUp: signUp)
+ }
+ } onError: { error in
+ self.generalError = error
+ }
+ .simultaneousGesture(TapGesture())
+ }
+ }
+ }
+ .padding(.bottom, 32)
+
+ SecuredByClerkView()
+ }
+ .padding(16)
+ }
+ .scrollDismissesKeyboard(.interactively)
+ .clerkErrorPresenting($generalError)
+ .background(theme.colors.background)
+ .sensoryFeedback(.error, trigger: fieldError?.localizedDescription) {
+ $1 != nil
+ }
+ .taskOnce {
+ if shouldStartOnPhoneNumber {
+ phoneNumberFieldIsActive = true
+ }
+ }
+ }
+}
+
+extension AuthStartView {
+
+ func startAuth() async {
+ dismissKeyboard()
+
+ switch authState.mode {
+ case .signInOrUp: await signIn(withSignUp: true)
+ case .signIn: await signIn(withSignUp: false)
+ case .signUp: await signUp()
+ }
+ }
+
+ private func signIn(withSignUp: Bool) async {
+ fieldError = nil
+
+ do {
+ var signIn = try await SignIn.create(
+ strategy: .identifier(
+ phoneNumberFieldIsActive
+ ? authState.authStartPhoneNumber
+ : authState.authStartIdentifier
+ )
+ )
+
+ if signIn.startingFirstFactor?.strategy == "enterprise_sso" {
+ signIn = try await signIn.prepareFirstFactor(strategy: .enterpriseSSO())
+
+ if signIn.firstFactorVerification?.externalVerificationRedirectUrl != nil {
+ let result = try await signIn.authenticateWithRedirect()
+ handleTransferFlowResult(result)
+ return
+ }
+ }
+
+ authState.setToStepForStatus(signIn: signIn)
+ } catch {
+ if withSignUp, let clerkApiError = error as? ClerkAPIError, ["form_identifier_not_found", "invitation_account_not_exists"].contains(clerkApiError.code) {
+ await signUp()
+ } else {
+ self.fieldError = error
+ }
+ }
+ }
+
+ private func signUp() async {
+ fieldError = nil
+
+ do {
+ let signUp = try await SignUp.create(strategy: signUpParams)
+ authState.setToStepForStatus(signUp: signUp)
+ } catch {
+ self.fieldError = error
+ }
+ }
+
+ private var signUpParams: SignUp.CreateStrategy {
+ if phoneNumberFieldIsActive {
+ return .standard(phoneNumber: authState.authStartPhoneNumber)
+ } else {
+ if authState.authStartIdentifier.isEmailAddress {
+ return .standard(emailAddress: authState.authStartIdentifier)
+ } else {
+ return .standard(username: authState.authStartIdentifier)
+ }
+ }
+ }
+
+ private func handleTransferFlowResult(_ result: TransferFlowResult) {
+ switch result {
+ case .signIn(let signIn):
+ authState.setToStepForStatus(signIn: signIn)
+ case .signUp(let signUp):
+ authState.setToStepForStatus(signUp: signUp)
+ }
+ }
+
+}
+
+#Preview {
+ AuthStartView()
+ .environment(\.clerk, .mock)
+}
+
+#Preview("Clerk Theme") {
+ AuthStartView()
+ .environment(\.clerk, .mock)
+ .environment(\.clerkTheme, .clerk)
+}
+
+#Preview("Localized") {
+ AuthStartView()
+ .environment(\.clerk, .mock)
+ .environment(\.clerkTheme, .clerk)
+ .environment(\.locale, .init(identifier: "en"))
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Components/Auth/AuthState.swift b/Sources/Clerk/ClerkUI/Components/Auth/AuthState.swift
new file mode 100644
index 000000000..1dd9c22fe
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Components/Auth/AuthState.swift
@@ -0,0 +1,129 @@
+//
+// SignInViewState.swift
+// Clerk
+//
+// Created by Mike Pitre on 4/15/25.
+//
+
+#if os(iOS)
+
+import Foundation
+import SwiftUI
+
+@Observable
+final class AuthState {
+
+ init(mode: AuthView.Mode = .signInOrUp) {
+ self.mode = mode
+ }
+
+ var path: [AuthView.Destination] = []
+ let mode: AuthView.Mode
+ var lastCodeSentAt: [String: Date] = [:]
+
+ // Auth Start Fields
+ var authStartIdentifier: String = UserDefaults.standard.string(forKey: "authStartIdentifier") ?? "" {
+ didSet {
+ UserDefaults.standard.set(authStartIdentifier, forKey: "authStartIdentifier")
+ }
+ }
+
+ var authStartPhoneNumber: String = UserDefaults.standard.string(forKey: "authStartPhoneNumber") ?? "" {
+ didSet {
+ UserDefaults.standard.set(authStartPhoneNumber, forKey: "authStartPhoneNumber")
+ }
+ }
+
+ // Sign In Fields
+ var signInPassword = ""
+ var signInNewPassword = ""
+ var signInConfirmNewPassword = ""
+ var signInBackupCode = ""
+
+ // Sign Up Fields
+ var signUpFirstName = ""
+ var signUpLastName = ""
+ var signUpPassword = ""
+ var signUpUsername = ""
+ var signUpEmailAddress = ""
+ var signUpPhoneNumber = ""
+
+ @MainActor
+ func setToStepForStatus(signIn: SignIn) {
+ switch signIn.status {
+ case .complete:
+ return
+ case .needsIdentifier:
+ path = []
+ case .needsFirstFactor:
+ guard let factor = signIn.startingFirstFactor else {
+ path.append(AuthView.Destination.signInGetHelp)
+ return
+ }
+ path.append(AuthView.Destination.signInFactorOne(factor: factor))
+ case .needsSecondFactor:
+ guard let factor = signIn.startingSecondFactor else {
+ path.append(AuthView.Destination.signInGetHelp)
+ return
+ }
+
+ path.append(AuthView.Destination.signInFactorTwo(factor: factor))
+ case .needsNewPassword:
+ path.append(AuthView.Destination.signInSetNewPassword)
+ case .unknown:
+ return
+ }
+ }
+
+ @MainActor
+ func setToStepForStatus(signUp: SignUp) {
+ switch signUp.status {
+ case .abandoned:
+ path = []
+ case .missingRequirements:
+ if let firstFieldToVerify = signUp.firstFieldToVerify {
+ switch firstFieldToVerify {
+ case "email_address":
+ guard let emailAddress = signUp.emailAddress else {
+ path = []
+ return
+ }
+
+ path.append(AuthView.Destination.signUpCode(.email(emailAddress)))
+ case "phone_number":
+ guard let phoneNumber = signUp.phoneNumber else {
+ path = []
+ return
+ }
+
+ path.append(AuthView.Destination.signUpCode(.phone(phoneNumber)))
+ default:
+ path = []
+ }
+ } else if let nextFieldToCollect = signUp.firstFieldToCollect {
+ switch nextFieldToCollect {
+ case "password":
+ path.append(AuthView.Destination.signUpCollectField(.password))
+ case "email_address":
+ path.append(AuthView.Destination.signUpCollectField(.emailAddress))
+ case "phone_number":
+ path.append(AuthView.Destination.signUpCollectField(.phoneNumber))
+ case "username":
+ path.append(AuthView.Destination.signUpCollectField(.username))
+ default:
+ path.append(AuthView.Destination.signUpCompleteProfile)
+ }
+ }
+ case .complete:
+ return
+ case .unknown:
+ return
+ }
+ }
+}
+
+extension EnvironmentValues {
+ @Entry var authState = AuthState()
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Components/Auth/AuthView.swift b/Sources/Clerk/ClerkUI/Components/Auth/AuthView.swift
new file mode 100644
index 000000000..3e8218ce5
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Components/Auth/AuthView.swift
@@ -0,0 +1,215 @@
+//
+// SignInView.swift
+// Clerk
+//
+// Created by Mike Pitre on 4/14/25.
+//
+
+#if os(iOS)
+
+import FactoryKit
+import SwiftUI
+
+/// A comprehensive authentication view that handles user sign-in and sign-up flows.
+///
+/// `AuthView` provides a complete authentication experience with support for multiple sign-in
+/// methods, sign-up flows, multi-factor authentication, password reset, and account recovery.
+/// The view can be configured for different authentication modes and automatically handles
+/// navigation between authentication steps.
+///
+/// ## Usage
+///
+/// Basic usage as a dismissable sheet:
+///
+/// ```swift
+/// struct HomeView: View {
+/// @Environment(\.clerk) private var clerk
+/// @State private var authIsPresented = false
+///
+/// var body: some View {
+/// ZStack {
+/// Group {
+/// if clerk.user != nil {
+/// UserButton()
+/// .frame(width: 36, height: 36)
+/// } else {
+/// Button("Sign in") {
+/// authIsPresented = true
+/// }
+/// }
+/// }
+/// }
+/// .sheet(isPresented: $authIsPresented) {
+/// AuthView()
+/// }
+/// }
+/// }
+/// ```
+///
+/// Full-screen authentication (non-dismissable):
+///
+/// ```swift
+/// struct ProfileView: View {
+/// @Environment(\.clerk) private var clerk
+///
+/// var body: some View {
+/// Group {
+/// if clerk.user != nil {
+/// UserProfileView(isDismissable: false)
+/// } else {
+/// AuthView(isDismissable: false)
+/// }
+/// }
+/// }
+/// }
+/// ```
+public struct AuthView: View {
+ @Environment(\.clerk) private var clerk
+ @Environment(\.clerkTheme) private var theme
+ @Environment(\.dismiss) private var dismiss
+ @State var authState: AuthState
+
+ /// The authentication mode that determines which flows are available to the user.
+ public enum Mode {
+ /// Allows users to choose between signing in to existing accounts or creating new accounts.
+ /// This is the default mode that provides the most flexibility for users.
+ case signInOrUp
+
+ /// Restricts the interface to sign-in flows only. Users can only authenticate with existing accounts.
+ /// Useful when you want to prevent new account creation in specific contexts.
+ case signIn
+
+ /// Restricts the interface to sign-up flows only. Users can only create new accounts.
+ /// Useful for dedicated registration flows or when sign-in is handled elsewhere.
+ case signUp
+ }
+
+ let isDismissable: Bool
+
+ /// Creates a new authentication view.
+ ///
+ /// - Parameters:
+ /// - mode: The authentication mode that determines available flows.
+ /// Defaults to `.signInOrUp` which allows both sign-in and sign-up.
+ /// - isDismissable: Whether the view can be dismissed by the user.
+ /// When `true`, a dismiss button appears and the view automatically
+ /// dismisses on successful authentication. When `false`, no dismiss
+ /// button is shown. Defaults to `true`.
+ public init(mode: Mode = .signInOrUp, isDismissable: Bool = true) {
+ self._authState = State(initialValue: .init(mode: mode))
+ self.isDismissable = isDismissable
+ }
+
+ public var body: some View {
+ NavigationStack(path: $authState.path) {
+ AuthStartView()
+ .toolbar {
+ if isDismissable {
+ ToolbarItem(placement: .topBarTrailing) {
+ DismissButton {
+ dismiss()
+ }
+ }
+ }
+ }
+ .navigationDestination(for: Destination.self) {
+ $0.view
+ .toolbar {
+ if isDismissable {
+ ToolbarItem(placement: .topBarTrailing) {
+ DismissButton {
+ dismiss()
+ }
+ }
+ }
+ }
+ }
+ }
+ .background(theme.colors.background)
+ .presentationBackground(theme.colors.background)
+ .tint(theme.colors.primary)
+ .environment(\.authState, authState)
+ .task {
+ _ = try? await Clerk.Environment.get()
+ }
+ .task {
+ if isDismissable {
+ for await event in clerk.authEventEmitter.events {
+ switch event {
+ case .signInCompleted, .signUpCompleted:
+ dismiss()
+ }
+ }
+ }
+ }
+ }
+}
+
+extension AuthView {
+ enum Destination: Hashable {
+
+ // Auth Start
+ case authStart
+
+ // Sign In
+ case signInFactorOne(factor: Factor)
+ case signInFactorOneUseAnotherMethod(currentFactor: Factor)
+ case signInFactorTwo(factor: Factor)
+ case signInFactorTwoUseAnotherMethod(currentFactor: Factor)
+ case signInForgotPassword
+ case signInSetNewPassword
+ case signInGetHelp
+
+ // Sign up
+ case signUpCollectField(SignUpCollectFieldView.Field)
+ case signUpCode(SignUpCodeView.Field)
+ case signUpCompleteProfile
+
+ @MainActor
+ @ViewBuilder
+ var view: some View {
+ switch self {
+ case .authStart:
+ AuthStartView()
+ case .signInFactorOne(let factor):
+ SignInFactorOneView(factor: factor)
+ case .signInFactorOneUseAnotherMethod(let currentFactor):
+ SignInFactorAlternativeMethodsView(currentFactor: currentFactor)
+ case .signInFactorTwo(let factor):
+ SignInFactorTwoView(factor: factor)
+ case .signInFactorTwoUseAnotherMethod(let currentFactor):
+ SignInFactorAlternativeMethodsView(
+ currentFactor: currentFactor,
+ isSecondFactor: true
+ )
+ case .signInForgotPassword:
+ SignInFactorOneForgotPasswordView()
+ case .signInSetNewPassword:
+ SignInSetNewPasswordView()
+ case .signInGetHelp:
+ SignInGetHelpView()
+ case .signUpCollectField(let field):
+ SignUpCollectFieldView(field: field)
+ case .signUpCode(let field):
+ SignUpCodeView(field: field)
+ case .signUpCompleteProfile:
+ SignUpCompleteProfileView()
+ }
+ }
+ }
+}
+
+#Preview("In sheet") {
+ Color.clear
+ .sheet(isPresented: .constant(true)) {
+ AuthView()
+ .environment(\.clerk, .mock)
+ }
+}
+
+#Preview("Not in sheet") {
+ AuthView(isDismissable: false)
+ .environment(\.clerk, .mock)
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Components/Auth/SignIn/SignInFactorAlternativeMethodsView.swift b/Sources/Clerk/ClerkUI/Components/Auth/SignIn/SignInFactorAlternativeMethodsView.swift
new file mode 100644
index 000000000..5c31c4cc5
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Components/Auth/SignIn/SignInFactorAlternativeMethodsView.swift
@@ -0,0 +1,193 @@
+//
+// SignInFactorAlternativeMethodsView.swift
+// Clerk
+//
+// Created by Mike Pitre on 4/23/25.
+//
+
+#if os(iOS)
+
+import FactoryKit
+import SwiftUI
+
+struct SignInFactorAlternativeMethodsView: View {
+ @Environment(\.clerk) private var clerk
+ @Environment(\.clerkTheme) private var theme
+ @Environment(\.authState) private var authState
+
+ @State private var error: Error?
+
+ let currentFactor: Factor
+ var isSecondFactor: Bool = false
+
+ var signIn: SignIn? {
+ clerk.client?.signIn
+ }
+
+ var alternativeFactors: [Factor] {
+ if isSecondFactor {
+ signIn?.alternativeSecondFactors(currentFactor: currentFactor) ?? []
+ } else {
+ signIn?.alternativeFirstFactors(currentFactor: currentFactor) ?? []
+ }
+ }
+
+ var socialProviders: [OAuthProvider] {
+ if isSecondFactor {
+ []
+ } else {
+ clerk.environment.authenticatableSocialProviders
+ }
+ }
+
+ func actionText(factor: Factor) -> LocalizedStringKey? {
+ switch factor.strategy {
+ case "phone_code":
+ guard let safeIdentifier = factor.safeIdentifier else { return nil }
+ return "Send SMS code to \(safeIdentifier.formattedAsPhoneNumberIfPossible)"
+ case "email_code":
+ guard let safeIdentifier = factor.safeIdentifier else { return nil }
+ return "Email code to \(safeIdentifier)"
+ case "passkey":
+ return "Sign in with your passkey"
+ case "password":
+ return "Sign in with your password"
+ case "totp":
+ return "Use your authenticator app"
+ case "backup_code":
+ return "Use a backup code"
+ default:
+ return nil
+ }
+ }
+
+ func iconName(factor: Factor) -> String? {
+ switch factor.strategy {
+ case "password":
+ return "icon-lock"
+ case "phone_code":
+ return "icon-sms"
+ case "email_code":
+ return "icon-email"
+ case "passkey":
+ return "icon-fingerprint"
+ case "totp":
+ return "icon-key"
+ case "backup_code":
+ return "icon-lock"
+ default:
+ return nil
+ }
+ }
+
+ var body: some View {
+ ScrollView {
+ VStack(spacing: 0) {
+ VStack(spacing: 8) {
+ HeaderView(style: .title, text: "Use another method")
+ HeaderView(style: .subtitle, text: "Facing issues? You can use any of these methods to sign in.")
+ }
+ .padding(.bottom, 32)
+
+ VStack(spacing: 16) {
+ SocialButtonLayout {
+ ForEach(socialProviders) { provider in
+ SocialButton(provider: provider) {
+ await signInWithProvider(provider)
+ }
+ .simultaneousGesture(TapGesture())
+ }
+ }
+
+ if !socialProviders.isEmpty && !alternativeFactors.isEmpty {
+ TextDivider(string: "or")
+ }
+
+ ForEach(alternativeFactors, id: \.self) { factor in
+ if let actionText = actionText(factor: factor) {
+ Button {
+ if isSecondFactor {
+ authState.path.append(
+ AuthView.Destination.signInFactorTwo(factor: factor)
+ )
+ } else {
+ authState.path.append(
+ AuthView.Destination.signInFactorOne(factor: factor)
+ )
+ }
+ } label: {
+ HStack(spacing: 6) {
+ if let iconName = iconName(factor: factor) {
+ Image(iconName, bundle: .module)
+ .resizable()
+ .frame(width: 16, height: 16)
+ .scaledToFit()
+ .foregroundStyle(theme.colors.textSecondary)
+ }
+ Text(actionText, bundle: .module)
+ .font(theme.fonts.body)
+ .foregroundStyle(theme.colors.text)
+ .lineLimit(1)
+ .truncationMode(.middle)
+ }
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.secondary())
+ .simultaneousGesture(TapGesture())
+ }
+ }
+ }
+ .padding(.bottom, 32)
+
+ SecuredByClerkView()
+ }
+ .padding(16)
+ }
+ .clerkErrorPresenting($error)
+ .background(theme.colors.background)
+ }
+}
+
+extension SignInFactorAlternativeMethodsView {
+
+ func signInWithProvider(_ provider: OAuthProvider) async {
+ do {
+ guard let signIn else {
+ authState.path = []
+ return
+ }
+
+ var result: TransferFlowResult
+
+ if provider == .apple {
+ result = try await SignInWithAppleUtils.signIn()
+ } else {
+ result =
+ try await signIn
+ .prepareFirstFactor(strategy: .oauth(provider: provider))
+ .authenticateWithRedirect()
+ }
+
+ switch result {
+ case .signIn(let signIn):
+ authState.setToStepForStatus(signIn: signIn)
+ case .signUp(let signUp):
+ authState.setToStepForStatus(signUp: signUp)
+ }
+ } catch {
+ if error.isUserCancelledError { return }
+ self.error = error
+ ClerkLogger.error("Failed to sign in with OAuth provider", error: error)
+ }
+ }
+
+}
+
+#Preview {
+ SignInFactorAlternativeMethodsView(
+ currentFactor: .mockEmailCode
+ )
+ .environment(\.clerk, .mock)
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Components/Auth/SignIn/SignInFactorCodeView.swift b/Sources/Clerk/ClerkUI/Components/Auth/SignIn/SignInFactorCodeView.swift
new file mode 100644
index 000000000..64adcdec1
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Components/Auth/SignIn/SignInFactorCodeView.swift
@@ -0,0 +1,446 @@
+//
+// SignInFactorCodeView.swift
+// Clerk
+//
+// Created by Mike Pitre on 4/21/25.
+//
+
+#if os(iOS)
+
+import FactoryKit
+import SwiftUI
+
+struct SignInFactorCodeView: View {
+ @Environment(\.clerk) private var clerk
+ @Environment(\.clerkTheme) private var theme
+ @Environment(\.authState) private var authState
+
+ @State private var code = ""
+ @State private var error: Error?
+ @State private var remainingSeconds: Int = 30
+ @State private var timer: Timer?
+ @State private var verificationState = VerificationState.default
+ @State private var otpFieldState: OTPField.FieldState = .default
+ @FocusState private var otpFieldIsFocused: Bool
+
+ let factor: Factor
+ var isSecondFactor: Bool = false
+
+ var signIn: SignIn? {
+ clerk.client?.signIn
+ }
+
+ enum VerificationState {
+ case `default`
+ case verifying
+ case success
+ case error(Error)
+
+ var showResend: Bool {
+ switch self {
+ case .default, .error:
+ true
+ case .verifying, .success:
+ false
+ }
+ }
+ }
+
+ var showResend: Bool {
+ switch factor.strategy {
+ case "totp":
+ return false
+ default:
+ return verificationState.showResend
+ }
+ }
+
+ var showUseAnotherMethod: Bool {
+ switch factor.strategy {
+ case "reset_password_email_code", "reset_password_phone_code":
+ false
+ default:
+ true
+ }
+ }
+
+ var title: LocalizedStringKey {
+ switch factor.strategy {
+ case "email_code":
+ "Check your email"
+ case "phone_code":
+ "Check your phone"
+ case "reset_password_email_code", "reset_password_phone_code":
+ "Reset password"
+ case "totp":
+ "Two-step verification"
+ default:
+ ""
+ }
+ }
+
+ var subtitleString: LocalizedStringKey {
+ switch factor.strategy {
+ case "reset_password_email_code":
+ "First, enter the code sent to your email address"
+ case "reset_password_phone_code":
+ "First, enter the code sent to your phone"
+ case "totp":
+ "To continue, please enter the verification code generated by your authenticator app"
+ default:
+ if let appName = clerk.environment.displayConfig?.applicationName {
+ "to continue to \(appName)"
+ } else {
+ "to continue"
+ }
+ }
+ }
+
+ var resendString: LocalizedStringKey {
+ if remainingSeconds > 0 {
+ "Resend (\(remainingSeconds))"
+ } else {
+ "Resend"
+ }
+ }
+
+ private func lastCodeSentAtKey(_ signIn: SignIn) -> String {
+ signIn.id + (factor.safeIdentifier ?? UUID().uuidString)
+ }
+
+ var body: some View {
+ ScrollView {
+ VStack(spacing: 0) {
+ VStack(spacing: 8) {
+ HeaderView(style: .title, text: title)
+ HeaderView(style: .subtitle, text: subtitleString)
+
+ if let identifier = factor.safeIdentifier {
+ Button {
+ authState.path = []
+ } label: {
+ IdentityPreviewView(label: identifier.formattedAsPhoneNumberIfPossible)
+ }
+ .buttonStyle(.secondary(config: .init(size: .small)))
+ .simultaneousGesture(TapGesture())
+ }
+ }
+ .padding(.bottom, 32)
+
+ VStack(spacing: 24) {
+ OTPField(
+ code: $code,
+ fieldState: $otpFieldState,
+ isFocused: $otpFieldIsFocused
+ ) { code in
+ await attempt()
+ }
+ .onAppear {
+ verificationState = .default
+ otpFieldIsFocused = true
+ }
+
+ Group {
+ switch verificationState {
+ case .verifying:
+ HStack(spacing: 4) {
+ SpinnerView()
+ .frame(width: 16, height: 16)
+ Text("Verifying...", bundle: .module)
+ }
+ .foregroundStyle(theme.colors.textSecondary)
+ case .success:
+ HStack(spacing: 4) {
+ Image("icon-check-circle", bundle: .module)
+ .foregroundStyle(theme.colors.success)
+ Text("Success", bundle: .module)
+ .foregroundStyle(theme.colors.textSecondary)
+ }
+ case .error(let error):
+ ErrorText(error: error)
+ default:
+ EmptyView()
+ }
+ }
+ .font(theme.fonts.subheadline)
+
+ if showResend {
+ AsyncButton {
+ await prepare()
+ } label: { isRunning in
+ HStack(spacing: 0) {
+ Text("Didn't recieve a code? ", bundle: .module)
+ Text(resendString, bundle: .module)
+ .foregroundStyle(
+ remainingSeconds > 0
+ ? theme.colors.textSecondary
+ : theme.colors.primary
+ )
+ .monospacedDigit()
+ .contentTransition(.numericText(countsDown: true))
+ .animation(.default, value: remainingSeconds)
+ }
+ .overlayProgressView(isActive: isRunning)
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(
+ .secondary(
+ config: .init(
+ emphasis: .none,
+ size: .small
+ )
+ )
+ )
+ .disabled(remainingSeconds > 0)
+ .simultaneousGesture(TapGesture())
+ }
+
+ if showUseAnotherMethod {
+ Button {
+ if isSecondFactor {
+ authState.path.append(
+ AuthView.Destination.signInFactorTwoUseAnotherMethod(
+ currentFactor: factor
+ )
+ )
+ } else {
+ authState.path.append(
+ AuthView.Destination.signInFactorOneUseAnotherMethod(
+ currentFactor: factor
+ )
+ )
+ }
+ } label: {
+ Text("Use another method", bundle: .module)
+ }
+ .buttonStyle(
+ .primary(
+ config: .init(
+ emphasis: .none,
+ size: .small
+ )
+ )
+ )
+ .simultaneousGesture(TapGesture())
+ }
+ }
+
+ SecuredByClerkView()
+ .padding(.top, 32)
+ }
+ .padding(16)
+ }
+ .scrollDismissesKeyboard(.interactively)
+ .clerkErrorPresenting($error)
+ .background(theme.colors.background)
+ .taskOnce {
+ startTimer()
+ if let signIn, authState.lastCodeSentAt[lastCodeSentAtKey(signIn)] == nil {
+ await prepare()
+ }
+ }
+ }
+}
+
+extension SignInFactorCodeView {
+
+ func startTimer() {
+ updateRemainingSeconds()
+ self.timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
+ Task { @MainActor in
+ updateRemainingSeconds()
+ }
+ }
+ RunLoop.current.add(timer!, forMode: .common)
+ }
+
+ func updateRemainingSeconds() {
+ guard let signIn, let lastCodeSentAt = authState.lastCodeSentAt[lastCodeSentAtKey(signIn)] else {
+ return
+ }
+
+ let elapsed = Int(Date.now.timeIntervalSince(lastCodeSentAt))
+ remainingSeconds = max(0, 30 - elapsed)
+ }
+
+ func prepare() async {
+ code = ""
+ verificationState = .default
+
+ guard let signIn else {
+ authState.path = []
+ return
+ }
+
+ do {
+ switch factor.strategy {
+ case "email_code":
+ try await signIn.prepareFirstFactor(
+ strategy: .emailCode(emailAddressId: factor.emailAddressId)
+ )
+ case "phone_code":
+ if isSecondFactor {
+ try await signIn.prepareSecondFactor(
+ strategy: .phoneCode
+ )
+ } else {
+ try await signIn.prepareFirstFactor(
+ strategy: .phoneCode(phoneNumberId: factor.phoneNumberId)
+ )
+ }
+
+ case "reset_password_email_code":
+ try await signIn.prepareFirstFactor(
+ strategy: .resetPasswordEmailCode(emailAddressId: factor.emailAddressId)
+ )
+
+ case "reset_password_phone_code":
+ try await signIn.prepareFirstFactor(
+ strategy: .resetPasswordPhoneCode(phoneNumberId: factor.phoneNumberId)
+ )
+ default:
+ break
+ }
+
+ authState.lastCodeSentAt[lastCodeSentAtKey(signIn)] = .now
+ updateRemainingSeconds()
+ } catch {
+ otpFieldIsFocused = false
+ self.error = error
+ ClerkLogger.error("Failed to prepare factor for sign in", error: error)
+ }
+ }
+
+ func attempt() async {
+ guard var signIn else {
+ authState.path = []
+ return
+ }
+
+ otpFieldState = .default
+ verificationState = .verifying
+
+ do {
+ switch factor.strategy {
+ case "email_code":
+ signIn = try await signIn.attemptFirstFactor(strategy: .emailCode(code: code))
+ case "phone_code":
+ if isSecondFactor {
+ signIn = try await signIn.attemptSecondFactor(strategy: .phoneCode(code: code))
+ } else {
+ signIn = try await signIn.attemptFirstFactor(strategy: .phoneCode(code: code))
+ }
+ case "reset_password_email_code":
+ signIn = try await signIn.attemptFirstFactor(strategy: .resetPasswordEmailCode(code: code))
+ case "reset_password_phone_code":
+ signIn = try await signIn.attemptFirstFactor(strategy: .resetPasswordPhoneCode(code: code))
+ case "totp":
+ signIn = try await signIn.attemptSecondFactor(strategy: .totp(code: code))
+ default:
+ throw ClerkClientError(message: "Unknown code verification method. Please use another method.")
+ }
+
+ otpFieldIsFocused = false
+ verificationState = .success
+ authState.setToStepForStatus(signIn: signIn)
+ } catch {
+ otpFieldState = .error
+ verificationState = .error(error)
+
+ if let clerkError = error as? ClerkAPIError, clerkError.meta?["param_name"] == nil {
+ self.error = error
+ ClerkLogger.error("Failed to attempt factor for sign in", error: error)
+ otpFieldIsFocused = false
+ }
+ }
+ }
+
+}
+
+#Preview("Email Code") {
+ Container.shared.signInService.preview { @MainActor in
+ .init(
+ prepareFirstFactor: { _, _, _ in
+ try! await Task.sleep(for: .seconds(1))
+ return .mock
+ },
+ attemptFirstFactor: { _, _ in
+ try! await Task.sleep(for: .seconds(1))
+ return .mock
+ }
+ )
+ }
+
+ SignInFactorCodeView(factor: .mockEmailCode)
+ .environment(\.clerk, .mock)
+}
+
+#Preview("Phone Code") {
+ Container.shared.signInService.preview { @MainActor in
+ .init(
+ prepareFirstFactor: { _, _, _ in
+ try! await Task.sleep(for: .seconds(1))
+ return .mock
+ },
+ attemptFirstFactor: { _, _ in
+ try! await Task.sleep(for: .seconds(1))
+ return .mock
+ }
+ )
+ }
+
+ SignInFactorCodeView(factor: .mockPhoneCode)
+ .environment(\.clerk, .mock)
+}
+
+#Preview("Reset Password Email Code") {
+ Container.shared.signInService.preview { @MainActor in
+ .init(
+ prepareFirstFactor: { _, _, _ in
+ try! await Task.sleep(for: .seconds(1))
+ return .mock
+ },
+ attemptFirstFactor: { _, _ in
+ try! await Task.sleep(for: .seconds(1))
+ return .mock
+ }
+ )
+ }
+
+ SignInFactorCodeView(factor: .mockResetPasswordEmailCode)
+ .environment(\.clerk, .mock)
+}
+
+#Preview("Reset Password Phone Code") {
+ Container.shared.signInService.preview { @MainActor in
+ .init(
+ prepareFirstFactor: { _, _, _ in
+ try! await Task.sleep(for: .seconds(1))
+ return .mock
+ },
+ attemptFirstFactor: { _, _ in
+ try! await Task.sleep(for: .seconds(1))
+ return .mock
+ }
+ )
+ }
+
+ SignInFactorCodeView(factor: .mockResetPasswordPhoneCode)
+ .environment(\.clerk, .mock)
+}
+
+#Preview("TOTP Code") {
+ Container.shared.signInService.preview { @MainActor in
+ .init(
+ attemptSecondFactor: { _, _ in
+ try! await Task.sleep(for: .seconds(1))
+ return .mock
+ }
+ )
+ }
+
+ SignInFactorCodeView(factor: .mockTotp)
+ .environment(\.clerk, .mock)
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Components/Auth/SignIn/SignInFactorOneForgotPasswordView.swift b/Sources/Clerk/ClerkUI/Components/Auth/SignIn/SignInFactorOneForgotPasswordView.swift
new file mode 100644
index 000000000..27c91b3f3
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Components/Auth/SignIn/SignInFactorOneForgotPasswordView.swift
@@ -0,0 +1,190 @@
+//
+// SignInFactorOneForgotPasswordView.swift
+// Clerk
+//
+// Created by Mike Pitre on 5/7/25.
+//
+
+#if os(iOS)
+
+import SwiftUI
+
+struct SignInFactorOneForgotPasswordView: View {
+ @Environment(\.clerk) private var clerk
+ @Environment(\.clerkTheme) private var theme
+ @Environment(\.authState) private var authState
+
+ @State private var error: Error?
+
+ var signIn: SignIn? {
+ clerk.client?.signIn
+ }
+
+ var alternativeFactors: [Factor] {
+ let factors = signIn?.alternativeFirstFactors(currentFactor: nil) ?? []
+ return factors.filter({ $0.strategy != "password" })
+ }
+
+ var socialProviders: [OAuthProvider] {
+ clerk.environment.authenticatableSocialProviders
+ }
+
+ func actionText(factor: Factor) -> LocalizedStringKey? {
+ switch factor.strategy {
+ case "phone_code":
+ guard let safeIdentifier = factor.safeIdentifier else { return nil }
+ return "Send SMS code to \(safeIdentifier.formattedAsPhoneNumberIfPossible)"
+ case "email_code":
+ guard let safeIdentifier = factor.safeIdentifier else { return nil }
+ return "Email code to \(safeIdentifier)"
+ case "passkey":
+ return "Sign in with your passkey"
+ case "password":
+ return "Sign in with your password"
+ case "totp":
+ return "Use your authenticator app"
+ case "backup_code":
+ return "Use a backup code"
+ default:
+ return nil
+ }
+ }
+
+ func iconName(factor: Factor) -> String? {
+ switch factor.strategy {
+ case "password":
+ return "icon-lock"
+ case "phone_code":
+ return "icon-sms"
+ case "email_code":
+ return "icon-email"
+ case "passkey":
+ return "icon-fingerprint"
+ default:
+ return nil
+ }
+ }
+
+ var body: some View {
+ ScrollView {
+ VStack(spacing: 0) {
+ HeaderView(style: .title, text: "Forgot password?")
+ .padding(.bottom, 32)
+
+ VStack(spacing: 16) {
+ AsyncButton {
+ await resetPassword()
+ } label: { isRunning in
+ Text("Reset your password", bundle: .module)
+ .frame(maxWidth: .infinity)
+ .overlayProgressView(isActive: isRunning) {
+ SpinnerView(color: theme.colors.textOnPrimaryBackground)
+ }
+ }
+ .buttonStyle(.primary())
+ .simultaneousGesture(TapGesture())
+
+ TextDivider(string: "Or, sign in with another method")
+
+ SocialButtonLayout {
+ ForEach(socialProviders) { provider in
+ SocialButton(provider: provider) {
+ await signInWithProvider(provider)
+ }
+ .simultaneousGesture(TapGesture())
+ }
+ }
+
+ ForEach(alternativeFactors, id: \.self) { factor in
+ if let actionText = actionText(factor: factor) {
+ Button {
+ authState.path.append(
+ AuthView.Destination.signInFactorOne(factor: factor)
+ )
+ } label: {
+ HStack(spacing: 6) {
+ if let iconName = iconName(factor: factor) {
+ Image(iconName, bundle: .module)
+ .resizable()
+ .frame(width: 16, height: 16)
+ .scaledToFit()
+ .foregroundStyle(theme.colors.textSecondary)
+ }
+ Text(actionText, bundle: .module)
+ .font(theme.fonts.body)
+ .foregroundStyle(theme.colors.text)
+ .lineLimit(1)
+ .truncationMode(.middle)
+ }
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.secondary())
+ .simultaneousGesture(TapGesture())
+ }
+ }
+ }
+ .padding(.bottom, 32)
+
+ SecuredByClerkView()
+ }
+ .padding(16)
+ }
+ .clerkErrorPresenting($error)
+ .background(theme.colors.background)
+ }
+}
+
+extension SignInFactorOneForgotPasswordView {
+
+ func resetPassword() async {
+ guard let signIn, let resetFactor = signIn.resetPasswordFactor else {
+ authState.path = []
+ return
+ }
+
+ authState.path.append(
+ AuthView.Destination.signInFactorOne(factor: resetFactor)
+ )
+ }
+
+ func signInWithProvider(_ provider: OAuthProvider) async {
+ do {
+ guard let signIn else {
+ authState.path = []
+ return
+ }
+
+ var result: TransferFlowResult
+
+ if provider == .apple {
+ result = try await SignInWithAppleUtils.signIn()
+ } else {
+ result =
+ try await signIn
+ .prepareFirstFactor(strategy: .oauth(provider: provider))
+ .authenticateWithRedirect()
+ }
+
+ switch result {
+ case .signIn(let signIn):
+ authState.setToStepForStatus(signIn: signIn)
+ case .signUp(let signUp):
+ authState.setToStepForStatus(signUp: signUp)
+ }
+
+ } catch {
+ if error.isUserCancelledError { return }
+ self.error = error
+ ClerkLogger.error("Failed to sign in with OAuth provider in forgot password flow", error: error)
+ }
+ }
+
+}
+
+#Preview {
+ SignInFactorOneForgotPasswordView()
+ .environment(\.clerk, .mock)
+ .environment(\.clerkTheme, .clerk)
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Components/Auth/SignIn/SignInFactorOnePasskeyView.swift b/Sources/Clerk/ClerkUI/Components/Auth/SignIn/SignInFactorOnePasskeyView.swift
new file mode 100644
index 000000000..f4eb148d6
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Components/Auth/SignIn/SignInFactorOnePasskeyView.swift
@@ -0,0 +1,156 @@
+//
+// SignInFactorOnePasskeyView.swift
+// Clerk
+//
+// Created by Mike Pitre on 4/22/25.
+//
+
+#if os(iOS)
+
+import SwiftUI
+
+struct SignInFactorOnePasskeyView: View {
+ @Environment(\.clerk) private var clerk
+ @Environment(\.clerkTheme) private var theme
+ @Environment(\.authState) private var authState
+
+ @State private var passkeyInProgress = true
+ @State private var animateSymbol = false
+ @State var error: Error?
+
+ var signIn: SignIn? {
+ clerk.client?.signIn
+ }
+
+ let factor: Factor
+
+ var body: some View {
+ ScrollView {
+ VStack(spacing: 0) {
+ VStack(spacing: 8) {
+ HeaderView(style: .title, text: "Use your passkey")
+ HeaderView(style: .subtitle, text: "Using your passkey confirms it's you. Your device may ask for your fingerprint, face or screen lock.")
+
+ if let identifier = factor.safeIdentifier {
+ Button {
+ authState.path = []
+ } label: {
+ IdentityPreviewView(label: identifier.formattedAsPhoneNumberIfPossible)
+ }
+ .buttonStyle(.secondary(config: .init(size: .small)))
+ .simultaneousGesture(TapGesture())
+ }
+ }
+ .padding(.bottom, 32)
+
+ VStack(spacing: 24) {
+ Image(systemName: "faceid")
+ .resizable()
+ .symbolRenderingMode(.palette)
+ .symbolEffect(
+ .bounce.down,
+ options: .nonRepeating,
+ value: animateSymbol
+ )
+ .foregroundStyle(theme.colors.text, theme.colors.primary)
+ .scaledToFit()
+ .frame(width: 64, height: 64)
+ .foregroundStyle(theme.colors.textSecondary)
+
+ AsyncButton {
+ await authWithPasskey()
+ } label: { isRunning in
+ HStack(spacing: 4) {
+ Text("Continue", bundle: .module)
+ Image("icon-triangle-right", bundle: .module)
+ .foregroundStyle(theme.colors.textOnPrimaryBackground)
+ .opacity(0.6)
+ }
+ .frame(maxWidth: .infinity)
+ .overlayProgressView(isActive: passkeyInProgress) {
+ SpinnerView(color: theme.colors.textOnPrimaryBackground)
+ }
+ }
+ .buttonStyle(.primary())
+ .disabled(passkeyInProgress)
+ .simultaneousGesture(TapGesture())
+
+ Button {
+ authState.path.append(
+ AuthView.Destination.signInFactorOneUseAnotherMethod(
+ currentFactor: factor
+ )
+ )
+ } label: {
+ Text("Use another method", bundle: .module)
+ }
+ .buttonStyle(
+ .primary(
+ config: .init(
+ emphasis: .none,
+ size: .small
+ )
+ )
+ )
+ .simultaneousGesture(TapGesture())
+ }
+ .padding(.bottom, 32)
+
+ SecuredByClerkView()
+ }
+ .padding(16)
+ }
+ .clerkErrorPresenting($error)
+ .background(theme.colors.background)
+ .onFirstAppear {
+ animateSymbol.toggle()
+ }
+ .taskOnce {
+ try? await Task.sleep(for: .seconds(0.5))
+ await authWithPasskey()
+ }
+ }
+}
+
+extension SignInFactorOnePasskeyView {
+
+ func authWithPasskey() async {
+ guard var signIn else {
+ authState.path = []
+ return
+ }
+
+ passkeyInProgress = true
+ defer { passkeyInProgress = false }
+
+ do {
+ signIn = try await signIn.prepareFirstFactor(strategy: .passkey)
+ let credential = try await signIn.getCredentialForPasskey()
+ signIn = try await signIn.attemptFirstFactor(
+ strategy: .passkey(publicKeyCredential: credential)
+ )
+
+ self.error = nil
+ authState.setToStepForStatus(signIn: signIn)
+ } catch {
+ if error.isUserCancelledError { return }
+ self.error = error
+ ClerkLogger.error("Failed to authenticate with passkey", error: error)
+ }
+ }
+
+}
+
+#Preview {
+ SignInFactorOnePasskeyView(factor: .mockPasskey)
+ .environment(\.clerk, .mock)
+ .environment(\.clerkTheme, .clerk)
+}
+
+#Preview("Localized") {
+ SignInFactorOnePasskeyView(factor: .mockPasskey)
+ .environment(\.clerk, .mock)
+ .environment(\.locale, .init(identifier: "fr"))
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Components/Auth/SignIn/SignInFactorOnePasswordView.swift b/Sources/Clerk/ClerkUI/Components/Auth/SignIn/SignInFactorOnePasswordView.swift
new file mode 100644
index 000000000..6be9d226a
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Components/Auth/SignIn/SignInFactorOnePasswordView.swift
@@ -0,0 +1,181 @@
+//
+// SignInFactorOnePasswordView.swift
+// Clerk
+//
+// Created by Mike Pitre on 4/17/25.
+//
+
+#if os(iOS)
+
+import SwiftUI
+
+struct SignInFactorOnePasswordView: View {
+ @Environment(\.clerk) private var clerk
+ @Environment(\.clerkTheme) private var theme
+ @Environment(\.authState) private var authState
+
+ @FocusState private var isFocused: Bool
+ @State private var fieldError: Error?
+
+ var signIn: SignIn? {
+ clerk.client?.signIn
+ }
+
+ let factor: Factor
+
+ var body: some View {
+ @Bindable var authState = authState
+
+ ScrollView {
+ VStack(spacing: 0) {
+ VStack(spacing: 8) {
+ HeaderView(style: .title, text: "Enter password")
+ HeaderView(style: .subtitle, text: "Enter the password for your account")
+
+ if let identifier = factor.safeIdentifier {
+ Button {
+ authState.path = []
+ } label: {
+ IdentityPreviewView(label: identifier.formattedAsPhoneNumberIfPossible)
+ }
+ .buttonStyle(.secondary(config: .init(size: .small)))
+ .simultaneousGesture(TapGesture())
+ }
+ }
+ .padding(.bottom, 32)
+
+ VStack(spacing: 24) {
+
+ VStack(spacing: 8) {
+ ClerkTextField(
+ "Enter your password",
+ text: $authState.signInPassword,
+ isSecure: true,
+ fieldState: fieldError != nil ? .error : .default
+ )
+ .textContentType(.password)
+ .textInputAutocapitalization(.never)
+ .focused($isFocused)
+ .onFirstAppear {
+ isFocused = true
+ }
+
+ if let fieldError {
+ ErrorText(error: fieldError, alignment: .leading)
+ .font(theme.fonts.subheadline)
+ .transition(.blurReplace.animation(.default.speed(2)))
+ .id(fieldError.localizedDescription)
+ }
+ }
+
+ AsyncButton {
+ await submitPassword()
+ } label: { isRunning in
+ HStack(spacing: 4) {
+ Text("Continue", bundle: .module)
+ Image("icon-triangle-right", bundle: .module)
+ .foregroundStyle(theme.colors.textOnPrimaryBackground)
+ .opacity(0.6)
+ }
+ .frame(maxWidth: .infinity)
+ .overlayProgressView(isActive: isRunning) {
+ SpinnerView(color: theme.colors.textOnPrimaryBackground)
+ }
+ }
+ .buttonStyle(.primary())
+ .disabled(authState.signInPassword.isEmpty)
+ .simultaneousGesture(TapGesture())
+ }
+ .padding(.bottom, 16)
+
+ HStack(spacing: 16) {
+ Button {
+ authState.path.append(
+ AuthView.Destination.signInFactorOneUseAnotherMethod(
+ currentFactor: factor
+ )
+ )
+ } label: {
+ Text("Use another method", bundle: .module)
+ .frame(maxWidth: .infinity)
+ }
+
+ Rectangle()
+ .foregroundStyle(theme.colors.border)
+ .frame(width: 1, height: 16)
+
+ Button {
+ if signIn?.resetPasswordFactor != nil {
+ authState.path.append(
+ AuthView.Destination.signInForgotPassword
+ )
+ } else {
+ authState.path.append(
+ AuthView.Destination.signInFactorOneUseAnotherMethod(
+ currentFactor: factor
+ )
+ )
+ }
+ } label: {
+ Text("Forgot password?", bundle: .module)
+ .frame(maxWidth: .infinity)
+ }
+ }
+ .buttonStyle(
+ .primary(
+ config: .init(
+ emphasis: .none,
+ size: .small
+ )
+ )
+ )
+ .simultaneousGesture(TapGesture())
+ .padding(.bottom, 32)
+
+ SecuredByClerkView()
+ }
+ .padding(16)
+ }
+ .background(theme.colors.background)
+ .sensoryFeedback(.error, trigger: fieldError?.localizedDescription) {
+ $1 != nil
+ }
+ }
+}
+
+extension SignInFactorOnePasswordView {
+
+ func submitPassword() async {
+ isFocused = false
+
+ do {
+ guard var signIn else {
+ authState.path = []
+ return
+ }
+
+ signIn = try await signIn.attemptFirstFactor(
+ strategy: .password(password: authState.signInPassword)
+ )
+
+ fieldError = nil
+ authState.setToStepForStatus(signIn: signIn)
+ } catch {
+ self.fieldError = error
+ }
+ }
+
+}
+
+#Preview {
+ SignInFactorOnePasswordView(factor: .mockPassword)
+ .environment(\.clerk, .mock)
+}
+
+#Preview("Localized") {
+ SignInFactorOnePasswordView(factor: .mockPassword)
+ .environment(\.clerk, .mock)
+ .environment(\.locale, .init(identifier: "es"))
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Components/Auth/SignIn/SignInFactorOneView.swift b/Sources/Clerk/ClerkUI/Components/Auth/SignIn/SignInFactorOneView.swift
new file mode 100644
index 000000000..5d2bf25a4
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Components/Auth/SignIn/SignInFactorOneView.swift
@@ -0,0 +1,47 @@
+//
+// SignInFactorOneView.swift
+// Clerk
+//
+// Created by Mike Pitre on 4/15/25.
+//
+
+#if os(iOS)
+
+import SwiftUI
+
+struct SignInFactorOneView: View {
+ @Environment(\.clerkTheme) private var theme
+
+ let factor: Factor
+
+ @ViewBuilder
+ var viewForFactor: some View {
+ switch factor.strategy {
+ case "passkey":
+ SignInFactorOnePasskeyView(factor: factor)
+ case "password":
+ SignInFactorOnePasswordView(factor: factor)
+ case "email_code",
+ "phone_code",
+ "reset_password_email_code",
+ "reset_password_phone_code":
+ SignInFactorCodeView(factor: factor)
+ default:
+ SignInGetHelpView()
+ }
+ }
+
+ var body: some View {
+ viewForFactor
+ }
+}
+
+#Preview {
+ SignInFactorOneView(
+ factor: .init(
+ strategy: "passkey"
+ )
+ )
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Components/Auth/SignIn/SignInFactorTwoBackupCodeView.swift b/Sources/Clerk/ClerkUI/Components/Auth/SignIn/SignInFactorTwoBackupCodeView.swift
new file mode 100644
index 000000000..ff448f731
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Components/Auth/SignIn/SignInFactorTwoBackupCodeView.swift
@@ -0,0 +1,131 @@
+//
+// SignInFactorTwoBackupCodeView.swift
+// Clerk
+//
+// Created by Mike Pitre on 5/14/25.
+//
+
+#if os(iOS)
+
+import SwiftUI
+
+struct SignInFactorTwoBackupCodeView: View {
+ @Environment(\.clerk) private var clerk
+ @Environment(\.clerkTheme) private var theme
+ @Environment(\.authState) private var authState
+
+ @FocusState private var isFocused: Bool
+ @State private var fieldError: Error?
+
+ var signIn: SignIn? {
+ clerk.client?.signIn
+ }
+
+ let factor: Factor
+
+ var body: some View {
+ @Bindable var authState = authState
+
+ ScrollView {
+ VStack(spacing: 0) {
+ VStack(spacing: 8) {
+ HeaderView(style: .title, text: "Enter a backup code")
+ HeaderView(style: .subtitle, text: "Your backup code is the one you got when setting up two-step authentication.")
+ }
+ .padding(.bottom, 32)
+
+ VStack(spacing: 24) {
+
+ VStack(spacing: 8) {
+ ClerkTextField(
+ "Backup code",
+ text: $authState.signInBackupCode,
+ fieldState: fieldError != nil ? .error : .default
+ )
+ .textInputAutocapitalization(.never)
+ .focused($isFocused)
+ .onFirstAppear {
+ isFocused = true
+ }
+
+ if let fieldError {
+ ErrorText(error: fieldError, alignment: .leading)
+ .font(theme.fonts.subheadline)
+ .transition(.blurReplace.animation(.default.speed(2)))
+ .id(fieldError.localizedDescription)
+ }
+ }
+
+ AsyncButton {
+ await submit()
+ } label: { isRunning in
+ HStack(spacing: 4) {
+ Text("Continue", bundle: .module)
+ Image("icon-triangle-right", bundle: .module)
+ .foregroundStyle(theme.colors.textOnPrimaryBackground)
+ .opacity(0.6)
+ }
+ .frame(maxWidth: .infinity)
+ .overlayProgressView(isActive: isRunning) {
+ SpinnerView(color: theme.colors.textOnPrimaryBackground)
+ }
+ }
+ .buttonStyle(.primary())
+ .disabled(authState.signInBackupCode.isEmpty)
+ .simultaneousGesture(TapGesture())
+ }
+ .padding(.bottom, 16)
+
+ Button {
+ authState.path.append(
+ AuthView.Destination.signInFactorTwoUseAnotherMethod(
+ currentFactor: factor
+ )
+ )
+ } label: {
+ Text("Use another method", bundle: .module)
+ .frame(maxWidth: .infinity)
+ }
+ .padding(.bottom, 32)
+
+ SecuredByClerkView()
+ }
+ .padding(16)
+ }
+ .background(theme.colors.background)
+ .sensoryFeedback(.error, trigger: fieldError?.localizedDescription) {
+ $1 != nil
+ }
+ }
+}
+
+extension SignInFactorTwoBackupCodeView {
+
+ func submit() async {
+ isFocused = false
+
+ do {
+ guard var signIn else {
+ authState.path = []
+ return
+ }
+
+ signIn = try await signIn.attemptSecondFactor(
+ strategy: .backupCode(code: authState.signInBackupCode)
+ )
+
+ fieldError = nil
+ authState.setToStepForStatus(signIn: signIn)
+ } catch {
+ self.fieldError = error
+ }
+ }
+
+}
+
+#Preview {
+ SignInFactorTwoBackupCodeView(factor: .mockBackupCode)
+ .environment(\.clerkTheme, .clerk)
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Components/Auth/SignIn/SignInFactorTwoView.swift b/Sources/Clerk/ClerkUI/Components/Auth/SignIn/SignInFactorTwoView.swift
new file mode 100644
index 000000000..6586f4c75
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Components/Auth/SignIn/SignInFactorTwoView.swift
@@ -0,0 +1,42 @@
+//
+// SwiftUIView.swift
+// Clerk
+//
+// Created by Mike Pitre on 5/13/25.
+//
+
+#if os(iOS)
+
+import SwiftUI
+
+struct SignInFactorTwoView: View {
+ @Environment(\.clerkTheme) private var theme
+
+ let factor: Factor
+
+ @ViewBuilder
+ var viewForFactor: some View {
+ switch factor.strategy {
+ case "totp", "phone_code":
+ SignInFactorCodeView(factor: factor, isSecondFactor: true)
+ case "backup_code":
+ SignInFactorTwoBackupCodeView(factor: factor)
+ default:
+ SignInGetHelpView()
+ }
+ }
+
+ var body: some View {
+ viewForFactor
+ }
+}
+
+#Preview {
+ SignInFactorOneView(
+ factor: .init(
+ strategy: "totp"
+ )
+ )
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Components/Auth/SignIn/SignInGetHelpView.swift b/Sources/Clerk/ClerkUI/Components/Auth/SignIn/SignInGetHelpView.swift
new file mode 100644
index 000000000..5a936ee22
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Components/Auth/SignIn/SignInGetHelpView.swift
@@ -0,0 +1,64 @@
+//
+// SignInGetHelpView.swift
+// Clerk
+//
+// Created by Mike Pitre on 4/21/25.
+//
+
+#if os(iOS)
+
+import SwiftUI
+
+struct SignInGetHelpView: View {
+ @Environment(\.clerk) private var clerk
+ @Environment(\.clerkTheme) private var theme
+ @Environment(\.authState) private var authState
+
+ var body: some View {
+ ScrollView {
+ VStack(spacing: 0) {
+ VStack(spacing: 8) {
+ HeaderView(style: .title, text: "Get help")
+ HeaderView(style: .subtitle, text: "If you have trouble signing into your account, email us and we will work with you to restore access as soon as possible.")
+ }
+ .padding(.bottom, 32)
+
+ VStack(spacing: 16) {
+ Button {
+ openEmail(to: clerk.environment.displayConfig?.supportEmail ?? "")
+ } label: {
+ Text("Email support", bundle: .module)
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.primary())
+ .simultaneousGesture(TapGesture())
+ }
+ .padding(.bottom, 32)
+
+ SecuredByClerkView()
+ }
+ .padding(16)
+ }
+ .background(theme.colors.background)
+ }
+}
+
+extension SignInGetHelpView {
+
+ func openEmail(to: String) {
+ let urlString = "mailto:\(to)"
+
+ if let url = URL(string: urlString) {
+ UIApplication.shared.open(url)
+ }
+ }
+
+}
+
+#Preview {
+ SignInGetHelpView()
+ .environment(\.clerk, .mock)
+ .environment(\.clerkTheme, .clerk)
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Components/Auth/SignIn/SignInSetNewPasswordView.swift b/Sources/Clerk/ClerkUI/Components/Auth/SignIn/SignInSetNewPasswordView.swift
new file mode 100644
index 000000000..bbc60772b
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Components/Auth/SignIn/SignInSetNewPasswordView.swift
@@ -0,0 +1,155 @@
+//
+// SignInResetPasswordView.swift
+// Clerk
+//
+// Created by Mike Pitre on 5/7/25.
+//
+
+#if os(iOS)
+
+import SwiftUI
+
+struct SignInSetNewPasswordView: View {
+ @Environment(\.clerk) private var clerk
+ @Environment(\.clerkTheme) private var theme
+ @Environment(\.authState) private var authState
+
+ @State private var identifier = ""
+ @State private var signOutOfOtherDevices = false
+ @State private var fieldError: Error?
+ @FocusState private var focusedField: Field?
+
+ var signIn: SignIn? {
+ clerk.client?.signIn
+ }
+
+ var resetButtonIsDisabled: Bool {
+ authState.signInNewPassword.isEmptyTrimmed || authState.signInConfirmNewPassword.isEmptyTrimmed || authState.signInNewPassword != authState.signInConfirmNewPassword
+ }
+
+ enum Field {
+ case new, confirm
+ }
+
+ var body: some View {
+ @Bindable var authState = authState
+
+ ScrollView {
+ VStack(spacing: 0) {
+ HeaderView(style: .title, text: "Set new password")
+ .padding(.bottom, 32)
+
+ VStack(spacing: 24) {
+ ClerkTextField(
+ "New password",
+ text: $authState.signInNewPassword,
+ isSecure: true,
+ fieldState: fieldError != nil ? .error : .default
+ )
+ .textContentType(.newPassword)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ .focused($focusedField, equals: .new)
+ .hiddenTextField(text: $identifier, textContentType: .username)
+ .onFirstAppear {
+ focusedField = .new
+
+ // we need to create a local copy of this to keep around because
+ // once the reset flow is complete the sign in identifier gets cleared out
+ identifier = signIn?.identifier ?? ""
+ }
+
+ VStack(spacing: 8) {
+ ClerkTextField(
+ "Confirm password",
+ text: $authState.signInConfirmNewPassword,
+ isSecure: true,
+ fieldState: fieldError != nil ? .error : .default
+ )
+ .textContentType(.newPassword)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ .focused($focusedField, equals: .confirm)
+
+ if let fieldError {
+ ErrorText(error: fieldError, alignment: .leading)
+ .font(theme.fonts.subheadline)
+ .transition(.blurReplace.animation(.default.speed(2)))
+ .id(fieldError.localizedDescription)
+ }
+ }
+
+ Toggle("Sign out of all other devices", isOn: $signOutOfOtherDevices)
+ .font(theme.fonts.body)
+ .foregroundStyle(theme.colors.text)
+ .tint(theme.colors.primary)
+ .padding(.horizontal, 16)
+ .padding(.vertical, 8)
+ .background(theme.colors.backgroundSecondary)
+ .clipShape(.rect(cornerRadius: theme.design.borderRadius))
+
+ AsyncButton {
+ await setNewPassword()
+ } label: { isRunning in
+ HStack(spacing: 4) {
+ Text("Reset password", bundle: .module)
+ }
+ .frame(maxWidth: .infinity)
+ .overlayProgressView(isActive: isRunning) {
+ SpinnerView(color: theme.colors.textOnPrimaryBackground)
+ }
+ }
+ .buttonStyle(.primary())
+ .disabled(resetButtonIsDisabled)
+ .simultaneousGesture(TapGesture())
+ }
+ .padding(.bottom, 32)
+
+ SecuredByClerkView()
+ }
+ .padding(16)
+ }
+ .background(theme.colors.background)
+ .sensoryFeedback(.error, trigger: fieldError?.localizedDescription) {
+ $1 != nil
+ }
+ .navigationBarBackButtonHidden()
+ }
+}
+
+extension SignInSetNewPasswordView {
+
+ func setNewPassword() async {
+ fieldError = nil
+ focusedField = nil
+
+ do {
+ guard authState.signInNewPassword == authState.signInConfirmNewPassword else {
+ throw ClerkClientError(message: "Passwords don't match.")
+ }
+
+ guard var signIn else {
+ authState.path = []
+ return
+ }
+
+ signIn = try await signIn.resetPassword(
+ .init(
+ password: authState.signInNewPassword,
+ signOutOfOtherSessions: signOutOfOtherDevices
+ ))
+
+ authState.setToStepForStatus(signIn: signIn)
+ } catch {
+ fieldError = error
+ }
+ }
+
+}
+
+#Preview {
+ SignInSetNewPasswordView()
+ .environment(\.clerkTheme, .clerk)
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Components/Auth/SignUp/SignUpCodeView.swift b/Sources/Clerk/ClerkUI/Components/Auth/SignUp/SignUpCodeView.swift
new file mode 100644
index 000000000..5ac2d5085
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Components/Auth/SignUp/SignUpCodeView.swift
@@ -0,0 +1,291 @@
+//
+// SignUpCodeView.swift
+// Clerk
+//
+// Created by Mike Pitre on 6/20/25.
+//
+
+#if os(iOS)
+
+import SwiftUI
+
+struct SignUpCodeView: View {
+ @Environment(\.clerk) private var clerk
+ @Environment(\.clerkTheme) private var theme
+ @Environment(\.authState) private var authState
+
+ @State private var code = ""
+ @State private var remainingSeconds: Int = 30
+ @State private var timer: Timer?
+ @State private var verificationState = VerificationState.default
+ @State private var otpFieldState = OTPField.FieldState.default
+ @State private var error: Error?
+
+ @FocusState private var otpFieldIsFocused: Bool
+
+ var signUp: SignUp? {
+ clerk.client?.signUp
+ }
+
+ enum Field: Hashable {
+ case email(String)
+ case phone(String)
+
+ var title: LocalizedStringKey {
+ switch self {
+ case .email:
+ "Check your email"
+ case .phone:
+ "Check your phone"
+ }
+ }
+
+ var identityPreviewString: String {
+ switch self {
+ case .email(let emailAddress):
+ emailAddress
+ case .phone(let phoneNumber):
+ phoneNumber.formattedAsPhoneNumberIfPossible
+ }
+ }
+ }
+
+ enum VerificationState {
+ case `default`
+ case verifying
+ case success
+ case error(Error)
+
+ var showResend: Bool {
+ switch self {
+ case .default, .error:
+ true
+ case .verifying, .success:
+ false
+ }
+ }
+ }
+
+ var resendString: LocalizedStringKey {
+ if remainingSeconds > 0 {
+ "Resend (\(remainingSeconds))"
+ } else {
+ "Resend"
+ }
+ }
+
+ private func lastCodeSentAtKey(_ signUp: SignUp) -> String {
+ signUp.id + field.identityPreviewString
+ }
+
+ let field: Field
+
+ var body: some View {
+ ScrollView {
+ VStack(spacing: 32) {
+ VStack(spacing: 8) {
+ HeaderView(style: .title, text: field.title)
+ Button {
+ authState.path = []
+ } label: {
+ IdentityPreviewView(label: field.identityPreviewString)
+ }
+ .buttonStyle(.secondary(config: .init(size: .small)))
+ .simultaneousGesture(TapGesture())
+ }
+
+ VStack(spacing: 24) {
+ OTPField(code: $code, fieldState: $otpFieldState, isFocused: $otpFieldIsFocused) { code in
+ await attempt()
+ }
+ .onAppear {
+ verificationState = .default
+ otpFieldIsFocused = true
+ }
+
+ Group {
+ switch verificationState {
+ case .verifying:
+ HStack(spacing: 4) {
+ SpinnerView()
+ .frame(width: 16, height: 16)
+ Text("Verifying...", bundle: .module)
+ }
+ .foregroundStyle(theme.colors.textSecondary)
+ case .success:
+ HStack(spacing: 4) {
+ Image("icon-check-circle", bundle: .module)
+ .foregroundStyle(theme.colors.success)
+ Text("Success", bundle: .module)
+ .foregroundStyle(theme.colors.textSecondary)
+ }
+ case .error(let error):
+ ErrorText(error: error)
+ default:
+ EmptyView()
+ }
+ }
+ .font(theme.fonts.subheadline)
+
+ if verificationState.showResend {
+ AsyncButton {
+ await prepare()
+ } label: { isRunning in
+ HStack(spacing: 0) {
+ Text("Didn't recieve a code? ", bundle: .module)
+ Text(resendString, bundle: .module)
+ .foregroundStyle(
+ remainingSeconds > 0
+ ? theme.colors.textSecondary
+ : theme.colors.primary
+ )
+ .monospacedDigit()
+ .contentTransition(.numericText(countsDown: true))
+ .animation(.default, value: remainingSeconds)
+ }
+ .overlayProgressView(isActive: isRunning)
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(
+ .secondary(
+ config: .init(
+ emphasis: .none,
+ size: .small
+ )
+ )
+ )
+ .disabled(remainingSeconds > 0)
+ .simultaneousGesture(TapGesture())
+ }
+ }
+
+ SecuredByClerkView()
+ }
+ .padding(16)
+ }
+ .scrollDismissesKeyboard(.interactively)
+ .toolbar {
+ ToolbarItem(placement: .principal) {
+ Text("Sign up", bundle: .module)
+ .font(theme.fonts.headline)
+ .foregroundStyle(theme.colors.text)
+ }
+ }
+ .navigationBarTitleDisplayMode(.inline)
+ .background(theme.colors.background)
+ .clerkErrorPresenting(
+ $error,
+ action: { error in
+ if let clerkApiError = error as? ClerkAPIError, clerkApiError.code == "verification_already_verified", let signUp {
+ return .init(text: "Continue") {
+ authState.setToStepForStatus(signUp: signUp)
+ }
+ }
+ return nil
+ }
+ )
+ .taskOnce {
+ startTimer()
+ if let signUp, authState.lastCodeSentAt[lastCodeSentAtKey(signUp)] == nil {
+ await prepare()
+ }
+ }
+ }
+}
+
+extension SignUpCodeView {
+
+ func startTimer() {
+ updateRemainingSeconds()
+ self.timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
+ Task { @MainActor in
+ updateRemainingSeconds()
+ }
+ }
+ RunLoop.current.add(timer!, forMode: .common)
+ }
+
+ func updateRemainingSeconds() {
+ guard let signUp, let lastCodeSentAt = authState.lastCodeSentAt[lastCodeSentAtKey(signUp)] else {
+ return
+ }
+
+ let elapsed = Int(Date.now.timeIntervalSince(lastCodeSentAt))
+ remainingSeconds = max(0, 30 - elapsed)
+ }
+
+ func prepare() async {
+ code = ""
+ otpFieldState = .default
+ verificationState = .default
+
+ guard var signUp else {
+ authState.path = []
+ return
+ }
+
+ do {
+ switch field {
+ case .email:
+ signUp = try await signUp.prepareVerification(strategy: .emailCode)
+ case .phone:
+ signUp = try await signUp.prepareVerification(strategy: .phoneCode)
+ }
+
+ authState.lastCodeSentAt[lastCodeSentAtKey(signUp)] = .now
+ updateRemainingSeconds()
+ } catch {
+ otpFieldIsFocused = false
+ self.error = error
+ ClerkLogger.error("Failed to prepare verification for sign up", error: error)
+ }
+ }
+
+ func attempt() async {
+ guard var signUp else {
+ authState.path = []
+ return
+ }
+
+ otpFieldState = .default
+ verificationState = .verifying
+
+ do {
+ switch field {
+ case .email:
+ signUp = try await signUp.attemptVerification(strategy: .emailCode(code: code))
+ case .phone:
+ signUp = try await signUp.attemptVerification(strategy: .phoneCode(code: code))
+ }
+
+ otpFieldIsFocused = false
+ verificationState = .success
+ authState.setToStepForStatus(signUp: signUp)
+ } catch {
+ otpFieldState = .error
+ verificationState = .error(error)
+
+ if let clerkApiError = error as? ClerkAPIError, clerkApiError.meta?["param_name"] == nil {
+ self.error = clerkApiError
+ otpFieldIsFocused = false
+ }
+ }
+ }
+
+}
+
+#Preview("Email") {
+ NavigationStack {
+ SignUpCodeView(field: .email(EmailAddress.mock.emailAddress))
+ }
+ .environment(\.clerkTheme, .clerk)
+}
+
+#Preview("Phone") {
+ NavigationStack {
+ SignUpCodeView(field: .phone(PhoneNumber.mock.phoneNumber))
+ .environment(\.clerkTheme, .clerk)
+ }
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Components/Auth/SignUp/SignUpCollectFieldView.swift b/Sources/Clerk/ClerkUI/Components/Auth/SignUp/SignUpCollectFieldView.swift
new file mode 100644
index 000000000..37b9f14ab
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Components/Auth/SignUp/SignUpCollectFieldView.swift
@@ -0,0 +1,216 @@
+//
+// SignUpCollectFieldView.swift
+// Clerk
+//
+// Created by Mike Pitre on 6/20/25.
+//
+
+#if os(iOS)
+
+import SwiftUI
+
+struct SignUpCollectFieldView: View {
+ @Environment(\.clerk) private var clerk
+ @Environment(\.clerkTheme) private var theme
+ @Environment(\.authState) private var authState
+
+ @State private var error: Error?
+ @State private var usernameForPasswordKeeper = ""
+
+ @FocusState private var isFocused: Bool
+
+ var signUp: SignUp? {
+ clerk.client?.signUp
+ }
+
+ enum Field: String {
+ case emailAddress = "email_address"
+ case phoneNumber = "phone_number"
+ case password = "password"
+ case username = "username"
+ }
+
+ var title: LocalizedStringKey {
+ switch field {
+ case .emailAddress:
+ "Email address"
+ case .phoneNumber:
+ "Phone number"
+ case .password:
+ "Password"
+ case .username:
+ "Username"
+ }
+ }
+
+ var subtitle: LocalizedStringKey {
+ switch field {
+ case .emailAddress:
+ "Enter the email address you'd like to use."
+ case .phoneNumber:
+ "Enter the phone number you'd like to use."
+ case .password:
+ "Create a unique password."
+ case .username:
+ "Choose a username."
+ }
+ }
+
+ @ViewBuilder
+ var textField: some View {
+ @Bindable var authState = authState
+
+ switch field {
+ case .emailAddress:
+ ClerkTextField(
+ "Enter your email",
+ text: $authState.signUpEmailAddress
+ )
+ .textContentType(.emailAddress)
+ .keyboardType(.emailAddress)
+ case .phoneNumber:
+ ClerkPhoneNumberField(
+ "Enter your phone number",
+ text: $authState.signUpPhoneNumber
+ )
+ case .password:
+ ClerkTextField(
+ "Choose your password",
+ text: $authState.signUpPassword,
+ isSecure: true
+ )
+ .textContentType(.newPassword)
+ .hiddenTextField(
+ text: $usernameForPasswordKeeper,
+ textContentType: .username
+ )
+ .onAppear {
+ usernameForPasswordKeeper = signUp?.username ?? signUp?.emailAddress ?? signUp?.phoneNumber ?? ""
+ }
+ case .username:
+ ClerkTextField(
+ "Choose your username",
+ text: $authState.signUpUsername
+ )
+ .textContentType(.username)
+ }
+ }
+
+ var continueIsDisabled: Bool {
+ switch field {
+ case .emailAddress:
+ authState.signUpEmailAddress.isEmptyTrimmed
+ case .phoneNumber:
+ authState.signUpPhoneNumber.isEmptyTrimmed
+ case .password:
+ authState.signUpPassword.isEmptyTrimmed
+ case .username:
+ authState.signUpUsername.isEmptyTrimmed
+ }
+ }
+
+ var fieldIsOptional: Bool {
+ signUp?.fieldIsRequired(field: field.rawValue) == false
+ }
+
+ let field: Field
+
+ var body: some View {
+ ScrollView {
+ VStack(spacing: 32) {
+ VStack(spacing: 8) {
+ HeaderView(style: .title, text: title)
+ HeaderView(style: .subtitle, text: subtitle)
+ }
+
+ VStack(spacing: 24) {
+ textField
+ .autocorrectionDisabled()
+ .textInputAutocapitalization(.never)
+ .focused($isFocused)
+ .onAppear {
+ isFocused = true
+ }
+
+ AsyncButton {
+ await updateSignUp()
+ } label: { isRunning in
+ HStack(spacing: 4) {
+ Text("Continue", bundle: .module)
+ Image("icon-triangle-right", bundle: .module)
+ .foregroundStyle(theme.colors.textOnPrimaryBackground)
+ .opacity(0.6)
+ }
+ .frame(maxWidth: .infinity)
+ .overlayProgressView(isActive: isRunning) {
+ SpinnerView(color: theme.colors.textOnPrimaryBackground)
+ }
+ }
+ .buttonStyle(.primary())
+ .disabled(continueIsDisabled)
+ .simultaneousGesture(TapGesture())
+
+ if fieldIsOptional, let signUp {
+ Button {
+ authState.setToStepForStatus(signUp: signUp)
+ } label: {
+ Text("Skip", bundle: .module)
+ }
+ .buttonStyle(
+ .primary(
+ config: .init(emphasis: .none, size: .small)
+ )
+ )
+ }
+ }
+
+ SecuredByClerkView()
+ }
+ .padding(16)
+ }
+ .scrollDismissesKeyboard(.interactively)
+ .clerkErrorPresenting($error)
+ .background(theme.colors.background)
+ .toolbar {
+ ToolbarItem(placement: .principal) {
+ Text("Sign up", bundle: .module)
+ .font(theme.fonts.headline)
+ .foregroundStyle(theme.colors.text)
+ }
+ }
+ .navigationBarTitleDisplayMode(.inline)
+ }
+}
+
+extension SignUpCollectFieldView {
+
+ func updateSignUp() async {
+ guard var signUp else { return }
+
+ do {
+ switch field {
+ case .emailAddress:
+ signUp = try await signUp.update(params: .init(emailAddress: authState.signUpEmailAddress))
+ case .phoneNumber:
+ signUp = try await signUp.update(params: .init(phoneNumber: authState.signUpPhoneNumber))
+ case .password:
+ signUp = try await signUp.update(params: .init(password: authState.signUpPassword))
+ case .username:
+ signUp = try await signUp.update(params: .init(username: authState.signUpUsername))
+ }
+
+ authState.setToStepForStatus(signUp: signUp)
+ } catch {
+ self.error = error
+ ClerkLogger.error("Failed to update sign up with field data", error: error)
+ }
+ }
+
+}
+
+#Preview {
+ SignUpCollectFieldView(field: .password)
+ .environment(\.clerkTheme, .clerk)
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Components/Auth/SignUp/SignUpCompleteProfileView.swift b/Sources/Clerk/ClerkUI/Components/Auth/SignUp/SignUpCompleteProfileView.swift
new file mode 100644
index 000000000..298dbc3f6
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Components/Auth/SignUp/SignUpCompleteProfileView.swift
@@ -0,0 +1,210 @@
+//
+// SignUpCompleteProfileView.swift
+// Clerk
+//
+// Created by Mike Pitre on 6/23/25.
+//
+
+#if os(iOS)
+
+import SwiftUI
+
+struct SignUpCompleteProfileView: View {
+ @Environment(\.clerk) private var clerk
+ @Environment(\.clerkTheme) private var theme
+ @Environment(\.authState) private var authState
+
+ @State private var error: Error?
+
+ @FocusState private var focused: Field?
+
+ enum Field: CaseIterable {
+ case firstName
+ case lastName
+ }
+
+ func fieldIsEnabled(_ field: Field) -> Bool {
+ switch field {
+ case .firstName:
+ clerk.environment.firstNameIsEnabled
+ case .lastName:
+ clerk.environment.lastNameIsEnabled
+ }
+ }
+
+ // Get the text for each field
+ func textForField(_ field: Field) -> String {
+ switch field {
+ case .firstName:
+ return authState.signUpFirstName
+ case .lastName:
+ return authState.signUpLastName
+ }
+ }
+
+ // Find the first enabled field that's empty
+ func firstEmptyEnabledField() -> Field? {
+ return Field.allCases.first { field in
+ fieldIsEnabled(field) && textForField(field).isEmptyTrimmed
+ }
+ }
+
+ // Get the first enabled field (fallback)
+ func firstEnabledField() -> Field? {
+ return Field.allCases.first { fieldIsEnabled($0) }
+ }
+
+ // Get the last enabled field
+ func lastEnabledField() -> Field? {
+ return Field.allCases.last { fieldIsEnabled($0) }
+ }
+
+ // Determine the submit label for each field
+ func submitLabelFor(_ field: Field) -> SubmitLabel {
+ return field == lastEnabledField() ? .done : .next
+ }
+
+ func nextEnabledField(after currentField: Field) -> Field? {
+ guard let currentIndex = Field.allCases.firstIndex(of: currentField) else {
+ return nil
+ }
+
+ let fieldsAfterCurrent = Field.allCases.dropFirst(currentIndex + 1)
+ return fieldsAfterCurrent.first { fieldIsEnabled($0) }
+ }
+
+ func handleReturnKey() {
+ guard let currentField = focused else { return }
+
+ if let nextField = nextEnabledField(after: currentField) {
+ focused = nextField
+ } else {
+ focused = nil
+ }
+ }
+
+ // Dynamically update focus when text changes
+ func updateFocusIfNeeded() {
+ // Only update focus if we're not currently focused on a field
+ // or if the current field is now filled and there's an empty field to focus on
+ if focused == nil, let firstEmpty = firstEmptyEnabledField() {
+ focused = firstEmpty
+ }
+ }
+
+ var signUp: SignUp? {
+ clerk.client?.signUp
+ }
+
+ var firstOrLastNameIsEnabled: Bool {
+ fieldIsEnabled(.firstName) || fieldIsEnabled(.lastName)
+ }
+
+ var continueIsDisabled: Bool {
+ authState.signUpFirstName.isEmptyTrimmed || authState.signUpLastName.isEmptyTrimmed
+ }
+
+ var body: some View {
+ @Bindable var authState = authState
+
+ ScrollView {
+ VStack(spacing: 32) {
+ VStack(spacing: 8) {
+ HeaderView(style: .title, text: "Profile Details")
+ HeaderView(style: .subtitle, text: "Complete your profile")
+ }
+
+ VStack(spacing: 24) {
+ if firstOrLastNameIsEnabled {
+ HStack(spacing: 24) {
+ if fieldIsEnabled(.firstName) {
+ ClerkTextField("First name", text: $authState.signUpFirstName)
+ .textContentType(.givenName)
+ .focused($focused, equals: .firstName)
+ .submitLabel(submitLabelFor(.firstName))
+ .onChange(of: authState.signUpFirstName) {
+ updateFocusIfNeeded()
+ }
+ }
+ if fieldIsEnabled(.lastName) {
+ ClerkTextField("Last name", text: $authState.signUpLastName)
+ .textContentType(.familyName)
+ .focused($focused, equals: .lastName)
+ .submitLabel(submitLabelFor(.lastName))
+ .onChange(of: authState.signUpLastName) {
+ updateFocusIfNeeded()
+ }
+ }
+ }
+ .autocorrectionDisabled()
+ .onSubmit { handleReturnKey() }
+ }
+
+ AsyncButton {
+ await updateSignUp()
+ } label: { isRunning in
+ HStack(spacing: 4) {
+ Text("Continue", bundle: .module)
+ Image("icon-triangle-right", bundle: .module)
+ .foregroundStyle(theme.colors.textOnPrimaryBackground)
+ .opacity(0.6)
+ }
+ .frame(maxWidth: .infinity)
+ .overlayProgressView(isActive: isRunning) {
+ SpinnerView(color: theme.colors.textOnPrimaryBackground)
+ }
+ }
+ .buttonStyle(.primary())
+ .disabled(continueIsDisabled)
+ .simultaneousGesture(TapGesture())
+ }
+
+ SecuredByClerkView()
+ }
+ .padding(16)
+ }
+ .scrollDismissesKeyboard(.interactively)
+ .clerkErrorPresenting($error)
+ .background(theme.colors.background)
+ .toolbar {
+ ToolbarItem(placement: .principal) {
+ Text("Sign up", bundle: .module)
+ .font(theme.fonts.headline)
+ .foregroundStyle(theme.colors.text)
+ }
+ }
+ .navigationBarTitleDisplayMode(.inline)
+ .onFirstAppear {
+ focused = firstEmptyEnabledField() ?? firstEnabledField()
+ }
+ }
+}
+
+extension SignUpCompleteProfileView {
+
+ func updateSignUp() async {
+ guard var signUp else { return }
+
+ do {
+ signUp = try await signUp.update(
+ params: .init(
+ firstName: authState.signUpFirstName,
+ lastName: authState.signUpLastName
+ )
+ )
+ authState.setToStepForStatus(signUp: signUp)
+ } catch {
+ self.error = error
+ ClerkLogger.error("Failed to update sign up with profile data", error: error)
+ }
+ }
+
+}
+
+#Preview {
+ SignUpCompleteProfileView()
+ .environment(\.clerk, .mock)
+ .environment(\.clerkTheme, .clerk)
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Components/UserButton/UserButton.swift b/Sources/Clerk/ClerkUI/Components/UserButton/UserButton.swift
new file mode 100644
index 000000000..c4361ea84
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Components/UserButton/UserButton.swift
@@ -0,0 +1,112 @@
+//
+// UserButton.swift
+// Clerk
+//
+// Created by Mike Pitre on 5/1/25.
+//
+
+#if os(iOS)
+
+import Kingfisher
+import SwiftUI
+
+/// A circular button that displays the current user's profile image and opens the user profile when tapped.
+///
+/// `UserButton` automatically displays the signed-in user's profile image in a circular button.
+/// When tapped, it presents a sheet with the full user profile view. The button only appears
+/// when a user is signed in.
+///
+/// ## Usage
+///
+/// Basic usage with authentication state handling:
+///
+/// ```swift
+/// struct HomeView: View {
+/// @Environment(\.clerk) private var clerk
+/// @State private var authIsPresented = false
+///
+/// var body: some View {
+/// ZStack {
+/// Group {
+/// if clerk.user != nil {
+/// UserButton()
+/// .frame(width: 36, height: 36)
+/// } else {
+/// Button("Sign in") {
+/// authIsPresented = true
+/// }
+/// }
+/// }
+/// }
+/// .sheet(isPresented: $authIsPresented) {
+/// AuthView()
+/// }
+/// }
+/// }
+/// ```
+///
+/// In a navigation toolbar:
+///
+/// ```swift
+/// .toolbar {
+/// ToolbarItem(placement: .navigationBarTrailing) {
+/// if clerk.user != nil {
+/// UserButton()
+/// .frame(width: 36, height: 36)
+/// }
+/// }
+/// }
+/// ```
+public struct UserButton: View {
+ @Environment(\.clerk) private var clerk
+ @Environment(\.clerkTheme) private var theme
+
+ @State private var userProfileIsPresented: Bool = false
+
+ /// Creates a new user button.
+ ///
+ /// The button will automatically display the current user's profile image
+ /// and handle presenting the user profile sheet when tapped.
+ public init() {}
+
+ public var body: some View {
+ ZStack {
+ if let user = clerk.user {
+ Button {
+ userProfileIsPresented = true
+ } label: {
+ KFImage(URL(string: user.imageUrl))
+ .placeholder {
+ Image("icon-profile", bundle: .module)
+ .resizable()
+ .scaledToFit()
+ .foregroundStyle(theme.colors.primary.gradient)
+ .opacity(0.5)
+ }
+ .resizable()
+ .fade(duration: 0.2)
+ .scaledToFill()
+ .clipShape(.circle)
+ }
+ }
+ }
+ .sheet(isPresented: $userProfileIsPresented) {
+ UserProfileView()
+ .presentationDragIndicator(.visible)
+ }
+ .onChange(of: clerk.user) { _, newValue in
+ if newValue == nil {
+ userProfileIsPresented = false
+ }
+ }
+ }
+}
+
+#Preview {
+ UserButton()
+ .frame(width: 36, height: 36)
+ .environment(\.clerk, .mock)
+ .environment(\.clerkTheme, .clerk)
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Components/UserButton/UserButtonAccountSwitcher.swift b/Sources/Clerk/ClerkUI/Components/UserButton/UserButtonAccountSwitcher.swift
new file mode 100644
index 000000000..43bb013d3
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Components/UserButton/UserButtonAccountSwitcher.swift
@@ -0,0 +1,177 @@
+//
+// UserButtonAccountSwitcher.swift
+// Clerk
+//
+// Created by Mike Pitre on 5/6/25.
+//
+
+#if os(iOS)
+
+import SwiftUI
+
+struct UserButtonAccountSwitcher: View {
+ @Environment(\.clerk) private var clerk
+ @Environment(\.clerkTheme) private var theme
+ @Environment(\.userProfileSharedState) private var sharedState
+ @Environment(\.dismiss) private var dismiss
+
+ @Binding private var contentHeight: CGFloat
+ @State private var securedByClerkHeight: CGFloat = 0
+ @State private var error: Error?
+
+ var sessions: [Session] {
+ (clerk.client?.sessions ?? [])
+ .sorted { lhs, rhs in
+ if lhs.id == clerk.session?.id {
+ return true
+ } else if rhs.id == clerk.session?.id {
+ return false
+ } else {
+ return false
+ }
+ }
+ }
+
+ func setActiveSession(_ session: Session) async {
+ do {
+ try await clerk.setActive(sessionId: session.id)
+ dismiss()
+ } catch {
+ self.error = error
+ ClerkLogger.error("Failed to set active session", error: error)
+ }
+ }
+
+ func signOutOfAllAccounts() async {
+ do {
+ try await clerk.signOut()
+ } catch {
+ self.error = error
+ ClerkLogger.error("Failed to sign out of all accounts", error: error)
+ }
+ }
+
+ var extraContentHeight: CGFloat {
+ if #available(iOS 26.0, *) {
+ return 0
+ } else {
+ return 7
+ }
+ }
+
+ init(contentHeight: Binding = .constant(0)) {
+ self._contentHeight = contentHeight
+ }
+
+ var body: some View {
+ NavigationStack {
+ VStack(spacing: 0) {
+ ScrollView {
+ VStack(spacing: 0) {
+ ForEach(sessions) { session in
+ if let user = session.user {
+ AsyncButton {
+ await setActiveSession(session)
+ } label: { isRunning in
+ HStack {
+ UserPreviewView(user: user)
+ Spacer()
+ if clerk.session?.id == session.id {
+ Image("icon-check", bundle: .module)
+ .resizable()
+ .scaledToFit()
+ .frame(width: 20, height: 20)
+ .foregroundStyle(theme.colors.primary)
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.vertical, 16)
+ .padding(.horizontal, 24)
+ .frame(maxWidth: .infinity)
+ .contentShape(.rect)
+ .overlayProgressView(isActive: isRunning)
+ }
+ .overlay(alignment: .bottom) {
+ Rectangle()
+ .frame(height: 1)
+ .foregroundStyle(theme.colors.border)
+ }
+ .buttonStyle(.pressedBackground)
+ .disabled(clerk.session?.id == session.id)
+ .simultaneousGesture(TapGesture())
+ }
+ }
+
+ Button {
+ sharedState.accountSwitcherIsPresented = false
+ sharedState.authViewIsPresented = true
+ } label: {
+ UserProfileRowView(icon: "icon-plus", text: "Add account")
+ }
+ .overlay(alignment: .bottom) {
+ Rectangle()
+ .frame(height: 1)
+ .foregroundStyle(theme.colors.border)
+ }
+ .buttonStyle(.pressedBackground)
+ .simultaneousGesture(TapGesture())
+
+ AsyncButton {
+ await signOutOfAllAccounts()
+ } label: { isRunning in
+ UserProfileRowView(icon: "icon-sign-out", text: "Sign out of all accounts")
+ .overlayProgressView(isActive: isRunning)
+ }
+ .overlay(alignment: .bottom) {
+ Rectangle()
+ .frame(height: 1)
+ .foregroundStyle(theme.colors.border)
+ }
+ .buttonStyle(.pressedBackground)
+ .simultaneousGesture(TapGesture())
+ }
+ .onGeometryChange(
+ for: CGFloat.self,
+ of: { proxy in
+ proxy.size.height
+ },
+ action: { newValue in
+ contentHeight = newValue + UITabBarController().tabBar.frame.size.height + extraContentHeight
+ })
+ }
+ .scrollBounceBehavior(.basedOnSize)
+ }
+ .animation(.default, value: sessions)
+ .clerkErrorPresenting($error)
+ .navigationBarTitleDisplayMode(.inline)
+ .preGlassSolidNavBar()
+ .preGlassDetentSheetBackground()
+ .toolbar {
+ ToolbarItem(placement: .topBarTrailing) {
+ Button {
+ dismiss()
+ } label: {
+ Text("Done", bundle: .module)
+ .font(theme.fonts.body)
+ .fontWeight(.semibold)
+ .foregroundStyle(theme.colors.primary)
+ }
+ }
+
+ ToolbarItem(placement: .principal) {
+ Text("Switch account", bundle: .module)
+ .font(theme.fonts.headline)
+ .foregroundStyle(theme.colors.text)
+ }
+ }
+ }
+ }
+}
+
+#Preview {
+ UserButtonAccountSwitcher()
+ .environment(\.clerk, .mock)
+ .environment(\.clerkTheme, .clerk)
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Components/UserButton/UserButtonPopover.swift b/Sources/Clerk/ClerkUI/Components/UserButton/UserButtonPopover.swift
new file mode 100644
index 000000000..6523f1b36
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Components/UserButton/UserButtonPopover.swift
@@ -0,0 +1,139 @@
+//
+// UserButtonPopover.swift
+// Clerk
+//
+// Created by Mike Pitre on 5/1/25.
+//
+
+#if os(iOS)
+
+import FactoryKit
+import SwiftUI
+
+struct UserButtonPopover: View {
+ @Environment(\.clerk) private var clerk
+ @Environment(\.clerkTheme) private var theme
+ @Environment(\.dismiss) private var dismiss
+
+ @State private var accountSwitcherIsPresented = false
+ @State private var error: Error?
+
+ var sessions: [Session] {
+ clerk.client?.activeSessions ?? []
+ }
+
+ var body: some View {
+ VStack(spacing: 0) {
+ HStack {
+ Text("Account", bundle: .module)
+ .font(theme.fonts.title3)
+ .fontWeight(.semibold)
+ .foregroundStyle(theme.colors.text)
+ .frame(minHeight: 25)
+ Spacer()
+ DismissButton()
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 12)
+
+ Rectangle()
+ .frame(height: 1)
+ .foregroundStyle(theme.colors.border)
+
+ ScrollView {
+ VStack(spacing: 0) {
+ if let user = clerk.user {
+ UserPreviewView(user: user)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.vertical, 16)
+ .padding(.horizontal, 24)
+ .overlay(alignment: .bottom) {
+ Rectangle()
+ .frame(height: 1)
+ .foregroundStyle(theme.colors.border)
+ }
+ }
+
+ Button {
+ //
+ } label: {
+ UserProfileRowView(icon: "icon-cog", text: "Manage account")
+ }
+ .overlay(alignment: .bottom) {
+ Rectangle()
+ .frame(height: 1)
+ .foregroundStyle(theme.colors.border)
+ }
+ .buttonStyle(.pressedBackground)
+ .simultaneousGesture(TapGesture())
+
+ if clerk.environment.mutliSessionModeIsEnabled {
+ Button {
+ accountSwitcherIsPresented = true
+ } label: {
+ UserProfileRowView(icon: "icon-switch", text: "Switch account")
+ }
+ .overlay(alignment: .bottom) {
+ Rectangle()
+ .frame(height: 1)
+ .foregroundStyle(theme.colors.border)
+ }
+ .buttonStyle(.pressedBackground)
+ .simultaneousGesture(TapGesture())
+ }
+
+ AsyncButton {
+ guard let sessionId = clerk.session?.id else { return }
+ await signOut(sessionId: sessionId)
+ } label: { isRunning in
+ UserProfileRowView(icon: "icon-sign-out", text: "Sign out")
+ .overlayProgressView(isActive: isRunning)
+ }
+ .overlay(alignment: .bottom) {
+ Rectangle()
+ .frame(height: 1)
+ .foregroundStyle(theme.colors.border)
+ }
+ .buttonStyle(.pressedBackground)
+ .simultaneousGesture(TapGesture())
+ }
+ }
+
+ SecuredByClerkView()
+ .padding(16)
+ .frame(maxWidth: .infinity)
+ .background(theme.colors.backgroundSecondary)
+ }
+ .preGlassSolidNavBar()
+ .preGlassDetentSheetBackground()
+ .clerkErrorPresenting($error)
+ .sheet(isPresented: $accountSwitcherIsPresented) {
+ UserButtonAccountSwitcher()
+ .presentationDetents([.medium, .large])
+ .presentationDragIndicator(.hidden)
+ }
+ }
+}
+
+extension UserButtonPopover {
+
+ func signOut(sessionId: String) async {
+ do {
+ try await clerk.signOut(sessionId: sessionId)
+ if clerk.session == nil {
+ dismiss()
+ }
+ } catch {
+ self.error = error
+ ClerkLogger.error("Failed to sign out from popover", error: error)
+ }
+ }
+
+}
+
+#Preview {
+ UserButtonPopover()
+ .environment(\.clerk, .mock)
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Components/UserButton/UserPreviewView.swift b/Sources/Clerk/ClerkUI/Components/UserButton/UserPreviewView.swift
new file mode 100644
index 000000000..503df163f
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Components/UserButton/UserPreviewView.swift
@@ -0,0 +1,59 @@
+//
+// UserPreviewView.swift
+// Clerk
+//
+// Created by Mike Pitre on 5/1/25.
+//
+
+#if os(iOS)
+
+import Kingfisher
+import SwiftUI
+
+struct UserPreviewView: View {
+ @Environment(\.clerkTheme) private var theme
+
+ let user: User
+
+ var body: some View {
+ HStack(spacing: 16) {
+ KFImage(URL(string: user.imageUrl))
+ .placeholder { Rectangle().fill(theme.colors.primary.gradient) }
+ .resizable()
+ .fade(duration: 0.2)
+ .scaledToFill()
+ .clipShape(.circle)
+ .frame(width: 48, height: 48)
+
+ VStack(alignment: .leading, spacing: 4) {
+ if let fullName = user.fullName {
+ Text(fullName)
+ .font(theme.fonts.body)
+ .foregroundStyle(theme.colors.text)
+ .frame(minHeight: 22)
+ }
+
+ if let identifier = user.identifier {
+ Text(identifier)
+ .font(
+ user.fullName == nil
+ ? theme.fonts.body
+ : theme.fonts.subheadline
+ )
+ .foregroundStyle(
+ user.fullName == nil
+ ? theme.colors.text
+ : theme.colors.textSecondary
+ )
+ }
+ }
+ }
+ }
+}
+
+#Preview {
+ UserPreviewView(user: .mock)
+ .environment(\.clerkTheme, .clerk)
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Components/UserButton/UserProfileRowView.swift b/Sources/Clerk/ClerkUI/Components/UserButton/UserProfileRowView.swift
new file mode 100644
index 000000000..4c11bdbc8
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Components/UserButton/UserProfileRowView.swift
@@ -0,0 +1,42 @@
+//
+// UserProfileRowView.swift
+// Clerk
+//
+// Created by Mike Pitre on 5/6/25.
+//
+
+#if os(iOS)
+
+import SwiftUI
+
+struct UserProfileRowView: View {
+ @Environment(\.clerkTheme) private var theme
+
+ let icon: String
+ let text: LocalizedStringKey
+
+ var body: some View {
+ HStack(spacing: 16) {
+ Image(icon, bundle: .module)
+ .resizable()
+ .scaledToFit()
+ .frame(width: 48, height: 24)
+ .foregroundStyle(theme.colors.textSecondary)
+ Text(text, bundle: .module)
+ .font(theme.fonts.body)
+ .fontWeight(.semibold)
+ .foregroundStyle(theme.colors.text)
+ .frame(minHeight: 22)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.vertical, 16)
+ .padding(.horizontal, 24)
+ .contentShape(.rect)
+ }
+}
+
+#Preview {
+ UserProfileRowView(icon: "icon-switch", text: "Switch account")
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Components/UserProfile/BackupCodesView.swift b/Sources/Clerk/ClerkUI/Components/UserProfile/BackupCodesView.swift
new file mode 100644
index 000000000..5e422b6e2
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Components/UserProfile/BackupCodesView.swift
@@ -0,0 +1,138 @@
+//
+// BackupCodesView.swift
+// Clerk
+//
+// Created by Mike Pitre on 6/6/25.
+//
+
+#if os(iOS)
+
+import SwiftUI
+
+struct BackupCodesView: View {
+ @Environment(\.clerkTheme) private var theme
+ @Environment(\.userProfileSharedState) private var sharedState
+ @Environment(\.dismiss) private var dismiss
+
+ enum MfaType {
+ case phoneCode
+ case authenticatorApp
+ case backupCodes
+
+ var instructions: LocalizedStringKey {
+ switch self {
+ case .phoneCode:
+ return "When signing in, you will need to enter a verification code sent to this phone number as an additional step.\n\nSave these backup codes and store them somewhere safe. If you lose access to your authentication device, you can use backup codes to sign in."
+ case .authenticatorApp:
+ return "Two-step verification is now enabled. When signing in, you will need to enter a verification code from this authenticator app as an additional step.\n\nSave these backup codes and store them somewhere safe. If you lose access to your authentication device, you can use backup codes to sign in."
+ case .backupCodes:
+ return "Backup codes are now enabled. You can use one of these to sign in to your account, if you lose access to your authentication device. Each code can only be used once."
+ }
+ }
+ }
+
+ let backupCodes: [String]
+ var mfaType: MfaType = .backupCodes
+
+ var body: some View {
+ ScrollView {
+ VStack(spacing: 24) {
+ Text(mfaType.instructions, bundle: .module)
+ .font(theme.fonts.subheadline)
+ .foregroundStyle(theme.colors.textSecondary)
+ BackupCodesGrid(backupCodes: backupCodes)
+ Button {
+ copyToClipboard(backupCodes.joined(separator: ", "))
+ } label: {
+ HStack(spacing: 6) {
+ Image("icon-clipboard", bundle: .module)
+ .foregroundStyle(theme.colors.textSecondary)
+ Text("Copy to clipboard", bundle: .module)
+ }
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.secondary())
+ }
+ .padding(24)
+ }
+ .presentationBackground(theme.colors.background)
+ .background(theme.colors.background)
+ .navigationBarTitleDisplayMode(.inline)
+ .navigationBarBackButtonHidden()
+ .preGlassSolidNavBar()
+ .toolbar {
+ ToolbarItem(placement: .topBarTrailing) {
+ Button {
+ switch mfaType {
+ case .phoneCode, .authenticatorApp:
+ sharedState.presentedAddMfaType = nil
+ case .backupCodes:
+ dismiss()
+ }
+ } label: {
+ Text("Done", bundle: .module)
+ .font(theme.fonts.body)
+ .fontWeight(.semibold)
+ .foregroundStyle(theme.colors.primary)
+ }
+ }
+
+ ToolbarItem(placement: .principal) {
+ Text("Backup codes", bundle: .module)
+ .font(theme.fonts.headline)
+ .foregroundStyle(theme.colors.text)
+ }
+ }
+ }
+}
+
+private func copyToClipboard(_ text: String) {
+ #if os(iOS)
+ UIPasteboard.general.string = text
+ #elseif os(macOS)
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(text, forType: .string)
+ #endif
+}
+
+struct BackupCodesGrid: View {
+ @Environment(\.clerkTheme) private var theme
+
+ let backupCodes: [String]
+
+ private let columns = [
+ GridItem(.flexible(), spacing: 10),
+ GridItem(.flexible(), spacing: 10)
+ ]
+
+ var body: some View {
+ VStack(spacing: 20) {
+ Text("Backup codes", bundle: .module)
+ .font(theme.fonts.caption)
+ .foregroundStyle(theme.colors.textSecondary)
+ LazyVGrid(columns: columns, spacing: 24) {
+ ForEach(backupCodes, id: \.self) { code in
+ Text(code)
+ .font(theme.fonts.body)
+ .foregroundStyle(theme.colors.text)
+ }
+ }
+ }
+ .padding(.top, 6)
+ .padding(.bottom, 20)
+ .padding(.horizontal, 16)
+ .overlay {
+ RoundedRectangle(cornerRadius: theme.design.borderRadius)
+ .strokeBorder(theme.colors.border, lineWidth: 1)
+ }
+ }
+}
+
+#Preview {
+ BackupCodesView(
+ backupCodes: ["abc", "def", "ghi", "jkl", "lmn", "opq", "rst", "uvw", "xyz"],
+ mfaType: .authenticatorApp
+ )
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileAddConnectedAccountView.swift b/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileAddConnectedAccountView.swift
new file mode 100644
index 000000000..0c51f4302
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileAddConnectedAccountView.swift
@@ -0,0 +1,123 @@
+//
+// UserProfileAddConnectedAccountView.swift
+// Clerk
+//
+// Created by Mike Pitre on 5/28/25.
+//
+
+#if os(iOS)
+
+import FactoryKit
+import SwiftUI
+
+struct UserProfileAddConnectedAccountView: View {
+ @Environment(\.clerk) private var clerk
+ @Environment(\.clerkTheme) private var theme
+ @Environment(\.dismiss) private var dismiss
+
+ @Binding private var contentHeight: CGFloat
+ @State private var error: Error?
+
+ private var user: User? {
+ clerk.user
+ }
+
+ private var unconnectedProviders: [OAuthProvider] {
+ user?.unconnectedProviders ?? []
+ }
+
+ var extraContentHeight: CGFloat {
+ if #available(iOS 26.0, *) {
+ return 0
+ } else {
+ return 7
+ }
+ }
+
+ init(contentHeight: Binding = .constant(0)) {
+ self._contentHeight = contentHeight
+ }
+
+ var body: some View {
+ NavigationStack {
+ ScrollView {
+ VStack(spacing: 24) {
+ Text("Link another login option to your account. You’ll need to verify it before it can be used.", bundle: .module)
+ .font(theme.fonts.subheadline)
+ .foregroundStyle(theme.colors.textSecondary)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .fixedSize(horizontal: false, vertical: true)
+
+ SocialButtonLayout {
+ ForEach(unconnectedProviders) { provider in
+ SocialButton(provider: provider) {
+ await connectExternalAccount(provider: provider)
+ }
+ }
+ }
+ }
+ .padding(24)
+ .clerkErrorPresenting($error)
+ .navigationBarTitleDisplayMode(.inline)
+ .preGlassSolidNavBar()
+ .preGlassDetentSheetBackground()
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") {
+ dismiss()
+ }
+ .foregroundStyle(theme.colors.primary)
+ }
+
+ ToolbarItem(placement: .principal) {
+ Text("Connect account", bundle: .module)
+ .font(theme.fonts.headline)
+ .foregroundStyle(theme.colors.text)
+ }
+ }
+ .onGeometryChange(for: CGFloat.self) { proxy in
+ proxy.size.height
+ } action: { newValue in
+ contentHeight = newValue + UITabBarController().tabBar.frame.size.height + extraContentHeight
+ }
+ }
+ .scrollBounceBehavior(.basedOnSize)
+ }
+ }
+}
+
+extension UserProfileAddConnectedAccountView {
+
+ func connectExternalAccount(provider: OAuthProvider) async {
+ guard let user else { return }
+
+ do {
+ if provider == .apple {
+ let credential = try await SignInWithAppleHelper.getAppleIdCredential()
+ try await user.createExternalAccount(provider: .apple, idToken: credential.tokenString)
+ } else {
+ let newExternalAccount = try await user.createExternalAccount(provider: provider)
+ try await newExternalAccount.reauthorize()
+ }
+
+ dismiss()
+ } catch {
+ if error.isUserCancelledError { return }
+ self.error = error
+ ClerkLogger.error("Failed to connect external account", error: error)
+ }
+ }
+
+}
+
+#Preview {
+ Container.shared.clerk.preview { @MainActor in
+ .mock
+ }
+
+ UserProfileAddConnectedAccountView(contentHeight: .constant(300))
+ .environment(\.clerk, .mock)
+ .environment(\.clerkTheme, .clerk)
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileAddEmailView.swift b/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileAddEmailView.swift
new file mode 100644
index 000000000..7e2cd80e6
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileAddEmailView.swift
@@ -0,0 +1,144 @@
+//
+// UserProfileAddEmailView.swift
+// Clerk
+//
+// Created by Mike Pitre on 5/16/25.
+//
+
+#if os(iOS)
+
+import SwiftUI
+
+struct UserProfileAddEmailView: View {
+ @Environment(\.clerk) private var clerk
+ @Environment(\.clerkTheme) private var theme
+ @Environment(\.dismiss) private var dismiss
+
+ @State private var path = NavigationPath()
+ @State private var email = ""
+ @State private var error: Error?
+ @FocusState private var isFocused: Bool
+
+ enum Destination: Hashable, Identifiable {
+ case add // should never be added to the path
+ case verify(EmailAddress)
+
+ var id: Self { self }
+ }
+
+ var user: User? {
+ clerk.user
+ }
+
+ init(desintation: Destination? = nil) {
+ if case .verify(let email) = desintation {
+ var path = NavigationPath()
+ path.append(Destination.verify(email))
+ _path = State(initialValue: path)
+ }
+ }
+
+ var body: some View {
+ NavigationStack(path: $path) {
+ ScrollView {
+ VStack(spacing: 24) {
+ Text("You'll need to verify this email address before it can be added to your account.", bundle: .module)
+ .font(theme.fonts.subheadline)
+ .foregroundStyle(theme.colors.textSecondary)
+ .fixedSize(horizontal: false, vertical: true)
+
+ VStack(spacing: 4) {
+ ClerkTextField("Enter your email", text: $email)
+ .textContentType(.emailAddress)
+ .keyboardType(.emailAddress)
+ .autocorrectionDisabled()
+ .textInputAutocapitalization(.never)
+ .focused($isFocused)
+ .onFirstAppear {
+ isFocused = true
+ }
+
+ if let error {
+ ErrorText(error: error, alignment: .leading)
+ .font(theme.fonts.subheadline)
+ .transition(.blurReplace.animation(.default))
+ .id(error.localizedDescription)
+ }
+ }
+
+ AsyncButton {
+ await addEmailAddress()
+ } label: { isRunning in
+ HStack {
+ Text("Continue", bundle: .module)
+ Image("icon-triangle-right", bundle: .module)
+ .foregroundStyle(theme.colors.textOnPrimaryBackground)
+ .opacity(0.6)
+ }
+ .frame(maxWidth: .infinity)
+ .overlayProgressView(isActive: isRunning) {
+ SpinnerView(color: theme.colors.textOnPrimaryBackground)
+ }
+ }
+ .buttonStyle(.primary())
+ }
+ .padding(24)
+ }
+ .presentationBackground(theme.colors.background)
+ .navigationBarTitleDisplayMode(.inline)
+ .preGlassSolidNavBar()
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") {
+ dismiss()
+ }
+ .foregroundStyle(theme.colors.primary)
+ }
+
+ ToolbarItem(placement: .principal) {
+ Text("Add email address", bundle: .module)
+ .font(theme.fonts.headline)
+ .foregroundStyle(theme.colors.text)
+ }
+ }
+ .navigationDestination(for: Destination.self) {
+ switch $0 {
+ case .verify(let email):
+ UserProfileVerifyView(
+ mode: .email(email)
+ ) { _ in
+ dismiss()
+ } customDismiss: {
+ dismiss()
+ }
+ case .add:
+ EmptyView() // should never be hit, .add should never be added to path
+ .task { dismiss() }
+ }
+ }
+ }
+ }
+}
+
+extension UserProfileAddEmailView {
+
+ func addEmailAddress() async {
+ guard let user else { return }
+
+ do {
+ let emailAddress = try await user.createEmailAddress(email)
+ path.append(Destination.verify(emailAddress))
+ } catch {
+ self.error = error
+ ClerkLogger.error("Failed to add email address", error: error)
+ }
+ }
+
+}
+
+#Preview {
+ UserProfileAddEmailView()
+ .environment(\.clerkTheme, .clerk)
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileAddMfaView.swift b/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileAddMfaView.swift
new file mode 100644
index 000000000..4675ef5f2
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileAddMfaView.swift
@@ -0,0 +1,162 @@
+//
+// UserProfileAddMfaView.swift
+// Clerk
+//
+// Created by Mike Pitre on 6/3/25.
+//
+
+#if os(iOS)
+
+import FactoryKit
+import SwiftUI
+
+struct UserProfileAddMfaView: View {
+ @Environment(\.clerk) private var clerk
+ @Environment(\.clerkTheme) private var theme
+ @Environment(\.userProfileSharedState) private var sharedState
+ @Environment(\.dismiss) private var dismiss
+
+ @State private var error: Error?
+
+ @Binding private var contentHeight: CGFloat
+
+ enum PresentedView: Identifiable, Hashable {
+ case sms
+ case authApp(TOTPResource)
+ var id: Self { self }
+
+ @MainActor
+ @ViewBuilder
+ var view: some View {
+ switch self {
+ case .sms:
+ UserProfileMfaAddSmsView()
+ case .authApp(let totp):
+ UserProfileMfaAddTotpView(totp: totp)
+ }
+ }
+ }
+
+ var extraContentHeight: CGFloat {
+ if #available(iOS 26.0, *) {
+ return 0
+ } else {
+ return 7
+ }
+ }
+
+ var environment: Clerk.Environment { clerk.environment }
+ var user: User? { clerk.user }
+
+ init(
+ contentHeight: Binding = .constant(0)
+ ) {
+ self._contentHeight = contentHeight
+ }
+
+ var body: some View {
+ NavigationStack {
+ ScrollView {
+ VStack(spacing: 24) {
+ Text("Choose how you'd like to receive your two-step verification code.", bundle: .module)
+ .font(theme.fonts.subheadline)
+ .foregroundStyle(theme.colors.textSecondary)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .fixedSize(horizontal: false, vertical: true)
+ .padding(.horizontal, 24)
+
+ VStack(spacing: 0) {
+ Group {
+ if environment.mfaPhoneCodeIsEnabled {
+ Button {
+ sharedState.chooseMfaTypeIsPresented = false
+ sharedState.presentedAddMfaType = .sms
+ } label: {
+ UserProfileRowView(icon: "icon-phone", text: "SMS code")
+ }
+ }
+
+ if environment.mfaAuthenticatorAppIsEnabled, user?.totpEnabled != true {
+ AsyncButton {
+ await createTotp()
+ } label: { isRunning in
+ UserProfileRowView(icon: "icon-key", text: "Authenticator application")
+ .overlayProgressView(isActive: isRunning)
+ }
+ }
+ }
+ .overlay(alignment: .bottom) {
+ Rectangle()
+ .frame(height: 1)
+ .foregroundStyle(theme.colors.border)
+ }
+ .buttonStyle(.pressedBackground)
+ .simultaneousGesture(TapGesture())
+ }
+ .overlay(alignment: .top) {
+ Rectangle()
+ .frame(height: 1)
+ .foregroundStyle(theme.colors.border)
+ }
+ }
+ .padding(.top, 24)
+ .clerkErrorPresenting($error)
+ .navigationBarTitleDisplayMode(.inline)
+ .preGlassSolidNavBar()
+ .preGlassDetentSheetBackground()
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") {
+ dismiss()
+ }
+ .foregroundStyle(theme.colors.primary)
+ }
+
+ ToolbarItem(placement: .principal) {
+ Text("Add two-step verification", bundle: .module)
+ .font(theme.fonts.headline)
+ .foregroundStyle(theme.colors.text)
+ }
+ }
+ .onGeometryChange(for: CGFloat.self) { proxy in
+ proxy.size.height
+ } action: { newValue in
+ contentHeight = newValue + UITabBarController().tabBar.frame.size.height + extraContentHeight
+ }
+ }
+ .scrollBounceBehavior(.basedOnSize)
+ }
+ }
+}
+
+extension UserProfileAddMfaView {
+
+ private func createTotp() async {
+ guard let user else { return }
+
+ do {
+ let totp = try await user.createTOTP()
+ sharedState.chooseMfaTypeIsPresented = false
+ sharedState.presentedAddMfaType = .authApp(totp)
+ } catch {
+ self.error = error
+ ClerkLogger.error("Failed to create TOTP", error: error)
+ }
+ }
+
+}
+
+#Preview {
+ Container.shared.userService.preview { @MainActor in
+ UserService(createTotp: {
+ try await Task.sleep(for: .seconds(1))
+ return .mock
+ })
+ }
+
+ UserProfileAddMfaView()
+ .environment(\.clerk, .mock)
+ .environment(\.clerkTheme, .clerk)
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileAddPhoneView.swift b/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileAddPhoneView.swift
new file mode 100644
index 000000000..b204bdf12
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileAddPhoneView.swift
@@ -0,0 +1,142 @@
+//
+// UserProfileAddPhoneView.swift
+// Clerk
+//
+// Created by Mike Pitre on 5/27/25.
+//
+
+#if os(iOS)
+
+import SwiftUI
+
+struct UserProfileAddPhoneView: View {
+ @Environment(\.clerk) private var clerk
+ @Environment(\.clerkTheme) private var theme
+ @Environment(\.dismiss) private var dismiss
+
+ @State private var path = NavigationPath()
+ @State private var phoneNumber = ""
+ @State private var error: Error?
+ @FocusState private var isFocused: Bool
+
+ enum Destination: Hashable, Identifiable {
+ case add // should never be added to the path
+ case verify(PhoneNumber)
+
+ var id: Self { self }
+ }
+
+ var user: User? {
+ clerk.user
+ }
+
+ init(desintation: Destination? = nil) {
+ if case .verify(let phoneNumber) = desintation {
+ var path = NavigationPath()
+ path.append(Destination.verify(phoneNumber))
+ _path = State(initialValue: path)
+ }
+ }
+
+ var body: some View {
+ NavigationStack(path: $path) {
+ ScrollView {
+ VStack(spacing: 24) {
+ Text("A text message containing a verification code will be sent to this phone number. Message and data rates may apply.", bundle: .module)
+ .font(theme.fonts.subheadline)
+ .foregroundStyle(theme.colors.textSecondary)
+ .fixedSize(horizontal: false, vertical: true)
+
+ VStack(spacing: 4) {
+ ClerkPhoneNumberField("Enter your phone number", text: $phoneNumber)
+ .textContentType(.telephoneNumber)
+ .keyboardType(.numberPad)
+ .focused($isFocused)
+ .onFirstAppear {
+ isFocused = true
+ }
+
+ if let error {
+ ErrorText(error: error, alignment: .leading)
+ .font(theme.fonts.subheadline)
+ .transition(.blurReplace.animation(.default))
+ .id(error.localizedDescription)
+ }
+ }
+
+ AsyncButton {
+ await addPhoneNumber()
+ } label: { isRunning in
+ HStack {
+ Text("Continue", bundle: .module)
+ Image("icon-triangle-right", bundle: .module)
+ .foregroundStyle(theme.colors.textOnPrimaryBackground)
+ .opacity(0.6)
+ }
+ .frame(maxWidth: .infinity)
+ .overlayProgressView(isActive: isRunning) {
+ SpinnerView(color: theme.colors.textOnPrimaryBackground)
+ }
+ }
+ .buttonStyle(.primary())
+ }
+ .padding(24)
+ }
+ .presentationBackground(theme.colors.background)
+ .navigationBarTitleDisplayMode(.inline)
+ .preGlassSolidNavBar()
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") {
+ dismiss()
+ }
+ .foregroundStyle(theme.colors.primary)
+ }
+
+ ToolbarItem(placement: .principal) {
+ Text("Add phone number", bundle: .module)
+ .font(theme.fonts.headline)
+ .foregroundStyle(theme.colors.text)
+ }
+ }
+ .navigationDestination(for: Destination.self) {
+ switch $0 {
+ case .verify(let phoneNumber):
+ UserProfileVerifyView(
+ mode: .phone(phoneNumber)
+ ) { _ in
+ dismiss()
+ } customDismiss: {
+ dismiss()
+ }
+ case .add:
+ EmptyView() // should never be hit, .add should never be added to path
+ .task { dismiss() }
+ }
+ }
+ }
+ }
+}
+
+extension UserProfileAddPhoneView {
+
+ func addPhoneNumber() async {
+ guard let user else { return }
+
+ do {
+ let phoneNumber = try await user.createPhoneNumber(phoneNumber)
+ path.append(Destination.verify(phoneNumber))
+ } catch {
+ self.error = error
+ ClerkLogger.error("Failed to add phone number", error: error)
+ }
+ }
+
+}
+
+#Preview {
+ UserProfileAddPhoneView()
+ .environment(\.clerkTheme, .clerk)
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileButtonRow.swift b/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileButtonRow.swift
new file mode 100644
index 000000000..ed5327cc0
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileButtonRow.swift
@@ -0,0 +1,65 @@
+//
+// UserProfileButtonRow.swift
+// Clerk
+//
+// Created by Mike Pitre on 5/12/25.
+//
+
+#if os(iOS)
+
+import SwiftUI
+
+struct UserProfileButtonRow: View {
+ @Environment(\.clerkTheme) private var theme
+
+ enum Style {
+ case `default`
+ case danger
+ }
+
+ var foregroundColor: Color {
+ switch style {
+ case .default:
+ theme.colors.primary
+ case .danger:
+ theme.colors.danger
+ }
+ }
+
+ let text: LocalizedStringKey
+ var style = Style.default
+ let action: () async -> Void
+
+ var body: some View {
+ AsyncButton {
+ await action()
+ } label: { isRunning in
+ Text(text, bundle: .module)
+ .font(theme.fonts.body)
+ .fontWeight(.semibold)
+ .frame(minHeight: 22)
+ .foregroundStyle(foregroundColor)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal, 24)
+ .padding(.vertical, 16)
+ .contentShape(.rect)
+ .overlayProgressView(isActive: isRunning)
+ }
+ .overlay(alignment: .bottom) {
+ Rectangle()
+ .frame(height: 1)
+ .foregroundStyle(theme.colors.border)
+ }
+ .buttonStyle(.pressedBackground)
+ .simultaneousGesture(TapGesture())
+ }
+}
+
+#Preview {
+ UserProfileButtonRow(text: "Add email address") {
+ try! await Task.sleep(for: .seconds(1))
+ }
+ .environment(\.clerkTheme, .clerk)
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileChangePasswordView.swift b/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileChangePasswordView.swift
new file mode 100644
index 000000000..c64573240
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileChangePasswordView.swift
@@ -0,0 +1,234 @@
+//
+// UserProfileChangePasswordView.swift
+// Clerk
+//
+// Created by Mike Pitre on 6/16/25.
+//
+
+#if os(iOS)
+
+import SwiftUI
+
+struct UserProfileChangePasswordView: View {
+ @Environment(\.clerk) private var clerk
+ @Environment(\.clerkTheme) private var theme
+ @Environment(\.dismiss) private var dismiss
+
+ @State private var path = NavigationPath()
+ @State private var currentPassword = ""
+ @State private var newPassword = ""
+ @State private var confirmNewPassword = ""
+ @State private var signOutOfOtherSessions = false
+ @State private var error: Error?
+
+ @FocusState private var focusedField: Field?
+
+ enum Field {
+ case currentPassword, newPassword, confirmNewPassword
+ }
+
+ enum Destination {
+ case updatePassword
+ }
+
+ var nextIsDisabled: Bool {
+ currentPassword.isEmptyTrimmed
+ }
+
+ var saveIsDisabled: Bool {
+ newPassword.isEmptyTrimmed || confirmNewPassword.isEmptyTrimmed || newPassword != confirmNewPassword
+ }
+
+ var user: User? { clerk.user }
+
+ var isAddingPassword: Bool = false
+
+ var body: some View {
+ NavigationStack(path: $path) {
+ if isAddingPassword {
+ updatePasswordView
+ } else {
+ currentPasswordView
+ .navigationDestination(for: Destination.self) {
+ switch $0 {
+ case .updatePassword:
+ updatePasswordView
+ }
+ }
+ }
+ }
+ .presentationBackground(theme.colors.background)
+ .background(theme.colors.background)
+ }
+
+ @ViewBuilder
+ private var currentPasswordView: some View {
+ ScrollView {
+ VStack(spacing: 24) {
+ Text("Enter your current password to set a new one.", bundle: .module)
+ .font(theme.fonts.subheadline)
+ .foregroundStyle(theme.colors.textSecondary)
+ .frame(maxWidth: .infinity, minHeight: 20, alignment: .leading)
+ .multilineTextAlignment(.leading)
+
+ ClerkTextField("Current password", text: $currentPassword, isSecure: true)
+ .textContentType(.password)
+ .focused($focusedField, equals: .currentPassword)
+
+ Button {
+ path.append(Destination.updatePassword)
+ } label: {
+ Text("Next")
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.primary())
+ .disabled(nextIsDisabled)
+ }
+ .padding(24)
+ }
+ .navigationBarTitleDisplayMode(.inline)
+ .preGlassSolidNavBar()
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") {
+ dismiss()
+ }
+ .foregroundStyle(theme.colors.primary)
+ }
+
+ ToolbarItem(placement: .principal) {
+ Text("Update password", bundle: .module)
+ .font(theme.fonts.headline)
+ .foregroundStyle(theme.colors.text)
+ }
+ }
+ .onAppear {
+ focusedField = .currentPassword
+ }
+ }
+
+ @ViewBuilder
+ private var updatePasswordView: some View {
+ ScrollView {
+ VStack(spacing: 24) {
+ Group {
+ ClerkTextField("New password", text: $newPassword, isSecure: true)
+ .textContentType(.newPassword)
+ .focused($focusedField, equals: .newPassword)
+ .hiddenTextField(text: .constant(user?.usernameForPasswordKeeper ?? ""), textContentType: .username)
+
+ ClerkTextField("Confirm password", text: $confirmNewPassword, isSecure: true)
+ .textContentType(.newPassword)
+ .focused($focusedField, equals: .confirmNewPassword)
+ }
+ .autocorrectionDisabled()
+ .textInputAutocapitalization(.never)
+
+ signOutOfOtherDevicesView
+
+ AsyncButton {
+ await resetPassword()
+ } label: { isRunning in
+ Text("Save")
+ .frame(maxWidth: .infinity)
+ .overlayProgressView(isActive: isRunning) {
+ SpinnerView(color: theme.colors.textOnPrimaryBackground)
+ }
+ }
+ .buttonStyle(.primary())
+ .disabled(saveIsDisabled)
+ }
+ .padding(24)
+ }
+ .navigationBarTitleDisplayMode(.inline)
+ .preGlassSolidNavBar()
+ .clerkErrorPresenting(
+ $error,
+ action: { error in
+ if let clerkApiError = error as? ClerkAPIError, clerkApiError.meta?["param_name"]?.stringValue == "current_password" {
+ return .init(text: "Go back") {
+ path = NavigationPath()
+ }
+ }
+
+ return nil
+ }
+ )
+ .toolbar {
+ if isAddingPassword {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") {
+ dismiss()
+ }
+ .foregroundStyle(theme.colors.primary)
+ }
+ }
+
+ ToolbarItem(placement: .principal) {
+ Text(isAddingPassword ? "Add password" : "Update password", bundle: .module)
+ .font(theme.fonts.headline)
+ .foregroundStyle(theme.colors.text)
+ }
+ }
+ .onFirstAppear {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) {
+ focusedField = .newPassword
+ }
+ }
+ }
+
+ @ViewBuilder
+ private var signOutOfOtherDevicesView: some View {
+ VStack(spacing: 8) {
+ Toggle("Sign out of all other devices", isOn: $signOutOfOtherSessions)
+ .font(theme.fonts.body)
+ .foregroundStyle(theme.colors.text)
+ .tint(theme.colors.primary)
+ .frame(minHeight: 22)
+
+ Text("It is recommended to sign out of all other devices which may have used your old password.", bundle: .module)
+ .font(theme.fonts.subheadline)
+ .foregroundStyle(theme.colors.textSecondary)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 8)
+ .background(theme.colors.backgroundSecondary, in: .rect(cornerRadius: theme.design.borderRadius))
+ }
+}
+
+extension UserProfileChangePasswordView {
+
+ func resetPassword() async {
+ guard let user else { return }
+
+ do {
+ try await user.updatePassword(
+ .init(
+ currentPassword: isAddingPassword ? nil : currentPassword,
+ newPassword: newPassword,
+ signOutOfOtherSessions: signOutOfOtherSessions
+ )
+ )
+ dismiss()
+ } catch {
+ self.error = error
+ ClerkLogger.error("Failed to reset password", error: error)
+ }
+ }
+
+}
+
+#Preview("Reset") {
+ UserProfileChangePasswordView()
+ .environment(\.clerk, .mock)
+ .environment(\.clerkTheme, .clerk)
+}
+
+#Preview("Adding") {
+ UserProfileChangePasswordView(isAddingPassword: true)
+ .environment(\.clerk, .mock)
+ .environment(\.clerkTheme, .clerk)
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileDeleteAccountConfirmationView.swift b/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileDeleteAccountConfirmationView.swift
new file mode 100644
index 000000000..209a46b19
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileDeleteAccountConfirmationView.swift
@@ -0,0 +1,119 @@
+//
+// UserProfileDeleteAccountConfirmationView.swift
+// Clerk
+//
+// Created by Mike Pitre on 6/2/25.
+//
+
+#if os(iOS)
+
+import SwiftUI
+
+struct UserProfileDeleteAccountConfirmationView: View {
+ @Environment(\.clerk) private var clerk
+ @Environment(\.clerkTheme) private var theme
+ @Environment(\.dismiss) private var dismiss
+ @Environment(\.userProfileSharedState) private var sharedState
+
+ @State private var deleteAccount = ""
+ @State private var error: Error?
+ @FocusState private var isFocused: Bool
+
+ var user: User? {
+ clerk.user
+ }
+
+ var buttonIsDisabled: Bool {
+ deleteAccount != String(localized: "DELETE", bundle: .module)
+ }
+
+ var body: some View {
+ NavigationStack {
+ ScrollView {
+ VStack(spacing: 24) {
+ Text("Are you sure you want to delete your account? This action is permanent and irreversible.", bundle: .module)
+ .font(theme.fonts.subheadline)
+ .foregroundStyle(theme.colors.danger)
+ .fixedSize(horizontal: false, vertical: true)
+ .frame(maxWidth: .infinity, alignment: .leading)
+
+ VStack(spacing: 4) {
+ ClerkTextField("Type \"DELETE\" to continue", text: $deleteAccount)
+ .autocorrectionDisabled()
+ .textInputAutocapitalization(.characters)
+ .focused($isFocused)
+ .onFirstAppear {
+ isFocused = true
+ }
+
+ if let error {
+ ErrorText(error: error, alignment: .leading)
+ .font(theme.fonts.subheadline)
+ .transition(.blurReplace.animation(.default))
+ .id(error.localizedDescription)
+ }
+ }
+
+ AsyncButton {
+ await deleteAccount()
+ } label: { isRunning in
+ Text("Delete account", bundle: .module)
+ .frame(maxWidth: .infinity)
+ .overlayProgressView(isActive: isRunning) {
+ SpinnerView(color: theme.colors.textOnPrimaryBackground)
+ }
+ }
+ .buttonStyle(.negative())
+ .disabled(buttonIsDisabled)
+ }
+ .padding(24)
+ }
+ .navigationBarTitleDisplayMode(.inline)
+ .preGlassSolidNavBar()
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") {
+ dismiss()
+ }
+ .foregroundStyle(theme.colors.primary)
+ }
+
+ ToolbarItem(placement: .principal) {
+ Text("Delete account", bundle: .module)
+ .font(theme.fonts.headline)
+ .foregroundStyle(theme.colors.text)
+ }
+ }
+ }
+ .background(theme.colors.background)
+ .presentationBackground(theme.colors.background)
+ }
+}
+
+extension UserProfileDeleteAccountConfirmationView {
+
+ func deleteAccount() async {
+ guard let user else { return }
+
+ do {
+ try await user.delete()
+ dismiss()
+ sharedState.path = NavigationPath()
+ if clerk.session != nil && (clerk.client?.activeSessions ?? []).count > 1 {
+ sharedState.accountSwitcherIsPresented = true
+ }
+ } catch {
+ self.error = error
+ ClerkLogger.error("Failed to delete account", error: error)
+ }
+ }
+
+}
+
+#Preview {
+ UserProfileDeleteAccountConfirmationView()
+ .environment(\.clerkTheme, .clerk)
+ .environment(\.locale, .init(identifier: "es"))
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileDeleteAccountSection.swift b/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileDeleteAccountSection.swift
new file mode 100644
index 000000000..62613929c
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileDeleteAccountSection.swift
@@ -0,0 +1,32 @@
+//
+// UserProfileDeleteAccountSection.swift
+// Clerk
+//
+// Created by Mike Pitre on 5/13/25.
+//
+
+import SwiftUI
+
+struct UserProfileDeleteAccountSection: View {
+ @Environment(\.clerkTheme) private var theme
+
+ @State private var confirmationIsPresented = false
+
+ var body: some View {
+ Section {
+ UserProfileButtonRow(text: "Delete account", style: .danger) {
+ confirmationIsPresented = true
+ }
+ .background(theme.colors.background)
+ } header: {
+ UserProfileSectionHeader(text: "DELETE ACCOUNT")
+ }
+ .sheet(isPresented: $confirmationIsPresented) {
+ UserProfileDeleteAccountConfirmationView()
+ }
+ }
+}
+
+#Preview {
+ UserProfileDeleteAccountSection()
+}
diff --git a/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileDetailView.swift b/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileDetailView.swift
new file mode 100644
index 000000000..2fdc9b6e8
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileDetailView.swift
@@ -0,0 +1,173 @@
+//
+// UserProfileDetailView.swift
+// Clerk
+//
+// Created by Mike Pitre on 5/9/25.
+//
+
+#if os(iOS)
+
+import FactoryKit
+import Kingfisher
+import SwiftUI
+
+struct UserProfileDetailView: View {
+ @Environment(\.clerk) private var clerk
+ @Environment(\.clerkTheme) private var theme
+ @Environment(\.userProfileSharedState) private var sharedState
+
+ @State private var addEmailAddressDestination: UserProfileAddEmailView.Destination?
+ @State private var addPhoneNumberDestination: UserProfileAddPhoneView.Destination?
+ @State private var addConnectedAccountIsPresented = false
+ @State private var connectAccountSheetHeight: CGFloat = 200
+
+ private var user: User? {
+ clerk.user
+ }
+
+ var sortedEmails: [EmailAddress] {
+ (user?.emailAddresses ?? [])
+ .sorted { lhs, rhs in
+ if lhs == user?.primaryEmailAddress {
+ return true
+ } else if rhs == user?.primaryEmailAddress {
+ return false
+ } else {
+ return lhs.createdAt < rhs.createdAt
+ }
+ }
+ }
+
+ var sortedPhoneNumbers: [PhoneNumber] {
+ (user?.phoneNumbers ?? [])
+ .sorted { lhs, rhs in
+ if lhs == user?.primaryPhoneNumber {
+ return true
+ } else if rhs == user?.primaryPhoneNumber {
+ return false
+ } else {
+ return lhs.createdAt < rhs.createdAt
+ }
+ }
+ }
+
+ var sortedExternalAccounts: [ExternalAccount] {
+ (user?.externalAccounts.filter({
+ $0.verification?.status == .verified || $0.verification?.error != nil
+ }) ?? [])
+ .sorted { lhs, rhs in
+ lhs.createdAt < rhs.createdAt
+ }
+ }
+
+ var body: some View {
+ ZStack {
+ if let user {
+ VStack(spacing: 0) {
+ ScrollView {
+ LazyVStack(spacing: 0) {
+
+ if clerk.environment.emailIsEnabled {
+ Section {
+ Group {
+ ForEach(sortedEmails) { emailAddress in
+ UserProfileEmailRow(emailAddress: emailAddress)
+ }
+
+ UserProfileButtonRow(text: "Add email address") {
+ addEmailAddressDestination = .add
+ }
+ }
+ .background(theme.colors.background)
+
+ } header: {
+ UserProfileSectionHeader(text: "EMAIL ADDRESSES")
+ }
+ }
+
+ if clerk.environment.phoneNumberIsEnabled {
+ Section {
+ Group {
+ ForEach(sortedPhoneNumbers) { phoneNumber in
+ UserProfilePhoneRow(phoneNumber: phoneNumber)
+ }
+
+ UserProfileButtonRow(text: "Add phone number") {
+ addPhoneNumberDestination = .add
+ }
+ }
+ .background(theme.colors.background)
+ } header: {
+ UserProfileSectionHeader(text: "PHONE NUMBERS")
+ }
+ }
+
+ if !clerk.environment.allSocialProviders.isEmpty {
+ Section {
+ Group {
+ ForEach(sortedExternalAccounts) { externalAccount in
+ UserProfileExternalAccountRow(externalAccount: externalAccount)
+ }
+
+ if !user.unconnectedProviders.isEmpty {
+ UserProfileButtonRow(text: "Connect account") {
+ addConnectedAccountIsPresented = true
+ }
+ }
+ }
+ .background(theme.colors.background)
+ } header: {
+ UserProfileSectionHeader(text: "CONNECTED ACCOUNTS")
+ }
+ }
+ }
+ .animation(.default, value: sortedEmails)
+ .animation(.default, value: sortedPhoneNumbers)
+ .animation(.default, value: sortedExternalAccounts)
+ }
+ .background(theme.colors.backgroundSecondary)
+
+ SecuredByClerkFooter()
+ }
+ }
+ }
+ .toolbar {
+ ToolbarItem(placement: .principal) {
+ Text("Profile", bundle: .module)
+ .font(theme.fonts.headline)
+ .fontWeight(.semibold)
+ .foregroundStyle(theme.colors.text)
+ }
+ }
+ .navigationBarTitleDisplayMode(.inline)
+ .presentationBackground(theme.colors.background)
+ .background(theme.colors.background)
+ .sheet(item: $addEmailAddressDestination) {
+ UserProfileAddEmailView(desintation: $0)
+ }
+ .sheet(item: $addPhoneNumberDestination) {
+ UserProfileAddPhoneView(desintation: $0)
+ }
+ .sheet(isPresented: $addConnectedAccountIsPresented) {
+ UserProfileAddConnectedAccountView(contentHeight: $connectAccountSheetHeight)
+ .presentationDetents([.height(connectAccountSheetHeight)])
+ }
+ .task {
+ _ = try? await Client.get()
+ }
+ }
+}
+
+#Preview {
+ Container.shared.clerk.preview { @MainActor in
+ .mock
+ }
+
+ NavigationStack {
+ UserProfileDetailView()
+ .environment(\.clerk, .mock)
+ .environment(\.clerkTheme, .clerk)
+ }
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileDeviceRow.swift b/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileDeviceRow.swift
new file mode 100644
index 000000000..7f4eac84f
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileDeviceRow.swift
@@ -0,0 +1,114 @@
+//
+// UserProfileDeviceRow.swift
+// Clerk
+//
+// Created by Mike Pitre on 5/13/25.
+//
+
+#if os(iOS)
+
+import SwiftUI
+
+struct UserProfileDeviceRow: View {
+ @Environment(\.clerk) private var clerk
+ @Environment(\.clerkTheme) private var theme
+
+ @State private var isLoading = false
+ @State private var error: Error?
+
+ var user: User? {
+ clerk.user
+ }
+
+ let session: Session
+
+ var body: some View {
+ HStack(spacing: 16) {
+ HStack(alignment: .top) {
+ if let activity = session.latestActivity {
+ activity.deviceImage
+ .resizable()
+ .scaledToFit()
+ .frame(width: 24, height: 24)
+
+ VStack(alignment: .leading, spacing: 8) {
+
+ if session.isThisDevice {
+ Badge(key: "This device", style: .secondary)
+ }
+
+ VStack(alignment: .leading, spacing: 4) {
+ activity.deviceText
+ .font(theme.fonts.body)
+ .foregroundStyle(theme.colors.text)
+ .frame(minHeight: 22)
+
+ VStack(alignment: .leading, spacing: 0) {
+ Group {
+ Text(verbatim: activity.browserFormatted)
+ Text(verbatim: activity.ipAndLocationFormatted)
+ Text(session.lastActiveAt.relativeNamedFormat)
+ }
+ .font(theme.fonts.subheadline)
+ .foregroundStyle(theme.colors.textSecondary)
+ .frame(minHeight: 20)
+ }
+ }
+ }
+ }
+ }
+
+ Spacer(minLength: 0)
+
+ if !session.isThisDevice {
+ Menu {
+ AsyncButton(role: .destructive) {
+ await signOutOfDevice()
+ } label: { _ in
+ Text("Sign out of device", bundle: .module)
+ }
+ .onIsRunningChanged { isLoading = $0 }
+ } label: {
+ Image("icon-three-dots-vertical", bundle: .module)
+ .resizable()
+ .scaledToFit()
+ .foregroundColor(theme.colors.textSecondary)
+ .frame(width: 20, height: 20)
+ }
+ .frame(width: 30, height: 30)
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal, 24)
+ .padding(.vertical, 16)
+ .overlayProgressView(isActive: isLoading)
+ .overlay(alignment: .bottom) {
+ Rectangle()
+ .frame(height: 1)
+ .foregroundStyle(theme.colors.border)
+ }
+ .clerkErrorPresenting($error)
+ .animation(.default, value: isLoading)
+ }
+}
+
+extension UserProfileDeviceRow {
+
+ func signOutOfDevice() async {
+ do {
+ try await session.revoke()
+ try await user?.getSessions()
+ } catch {
+ self.error = error
+ ClerkLogger.error("Failed to sign out of device", error: error)
+ }
+ }
+
+}
+
+#Preview {
+ UserProfileDeviceRow(session: .mock)
+ .environment(\.clerk, .mock)
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileDevicesSection.swift b/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileDevicesSection.swift
new file mode 100644
index 000000000..a0e9bb3a3
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileDevicesSection.swift
@@ -0,0 +1,54 @@
+//
+// UserProfileDevicesSection.swift
+// Clerk
+//
+// Created by Mike Pitre on 5/13/25.
+//
+
+#if os(iOS)
+
+import SwiftUI
+
+struct UserProfileDevicesSection: View {
+ @Environment(\.clerk) private var clerk
+ @Environment(\.clerkTheme) private var theme
+
+ var user: User? {
+ clerk.user
+ }
+
+ var sortedSessions: [Session] {
+ guard let user else { return [] }
+ let sessions = (clerk.sessionsByUserId[user.id] ?? []).filter({ $0.latestActivity != nil })
+ return sessions.sorted { lhs, rhs in
+ if lhs.isThisDevice {
+ return true
+ } else if rhs.isThisDevice {
+ return false
+ } else {
+ return lhs.lastActiveAt > rhs.lastActiveAt
+ }
+ }
+ }
+
+ var body: some View {
+ Section {
+ VStack(spacing: 0) {
+ ForEach(sortedSessions) { session in
+ UserProfileDeviceRow(session: session)
+ }
+ }
+ .background(theme.colors.background)
+ } header: {
+ UserProfileSectionHeader(text: "ACTIVE DEVICES")
+ }
+ }
+}
+
+#Preview {
+ UserProfileDevicesSection()
+ .environment(\.clerk, .mock)
+ .environment(\.clerkTheme, .clerk)
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileEmailRow.swift b/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileEmailRow.swift
new file mode 100644
index 000000000..381eff5d3
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileEmailRow.swift
@@ -0,0 +1,154 @@
+//
+// UserProfileEmailRow.swift
+// Clerk
+//
+// Created by Mike Pitre on 6/10/25.
+//
+
+import SwiftUI
+
+#if os(iOS)
+
+struct UserProfileEmailRow: View {
+ @Environment(\.clerk) private var clerk
+ @Environment(\.clerkTheme) private var theme
+
+ @State private var addEmailAddressDestination: UserProfileAddEmailView.Destination?
+ @State private var isLoading = false
+ @State private var removeResource: RemoveResource?
+ @State private var isConfirmingRemoval = false
+ @State private var error: Error?
+
+ var user: User? {
+ clerk.user
+ }
+
+ let emailAddress: EmailAddress
+
+ var body: some View {
+ HStack(spacing: 16) {
+ VStack(alignment: .leading, spacing: 4) {
+ WrappingHStack(alignment: .leading) {
+ if user?.primaryEmailAddress == emailAddress {
+ Badge(key: "Primary", style: .secondary)
+ }
+
+ if emailAddress.verification?.status != .verified {
+ Badge(key: "Unverified", style: .warning)
+ }
+
+ if emailAddress.linkedTo?.isEmpty == false {
+ Badge(key: "Linked", style: .secondary)
+ }
+ }
+
+ Text(emailAddress.emailAddress)
+ .font(theme.fonts.body)
+ .foregroundStyle(theme.colors.text)
+ .frame(minHeight: 22)
+ }
+
+ Spacer(minLength: 0)
+
+ Menu {
+ if user?.primaryEmailAddress != emailAddress, emailAddress.verification?.status == .verified {
+ AsyncButton {
+ await setEmailAsPrimary(emailAddress)
+ } label: { isRunning in
+ Text("Set as primary", bundle: .module)
+ }
+ .onIsRunningChanged { isLoading = $0 }
+ .onDisappear { isLoading = false }
+ }
+
+ if emailAddress.verification?.status != .verified {
+ Button {
+ addEmailAddressDestination = .verify(emailAddress)
+ } label: {
+ Text("Verify", bundle: .module)
+ }
+ }
+
+ Button(role: .destructive) {
+ removeResource = .email(emailAddress)
+ } label: {
+ Text("Remove email", bundle: .module)
+ }
+
+ } label: {
+ Image("icon-three-dots-vertical", bundle: .module)
+ .resizable()
+ .scaledToFit()
+ .foregroundColor(theme.colors.textSecondary)
+ .frame(width: 20, height: 20)
+ }
+ .frame(width: 30, height: 30)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal, 24)
+ .padding(.vertical, 16)
+ .overlayProgressView(isActive: isLoading)
+ .overlay(alignment: .bottom) {
+ Rectangle()
+ .frame(height: 1)
+ .foregroundStyle(theme.colors.border)
+ }
+ .clerkErrorPresenting($error)
+ .sheet(item: $addEmailAddressDestination) {
+ UserProfileAddEmailView(desintation: $0)
+ }
+ .onChange(of: removeResource) {
+ if $1 != nil { isConfirmingRemoval = true }
+ }
+ .confirmationDialog(
+ removeResource?.messageLine1 ?? "",
+ isPresented: $isConfirmingRemoval,
+ titleVisibility: .visible,
+ actions: {
+ AsyncButton(role: .destructive) {
+ await removeResource(removeResource)
+ } label: { isRunning in
+ Text(removeResource?.title ?? "", bundle: .module)
+ }
+ .onIsRunningChanged { isLoading = $0 }
+
+ Button(role: .cancel) {
+ isConfirmingRemoval = false
+ removeResource = nil
+ } label: {
+ Text("Cancel", bundle: .module)
+ }
+ }
+ )
+ }
+}
+
+extension UserProfileEmailRow {
+
+ private func setEmailAsPrimary(_ email: EmailAddress) async {
+ do {
+ try await user?.update(.init(primaryEmailAddressId: email.id))
+ } catch {
+ self.error = error
+ ClerkLogger.error("Failed to set email as primary", error: error)
+ }
+ }
+
+ private func removeResource(_ resource: RemoveResource?) async {
+ defer { removeResource = nil }
+
+ do {
+ try await resource?.deleteAction()
+ } catch {
+ self.error = error
+ ClerkLogger.error("Failed to remove email resource", error: error)
+ }
+ }
+
+}
+
+#Preview {
+ UserProfileEmailRow(emailAddress: .mock)
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileExternalAccountRow.swift b/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileExternalAccountRow.swift
new file mode 100644
index 000000000..54f466adc
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileExternalAccountRow.swift
@@ -0,0 +1,165 @@
+//
+// UserProfileExternalAccountRow.swift
+// Clerk
+//
+// Created by Mike Pitre on 6/10/25.
+//
+
+#if os(iOS)
+
+import Kingfisher
+import SwiftUI
+
+struct UserProfileExternalAccountRow: View {
+ @Environment(\.clerk) private var clerk
+ @Environment(\.clerkTheme) private var theme
+ @Environment(\.colorScheme) private var colorScheme
+
+ @State private var removeResource: RemoveResource?
+ @State private var isConfirmingRemoval = false
+ @State private var isLoading = false
+ @State private var error: Error?
+
+ var user: User? {
+ clerk.user
+ }
+
+ let externalAccount: ExternalAccount
+
+ var body: some View {
+ HStack(spacing: 16) {
+ VStack(alignment: .leading, spacing: 4) {
+ WrappingHStack(alignment: .leading) {
+ HStack(spacing: 8) {
+ KFImage(
+ externalAccount.oauthProvider.iconImageUrl(darkMode: colorScheme == .dark)
+ )
+ .resizable()
+ .placeholder {
+ #if DEBUG
+ Image(systemName: "globe")
+ .resizable()
+ .scaledToFit()
+ #endif
+ }
+ .fade(duration: 0.25)
+ .scaledToFit()
+ .frame(width: 20, height: 20)
+
+ Text(externalAccount.oauthProvider.name)
+ .font(theme.fonts.subheadline)
+ .foregroundStyle(theme.colors.textSecondary)
+ .frame(minHeight: 20)
+ }
+ }
+
+ if !externalAccount.emailAddress.isEmpty {
+ Text(externalAccount.emailAddress)
+ .font(theme.fonts.body)
+ .foregroundStyle(theme.colors.text)
+ .frame(minHeight: 22)
+ }
+
+ if let error = externalAccount.verification?.error {
+ ErrorText(error: error, alignment: .leading)
+ .font(theme.fonts.footnote)
+ }
+ }
+
+ Spacer()
+
+ Menu {
+ if externalAccount.verification?.error != nil {
+ AsyncButton {
+ await reconnect()
+ } label: { _ in
+ Text("Reconnect", bundle: .module)
+ }
+ .onIsRunningChanged { isLoading = $0 }
+ .onDisappear { isLoading = false }
+ }
+
+ Button(role: .destructive) {
+ removeResource = .externalAccount(externalAccount)
+ } label: {
+ Text("Remove connection", bundle: .module)
+ }
+
+ } label: {
+ Image("icon-three-dots-vertical", bundle: .module)
+ .resizable()
+ .scaledToFit()
+ .foregroundColor(theme.colors.textSecondary)
+ .frame(width: 20, height: 20)
+ }
+ .frame(width: 30, height: 30)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal, 24)
+ .padding(.vertical, 16)
+ .background(theme.colors.background)
+ .overlayProgressView(isActive: isLoading)
+ .overlay(alignment: .bottom) {
+ Rectangle()
+ .frame(height: 1)
+ .foregroundStyle(theme.colors.border)
+ }
+ .clerkErrorPresenting($error)
+ .onChange(of: removeResource) {
+ if $1 != nil { isConfirmingRemoval = true }
+ }
+ .confirmationDialog(
+ removeResource?.messageLine1 ?? "",
+ isPresented: $isConfirmingRemoval,
+ titleVisibility: .visible,
+ actions: {
+ AsyncButton(role: .destructive) {
+ await removeResource(removeResource)
+ } label: { isRunning in
+ Text(removeResource?.title ?? "", bundle: .module)
+ }
+ .onIsRunningChanged { isLoading = $0 }
+
+ Button(role: .cancel) {
+ isConfirmingRemoval = false
+ removeResource = nil
+ } label: {
+ Text("Cancel", bundle: .module)
+ }
+ }
+ )
+ }
+}
+
+extension UserProfileExternalAccountRow {
+
+ private func reconnect() async {
+ guard let user else { return }
+
+ do {
+ let account = try await user.createExternalAccount(provider: externalAccount.oauthProvider)
+ try await account.reauthorize()
+ } catch {
+ self.error = error
+ ClerkLogger.error("Failed to reconnect external account", error: error)
+ }
+ }
+
+ private func removeResource(_ resource: RemoveResource?) async {
+ defer { removeResource = nil }
+
+ do {
+ try await resource?.deleteAction()
+ } catch {
+ self.error = error
+ ClerkLogger.error("Failed to remove external account resource", error: error)
+ }
+ }
+
+}
+
+#Preview {
+ UserProfileExternalAccountRow(externalAccount: .mockVerified)
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileMfaAddSmsView.swift b/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileMfaAddSmsView.swift
new file mode 100644
index 000000000..27fd31396
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileMfaAddSmsView.swift
@@ -0,0 +1,257 @@
+//
+// UserProfileMfaAddSmsView.swift
+// Clerk
+//
+// Created by Mike Pitre on 6/4/25.
+//
+
+#if os(iOS)
+
+import FactoryKit
+import PhoneNumberKit
+import SwiftUI
+
+struct UserProfileMfaAddSmsView: View {
+ @Environment(\.clerk) private var clerk
+ @Environment(\.clerkTheme) private var theme
+ @Environment(\.dismiss) private var dismiss
+ @Environment(\.userProfileSharedState) private var sharedState
+
+ @State private var selectedPhoneNumber: PhoneNumber?
+ @State private var addPhoneNumberIsPresented = false
+ @State private var path = NavigationPath()
+ @State private var error: Error?
+
+ enum Destination: Hashable {
+ case backupCodes([String])
+
+ @MainActor
+ @ViewBuilder
+ var view: some View {
+ switch self {
+ case .backupCodes(let backupCodes):
+ BackupCodesView(backupCodes: backupCodes, mfaType: .phoneCode)
+ }
+ }
+ }
+
+ private var user: User? {
+ clerk.user
+ }
+
+ private var availablePhoneNumbers: [PhoneNumber] {
+ (user?.phoneNumbersAvailableForMfa ?? [])
+ .filter { $0.verification?.status == .verified }
+ .sorted { $0.createdAt < $1.createdAt }
+ }
+
+ var body: some View {
+ NavigationStack(path: $path) {
+ ScrollView {
+ VStack(spacing: 24) {
+ Text("Select an existing phone number to register for SMS code two-step verification or add a new one.", bundle: .module)
+ .font(theme.fonts.subheadline)
+ .foregroundStyle(theme.colors.textSecondary)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .fixedSize(horizontal: false, vertical: true)
+
+ VStack(spacing: 12) {
+ ForEach(availablePhoneNumbers) { phoneNumber in
+ Button {
+ selectedPhoneNumber = phoneNumber
+ } label: {
+ AddMfaSmsRow(
+ phoneNumber: phoneNumber,
+ isSelected: selectedPhoneNumber == phoneNumber
+ )
+ }
+ .buttonStyle(.pressedBackground)
+ }
+ }
+
+ AsyncButton {
+ guard let selectedPhoneNumber else { return }
+ await reserveForSecondFactor(phoneNumber: selectedPhoneNumber)
+ } label: { isRunning in
+ HStack {
+ Text("Continue", bundle: .module)
+ Image("icon-triangle-right", bundle: .module)
+ .foregroundStyle(theme.colors.textOnPrimaryBackground)
+ .opacity(0.6)
+ }
+ .frame(maxWidth: .infinity)
+ .overlayProgressView(isActive: isRunning) {
+ SpinnerView(color: theme.colors.textOnPrimaryBackground)
+ }
+ }
+ .buttonStyle(.primary())
+ .disabled(selectedPhoneNumber == nil)
+
+ Button {
+ addPhoneNumberIsPresented = true
+ } label: {
+ Text("Add phone number", bundle: .module)
+ }
+ .buttonStyle(
+ .primary(
+ config: .init(
+ emphasis: .none,
+ size: .small
+ )
+ )
+ )
+ }
+ .padding(24)
+ .clerkErrorPresenting($error)
+ .navigationBarTitleDisplayMode(.inline)
+ .preGlassSolidNavBar()
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") {
+ dismiss()
+ }
+ .foregroundStyle(theme.colors.primary)
+ }
+
+ ToolbarItem(placement: .principal) {
+ Text("Add SMS code verification", bundle: .module)
+ .font(theme.fonts.headline)
+ .foregroundStyle(theme.colors.text)
+ }
+ }
+ }
+ .navigationDestination(for: Destination.self) {
+ $0.view
+ }
+ }
+ .background(theme.colors.background)
+ .presentationBackground(theme.colors.background)
+ .sensoryFeedback(.selection, trigger: selectedPhoneNumber)
+ .sheet(isPresented: $addPhoneNumberIsPresented) {
+ UserProfileAddPhoneView()
+ }
+ }
+}
+
+extension UserProfileMfaAddSmsView {
+
+ private func reserveForSecondFactor(phoneNumber: PhoneNumber) async {
+ do {
+ let phoneNumber = try await phoneNumber.setReservedForSecondFactor()
+ if let backupCodes = phoneNumber.backupCodes {
+ path.append(Destination.backupCodes(backupCodes))
+ } else {
+ sharedState.presentedAddMfaType = nil
+ }
+ } catch {
+ self.error = error
+ ClerkLogger.error("Failed to reserve phone number for second factor", error: error)
+ }
+ }
+
+}
+
+struct AddMfaSmsRow: View {
+ @Environment(\.clerkTheme) private var theme
+ let utility = Container.shared.phoneNumberUtility()
+
+ let phoneNumber: PhoneNumber
+ let isSelected: Bool
+
+ var country: CountryCodePickerViewController.Country? {
+ if let phoneNumber = try? utility.parse(phoneNumber.phoneNumber),
+ let regionId = phoneNumber.regionID
+ {
+ return CountryCodePickerViewController.Country(
+ for: regionId,
+ with: utility
+ )
+ }
+
+ return CountryCodePickerViewController.Country(
+ for: "US",
+ with: utility
+ )
+ }
+
+ @ViewBuilder
+ var countryIndicator: some View {
+ if let country {
+ Text(verbatim: "\(country.flag) \(country.code)")
+ .font(theme.fonts.footnote)
+ .foregroundStyle(theme.colors.text)
+ .monospaced()
+ .padding(.vertical, 13)
+ .padding(.horizontal, 10)
+ .background(theme.colors.backgroundSecondary)
+ .clipShape(.rect(cornerRadius: theme.design.borderRadius))
+ .contentShape(.rect(cornerRadius: theme.design.borderRadius))
+ }
+ }
+
+ @ViewBuilder
+ var selectedIndicator: some View {
+ Image(systemName: isSelected ? "record.circle.fill" : "record.circle")
+ .resizable()
+ .scaledToFit()
+ .symbolRenderingMode(.palette)
+ .foregroundStyle(
+ isSelected ? theme.colors.background : .clear,
+ isSelected ? theme.colors.primary : theme.colors.inputBorder
+ )
+ .frame(width: 20, height: 20)
+ .contentTransition(.symbolEffect(.replace.offUp))
+ }
+
+ var body: some View {
+ HStack(spacing: 8) {
+ countryIndicator
+ Text(phoneNumber.phoneNumber.formattedAsPhoneNumberIfPossible)
+ .font(theme.fonts.body)
+ .foregroundStyle(theme.colors.text)
+ Spacer(minLength: 0)
+ selectedIndicator
+ }
+ .padding(.vertical, 8)
+ .padding(.leading, 6)
+ .padding(.trailing, 16)
+ .overlay {
+ RoundedRectangle(cornerRadius: theme.design.borderRadius)
+ .strokeBorder(
+ isSelected ? theme.colors.primary : theme.colors.inputBorder,
+ lineWidth: 1
+ )
+ }
+ .contentShape(.rect)
+ .animation(.default, value: isSelected)
+ }
+}
+
+#Preview {
+ UserProfileMfaAddSmsView()
+ .environment(\.clerk, .mock)
+ .environment(\.clerkTheme, .clerk)
+}
+
+#Preview("Row") {
+ @Previewable @State var selectedPhoneNumber: PhoneNumber?
+ let phoneNumbers: [PhoneNumber] = [.mock, .mockMfa]
+
+ VStack {
+ ForEach(phoneNumbers) { phoneNumber in
+ Button {
+ selectedPhoneNumber = phoneNumber
+ } label: {
+ AddMfaSmsRow(
+ phoneNumber: phoneNumber,
+ isSelected: selectedPhoneNumber == phoneNumber
+ )
+ }
+ .buttonStyle(.pressedBackground)
+ }
+ }
+ .padding()
+ .environment(\.clerkTheme, .clerk)
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileMfaAddTotpView.swift b/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileMfaAddTotpView.swift
new file mode 100644
index 000000000..dfd00ac53
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileMfaAddTotpView.swift
@@ -0,0 +1,171 @@
+//
+// UserProfileMfaAddTotpView.swift
+// Clerk
+//
+// Created by Mike Pitre on 6/12/25.
+//
+
+#if os(iOS)
+
+import FactoryKit
+import SwiftUI
+
+struct UserProfileMfaAddTotpView: View {
+ @Environment(\.clerk) private var clerk
+ @Environment(\.clerkTheme) private var theme
+ @Environment(\.userProfileSharedState) private var sharedState
+ @Environment(\.dismiss) private var dismiss
+
+ @State private var path = NavigationPath()
+ @State private var error: Error?
+
+ let totp: TOTPResource
+
+ enum Destination: Hashable {
+ case verify
+ case backupCodes([String])
+ }
+
+ @ViewBuilder
+ func viewForDestination(_ destination: Destination) -> some View {
+ switch destination {
+ case .verify:
+ UserProfileVerifyView(mode: .totp) { backupCodes in
+ if let backupCodes {
+ path.append(Destination.backupCodes(backupCodes))
+ } else {
+ sharedState.presentedAddMfaType = nil
+ }
+ }
+ case .backupCodes(let backupCodes):
+ BackupCodesView(backupCodes: backupCodes, mfaType: .authenticatorApp)
+ }
+ }
+
+ private var user: User? { clerk.user }
+
+ var body: some View {
+ NavigationStack(path: $path) {
+ ScrollView {
+ VStack(spacing: 24) {
+ if let secret = totp.secret {
+ Text("Set up a new sign-in method in your authenticator and enter the Key provided below.\n\nMake sure Time-based or One-time passwords is enabled, then finish linking your account.", bundle: .module)
+ .font(theme.fonts.subheadline)
+ .foregroundStyle(theme.colors.textSecondary)
+
+ VStack(spacing: 12) {
+ copyableText(secret)
+
+ Button {
+ copyToClipboard(secret)
+ } label: {
+ HStack(spacing: 6) {
+ Image("icon-clipboard", bundle: .module)
+ .foregroundStyle(theme.colors.textSecondary)
+ Text("Copy to clipboard", bundle: .module)
+ }
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.secondary())
+ }
+ }
+
+ if let uri = totp.uri {
+ Text("Alternatively, if your authenticator supports TOTP URIs, you can also copy the full URI.", bundle: .module)
+ .font(theme.fonts.subheadline)
+ .foregroundStyle(theme.colors.textSecondary)
+
+ VStack(spacing: 12) {
+ copyableText(uri)
+
+ Button {
+ copyToClipboard(uri)
+ } label: {
+ HStack(spacing: 6) {
+ Image("icon-clipboard", bundle: .module)
+ .foregroundStyle(theme.colors.textSecondary)
+ Text("Copy to clipboard", bundle: .module)
+ }
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.secondary())
+ }
+ }
+
+ Button {
+ path.append(Destination.verify)
+ } label: {
+ HStack(spacing: 4) {
+ Text("Continue", bundle: .module)
+ Image("icon-triangle-right", bundle: .module)
+ .foregroundStyle(theme.colors.textOnPrimaryBackground)
+ .opacity(0.6)
+ }
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.primary())
+ }
+ .padding(24)
+ }
+ .clerkErrorPresenting($error)
+ .navigationBarTitleDisplayMode(.inline)
+ .preGlassSolidNavBar()
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") {
+ dismiss()
+ }
+ .foregroundStyle(theme.colors.primary)
+ }
+
+ ToolbarItem(placement: .principal) {
+ Text("Add authenticator application", bundle: .module)
+ .font(theme.fonts.headline)
+ .foregroundStyle(theme.colors.text)
+ }
+ }
+ .navigationDestination(for: Destination.self) {
+ viewForDestination($0)
+ }
+ }
+ .presentationBackground(theme.colors.background)
+ .background(theme.colors.background)
+ }
+
+ @ViewBuilder
+ func copyableText(_ string: String) -> some View {
+ Text(verbatim: string)
+ .font(theme.fonts.subheadline)
+ .foregroundStyle(theme.colors.text)
+ .frame(maxWidth: .infinity, minHeight: 20)
+ .lineLimit(1)
+ .padding(.vertical, 18)
+ .padding(.horizontal, 16)
+ .background(theme.colors.backgroundSecondary)
+ .clipShape(.rect(cornerRadius: theme.design.borderRadius))
+ .overlay {
+ RoundedRectangle(cornerRadius: theme.design.borderRadius)
+ .strokeBorder(theme.colors.border, lineWidth: 1)
+ }
+ }
+}
+
+extension UserProfileMfaAddTotpView {
+
+ private func copyToClipboard(_ text: String) {
+ #if os(iOS)
+ UIPasteboard.general.string = text
+ #elseif os(macOS)
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(text, forType: .string)
+ #endif
+ }
+}
+
+#Preview {
+ UserProfileMfaAddTotpView(totp: .mock)
+ .environment(\.clerk, .mock)
+ .environment(\.clerkTheme, .clerk)
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileMfaRow.swift b/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileMfaRow.swift
new file mode 100644
index 000000000..788fae3ca
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileMfaRow.swift
@@ -0,0 +1,216 @@
+//
+// UserProfileMfaRow.swift
+// Clerk
+//
+// Created by Mike Pitre on 5/12/25.
+//
+
+#if os(iOS)
+
+import SwiftUI
+
+struct UserProfileMfaRow: View {
+ @Environment(\.clerk) private var clerk
+ @Environment(\.clerkTheme) private var theme
+
+ @State private var isConfirmingRemoval = false
+ @State private var removeResource: RemoveResource?
+ @State private var backupCodes: BackupCodeResource?
+ @State private var isLoading = false
+ @State private var error: Error?
+
+ var user: User? {
+ clerk.user
+ }
+
+ enum Style {
+ case authenticatorApp
+ case sms(phoneNumber: PhoneNumber)
+ case backupCodes
+ }
+
+ private var icon: Image {
+ return switch style {
+ case .authenticatorApp:
+ Image("icon-key", bundle: .module)
+ case .sms:
+ Image("icon-phone", bundle: .module)
+ case .backupCodes:
+ Image("icon-lock", bundle: .module)
+ }
+ }
+
+ private var text: Text {
+ return switch style {
+ case .authenticatorApp:
+ Text("Authenticator app", bundle: .module)
+ case .sms:
+ Text("SMS code", bundle: .module)
+ case .backupCodes:
+ Text("Backup codes", bundle: .module)
+ }
+ }
+
+ @ViewBuilder
+ private var menuItems: some View {
+ switch style {
+ case .authenticatorApp:
+ Button("Remove", role: .destructive) {
+ removeResource = .totp
+ }
+ case .sms(let phoneNumber):
+ if user?.totpEnabled != true && !phoneNumber.defaultSecondFactor {
+ AsyncButton {
+ await makeDefaultSecondFactor(phoneNumber: phoneNumber)
+ } label: { _ in
+ Text("Set as default")
+ }
+ .onIsRunningChanged { isLoading = $0 }
+ .onDisappear { isLoading = false }
+ }
+
+ Button("Remove", role: .destructive) {
+ removeResource = .secondFactorPhoneNumber(phoneNumber)
+ }
+ case .backupCodes:
+ AsyncButton {
+ await regenerateBackupCodes()
+ } label: { isRunning in
+ Text("Regenerate", bundle: .module)
+ }
+ .onIsRunningChanged { isLoading = $0 }
+ }
+ }
+
+ let style: Style
+ var isDefault: Bool = false
+
+ var body: some View {
+ HStack(spacing: 0) {
+ HStack(alignment: .top, spacing: 16) {
+ icon
+ .resizable()
+ .scaledToFit()
+ .frame(width: 24, height: 24)
+ .foregroundStyle(theme.colors.textSecondary)
+ VStack(alignment: .leading, spacing: 4) {
+ if isDefault {
+ Badge(key: "Default", style: .secondary)
+ }
+
+ HStack(spacing: 4) {
+ text
+ if case .sms(let phoneNumber) = style {
+ Text(verbatim: phoneNumber.phoneNumber.formattedAsPhoneNumberIfPossible)
+ }
+ }
+ .font(theme.fonts.body)
+ .foregroundStyle(theme.colors.text)
+ .frame(minHeight: 22)
+ }
+ }
+
+ Spacer(minLength: 0)
+
+ Menu {
+ menuItems
+ } label: {
+ Image("icon-three-dots-vertical", bundle: .module)
+ .resizable()
+ .scaledToFit()
+ .foregroundColor(theme.colors.textSecondary)
+ .frame(width: 20, height: 20)
+ }
+ .frame(width: 30, height: 30)
+ }
+ .padding(.horizontal, 24)
+ .padding(.vertical, 16)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .contentShape(.rect)
+ .overlayProgressView(isActive: isLoading)
+ .overlay(alignment: .bottom) {
+ Rectangle()
+ .frame(height: 1)
+ .foregroundStyle(theme.colors.border)
+ }
+ .onChange(of: removeResource) {
+ if $1 != nil { isConfirmingRemoval = true }
+ }
+ .confirmationDialog(
+ removeResource?.messageLine1 ?? "",
+ isPresented: $isConfirmingRemoval,
+ titleVisibility: .visible,
+ actions: {
+ AsyncButton(role: .destructive) {
+ await removeResource()
+ } label: { isRunning in
+ Text(removeResource?.title ?? "", bundle: .module)
+ }
+ .onIsRunningChanged { isLoading = $0 }
+
+ Button(role: .cancel) {
+ isConfirmingRemoval = false
+ removeResource = nil
+ } label: {
+ Text("Cancel", bundle: .module)
+ }
+ }
+ )
+ .sheet(item: $backupCodes) { backupCodes in
+ NavigationStack {
+ BackupCodesView(backupCodes: backupCodes.codes)
+ }
+ }
+ }
+}
+
+extension UserProfileMfaRow {
+
+ private func removeResource() async {
+ defer { removeResource = nil }
+
+ do {
+ try await removeResource?.deleteAction()
+ } catch {
+ self.error = error
+ ClerkLogger.error("Failed to remove MFA resource", error: error)
+ }
+ }
+
+ private func makeDefaultSecondFactor(phoneNumber: PhoneNumber) async {
+ do {
+ try await phoneNumber.makeDefaultSecondFactor()
+ } catch {
+ self.error = error
+ ClerkLogger.error("Failed to make phone number default second factor", error: error)
+ }
+ }
+
+ private func regenerateBackupCodes() async {
+ guard let user else { return }
+
+ do {
+ self.backupCodes = try await user.createBackupCodes()
+ } catch {
+ self.error = error
+ ClerkLogger.error("Failed to regenerate backup codes", error: error)
+ }
+ }
+}
+
+#Preview {
+ UserProfileMfaRow(
+ style: .authenticatorApp,
+ isDefault: true
+ )
+
+ UserProfileMfaRow(
+ style: .sms(phoneNumber: .mock)
+ )
+
+ UserProfileMfaRow(
+ style: .backupCodes
+ )
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileMfaSection.swift b/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileMfaSection.swift
new file mode 100644
index 000000000..f7c2f34d8
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileMfaSection.swift
@@ -0,0 +1,90 @@
+//
+// UserProfileMfaSection.swift
+// Clerk
+//
+// Created by Mike Pitre on 5/12/25.
+//
+
+#if os(iOS)
+
+import SwiftUI
+
+struct UserProfileMfaSection: View {
+ @Environment(\.clerk) private var clerk
+ @Environment(\.clerkTheme) private var theme
+ @Environment(\.userProfileSharedState) private var sharedState
+
+ @State private var addMfaHeight: CGFloat = 400
+
+ var user: User? {
+ clerk.user
+ }
+
+ var mfaPhoneNumbers: [PhoneNumber] {
+ (user?.phoneNumbers ?? [])
+ .filter { phoneNumber in
+ phoneNumber.reservedForSecondFactor
+ }
+ .sorted { lhs, rhs in
+ if lhs.defaultSecondFactor {
+ return true
+ } else if rhs.defaultSecondFactor {
+ return false
+ } else {
+ return lhs.createdAt < rhs.createdAt
+ }
+ }
+ }
+
+ var body: some View {
+ @Bindable var sharedState = sharedState
+
+ Section {
+ VStack(spacing: 0) {
+ if user?.totpEnabled == true {
+ UserProfileMfaRow(
+ style: .authenticatorApp,
+ isDefault: true
+ )
+ }
+
+ if clerk.environment.mfaPhoneCodeIsEnabled {
+ ForEach(mfaPhoneNumbers) { phoneNumber in
+ UserProfileMfaRow(
+ style: .sms(phoneNumber: phoneNumber),
+ isDefault: phoneNumber.defaultSecondFactor && user?.totpEnabled == false
+ )
+ }
+ }
+
+ if clerk.environment.mfaBackupCodeIsEnabled {
+ if user?.backupCodeEnabled == true {
+ UserProfileMfaRow(
+ style: .backupCodes
+ )
+ }
+ }
+
+ UserProfileButtonRow(text: "Add two-step verification") {
+ sharedState.chooseMfaTypeIsPresented = true
+ }
+ }
+ .background(theme.colors.background)
+ } header: {
+ UserProfileSectionHeader(text: "TWO-STEP VERIFICATION")
+ }
+ .sheet(isPresented: $sharedState.chooseMfaTypeIsPresented) {
+ UserProfileAddMfaView(contentHeight: $addMfaHeight)
+ .presentationDetents([.height(addMfaHeight)])
+ }
+ }
+}
+
+#Preview {
+ UserProfileMfaSection()
+ .environment(\.clerk, .mock)
+ .environment(\.clerkTheme, .clerk)
+ .environment(\.userProfileSharedState, .init())
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfilePasskeyRenameView.swift b/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfilePasskeyRenameView.swift
new file mode 100644
index 000000000..ac03e8a7a
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfilePasskeyRenameView.swift
@@ -0,0 +1,105 @@
+//
+// UserProfilePasskeyRenameView.swift
+// Clerk
+//
+// Created by Mike Pitre on 5/30/25.
+//
+
+#if os(iOS)
+
+import SwiftUI
+
+struct UserProfilePasskeyRenameView: View {
+ @Environment(\.clerkTheme) private var theme
+ @Environment(\.dismiss) private var dismiss
+
+ @State private var passkeyName: String
+ @State private var error: Error?
+ @FocusState private var isFocused: Bool
+
+ let passkey: Passkey
+
+ init(passkey: Passkey) {
+ self.passkey = passkey
+ self._passkeyName = State(initialValue: passkey.name)
+ }
+
+ var body: some View {
+ NavigationStack {
+ ScrollView {
+ VStack(spacing: 24) {
+ Text("You can change the passkey name to make it easier to find.", bundle: .module)
+ .font(theme.fonts.subheadline)
+ .foregroundStyle(theme.colors.textSecondary)
+ .fixedSize(horizontal: false, vertical: true)
+
+ VStack(spacing: 4) {
+ ClerkTextField("Name of passkey", text: $passkeyName)
+ .focused($isFocused)
+ .onFirstAppear {
+ isFocused = true
+ }
+
+ if let error {
+ ErrorText(error: error, alignment: .leading)
+ .font(theme.fonts.subheadline)
+ .transition(.blurReplace.animation(.default))
+ .id(error.localizedDescription)
+ }
+ }
+
+ AsyncButton {
+ await renamePasskey()
+ } label: { isRunning in
+ Text("Save", bundle: .module)
+ .frame(maxWidth: .infinity)
+ .overlayProgressView(isActive: isRunning) {
+ SpinnerView(color: theme.colors.textOnPrimaryBackground)
+ }
+ }
+ .buttonStyle(.primary())
+ }
+ .padding(24)
+ }
+ .navigationBarTitleDisplayMode(.inline)
+ .preGlassSolidNavBar()
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") {
+ dismiss()
+ }
+ .foregroundStyle(theme.colors.primary)
+ }
+
+ ToolbarItem(placement: .principal) {
+ Text("Rename passkey", bundle: .module)
+ .font(theme.fonts.headline)
+ .foregroundStyle(theme.colors.text)
+ }
+ }
+ }
+ .presentationBackground(theme.colors.background)
+ .background(theme.colors.background)
+ }
+}
+
+extension UserProfilePasskeyRenameView {
+
+ func renamePasskey() async {
+ do {
+ try await passkey.update(name: passkeyName)
+ dismiss()
+ } catch {
+ self.error = error
+ ClerkLogger.error("Failed to rename passkey", error: error)
+ }
+ }
+
+}
+
+#Preview {
+ UserProfilePasskeyRenameView(passkey: .mock)
+ .environment(\.clerkTheme, .clerk)
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfilePasskeyRow.swift b/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfilePasskeyRow.swift
new file mode 100644
index 000000000..64c07e53a
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfilePasskeyRow.swift
@@ -0,0 +1,124 @@
+//
+// UserProfilePasskeyRow.swift
+// Clerk
+//
+// Created by Mike Pitre on 5/29/25.
+//
+
+#if os(iOS)
+
+import SwiftUI
+
+struct UserProfilePasskeyRow: View {
+ @Environment(\.clerk) private var clerk
+ @Environment(\.clerkTheme) private var theme
+
+ @State private var renameIsPresented = false
+ @State private var isConfirmingRemoval = false
+ @State private var removeResource: RemoveResource?
+ @State private var isLoading = false
+ @State private var error: Error?
+
+ let passkey: Passkey
+
+ var body: some View {
+ HStack(spacing: 16) {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(verbatim: passkey.name)
+ .font(theme.fonts.body)
+ .foregroundStyle(theme.colors.text)
+ .frame(minHeight: 22)
+
+ VStack(alignment: .leading, spacing: 0) {
+ Group {
+ Text("Created: \(passkey.createdAt.relativeNamedFormat)", bundle: .module)
+
+ if let lastUsedAt = passkey.lastUsedAt {
+ Text("Last used: \(lastUsedAt.relativeNamedFormat)", bundle: .module)
+ }
+ }
+ .font(theme.fonts.subheadline)
+ .foregroundStyle(theme.colors.textSecondary)
+ .frame(minHeight: 20)
+ }
+ }
+
+ Spacer(minLength: 0)
+
+ Menu {
+ Button {
+ renameIsPresented = true
+ } label: {
+ Text("Rename", bundle: .module)
+ }
+
+ AsyncButton(role: .destructive) {
+ removeResource = .passkey(passkey)
+ } label: { isRunning in
+ Text("Remove", bundle: .module)
+ }
+
+ } label: {
+ Image("icon-three-dots-vertical", bundle: .module)
+ .resizable()
+ .scaledToFit()
+ .foregroundColor(theme.colors.textSecondary)
+ .frame(width: 20, height: 20)
+ }
+ .frame(width: 30, height: 30)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal, 24)
+ .padding(.vertical, 16)
+ .overlayProgressView(isActive: isLoading)
+ .overlay(alignment: .bottom) {
+ Rectangle()
+ .frame(height: 1)
+ .foregroundStyle(theme.colors.border)
+ }
+ .clerkErrorPresenting($error)
+ .sheet(isPresented: $renameIsPresented) {
+ UserProfilePasskeyRenameView(passkey: passkey)
+ }
+ .confirmationDialog(
+ removeResource?.messageLine1 ?? "",
+ isPresented: $isConfirmingRemoval,
+ titleVisibility: .visible,
+ actions: {
+ AsyncButton(role: .destructive) {
+ await removeResource()
+ } label: { isRunning in
+ Text(removeResource?.title ?? "", bundle: .module)
+ }
+ .onIsRunningChanged { isLoading = $0 }
+ }
+ )
+ .onChange(of: removeResource) {
+ if $1 != nil { isConfirmingRemoval = true }
+ }
+ .animation(.default, value: isLoading)
+ }
+}
+
+extension UserProfilePasskeyRow {
+
+ private func removeResource() async {
+ defer { removeResource = nil }
+
+ do {
+ try await removeResource?.deleteAction()
+ } catch {
+ self.error = error
+ ClerkLogger.error("Failed to remove passkey resource", error: error)
+ }
+ }
+
+}
+
+#Preview {
+ UserProfilePasskeyRow(passkey: .mock)
+ .environment(\.clerk, .mock)
+ .environment(\.clerkTheme, .clerk)
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfilePasskeySection.swift b/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfilePasskeySection.swift
new file mode 100644
index 000000000..8cc498187
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfilePasskeySection.swift
@@ -0,0 +1,68 @@
+//
+// UserProfilePasskeySection.swift
+// Clerk
+//
+// Created by Mike Pitre on 5/29/25.
+//
+
+#if os(iOS)
+
+import SwiftUI
+
+struct UserProfilePasskeySection: View {
+ @Environment(\.clerk) private var clerk
+ @Environment(\.clerkTheme) private var theme
+
+ @State private var error: Error?
+
+ var user: User? {
+ clerk.user
+ }
+
+ var sortedPasskeys: [Passkey] {
+ guard let user else { return [] }
+ return user.passkeys.sorted { lhs, rhs in
+ lhs.createdAt < rhs.createdAt
+ }
+ }
+
+ var body: some View {
+ Section {
+ VStack(spacing: 0) {
+ ForEach(sortedPasskeys) {
+ UserProfilePasskeyRow(passkey: $0)
+ }
+
+ UserProfileButtonRow(text: "Add a passkey") {
+ await createPasskey()
+ }
+ }
+ .background(theme.colors.background)
+ } header: {
+ UserProfileSectionHeader(text: "PASSKEYS")
+ }
+ .clerkErrorPresenting($error)
+ }
+}
+
+extension UserProfilePasskeySection {
+ func createPasskey() async {
+ guard let user else { return }
+
+ do {
+ try await user.createPasskey()
+ } catch {
+ if error.isUserCancelledError { return }
+ self.error = error
+ ClerkLogger.error("Failed to create passkey", error: error)
+ }
+ }
+}
+
+#Preview {
+ UserProfilePasskeySection()
+ .environment(\.clerk, .mock)
+ .environment(\.clerkTheme, .clerk)
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfilePasswordSection.swift b/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfilePasswordSection.swift
new file mode 100644
index 000000000..c01c06090
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfilePasswordSection.swift
@@ -0,0 +1,91 @@
+//
+// UserProfilePasswordRow.swift
+// Clerk
+//
+// Created by Mike Pitre on 5/12/25.
+//
+
+#if os(iOS)
+
+import Foundation
+import SwiftUI
+
+struct UserProfilePasswordSection: View {
+ @Environment(\.clerk) private var clerk
+ @Environment(\.clerkTheme) private var theme
+
+ enum PasswordAction: Hashable, Identifiable {
+ case add, reset
+ var id: Self { self }
+ }
+
+ @State private var passwordAction: PasswordAction?
+
+ var user: User? { clerk.user }
+
+ var body: some View {
+ Section {
+ Button {
+ passwordAction = .reset
+ } label: {
+ buttonLabel
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .contentShape(.rect)
+ }
+ .overlay(alignment: .bottom) {
+ Rectangle()
+ .frame(height: 1)
+ .foregroundStyle(theme.colors.border)
+ }
+ .background(theme.colors.background)
+ .buttonStyle(.pressedBackground)
+ .simultaneousGesture(TapGesture())
+ } header: {
+ UserProfileSectionHeader(text: "PASSWORD")
+ }
+ .sheet(item: $passwordAction) { action in
+ UserProfileChangePasswordView(isAddingPassword: action == .add)
+ }
+ }
+
+ @ViewBuilder
+ private var buttonLabel: some View {
+ if user?.passwordEnabled == true {
+ VStack(spacing: 0) {
+ HStack(alignment: .top, spacing: 16) {
+ Image("icon-lock", bundle: .module)
+ .resizable()
+ .scaledToFit()
+ .frame(width: 24, height: 24)
+ .foregroundStyle(theme.colors.textSecondary)
+ Text(verbatim: "•••••••••••••••••••••••••")
+ .font(theme.fonts.subheadline)
+ .foregroundStyle(theme.colors.textSecondary)
+ .frame(minHeight: 20)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal, 24)
+ .padding(.vertical, 16)
+
+ Rectangle()
+ .frame(height: 1)
+ .foregroundStyle(theme.colors.border)
+
+ UserProfileButtonRow(text: "Change password") {}
+ .disabled(true)
+ }
+ } else {
+ UserProfileButtonRow(text: "Add password") {
+ passwordAction = .add
+ }
+ }
+ }
+
+}
+
+#Preview {
+ UserProfilePasswordSection()
+ .environment(\.clerkTheme, .clerk)
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfilePhoneRow.swift b/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfilePhoneRow.swift
new file mode 100644
index 000000000..2b2ab8209
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfilePhoneRow.swift
@@ -0,0 +1,154 @@
+//
+// UserProfilePhoneRow.swift
+// Clerk
+//
+// Created by Mike Pitre on 6/10/25.
+//
+
+#if os(iOS)
+
+import SwiftUI
+
+struct UserProfilePhoneRow: View {
+ @Environment(\.clerk) private var clerk
+ @Environment(\.clerkTheme) private var theme
+
+ @State private var addPhoneNumberDestination: UserProfileAddPhoneView.Destination?
+ @State private var isLoading = false
+ @State private var removeResource: RemoveResource?
+ @State private var isConfirmingRemoval = false
+ @State private var error: Error?
+
+ var user: User? {
+ clerk.user
+ }
+
+ let phoneNumber: PhoneNumber
+
+ var body: some View {
+ HStack(spacing: 16) {
+ VStack(alignment: .leading, spacing: 4) {
+ WrappingHStack(alignment: .leading) {
+ if user?.primaryPhoneNumber == phoneNumber {
+ Badge(key: "Primary", style: .secondary)
+ }
+
+ if phoneNumber.verification?.status != .verified {
+ Badge(key: "Unverified", style: .warning)
+ }
+
+ if phoneNumber.reservedForSecondFactor {
+ Badge(key: "MFA reserved", style: .secondary)
+ }
+ }
+
+ Text(phoneNumber.phoneNumber.formattedAsPhoneNumberIfPossible)
+ .font(theme.fonts.body)
+ .foregroundStyle(theme.colors.text)
+ .frame(minHeight: 22)
+ }
+
+ Spacer()
+
+ Menu {
+ if user?.primaryPhoneNumber != phoneNumber, phoneNumber.verification?.status == .verified {
+ AsyncButton {
+ await setPhoneAsPrimary(phoneNumber)
+ } label: { isRunning in
+ Text("Set as primary", bundle: .module)
+ }
+ .onIsRunningChanged { isLoading = $0 }
+ .onDisappear { isLoading = false }
+ }
+
+ if phoneNumber.verification?.status != .verified {
+ Button {
+ addPhoneNumberDestination = .verify(phoneNumber)
+ } label: {
+ Text("Verify", bundle: .module)
+ }
+ }
+
+ Button(role: .destructive) {
+ removeResource = .phoneNumber(phoneNumber)
+ } label: {
+ Text("Remove phone", bundle: .module)
+ }
+
+ } label: {
+ Image("icon-three-dots-vertical", bundle: .module)
+ .resizable()
+ .scaledToFit()
+ .foregroundColor(theme.colors.textSecondary)
+ .frame(width: 20, height: 20)
+ }
+ .frame(width: 30, height: 30)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal, 24)
+ .padding(.vertical, 16)
+ .overlayProgressView(isActive: isLoading)
+ .overlay(alignment: .bottom) {
+ Rectangle()
+ .frame(height: 1)
+ .foregroundStyle(theme.colors.border)
+ }
+ .clerkErrorPresenting($error)
+ .onChange(of: removeResource) {
+ if $1 != nil { isConfirmingRemoval = true }
+ }
+ .sheet(item: $addPhoneNumberDestination) {
+ UserProfileAddPhoneView(desintation: $0)
+ }
+ .confirmationDialog(
+ removeResource?.messageLine1 ?? "",
+ isPresented: $isConfirmingRemoval,
+ titleVisibility: .visible,
+ actions: {
+ AsyncButton(role: .destructive) {
+ await removeResource(removeResource)
+ } label: { isRunning in
+ Text(removeResource?.title ?? "", bundle: .module)
+ }
+ .onIsRunningChanged { isLoading = $0 }
+
+ Button(role: .cancel) {
+ isConfirmingRemoval = false
+ removeResource = nil
+ } label: {
+ Text("Cancel", bundle: .module)
+ }
+ }
+ )
+ }
+}
+
+extension UserProfilePhoneRow {
+
+ private func setPhoneAsPrimary(_ phone: PhoneNumber) async {
+ do {
+ try await user?.update(.init(primaryPhoneNumberId: phone.id))
+ } catch {
+ self.error = error
+ ClerkLogger.error("Failed to set phone as primary", error: error)
+ }
+ }
+
+ private func removeResource(_ resource: RemoveResource?) async {
+ defer { removeResource = nil }
+
+ do {
+ try await resource?.deleteAction()
+ } catch {
+ self.error = error
+ ClerkLogger.error("Failed to remove phone resource", error: error)
+ }
+ }
+
+}
+
+#Preview {
+ UserProfilePhoneRow(phoneNumber: .mock)
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileSectionHeader.swift b/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileSectionHeader.swift
new file mode 100644
index 000000000..56df24598
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileSectionHeader.swift
@@ -0,0 +1,42 @@
+//
+// UserProfileSectionHeader.swift
+// Clerk
+//
+// Created by Mike Pitre on 5/9/25.
+//
+
+#if os(iOS)
+
+import SwiftUI
+
+struct UserProfileSectionHeader: View {
+ @Environment(\.clerkTheme) private var theme
+
+ let text: LocalizedStringKey
+
+ var body: some View {
+ Text(text, bundle: .module)
+ .font(theme.fonts.caption)
+ .fontWeight(.medium)
+ .foregroundStyle(theme.colors.textSecondary)
+ .frame(minHeight: 16)
+ .padding(.horizontal, 24)
+ .padding(.top, 32)
+ .padding(.bottom, 16)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .overlay(alignment: .bottom) {
+ Rectangle()
+ .frame(height: 1)
+ .foregroundStyle(theme.colors.border)
+ }
+ }
+}
+
+#Preview {
+ @Previewable @Environment(\.clerkTheme) var theme
+
+ UserProfileSectionHeader(text: "EMAIL ADDRESSES")
+ .background(theme.colors.backgroundSecondary)
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileSecurityView.swift b/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileSecurityView.swift
new file mode 100644
index 000000000..24dbc5d87
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileSecurityView.swift
@@ -0,0 +1,98 @@
+//
+// UserProfileSecurityView.swift
+// Clerk
+//
+// Created by Mike Pitre on 5/12/25.
+//
+
+#if os(iOS)
+
+import FactoryKit
+import SwiftUI
+
+struct UserProfileSecurityView: View {
+ @Environment(\.clerk) private var clerk
+ @Environment(\.clerkTheme) private var theme
+ @Environment(\.userProfileSharedState) private var sharedState
+
+ @State private var error: Error?
+
+ var user: User? {
+ clerk.user
+ }
+
+ var environment: Clerk.Environment {
+ clerk.environment
+ }
+
+ var body: some View {
+ @Bindable var sharedState = sharedState
+
+ VStack(spacing: 0) {
+ if let user {
+ ScrollView {
+ VStack(spacing: 0) {
+ if environment.passwordIsEnabled {
+ UserProfilePasswordSection()
+ }
+
+ if environment.passkeyIsEnabled {
+ UserProfilePasskeySection()
+ }
+
+ if environment.mfaIsEnabled {
+ UserProfileMfaSection()
+ }
+
+ if let sessions = clerk.sessionsByUserId[user.id],
+ !sessions.filter({ $0.latestActivity != nil }).isEmpty
+ {
+ UserProfileDevicesSection()
+ }
+
+ if environment.deleteSelfIsEnabled {
+ UserProfileDeleteAccountSection()
+ }
+ }
+ .animation(.default, value: user)
+ .animation(.default, value: clerk.sessionsByUserId)
+ .animation(.default, value: environment)
+ }
+ .background(theme.colors.backgroundSecondary)
+ }
+
+ SecuredByClerkFooter()
+ }
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .principal) {
+ Text("Security", bundle: .module)
+ .font(theme.fonts.headline)
+ .fontWeight(.semibold)
+ .foregroundStyle(theme.colors.text)
+ }
+ }
+ .presentationBackground(theme.colors.background)
+ .background(theme.colors.background)
+ .clerkErrorPresenting($error)
+ .task {
+ _ = try? await user?.getSessions()
+ }
+ .task {
+ _ = try? await Client.get()
+ }
+ .sheet(item: $sharedState.presentedAddMfaType) {
+ $0.view
+ }
+ }
+}
+
+#Preview {
+ NavigationStack {
+ UserProfileSecurityView()
+ }
+ .environment(\.clerk, .mock)
+ .environment(\.clerkTheme, .clerk)
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileUpdateProfileView.swift b/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileUpdateProfileView.swift
new file mode 100644
index 000000000..9ade3b191
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileUpdateProfileView.swift
@@ -0,0 +1,233 @@
+//
+// UserProfileUpdateProfileView.swift
+// Clerk
+//
+// Created by Mike Pitre on 5/14/25.
+//
+
+#if os(iOS)
+
+import Kingfisher
+import PhotosUI
+import SwiftUI
+
+struct UserProfileUpdateProfileView: View {
+ @Environment(\.clerk) private var clerk
+ @Environment(\.clerkTheme) private var theme
+ @Environment(\.dismiss) private var dismiss
+
+ @State private var photosPickerIsPresented = false
+ @State private var photosPickerItem: PhotosPickerItem?
+ @State private var imageIsLoading = false
+
+ @State private var firstName: String
+ @State private var lastName: String
+ @State private var username: String
+ @State private var error: Error?
+
+ var environment: Clerk.Environment {
+ clerk.environment
+ }
+
+ var showSaveButton: Bool {
+ environment.usernameIsEnabled || environment.firstNameIsEnabled || environment.lastNameIsEnabled
+ }
+
+ let user: User
+
+ init(user: User) {
+ self.user = user
+ _username = State(initialValue: user.username ?? "")
+ _firstName = State(initialValue: user.firstName ?? "")
+ _lastName = State(initialValue: user.lastName ?? "")
+ }
+
+ var body: some View {
+ NavigationStack {
+ ScrollView {
+ VStack(spacing: 32) {
+ menu
+
+ VStack(spacing: 24) {
+ if environment.usernameIsEnabled {
+ ClerkTextField("Username", text: $username)
+ .textContentType(.username)
+ .autocorrectionDisabled()
+ .textInputAutocapitalization(.never)
+ }
+
+ if environment.firstNameIsEnabled {
+ ClerkTextField("First name", text: $firstName)
+ .textContentType(.givenName)
+ }
+
+ if environment.lastNameIsEnabled {
+ ClerkTextField("Last name", text: $lastName)
+ .textContentType(.familyName)
+ }
+
+ AsyncButton {
+ await save()
+ } label: { isRunning in
+ Text("Save")
+ .frame(maxWidth: .infinity)
+ .overlayProgressView(isActive: isRunning) {
+ SpinnerView(color: theme.colors.textOnPrimaryBackground)
+ }
+ }
+ .buttonStyle(.primary())
+ }
+ }
+ .padding(.horizontal, 24)
+ .padding(.bottom, 24)
+ .padding(.top, 60)
+ }
+ .clerkErrorPresenting($error)
+ .navigationBarTitleDisplayMode(.inline)
+ .preGlassSolidNavBar()
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") {
+ dismiss()
+ }
+ .foregroundStyle(theme.colors.primary)
+ }
+
+ ToolbarItem(placement: .principal) {
+ Text("Update profile", bundle: .module)
+ .font(theme.fonts.headline)
+ .foregroundStyle(theme.colors.text)
+ }
+ }
+ .photosPicker(
+ isPresented: $photosPickerIsPresented,
+ selection: $photosPickerItem,
+ matching: .images
+ )
+ .onChange(of: photosPickerItem) { _, item in
+ guard let item else { return }
+
+ Task {
+ imageIsLoading = true
+
+ do {
+ guard
+ let data = try await item.loadTransferable(type: Data.self),
+ let uiImage = UIImage(data: data),
+ let resizedData =
+ uiImage
+ .resizedMaintainingAspectRatio(to: .init(width: 200, height: 200))
+ .jpegData(compressionQuality: 0.8)
+ else {
+ throw ClerkClientError(message: "There was an error loading the image from the photos library.")
+ }
+
+ try await user.setProfileImage(imageData: resizedData)
+ } catch {
+ self.error = error
+ ClerkLogger.error("Failed to set profile image", error: error)
+ imageIsLoading = false
+ }
+ }
+ }
+ }
+ .presentationBackground(theme.colors.background)
+ .background(theme.colors.background)
+ }
+
+ @ViewBuilder
+ private var menu: some View {
+ KFImage(URL(string: user.imageUrl))
+ .resizable()
+ .fade(duration: 0.25)
+ .placeholder { progress in
+ Image("icon-profile", bundle: .module)
+ .resizable()
+ .scaledToFit()
+ .foregroundStyle(theme.colors.primary.gradient)
+ .opacity(0.5)
+ }
+ .onSuccess { _ in imageIsLoading = false }
+ .onFailure { _ in imageIsLoading = false }
+ .overlay {
+ if imageIsLoading {
+ theme.colors.inputBorderFocused
+ SpinnerView(color: theme.colors.textOnPrimaryBackground)
+ .frame(width: 24, height: 24)
+ }
+ }
+ .scaledToFill()
+ .frame(width: 96, height: 96)
+ .clipShape(.circle)
+ .overlay(alignment: .bottomTrailing) {
+ Menu {
+ menuContent
+ } label: {
+ Image("icon-edit", bundle: .module)
+ .resizable()
+ .scaledToFit()
+ .frame(width: 16, height: 16)
+ .padding(8)
+ .foregroundStyle(theme.colors.textSecondary)
+ .background(theme.colors.background)
+ .clipShape(.rect(cornerRadius: theme.design.borderRadius))
+ .overlay {
+ RoundedRectangle(cornerRadius: theme.design.borderRadius)
+ .strokeBorder(theme.colors.buttonBorder, lineWidth: 1)
+ }
+ .shadow(color: theme.colors.buttonBorder, radius: 1, x: 0, y: 1)
+ }
+ }
+ }
+
+ @ViewBuilder
+ private var menuContent: some View {
+ Button("Choose from photo library") {
+ photosPickerIsPresented = true
+ }
+
+ if user.hasImage == true {
+ AsyncButton(role: .destructive) {
+ imageIsLoading = true
+ defer { imageIsLoading = false }
+
+ do {
+ try await user.deleteProfileImage()
+ } catch {
+ self.error = error
+ ClerkLogger.error("Failed to delete profile image", error: error)
+ }
+ } label: { _ in
+ Text("Remove photo")
+ }
+ }
+ }
+}
+
+extension UserProfileUpdateProfileView {
+
+ func save() async {
+ do {
+ try await user.update(
+ .init(
+ username: environment.usernameIsEnabled ? username : nil,
+ firstName: environment.firstNameIsEnabled ? firstName : nil,
+ lastName: environment.lastNameIsEnabled ? lastName : nil
+ ))
+
+ dismiss()
+ } catch {
+ self.error = error
+ ClerkLogger.error("Failed to update user profile", error: error)
+ }
+ }
+
+}
+
+#Preview {
+ UserProfileUpdateProfileView(user: .mock)
+ .environment(\.clerk, .mock)
+ .environment(\.clerkTheme, .clerk)
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileVerifyView.swift b/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileVerifyView.swift
new file mode 100644
index 000000000..b42eab3e2
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileVerifyView.swift
@@ -0,0 +1,335 @@
+//
+// UserProfileVerifyView.swift
+// Clerk
+//
+// Created by Mike Pitre on 5/16/25.
+//
+
+#if os(iOS)
+
+import SwiftUI
+
+struct UserProfileVerifyView: View {
+ @Environment(\.clerk) private var clerk
+ @Environment(\.clerkTheme) private var theme
+ @Environment(\.userProfileSharedState) private var sharedState
+ @Environment(\.dismiss) private var environmentDismiss
+
+ @State private var code = ""
+ @State private var error: Error?
+ @State private var remainingSeconds: Int = 30
+ @State private var timer: Timer?
+ @State private var verificationState = VerificationState.default
+ @State private var otpFieldState = OTPField.FieldState.default
+
+ @FocusState private var otpFieldIsFocused: Bool
+
+ var user: User? { clerk.user }
+
+ @State var mode: Mode
+ let onCompletion: (_ backupCodes: [String]?) -> Void
+ let customDismiss: (() -> Void)?
+
+ enum Mode {
+ case email(EmailAddress)
+ case phone(PhoneNumber)
+ case totp
+ }
+
+ private var titleKey: LocalizedStringKey {
+ switch mode {
+ case .email:
+ "Verify email address"
+ case .phone:
+ "Verify phone number"
+ case .totp:
+ "Verify authenticator app"
+ }
+ }
+
+ private var instructionsText: Text {
+ switch mode {
+ case .email(let emailAddress):
+ Text("Enter the verification code sent to \(emailAddress.emailAddress)", bundle: .module)
+ case .phone(let phoneNumber):
+ Text("Enter the verification code sent to \(phoneNumber.phoneNumber.formattedAsPhoneNumberIfPossible)", bundle: .module)
+ case .totp:
+ Text("Enter the verification code from your authenticator application.", bundle: .module)
+ }
+ }
+
+ private func dismiss() {
+ if let customDismiss {
+ customDismiss()
+ } else {
+ environmentDismiss()
+ }
+ }
+
+ private var hasCancelAction: Bool {
+ switch mode {
+ case .email, .phone:
+ true
+ case .totp:
+ false
+ }
+ }
+
+ private var lastCodeSentAtKey: String {
+ switch mode {
+ case .email(let emailAddress):
+ return emailAddress.emailAddress
+ case .phone(let phoneNumber):
+ return phoneNumber.phoneNumber
+ case .totp:
+ return ""
+ }
+ }
+
+ enum VerificationState {
+ case `default`
+ case verifying
+ case success
+ case error(Error)
+
+ var showResend: Bool {
+ switch self {
+ case .default, .error:
+ true
+ case .verifying, .success:
+ false
+ }
+ }
+ }
+
+ var showResend: Bool {
+ switch mode {
+ case .email, .phone:
+ true
+ case .totp:
+ false
+ }
+ }
+
+ var resendString: LocalizedStringKey {
+ if remainingSeconds > 0 {
+ "Resend (\(remainingSeconds))"
+ } else {
+ "Resend"
+ }
+ }
+
+ init(
+ mode: Mode,
+ onCompletion: @escaping (_ backupCodes: [String]?) -> Void,
+ customDismiss: (() -> Void)? = nil
+ ) {
+ self._mode = .init(initialValue: mode)
+ self.onCompletion = onCompletion
+ self.customDismiss = customDismiss
+ }
+
+ var body: some View {
+ ScrollView {
+ VStack(spacing: 24) {
+ instructionsText
+ .font(theme.fonts.subheadline)
+ .foregroundStyle(theme.colors.textSecondary)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .fixedSize(horizontal: false, vertical: true)
+
+ OTPField(
+ code: $code,
+ fieldState: $otpFieldState,
+ isFocused: $otpFieldIsFocused
+ ) { code in
+ await attempt()
+ }
+ .onAppear {
+ verificationState = .default
+ otpFieldIsFocused = true
+ }
+
+ Group {
+ switch verificationState {
+ case .verifying:
+ HStack(spacing: 4) {
+ SpinnerView()
+ .frame(width: 16, height: 16)
+ Text("Verifying...", bundle: .module)
+ }
+ .foregroundStyle(theme.colors.textSecondary)
+ case .success:
+ HStack(spacing: 4) {
+ Image("icon-check-circle", bundle: .module)
+ .foregroundStyle(theme.colors.success)
+ Text("Success", bundle: .module)
+ .foregroundStyle(theme.colors.textSecondary)
+ }
+ case .error(let error):
+ ErrorText(error: error)
+ default:
+ EmptyView()
+ }
+ }
+ .font(theme.fonts.subheadline)
+
+ if showResend {
+ AsyncButton {
+ await prepare()
+ } label: { isRunning in
+ HStack(spacing: 0) {
+ Text("Didn't recieve a code? ", bundle: .module)
+ Text(resendString, bundle: .module)
+ .foregroundStyle(
+ remainingSeconds > 0
+ ? theme.colors.textSecondary
+ : theme.colors.primary
+ )
+ .monospacedDigit()
+ .contentTransition(.numericText(countsDown: true))
+ .animation(.default, value: remainingSeconds)
+ }
+ .overlayProgressView(isActive: isRunning)
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(
+ .secondary(
+ config: .init(
+ emphasis: .none,
+ size: .small
+ )
+ )
+ )
+ .disabled(remainingSeconds > 0)
+ .simultaneousGesture(TapGesture())
+ }
+ }
+ .padding(24)
+ }
+ .clerkErrorPresenting($error)
+ .presentationBackground(theme.colors.background)
+ .background(theme.colors.background)
+ .navigationBarTitleDisplayMode(.inline)
+ .preGlassSolidNavBar()
+ .navigationBarBackButtonHidden(hasCancelAction)
+ .toolbar {
+ if hasCancelAction {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") {
+ dismiss()
+ }
+ .foregroundStyle(theme.colors.primary)
+ }
+ }
+
+ ToolbarItem(placement: .principal) {
+ Text(titleKey, bundle: .module)
+ .font(theme.fonts.headline)
+ .foregroundStyle(theme.colors.text)
+ }
+ }
+ .taskOnce {
+ if showResend {
+ startTimer()
+ if sharedState.lastCodeSentAt[lastCodeSentAtKey] == nil {
+ await prepare()
+ }
+ }
+ }
+ }
+}
+
+extension UserProfileVerifyView {
+
+ func startTimer() {
+ updateRemainingSeconds()
+ self.timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
+ Task { @MainActor in
+ updateRemainingSeconds()
+ }
+ }
+ RunLoop.current.add(timer!, forMode: .common)
+ }
+
+ func updateRemainingSeconds() {
+ guard let lastCodeSentAt = sharedState.lastCodeSentAt[lastCodeSentAtKey] else {
+ return
+ }
+
+ let elapsed = Int(Date.now.timeIntervalSince(lastCodeSentAt))
+ remainingSeconds = max(0, 30 - elapsed)
+ }
+
+ func prepare() async {
+ code = ""
+ verificationState = .default
+
+ do {
+ switch mode {
+ case .email(let emailAddress):
+ try await emailAddress.prepareVerification(strategy: .emailCode)
+ case .phone(let phoneNumber):
+ try await phoneNumber.prepareVerification()
+ case .totp:
+ return
+ }
+
+ sharedState.lastCodeSentAt[lastCodeSentAtKey] = .now
+ updateRemainingSeconds()
+ } catch {
+ otpFieldIsFocused = false
+ self.error = error
+ ClerkLogger.error("Failed to prepare verification", error: error)
+ }
+ }
+
+ func attempt() async {
+ verificationState = .verifying
+
+ do {
+ switch mode {
+ case .email(let emailAddress):
+ try await emailAddress.attemptVerification(strategy: .emailCode(code: code))
+ sharedState.lastCodeSentAt[emailAddress.emailAddress] = nil
+ verificationState = .success
+ onCompletion(nil)
+ case .phone(let phoneNumber):
+ try await phoneNumber.attemptVerification(code: code)
+ sharedState.lastCodeSentAt[phoneNumber.phoneNumber] = nil
+ verificationState = .success
+ onCompletion(nil)
+ case .totp:
+ guard let user else { return }
+ let totp = try await user.verifyTOTP(code: code)
+ verificationState = .success
+ onCompletion(totp.backupCodes)
+ }
+ } catch {
+ otpFieldState = .error
+ verificationState = .error(error)
+
+ if let clerkError = error as? ClerkAPIError, clerkError.meta?["param_name"] == nil {
+ self.error = clerkError
+ otpFieldIsFocused = false
+ }
+ }
+ }
+
+}
+
+#Preview("Email") {
+ NavigationStack {
+ UserProfileVerifyView(mode: .email(.mock)) { _ in }
+ }
+ .environment(\.clerkTheme, .clerk)
+}
+
+#Preview("Phone") {
+ NavigationStack {
+ UserProfileVerifyView(mode: .phone(.mock)) { _ in }
+ }
+ .environment(\.clerkTheme, .clerk)
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileView.swift b/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileView.swift
new file mode 100644
index 000000000..0bd535132
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Components/UserProfile/UserProfileView.swift
@@ -0,0 +1,328 @@
+//
+// UserProfileView.swift
+// Clerk
+//
+// Created by Mike Pitre on 5/8/25.
+//
+
+#if os(iOS)
+
+import Kingfisher
+import SwiftUI
+
+/// A comprehensive user profile view that displays user information and account management options.
+///
+/// ``UserProfileView`` provides an interface for users to view and manage their profile,
+/// including personal information, security settings, account switching, and sign-out functionality.
+///
+/// ## Usage
+///
+/// As a full-screen profile view:
+///
+/// ```swift
+/// struct ProfileView: View {
+/// @Environment(\.clerk) private var clerk
+///
+/// var body: some View {
+/// Group {
+/// if clerk.user != nil {
+/// UserProfileView(isDismissable: false)
+/// } else {
+/// AuthView(isDismissable: false)
+/// }
+/// }
+/// }
+/// }
+/// ```
+///
+/// As a dismissable sheet:
+///
+/// ```swift
+/// struct MainView: View {
+/// @State private var profileIsPresented = false
+///
+/// var body: some View {
+/// Button("Show Profile") {
+/// profileIsPresented = true
+/// }
+/// .sheet(isPresented: $profileIsPresented) {
+/// UserProfileView()
+/// }
+/// }
+/// }
+/// ```
+public struct UserProfileView: View {
+ @Environment(\.clerk) private var clerk
+ @Environment(\.clerkTheme) private var theme
+ @Environment(\.dismiss) private var dismiss
+
+ @State private var updateProfileIsPresented = false
+ @State private var accountSwitcherHeight: CGFloat = 400
+ @State private var sharedState = SharedState()
+ @State private var error: Error?
+
+ let isDismissable: Bool
+
+ /// Creates a new user profile view.
+ ///
+ /// - Parameter isDismissable: Whether the view can be dismissed by the user.
+ /// When `true`, a dismiss button appears in the navigation bar and the view
+ /// can be used in sheets or other dismissable contexts. When `false`, no
+ /// dismiss button is shown, making it suitable for full-screen usage.
+ /// Defaults to `true`.
+ public init(isDismissable: Bool = true) {
+ self.isDismissable = isDismissable
+ }
+
+ var user: User? {
+ clerk.user
+ }
+
+ @ViewBuilder
+ private func userProfileHeader(_ user: User) -> some View {
+ VStack(spacing: 12) {
+ KFImage(URL(string: user.imageUrl))
+ .resizable()
+ .placeholder {
+ Image("icon-profile", bundle: .module)
+ .resizable()
+ .scaledToFit()
+ .foregroundStyle(theme.colors.primary.gradient)
+ .opacity(0.5)
+ }
+ .fade(duration: 0.25)
+ .scaledToFill()
+ .frame(width: 96, height: 96)
+ .clipShape(.circle)
+
+ if let fullName = user.fullName {
+ Text(fullName)
+ .font(theme.fonts.title2)
+ .fontWeight(.bold)
+ .frame(minHeight: 28)
+ .foregroundStyle(theme.colors.text)
+ }
+
+ Button {
+ updateProfileIsPresented = true
+ } label: {
+ Text("Update profile", bundle: .module)
+ }
+ .buttonStyle(.secondary(config: .init(size: .small)))
+ .simultaneousGesture(TapGesture())
+ }
+ .padding(32)
+ .frame(maxWidth: .infinity)
+ }
+
+ @ViewBuilder
+ private func row(
+ icon: String,
+ text: LocalizedStringKey,
+ action: @escaping () async -> Void
+ ) -> some View {
+ AsyncButton {
+ await action()
+ } label: { isRunning in
+ UserProfileRowView(icon: icon, text: text)
+ .overlayProgressView(isActive: isRunning)
+ }
+ .overlay(alignment: .bottom) {
+ Rectangle()
+ .frame(height: 1)
+ .foregroundStyle(theme.colors.border)
+ }
+ .buttonStyle(.pressedBackground)
+ .simultaneousGesture(TapGesture())
+ }
+
+ public var body: some View {
+ if let user {
+ NavigationStack(path: $sharedState.path) {
+ VStack(spacing: 0) {
+ ScrollView {
+ LazyVStack(spacing: 0) {
+ userProfileHeader(user)
+
+ VStack(spacing: 48) {
+ VStack(spacing: 0) {
+ row(icon: "icon-profile", text: "Profile") {
+ sharedState.path.append(Destination.profileDetail)
+ }
+
+ row(icon: "icon-security", text: "Security") {
+ sharedState.path.append(Destination.security)
+ }
+ }
+ .background(theme.colors.background)
+ .overlay(alignment: .top) {
+ Rectangle()
+ .frame(height: 1)
+ .foregroundStyle(theme.colors.border)
+ }
+
+ VStack(spacing: 0) {
+ if clerk.environment.mutliSessionModeIsEnabled {
+ if let activeSessions = clerk.client?.activeSessions, activeSessions.count > 1 {
+ row(icon: "icon-switch", text: "Switch account") {
+ sharedState.accountSwitcherIsPresented = true
+ }
+ }
+
+ row(icon: "icon-plus", text: "Add account") {
+ sharedState.authViewIsPresented = true
+ }
+ }
+
+ row(icon: "icon-sign-out", text: "Sign out") {
+ guard let sessionId = clerk.session?.id else { return }
+ await signOut(sessionId: sessionId)
+ }
+ }
+ .background(theme.colors.background)
+ .overlay(alignment: .top) {
+ Rectangle()
+ .frame(height: 1)
+ .foregroundStyle(theme.colors.border)
+ }
+ }
+ }
+ }
+ .background(theme.colors.backgroundSecondary)
+
+ SecuredByClerkFooter()
+ }
+ .animation(.default, value: user)
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .principal) {
+ Text("Account", bundle: .module)
+ .font(theme.fonts.headline)
+ .fontWeight(.semibold)
+ .foregroundStyle(theme.colors.text)
+ }
+
+ if isDismissable {
+ ToolbarItem(placement: .topBarTrailing) {
+ DismissButton()
+ }
+ }
+ }
+ .navigationDestination(for: Destination.self) {
+ $0.view
+ }
+ }
+ .tint(theme.colors.primary)
+ .presentationBackground(theme.colors.background)
+ .background(theme.colors.background)
+ .clerkErrorPresenting($error)
+ .sheet(isPresented: $sharedState.accountSwitcherIsPresented) {
+ UserButtonAccountSwitcher(contentHeight: $accountSwitcherHeight)
+ .presentationDetents([.height(accountSwitcherHeight)])
+ }
+ .sheet(isPresented: $updateProfileIsPresented) {
+ UserProfileUpdateProfileView(user: user)
+ }
+ .sheet(isPresented: $sharedState.authViewIsPresented) {
+ AuthView()
+ .interactiveDismissDisabled()
+ }
+ .task {
+ for await event in clerk.authEventEmitter.events {
+ switch event {
+ case .signInCompleted, .signUpCompleted:
+ sharedState.authViewIsPresented = false
+ }
+ }
+ }
+ .task(id: user) {
+ await getSessionsOnAllDevices()
+ }
+ .task {
+ _ = try? await Clerk.Environment.get()
+ }
+ .task {
+ _ = try? await Client.get()
+ }
+ .environment(\.userProfileSharedState, sharedState)
+ }
+ }
+}
+
+extension UserProfileView {
+
+ func signOut(sessionId: String) async {
+ do {
+ try await clerk.signOut(sessionId: sessionId)
+ if clerk.session == nil {
+ dismiss()
+ }
+ } catch {
+ self.error = error
+ ClerkLogger.error("Failed to sign out", error: error)
+ }
+ }
+
+ func getSessionsOnAllDevices() async {
+ guard let user else { return }
+ do {
+ try await user.getSessions()
+ } catch {
+ if error.isCancellationError {
+ ClerkLogger.error("Get sessions on all devices cancelled.", error: error)
+ } else {
+ self.error = error
+ ClerkLogger.error("Failed to get sessions on all devices", error: error)
+ }
+ }
+ }
+
+}
+
+extension UserProfileView {
+ enum Destination: Hashable {
+ case profileDetail
+ case security
+
+ @MainActor
+ @ViewBuilder
+ var view: some View {
+ switch self {
+ case .profileDetail:
+ UserProfileDetailView()
+ case .security:
+ UserProfileSecurityView()
+ }
+ }
+ }
+}
+
+extension UserProfileView {
+ @Observable
+ class SharedState {
+ var path = NavigationPath()
+ var lastCodeSentAt: [String: Date] = [:]
+ var accountSwitcherIsPresented = false
+ var authViewIsPresented = false
+ var chooseMfaTypeIsPresented = false
+ var presentedAddMfaType: UserProfileAddMfaView.PresentedView?
+ }
+}
+
+extension EnvironmentValues {
+ @Entry var userProfileSharedState = UserProfileView.SharedState()
+}
+
+#Preview("Dismissable") {
+ UserProfileView()
+ .environment(\.clerk, .mock)
+ .environment(\.clerkTheme, .clerk)
+}
+
+#Preview("Not dismissable") {
+ UserProfileView(isDismissable: false)
+ .environment(\.clerk, .mock)
+ .environment(\.clerkTheme, .clerk)
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Extensions/Array+Ext.swift b/Sources/Clerk/ClerkUI/Extensions/Array+Ext.swift
new file mode 100644
index 000000000..d20f537a0
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Extensions/Array+Ext.swift
@@ -0,0 +1,22 @@
+//
+// Array+Ext.swift
+// Clerk
+//
+// Created by Mike Pitre on 6/25/25.
+//
+
+#if os(iOS)
+
+import Foundation
+
+extension Array where Element == String {
+ func sortedByPriority(_ priorityOrder: [String]) -> [String] {
+ return self.sorted { first, second in
+ let firstPriority = priorityOrder.firstIndex(of: first) ?? Int.max
+ let secondPriority = priorityOrder.firstIndex(of: second) ?? Int.max
+ return firstPriority < secondPriority
+ }
+ }
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Extensions/Color+Ext.swift b/Sources/Clerk/ClerkUI/Extensions/Color+Ext.swift
new file mode 100644
index 000000000..a5cc06834
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Extensions/Color+Ext.swift
@@ -0,0 +1,66 @@
+//
+// Color+Ext.swift
+// Clerk
+//
+// Created by Mike Pitre on 4/17/25.
+//
+
+#if os(iOS)
+
+import Foundation
+import SwiftUI
+
+extension Color {
+
+ /// Returns whether the color is considered "dark" based on relative luminance.
+ var isDark: Bool {
+ let rgb = self.rgbComponents
+ let luminance = 0.2126 * rgb.red.luminanceComponent + 0.7152 * rgb.green.luminanceComponent + 0.0722 * rgb.blue.luminanceComponent
+ return luminance < 0.5
+ }
+
+ /// Mixes the current color with another color by the given amount (0 = self, 1 = other).
+ func mix(with color: Color, amount: CGFloat) -> Color {
+ let from = self.rgbComponents
+ let to = color.rgbComponents
+
+ let r = from.red + (to.red - from.red) * amount
+ let g = from.green + (to.green - from.green) * amount
+ let b = from.blue + (to.blue - from.blue) * amount
+
+ return Color(red: Double(r), green: Double(g), blue: Double(b))
+ }
+
+ /// Lightens the color by mixing it with white.
+ func lighten(by amount: CGFloat) -> Color {
+ return mix(with: .white, amount: amount)
+ }
+
+ /// Darkens the color by mixing it with black.
+ func darken(by amount: CGFloat) -> Color {
+ return mix(with: .black, amount: amount)
+ }
+
+ private var rgbComponents: (red: CGFloat, green: CGFloat, blue: CGFloat) {
+ #if os(iOS) || os(tvOS) || os(watchOS)
+ let uiColor = UIColor(self)
+ var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0
+ uiColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
+ return (red, green, blue)
+ #elseif os(macOS)
+ let nsColor = NSColor(self)
+ let rgbColor = nsColor.usingColorSpace(.sRGB) ?? .black
+ return (rgbColor.redComponent, rgbColor.greenComponent, rgbColor.blueComponent)
+ #endif
+ }
+
+}
+
+private extension CGFloat {
+ /// Converts an sRGB component to its linearized form for luminance calculation.
+ var luminanceComponent: CGFloat {
+ return self <= 0.03928 ? self / 12.92 : pow((self + 0.055) / 1.055, 2.4)
+ }
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Extensions/Date+Ext.swift b/Sources/Clerk/ClerkUI/Extensions/Date+Ext.swift
new file mode 100644
index 000000000..29d8bf1e4
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Extensions/Date+Ext.swift
@@ -0,0 +1,22 @@
+//
+// Date+Ext.swift
+// Clerk
+//
+// Created by Mike Pitre on 5/29/25.
+//
+
+#if os(iOS)
+
+import Foundation
+
+extension Date {
+
+ var relativeNamedFormat: String {
+ var formatStyle = Date.RelativeFormatStyle()
+ formatStyle.presentation = .named
+ return self.formatted(formatStyle)
+ }
+
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Extensions/Environment+Ext.swift b/Sources/Clerk/ClerkUI/Extensions/Environment+Ext.swift
new file mode 100644
index 000000000..a7d06e245
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Extensions/Environment+Ext.swift
@@ -0,0 +1,144 @@
+//
+// Environment+Ext.swift
+// Clerk
+//
+// Created by Mike Pitre on 4/14/25.
+//
+
+#if os(iOS)
+
+import Foundation
+
+extension Clerk.Environment {
+
+ var authenticatableSocialProviders: [OAuthProvider] {
+ guard let social = userSettings?.social else {
+ return []
+ }
+
+ let authenticatables = social.filter { key, value in
+ value.authenticatable && value.enabled
+ }
+
+ return authenticatables.map({
+ OAuthProvider(strategy: $0.value.strategy)
+ }).sorted()
+ }
+
+ var allSocialProviders: [OAuthProvider] {
+ guard let social = userSettings?.social else {
+ return []
+ }
+
+ let enabledProviders = social.filter(\.value.enabled)
+
+ return enabledProviders.map({
+ OAuthProvider(strategy: $0.value.strategy)
+ }).sorted()
+ }
+
+ var enabledFirstFactorAttributes: [String] {
+ guard let userSettings else { return [] }
+
+ return userSettings.attributes
+ .filter { _, value in
+ value.enabled && value.usedForFirstFactor
+ }
+ .map(\.key)
+ }
+
+ var mutliSessionModeIsEnabled: Bool {
+ guard let authConfig else { return false }
+ return authConfig.singleSessionMode == false
+ }
+
+ var billingIsEnabled: Bool {
+ guard let commerceSettings else { return false }
+ return commerceSettings.billing.enabled
+ }
+
+ var passwordIsEnabled: Bool {
+ guard let userSettings else { return false }
+ return userSettings.attributes.contains { key, value in
+ key == "password" && value.enabled
+ }
+ }
+
+ var passkeyIsEnabled: Bool {
+ guard let userSettings else { return false }
+ return userSettings.attributes.contains { key, value in
+ key == "passkey" && value.enabled
+ }
+ }
+
+ var mfaIsEnabled: Bool {
+ guard let userSettings else { return false }
+ return userSettings.attributes.contains { _, value in
+ value.enabled && value.usedForSecondFactor
+ }
+ }
+
+ var mfaAuthenticatorAppIsEnabled: Bool {
+ guard let userSettings else { return false }
+ return userSettings.attributes.contains { key, value in
+ key == "authenticator_app" && value.enabled && value.usedForSecondFactor
+ }
+ }
+
+ var mfaPhoneCodeIsEnabled: Bool {
+ guard let userSettings else { return false }
+ return userSettings.attributes.contains { key, value in
+ key == "phone_number" && value.enabled && value.usedForSecondFactor
+ }
+ }
+
+ var mfaBackupCodeIsEnabled: Bool {
+ guard let userSettings else { return false }
+ return userSettings.attributes.contains { key, value in
+ key == "backup_code" && value.enabled && value.usedForSecondFactor
+ }
+ }
+
+ var deleteSelfIsEnabled: Bool {
+ guard let userSettings else { return false }
+ return userSettings.actions.deleteSelf
+ }
+
+ var emailIsEnabled: Bool {
+ guard let userSettings else { return false }
+ return userSettings.attributes.contains { key, value in
+ key == "email_address" && value.enabled
+ }
+ }
+
+ var phoneNumberIsEnabled: Bool {
+ guard let userSettings else { return false }
+ return userSettings.attributes.contains { key, value in
+ key == "phone_number" && value.enabled
+ }
+ }
+
+ var usernameIsEnabled: Bool {
+ guard let userSettings else { return false }
+ return userSettings.attributes.contains { key, value in
+ key == "username" && value.enabled
+ }
+ }
+
+ var firstNameIsEnabled: Bool {
+ guard let userSettings else { return false }
+ return userSettings.attributes.contains { key, value in
+ key == "first_name" && value.enabled
+ }
+ }
+
+ var lastNameIsEnabled: Bool {
+ guard let userSettings else { return false }
+ return userSettings.attributes.contains { key, value in
+ key == "last_name" && value.enabled
+ }
+ }
+
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Extensions/Error+Ext.swift b/Sources/Clerk/ClerkUI/Extensions/Error+Ext.swift
new file mode 100644
index 000000000..5ccd73ad7
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Extensions/Error+Ext.swift
@@ -0,0 +1,39 @@
+//
+// Error+Ext.swift
+// Clerk
+//
+// Created by Mike Pitre on 4/23/25.
+//
+
+#if os(iOS)
+
+import AuthenticationServices
+import Foundation
+
+extension Error {
+
+ var isUserCancelledError: Bool {
+ if case ASWebAuthenticationSessionError.canceledLogin = self { return true }
+
+ if let authError = self as? ASAuthorizationError, authError.errorUserInfo["NSLocalizedFailureReason"] == nil {
+ return true
+ }
+
+ return false
+ }
+
+ var isCancellationError: Bool {
+ if self is CancellationError {
+ return true
+ }
+
+ if let nsError = self as NSError?, nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorCancelled {
+ return true
+ }
+
+ return false
+ }
+
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Extensions/ExternalAccount+Ext.swift b/Sources/Clerk/ClerkUI/Extensions/ExternalAccount+Ext.swift
new file mode 100644
index 000000000..293bd3176
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Extensions/ExternalAccount+Ext.swift
@@ -0,0 +1,33 @@
+//
+// ExternalAccount+Ext.swift
+// Clerk
+//
+// Created by Mike Pitre on 5/9/25.
+//
+
+import Foundation
+
+extension ExternalAccount {
+
+ var oauthProvider: OAuthProvider {
+ .init(strategy: provider)
+ }
+
+ var displayName: String {
+ if let username, !username.isEmptyTrimmed {
+ return username
+ } else {
+ return emailAddress
+ }
+ }
+
+ var fullName: String? {
+ let fullName = [firstName, lastName]
+ .compactMap(\.self)
+ .joined(separator: " ")
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+
+ return fullName.isEmptyTrimmed ? nil : fullName
+ }
+
+}
diff --git a/Sources/Clerk/ClerkUI/Extensions/Factor+Ext.swift b/Sources/Clerk/ClerkUI/Extensions/Factor+Ext.swift
new file mode 100644
index 000000000..34104b674
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Extensions/Factor+Ext.swift
@@ -0,0 +1,23 @@
+//
+// Factor+Ext.swift
+// Clerk
+//
+// Created by Mike Pitre on 4/23/25.
+//
+
+#if os(iOS)
+
+import Foundation
+
+extension Factor {
+
+ var isResetFactor: Bool {
+ return [
+ "reset_password_email_code",
+ "reset_password_phone_code"
+ ].contains(strategy)
+ }
+
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Extensions/Factor+Sorting.swift b/Sources/Clerk/ClerkUI/Extensions/Factor+Sorting.swift
new file mode 100644
index 000000000..1dd5169ec
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Extensions/Factor+Sorting.swift
@@ -0,0 +1,103 @@
+//
+// Factor+Sorting.swift
+// Clerk
+//
+// Created by Mike Pitre on 4/21/25.
+//
+
+#if os(iOS)
+
+import Foundation
+
+extension Factor {
+
+ private static let strategySortOrderPasswordPref = [
+ "passkey",
+ "password",
+ "email_code",
+ "phone_code"
+ ]
+
+ private static let strategySortOrderOtpPref = [
+ "email_code",
+ "phone_code",
+ "passkey",
+ "password"
+ ]
+
+ private static let strategySortOrderAllStrategies = [
+ "email_code",
+ "phone_code",
+ "passkey",
+ "password"
+ ]
+
+ private static let strategySortOrderBackupCodePref = [
+ "totp",
+ "phone_code",
+ "backup_code"
+ ]
+
+ public struct PasswordPrefComparator: SortComparator {
+ public typealias Compared = Factor
+ public var order: SortOrder = .forward
+
+ public func compare(_ lhs: Factor, _ rhs: Factor) -> ComparisonResult {
+ guard let order1 = strategySortOrderPasswordPref.firstIndex(of: lhs.strategy),
+ let order2 = strategySortOrderPasswordPref.firstIndex(of: rhs.strategy)
+ else {
+ return .orderedSame
+ }
+ return order1 < order2 ? .orderedAscending : .orderedDescending
+ }
+ }
+
+ public struct OtpPrefComparator: SortComparator {
+ public typealias Compared = Factor
+ public var order: SortOrder = .forward
+
+ public func compare(_ lhs: Factor, _ rhs: Factor) -> ComparisonResult {
+ guard let order1 = strategySortOrderOtpPref.firstIndex(of: lhs.strategy),
+ let order2 = strategySortOrderOtpPref.firstIndex(of: rhs.strategy)
+ else {
+ return .orderedSame
+ }
+ return order1 < order2 ? .orderedAscending : .orderedDescending
+ }
+ }
+
+ public struct BackupCodePrefComparator: SortComparator {
+ public typealias Compared = Factor
+ public var order: SortOrder = .forward
+
+ public func compare(_ lhs: Factor, _ rhs: Factor) -> ComparisonResult {
+ guard let order1 = strategySortOrderBackupCodePref.firstIndex(of: lhs.strategy),
+ let order2 = strategySortOrderBackupCodePref.firstIndex(of: rhs.strategy)
+ else {
+ return .orderedSame
+ }
+ return order1 < order2 ? .orderedAscending : .orderedDescending
+ }
+ }
+
+ public struct AllStrategiesButtonsComparator: SortComparator {
+ public typealias Compared = Factor
+ public var order: SortOrder = .forward
+
+ public func compare(_ lhs: Factor, _ rhs: Factor) -> ComparisonResult {
+ guard let order1 = strategySortOrderAllStrategies.firstIndex(of: lhs.strategy),
+ let order2 = strategySortOrderAllStrategies.firstIndex(of: rhs.strategy)
+ else {
+ return .orderedSame
+ }
+ return order1 < order2 ? .orderedAscending : .orderedDescending
+ }
+ }
+
+ public static let passwordPrefComparator = PasswordPrefComparator()
+ public static let otpPrefComparator = OtpPrefComparator()
+ public static let backupCodePrefComparator = BackupCodePrefComparator()
+ public static let allStrategiesButtonsComparator = AllStrategiesButtonsComparator()
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Extensions/PhoneNumber+Ext.swift b/Sources/Clerk/ClerkUI/Extensions/PhoneNumber+Ext.swift
new file mode 100644
index 000000000..e1a7ab871
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Extensions/PhoneNumber+Ext.swift
@@ -0,0 +1,37 @@
+//
+// File.swift
+// Clerk
+//
+// Created by Mike Pitre on 4/29/25.
+//
+
+#if os(iOS)
+
+import FactoryKit
+import Foundation
+import PhoneNumberKit
+
+extension PhoneNumberKit.PhoneNumberUtility {
+
+ var allCountries: [CountryCodePickerViewController.Country] {
+ self
+ .allCountries()
+ .compactMap({
+ CountryCodePickerViewController.Country(for: $0, with: self)
+ })
+ .sorted(by: {
+ $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
+ })
+ }
+
+}
+
+extension Container {
+
+ var phoneNumberUtility: Factory {
+ self { PhoneNumberUtility() }
+ }
+
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Extensions/Session+Ext.swift b/Sources/Clerk/ClerkUI/Extensions/Session+Ext.swift
new file mode 100644
index 000000000..6eafc5acb
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Extensions/Session+Ext.swift
@@ -0,0 +1,52 @@
+//
+// Session+Ext.swift
+// Clerk
+//
+// Created by Mike Pitre on 5/13/25.
+//
+
+#if os(iOS)
+
+import Foundation
+import SwiftUI
+
+extension Session {
+
+ @MainActor
+ var isThisDevice: Bool {
+ id == Clerk.shared.session?.id
+ }
+
+}
+
+extension SessionActivity {
+
+ var deviceText: Text {
+ if let deviceType {
+ return Text(verbatim: deviceType)
+ } else if let isMobile {
+ return Text(isMobile ? "Mobile device" : "Desktop device", bundle: .module)
+ } else {
+ return Text("Unknown device", bundle: .module)
+ }
+ }
+
+ var deviceImage: Image {
+ Image(isMobile == true ? "device-mobile" : "device-desktop", bundle: .module)
+ }
+
+ var browserFormatted: String {
+ [browserName, browserVersion].compactMap(\.self).joined(separator: " ")
+ }
+
+ var locationFormatted: String {
+ [city, country].compactMap(\.self).joined(separator: ", ")
+ }
+
+ var ipAndLocationFormatted: String {
+ [ipAddress, "(\(locationFormatted))"].compactMap(\.self).joined(separator: " ")
+ }
+
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Extensions/SignIn+Ext.swift b/Sources/Clerk/ClerkUI/Extensions/SignIn+Ext.swift
new file mode 100644
index 000000000..dadf09ab7
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Extensions/SignIn+Ext.swift
@@ -0,0 +1,110 @@
+//
+// SignIn+Ext.swift
+// Clerk
+//
+// Created by Mike Pitre on 4/21/25.
+//
+
+#if os(iOS)
+
+import Foundation
+
+extension SignIn {
+
+ @MainActor
+ var startingFirstFactor: Factor? {
+ let preferredSignInStrategy = Clerk.shared.environment.displayConfig?.preferredSignInStrategy
+
+ if preferredSignInStrategy == .password {
+ return factorWhenPasswordIsPreferred
+ } else {
+ return factorWhenOtpIsPreferred
+ }
+ }
+
+ var factorWhenPasswordIsPreferred: Factor? {
+
+ // email links are not supported on iOS
+ let availableFirstFactors = supportedFirstFactors?.filter { factor in
+ factor.strategy != "email_link"
+ }
+
+ if let passkeyFactor = availableFirstFactors?.first(where: { factor in
+ factor.strategy == "passkey"
+ }) {
+ return passkeyFactor
+ }
+
+ if let passwordFactor = availableFirstFactors?.first(where: { factor in
+ factor.strategy == "password"
+ }) {
+ return passwordFactor
+ }
+
+ let sortedFactors = availableFirstFactors?.sorted(using: Factor.passwordPrefComparator)
+
+ return availableFirstFactors?.first { factor in
+ factor.safeIdentifier == identifier
+ } ?? sortedFactors?.first
+ }
+
+ var factorWhenOtpIsPreferred: Factor? {
+
+ // email links are not supported on iOS
+ let availableFirstFactors = supportedFirstFactors?.filter { factor in
+ factor.strategy != "email_link"
+ }
+
+ if let passkeyFactor = availableFirstFactors?.first(where: { factor in
+ factor.strategy == "passkey"
+ }) {
+ return passkeyFactor
+ }
+
+ let sortedFactors = availableFirstFactors?.sorted(using: Factor.otpPrefComparator)
+
+ return sortedFactors?.first { factor in
+ factor.safeIdentifier == identifier
+ } ?? sortedFactors?.first
+
+ }
+
+ func alternativeFirstFactors(currentFactor: Factor?) -> [Factor] {
+ // Remove the current factor, reset factors, oauth factors, enterprise SSO factors, saml factors, passkey factors
+ let firstFactors = supportedFirstFactors?.filter { factor in
+ factor != currentFactor && factor.isResetFactor == false && !(factor.strategy).hasPrefix("oauth_") && factor.strategy != "enterprise_sso" && factor.strategy != "saml"
+ }
+
+ return (firstFactors ?? []).sorted(using: Factor.allStrategiesButtonsComparator)
+ }
+
+ var startingSecondFactor: Factor? {
+ if let totp = supportedSecondFactors?.first(where: { $0.strategy == "totp" }) {
+ return totp
+ }
+
+ if let phoneCode = supportedSecondFactors?.first(where: { $0.strategy == "phone_code" }) {
+ return phoneCode
+ }
+
+ return supportedSecondFactors?.first
+ }
+
+ func alternativeSecondFactors(currentFactor: Factor?) -> [Factor] {
+ (supportedSecondFactors?.filter { $0 != currentFactor } ?? [])
+ .sorted(using: Factor.backupCodePrefComparator)
+ }
+
+ var resetPasswordFactor: Factor? {
+ if let resetPasswordEmailFactor = identifyingFirstFactor(strategy: .resetPasswordEmailCode()) {
+ return resetPasswordEmailFactor
+ } else if let resetPasswordPhoneFactor = identifyingFirstFactor(strategy: .resetPasswordPhoneCode()) {
+ return resetPasswordPhoneFactor
+ } else {
+ return supportedFirstFactors?.first(where: \.isResetFactor)
+ }
+ }
+
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Extensions/SignUp+Ext.swift b/Sources/Clerk/ClerkUI/Extensions/SignUp+Ext.swift
new file mode 100644
index 000000000..47b09e76e
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Extensions/SignUp+Ext.swift
@@ -0,0 +1,54 @@
+//
+// SignUp+Ext.swift
+// Clerk
+//
+// Created by Mike Pitre on 6/20/25.
+//
+
+#if os(iOS)
+
+import Foundation
+
+extension SignUp {
+
+ static let fieldPriority: [String] = ["email_address", "phone_number", "username", "password"]
+ static let individuallyCollectableFields = ["email_address", "phone_number", "username", "password"]
+
+ var firstFieldToCollect: String? {
+ missingFields.sortedByPriority(SignUp.fieldPriority).first
+ }
+
+ var firstFieldToVerify: String? {
+ unverifiedFields.sortedByPriority(SignUp.fieldPriority).first
+ }
+
+ func fieldIsRequired(field: String) -> Bool {
+ requiredFields.contains(field)
+ }
+
+ var firstVerification: Verification? {
+ verifications.first(where: { $0.key == firstFieldToVerify })?.value
+ }
+
+ func fieldWasCollected(field: String) -> Bool {
+ switch field {
+ case "email_address":
+ return emailAddress != nil
+ case "phone_number":
+ return phoneNumber != nil
+ case "username":
+ return username != nil
+ case "password":
+ return passwordEnabled
+ case "first_name":
+ return firstName != nil
+ case "last_name":
+ return lastName != nil
+ default:
+ return false
+ }
+ }
+
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Extensions/String+Ext.swift b/Sources/Clerk/ClerkUI/Extensions/String+Ext.swift
new file mode 100644
index 000000000..08bf76bd2
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Extensions/String+Ext.swift
@@ -0,0 +1,49 @@
+//
+// String+Ext.swift.swift
+// Clerk
+//
+// Created by Mike Pitre on 4/29/25.
+//
+
+#if os(iOS)
+
+import FactoryKit
+import Foundation
+import PhoneNumberKit
+
+extension String {
+ var formattedAsPhoneNumberIfPossible: String {
+ let utility = Container.shared.phoneNumberUtility()
+ let partialFormatter = PartialFormatter(utility: utility, withPrefix: true)
+ return partialFormatter.formatPartial(self).nonBreaking
+ }
+
+ var nonBreaking: String {
+ self
+ .replacingOccurrences(of: " ", with: "\u{00A0}")
+ .replacingOccurrences(of: "-", with: "\u{2011}")
+ }
+
+ var capitalizedSentence: String {
+ let firstLetter = self.prefix(1).capitalized
+ let remainingLetters = self.dropFirst().lowercased()
+ return firstLetter + remainingLetters
+ }
+
+ var isEmptyTrimmed: Bool {
+ trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ }
+
+ var isEmailAddress: Bool {
+ let emailRegex = #"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"#
+ let emailPredicate = NSPredicate(format: "SELF MATCHES %@", emailRegex)
+ return emailPredicate.evaluate(with: self)
+ }
+
+ var isPhoneNumber: Bool {
+ let utility = Container.shared.phoneNumberUtility()
+ return utility.isValidPhoneNumber(self)
+ }
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Extensions/UIImage+Ext.swift b/Sources/Clerk/ClerkUI/Extensions/UIImage+Ext.swift
new file mode 100644
index 000000000..e7ca624b7
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Extensions/UIImage+Ext.swift
@@ -0,0 +1,32 @@
+//
+// UIImage+Ext.swift
+// Clerk
+//
+// Created by Mike Pitre on 5/15/25.
+//
+
+#if os(iOS)
+
+import Foundation
+import SwiftUI
+
+extension UIImage {
+ func resizedMaintainingAspectRatio(to targetSize: CGSize) -> UIImage {
+ let aspectRatio = size.width / size.height
+ let targetAspectRatio = targetSize.width / targetSize.height
+
+ let newSize: CGSize
+ if aspectRatio > targetAspectRatio {
+ newSize = CGSize(width: targetSize.width, height: targetSize.width / aspectRatio)
+ } else {
+ newSize = CGSize(width: targetSize.height * aspectRatio, height: targetSize.height)
+ }
+
+ let renderer = UIGraphicsImageRenderer(size: newSize)
+ return renderer.image { _ in
+ self.draw(in: CGRect(origin: .zero, size: newSize))
+ }
+ }
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Extensions/User+Ext.swift b/Sources/Clerk/ClerkUI/Extensions/User+Ext.swift
new file mode 100644
index 000000000..2d123241a
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Extensions/User+Ext.swift
@@ -0,0 +1,90 @@
+//
+// User+Ext.swift
+// Clerk
+//
+// Created by Mike Pitre on 5/1/25.
+//
+
+#if os(iOS)
+
+import Foundation
+
+extension User {
+
+ var fullName: String? {
+ let fullName = [firstName, lastName]
+ .compactMap(\.self)
+ .joined(separator: " ")
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+
+ return fullName.isEmptyTrimmed ? nil : fullName
+ }
+
+ var intials: String? {
+ let initials = [firstName ?? "", lastName ?? ""]
+ .compactMap(\.self)
+ .joined(separator: " ")
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+
+ return initials.isEmptyTrimmed ? nil : initials
+ }
+
+ var identifier: String? {
+ if let username, !username.isEmptyTrimmed {
+ return username
+ }
+
+ if let primaryEmailAddress, !primaryEmailAddress.emailAddress.isEmptyTrimmed {
+ return primaryEmailAddress.emailAddress
+ }
+
+ if let primaryPhoneNumber, !primaryPhoneNumber.phoneNumber.isEmptyTrimmed {
+ return primaryPhoneNumber.phoneNumber.formattedAsPhoneNumberIfPossible
+ }
+
+ return nil
+ }
+
+ @MainActor
+ var usernameForPasswordKeeper: String {
+ guard let userSettings = Clerk.shared.environment.userSettings else { return "" }
+
+ if userSettings.attributes.contains(where: { $0 == "username" && $1.enabled && $1.usedForFirstFactor }),
+ let username
+ {
+ return username
+ }
+
+ if userSettings.attributes.contains(where: { $0 == "email_address" && $1.enabled && $1.usedForFirstFactor }),
+ let email = primaryEmailAddress?.emailAddress
+ {
+ return email
+ }
+
+ if userSettings.attributes.contains(where: { $0 == "phone_number" && $1.enabled && $1.usedForFirstFactor }),
+ let phone = primaryPhoneNumber?.phoneNumber
+ {
+ return phone
+ }
+
+ return ""
+ }
+
+ @MainActor
+ var unconnectedProviders: [OAuthProvider] {
+ let socialProviders = Clerk.shared.environment.allSocialProviders
+ let verifiedExternalProviders = verifiedExternalAccounts.compactMap { $0.oauthProvider }
+ return socialProviders.filter { !verifiedExternalProviders.contains($0) }
+ }
+
+ var phoneNumbersAvailableForMfa: [PhoneNumber] {
+ phoneNumbers.filter { !$0.reservedForSecondFactor }
+ }
+
+ var phoneNumbersReservedForMfa: [PhoneNumber] {
+ phoneNumbers.filter { $0.verification?.status == .verified && $0.reservedForSecondFactor }
+ }
+
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Extensions/View+DismissKeyboard.swift b/Sources/Clerk/ClerkUI/Extensions/View+DismissKeyboard.swift
new file mode 100644
index 000000000..ce6d2dacc
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Extensions/View+DismissKeyboard.swift
@@ -0,0 +1,26 @@
+//
+// View+DismissKeyboard.swift
+// Clerk
+//
+// Created by Mike Pitre on 4/28/25.
+//
+
+#if os(iOS)
+import SwiftUI
+
+extension EnvironmentValues {
+ var dismissKeyboard: @MainActor () -> Void {
+ get { self[DismissKeyboardKey.self] }
+ set { self[DismissKeyboardKey.self] = newValue }
+ }
+}
+
+// Create a custom environment key
+private struct DismissKeyboardKey: @preconcurrency EnvironmentKey {
+ @MainActor static let defaultValue: @MainActor () -> Void = {
+ DispatchQueue.main.async {
+ _ = UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
+ }
+ }
+}
+#endif
diff --git a/Sources/Clerk/ClerkUI/Extensions/View+ErrorPresenting.swift b/Sources/Clerk/ClerkUI/Extensions/View+ErrorPresenting.swift
new file mode 100644
index 000000000..192b88baf
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Extensions/View+ErrorPresenting.swift
@@ -0,0 +1,94 @@
+//
+// View+ErrorPresenting.swift
+//
+//
+// Created by Mike Pitre on 05/08/25.
+//
+
+#if os(iOS)
+
+import SwiftUI
+
+struct ClerkErrorViewModifier: ViewModifier {
+ @Environment(\.clerkTheme) private var theme
+
+ @Binding var error: Error?
+ var onDismiss: ((Error?) -> Void)?
+ var actionProvider: ((Error) -> ErrorView.ActionConfig?)?
+
+ @State private var sheetHeight: CGFloat?
+
+ var detents: Set {
+ if let sheetHeight {
+ return [PresentationDetent.height(sheetHeight)]
+ } else {
+ return [.medium]
+ }
+ }
+
+ func body(content: Content) -> some View {
+ content
+ .sheet(
+ isPresented: Binding(
+ get: { error != nil },
+ set: { isPresented in
+ if !isPresented {
+ error = nil
+ }
+ }
+ ),
+ onDismiss: {
+ onDismiss?(error)
+ }
+ ) {
+ if let error {
+ ErrorView(error: error, action: actionProvider?(error))
+ .padding()
+ .onGeometryChange(
+ for: CGFloat.self,
+ of: { geometry in
+ geometry.size.height
+ },
+ action: { newValue in
+ sheetHeight = newValue
+ }
+ )
+ .presentationDetents(detents)
+ .presentationDragIndicator(.visible)
+ }
+ }
+ }
+}
+
+extension View {
+
+ func clerkErrorPresenting(
+ _ error: Binding,
+ onDismiss: ((Error?) -> Void)? = nil,
+ action: ((Error) -> ErrorView.ActionConfig?)? = nil
+ ) -> some View {
+ modifier(ClerkErrorViewModifier(error: error, onDismiss: onDismiss, actionProvider: action))
+ }
+}
+
+#Preview {
+ @Previewable @State var error: Error?
+
+ Button("Show Error") {
+ error = ClerkClientError(message: "Password is incorrect. Try again, or use another method.")
+ }
+ .clerkErrorPresenting(
+ $error,
+ onDismiss: { error in
+ print("dismissed")
+ },
+ action: { error in
+ .init(
+ text: "Call to action",
+ action: {
+ try! await Task.sleep(for: .seconds(1))
+ })
+ })
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Extensions/View+HiddenTextField.swift b/Sources/Clerk/ClerkUI/Extensions/View+HiddenTextField.swift
new file mode 100644
index 000000000..0278d23ee
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Extensions/View+HiddenTextField.swift
@@ -0,0 +1,53 @@
+//
+// HiddenTextField.swift
+// Clerk
+//
+// Created by Mike Pitre on 5/14/25.
+//
+
+#if os(iOS)
+
+import Foundation
+import SwiftUI
+
+// Apple uses heuristics to determine when to show the save to
+// keychain prompt based on present textfield content types.
+// This means we often need to "fake" having a certain textfields
+// in the view, in order for the prompt to appear when they disappear.
+struct HiddenTextFieldModifier: ViewModifier {
+
+ @Binding var text: String
+ let textContentType: UITextContentType
+ let isSecure: Bool
+
+ func body(content: Content) -> some View {
+ content
+ .background {
+ field
+ .textContentType(textContentType)
+ .opacity(0.00001)
+ .offset(y: -100)
+ .disabled(true)
+ .accessibilityHidden(true)
+ .allowsHitTesting(false)
+ }
+ }
+
+ @ViewBuilder
+ var field: some View {
+ if isSecure {
+ SecureField("", text: $text)
+ } else {
+ TextField("", text: $text)
+ }
+ }
+
+}
+
+extension View {
+ public func hiddenTextField(text: Binding, textContentType: UITextContentType, isSecure: Bool = false) -> some View {
+ modifier(HiddenTextFieldModifier(text: text, textContentType: textContentType, isSecure: isSecure))
+ }
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Extensions/View+OnFirstAppear.swift b/Sources/Clerk/ClerkUI/Extensions/View+OnFirstAppear.swift
new file mode 100644
index 000000000..483562216
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Extensions/View+OnFirstAppear.swift
@@ -0,0 +1,33 @@
+//
+// OnFirstAppear.swift
+// Clerk
+//
+// Created by Mike Pitre on 4/18/25.
+//
+
+#if os(iOS)
+
+import Foundation
+import SwiftUI
+
+private struct FirstAppear: ViewModifier {
+ let action: () -> Void
+
+ @State private var hasAppeared = false
+
+ func body(content: Content) -> some View {
+ content.onAppear {
+ guard !hasAppeared else { return }
+ hasAppeared = true
+ action()
+ }
+ }
+}
+
+extension View {
+ func onFirstAppear(_ action: @escaping () -> ()) -> some View {
+ modifier(FirstAppear(action: action))
+ }
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Extensions/View+PreGlass.swift b/Sources/Clerk/ClerkUI/Extensions/View+PreGlass.swift
new file mode 100644
index 000000000..5e0eaedf5
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Extensions/View+PreGlass.swift
@@ -0,0 +1,59 @@
+//
+// View+PreGlass.swift
+// Clerk
+//
+// Created by Mike Pitre on 6/11/25.
+//
+
+#if os(iOS)
+
+import Foundation
+import SwiftUI
+
+// MARK: - Solid Navbar
+
+struct PreGlassSolidNavBarModifier: ViewModifier {
+ @Environment(\.clerkTheme) private var theme
+
+ func body(content: Content) -> some View {
+ if #available(iOS 26.0, *) {
+ content
+ } else {
+ content
+ .toolbarBackground(.visible, for: .navigationBar)
+ .toolbarBackground(theme.colors.background, for: .navigationBar)
+ }
+ }
+
+}
+
+extension View {
+ public func preGlassSolidNavBar() -> some View {
+ modifier(PreGlassSolidNavBarModifier())
+ }
+}
+
+// MARK: - Detent Sheet Background
+
+struct PreGlassDetentSheetBackgroundModifier: ViewModifier {
+ @Environment(\.clerkTheme) private var theme
+
+ func body(content: Content) -> some View {
+ if #available(iOS 26.0, *) {
+ content
+ } else {
+ content
+ .background(theme.colors.background)
+ .presentationBackground(theme.colors.background)
+ }
+ }
+
+}
+
+extension View {
+ public func preGlassDetentSheetBackground() -> some View {
+ modifier(PreGlassDetentSheetBackgroundModifier())
+ }
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Extensions/View+TaskOnce.swift b/Sources/Clerk/ClerkUI/Extensions/View+TaskOnce.swift
new file mode 100644
index 000000000..cb3f64124
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Extensions/View+TaskOnce.swift
@@ -0,0 +1,33 @@
+//
+// TaskOnce.swift
+// Clerk
+//
+// Created by Mike Pitre on 4/18/25.
+//
+
+#if os(iOS)
+
+import Foundation
+import SwiftUI
+
+private struct TaskOnce: ViewModifier {
+ let task: () async -> Void
+
+ @State private var hasAppeared = false
+
+ func body(content: Content) -> some View {
+ content.onAppear {
+ guard !hasAppeared else { return }
+ hasAppeared = true
+ Task { await task() }
+ }
+ }
+}
+
+extension View {
+ func taskOnce(_ task: @escaping () async -> ()) -> some View {
+ modifier(TaskOnce(task: task))
+ }
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Resources/Colors.xcassets/Background.colorset/Contents.json b/Sources/Clerk/ClerkUI/Resources/Colors.xcassets/Background.colorset/Contents.json
new file mode 100644
index 000000000..b39cca703
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Resources/Colors.xcassets/Background.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0xFF",
+ "green" : "0xFF",
+ "red" : "0xFF"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0x16",
+ "green" : "0x13",
+ "red" : "0x13"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Sources/Clerk/ClerkUI/Resources/Colors.xcassets/ClerkDanger.colorset/Contents.json b/Sources/Clerk/ClerkUI/Resources/Colors.xcassets/ClerkDanger.colorset/Contents.json
new file mode 100644
index 000000000..8a21c1f74
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Resources/Colors.xcassets/ClerkDanger.colorset/Contents.json
@@ -0,0 +1,20 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0x26",
+ "green" : "0x26",
+ "red" : "0xDC"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Sources/Clerk/ClerkUI/Resources/Colors.xcassets/ClerkNeutral.colorset/Contents.json b/Sources/Clerk/ClerkUI/Resources/Colors.xcassets/ClerkNeutral.colorset/Contents.json
new file mode 100644
index 000000000..2453ed8c9
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Resources/Colors.xcassets/ClerkNeutral.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0x34",
+ "green" : "0x2B",
+ "red" : "0x2B"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0xFF",
+ "green" : "0xFF",
+ "red" : "0xFF"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Sources/Clerk/ClerkUI/Resources/Colors.xcassets/ClerkPrimary.colorset/Contents.json b/Sources/Clerk/ClerkUI/Resources/Colors.xcassets/ClerkPrimary.colorset/Contents.json
new file mode 100644
index 000000000..9f293bec2
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Resources/Colors.xcassets/ClerkPrimary.colorset/Contents.json
@@ -0,0 +1,20 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0xFF",
+ "green" : "0x47",
+ "red" : "0x6C"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Sources/Clerk/ClerkUI/Resources/Colors.xcassets/ClerkTextOnPrimaryBackground.colorset/Contents.json b/Sources/Clerk/ClerkUI/Resources/Colors.xcassets/ClerkTextOnPrimaryBackground.colorset/Contents.json
new file mode 100644
index 000000000..fafa47672
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Resources/Colors.xcassets/ClerkTextOnPrimaryBackground.colorset/Contents.json
@@ -0,0 +1,20 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0xFF",
+ "green" : "0xFF",
+ "red" : "0xFF"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Sources/Clerk/ClerkUI/Resources/Colors.xcassets/Contents.json b/Sources/Clerk/ClerkUI/Resources/Colors.xcassets/Contents.json
new file mode 100644
index 000000000..73c00596a
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Resources/Colors.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Sources/Clerk/ClerkUI/Resources/Colors.xcassets/Danger.colorset/Contents.json b/Sources/Clerk/ClerkUI/Resources/Colors.xcassets/Danger.colorset/Contents.json
new file mode 100644
index 000000000..352b8986f
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Resources/Colors.xcassets/Danger.colorset/Contents.json
@@ -0,0 +1,20 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0x44",
+ "green" : "0x44",
+ "red" : "0xEF"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Sources/Clerk/ClerkUI/Resources/Colors.xcassets/InputBackground.colorset/Contents.json b/Sources/Clerk/ClerkUI/Resources/Colors.xcassets/InputBackground.colorset/Contents.json
new file mode 100644
index 000000000..8db6b7a14
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Resources/Colors.xcassets/InputBackground.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0xFF",
+ "green" : "0xFF",
+ "red" : "0xFF"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0x26",
+ "green" : "0x21",
+ "red" : "0x21"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Sources/Clerk/ClerkUI/Resources/Colors.xcassets/InputText.colorset/Contents.json b/Sources/Clerk/ClerkUI/Resources/Colors.xcassets/InputText.colorset/Contents.json
new file mode 100644
index 000000000..53fab0add
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Resources/Colors.xcassets/InputText.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0x37",
+ "green" : "0x30",
+ "red" : "0x2F"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0xFF",
+ "green" : "0xFF",
+ "red" : "0xFF"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Sources/Clerk/ClerkUI/Resources/Colors.xcassets/Neutral.colorset/Contents.json b/Sources/Clerk/ClerkUI/Resources/Colors.xcassets/Neutral.colorset/Contents.json
new file mode 100644
index 000000000..0c600f92f
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Resources/Colors.xcassets/Neutral.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0x00",
+ "green" : "0x00",
+ "red" : "0x00"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0xFF",
+ "green" : "0xFF",
+ "red" : "0xFF"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Sources/Clerk/ClerkUI/Resources/Colors.xcassets/Primary.colorset/Contents.json b/Sources/Clerk/ClerkUI/Resources/Colors.xcassets/Primary.colorset/Contents.json
new file mode 100644
index 000000000..77744a47a
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Resources/Colors.xcassets/Primary.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0x37",
+ "green" : "0x30",
+ "red" : "0x2F"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0xFB",
+ "green" : "0xFA",
+ "red" : "0xFA"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Sources/Clerk/ClerkUI/Resources/Colors.xcassets/Success.colorset/Contents.json b/Sources/Clerk/ClerkUI/Resources/Colors.xcassets/Success.colorset/Contents.json
new file mode 100644
index 000000000..f18115ee0
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Resources/Colors.xcassets/Success.colorset/Contents.json
@@ -0,0 +1,20 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0x43",
+ "green" : "0xC5",
+ "red" : "0x22"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Sources/Clerk/ClerkUI/Resources/Colors.xcassets/Text.colorset/Contents.json b/Sources/Clerk/ClerkUI/Resources/Colors.xcassets/Text.colorset/Contents.json
new file mode 100644
index 000000000..53fab0add
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Resources/Colors.xcassets/Text.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0x37",
+ "green" : "0x30",
+ "red" : "0x2F"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0xFF",
+ "green" : "0xFF",
+ "red" : "0xFF"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Sources/Clerk/ClerkUI/Resources/Colors.xcassets/TextOnPrimaryBackground.colorset/Contents.json b/Sources/Clerk/ClerkUI/Resources/Colors.xcassets/TextOnPrimaryBackground.colorset/Contents.json
new file mode 100644
index 000000000..9c0e331e9
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Resources/Colors.xcassets/TextOnPrimaryBackground.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0xFF",
+ "green" : "0xFF",
+ "red" : "0xFF"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0x00",
+ "green" : "0x00",
+ "red" : "0x00"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Sources/Clerk/ClerkUI/Resources/Colors.xcassets/TextSecondary.colorset/Contents.json b/Sources/Clerk/ClerkUI/Resources/Colors.xcassets/TextSecondary.colorset/Contents.json
new file mode 100644
index 000000000..a38ac4399
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Resources/Colors.xcassets/TextSecondary.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0x86",
+ "green" : "0x76",
+ "red" : "0x74"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0xC2",
+ "green" : "0xB8",
+ "red" : "0xB7"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Sources/Clerk/ClerkUI/Resources/Colors.xcassets/Warning.colorset/Contents.json b/Sources/Clerk/ClerkUI/Resources/Colors.xcassets/Warning.colorset/Contents.json
new file mode 100644
index 000000000..d7b75ebca
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Resources/Colors.xcassets/Warning.colorset/Contents.json
@@ -0,0 +1,20 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0x16",
+ "green" : "0x6B",
+ "red" : "0xF3"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Sources/Clerk/ClerkUI/Resources/Images.xcassets/Contents.json b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/Contents.json
new file mode 100644
index 000000000..73c00596a
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Sources/Clerk/ClerkUI/Resources/Images.xcassets/clerk-logo.imageset/Contents.json b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/clerk-logo.imageset/Contents.json
new file mode 100644
index 000000000..03822d4c4
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/clerk-logo.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images" : [
+ {
+ "filename" : "clerk-logo.pdf",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/Sources/Clerk/ClerkUI/Resources/Images.xcassets/clerk-logo.imageset/clerk-logo.pdf b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/clerk-logo.imageset/clerk-logo.pdf
new file mode 100644
index 000000000..a9bf0996c
Binary files /dev/null and b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/clerk-logo.imageset/clerk-logo.pdf differ
diff --git a/Sources/Clerk/ClerkUI/Resources/Images.xcassets/device-desktop.imageset/Contents.json b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/device-desktop.imageset/Contents.json
new file mode 100644
index 000000000..a89f0a4b1
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/device-desktop.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "Group 90.pdf",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Sources/Clerk/ClerkUI/Resources/Images.xcassets/device-desktop.imageset/Group 90.pdf b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/device-desktop.imageset/Group 90.pdf
new file mode 100644
index 000000000..9b1ec46ab
Binary files /dev/null and b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/device-desktop.imageset/Group 90.pdf differ
diff --git a/Sources/Clerk/ClerkUI/Resources/Images.xcassets/device-mobile.imageset/Contents.json b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/device-mobile.imageset/Contents.json
new file mode 100644
index 000000000..9bcec75b4
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/device-mobile.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "Group 89.pdf",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Sources/Clerk/ClerkUI/Resources/Images.xcassets/device-mobile.imageset/Group 89.pdf b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/device-mobile.imageset/Group 89.pdf
new file mode 100644
index 000000000..a13ddc853
Binary files /dev/null and b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/device-mobile.imageset/Group 89.pdf differ
diff --git a/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-check-circle.imageset/Contents.json b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-check-circle.imageset/Contents.json
new file mode 100644
index 000000000..2c9b5c7ac
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-check-circle.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images" : [
+ {
+ "filename" : "icon-start.pdf",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-check-circle.imageset/icon-start.pdf b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-check-circle.imageset/icon-start.pdf
new file mode 100644
index 000000000..a7327407a
Binary files /dev/null and b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-check-circle.imageset/icon-start.pdf differ
diff --git a/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-check.imageset/Contents.json b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-check.imageset/Contents.json
new file mode 100644
index 000000000..5ce034a2e
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-check.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images" : [
+ {
+ "filename" : "check.pdf",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-check.imageset/check.pdf b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-check.imageset/check.pdf
new file mode 100644
index 000000000..c219f2abe
Binary files /dev/null and b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-check.imageset/check.pdf differ
diff --git a/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-chevron-right.imageset/Contents.json b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-chevron-right.imageset/Contents.json
new file mode 100644
index 000000000..3a93db89e
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-chevron-right.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images" : [
+ {
+ "filename" : "chevron-right.pdf",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-chevron-right.imageset/chevron-right.pdf b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-chevron-right.imageset/chevron-right.pdf
new file mode 100644
index 000000000..49ff0a29c
Binary files /dev/null and b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-chevron-right.imageset/chevron-right.pdf differ
diff --git a/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-clipboard.imageset/Contents.json b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-clipboard.imageset/Contents.json
new file mode 100644
index 000000000..3e078369a
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-clipboard.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images" : [
+ {
+ "filename" : "icon-start.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-clipboard.imageset/icon-start.svg b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-clipboard.imageset/icon-start.svg
new file mode 100644
index 000000000..feab9e950
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-clipboard.imageset/icon-start.svg
@@ -0,0 +1,5 @@
+
diff --git a/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-cog.imageset/Contents.json b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-cog.imageset/Contents.json
new file mode 100644
index 000000000..2290ce5d8
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-cog.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images" : [
+ {
+ "filename" : "cog.pdf",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-cog.imageset/cog.pdf b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-cog.imageset/cog.pdf
new file mode 100644
index 000000000..f49bc97b2
Binary files /dev/null and b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-cog.imageset/cog.pdf differ
diff --git a/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-credit-card.imageset/Contents.json b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-credit-card.imageset/Contents.json
new file mode 100644
index 000000000..cfc238a38
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-credit-card.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images" : [
+ {
+ "filename" : "credit-card.pdf",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-credit-card.imageset/credit-card.pdf b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-credit-card.imageset/credit-card.pdf
new file mode 100644
index 000000000..0256bbd3f
Binary files /dev/null and b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-credit-card.imageset/credit-card.pdf differ
diff --git a/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-edit.imageset/Contents.json b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-edit.imageset/Contents.json
new file mode 100644
index 000000000..06fc3b6df
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-edit.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images" : [
+ {
+ "filename" : "Right Icon.pdf",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-edit.imageset/Right Icon.pdf b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-edit.imageset/Right Icon.pdf
new file mode 100644
index 000000000..b9675ceeb
Binary files /dev/null and b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-edit.imageset/Right Icon.pdf differ
diff --git a/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-email.imageset/Contents.json b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-email.imageset/Contents.json
new file mode 100644
index 000000000..2c9b5c7ac
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-email.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images" : [
+ {
+ "filename" : "icon-start.pdf",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-email.imageset/icon-start.pdf b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-email.imageset/icon-start.pdf
new file mode 100644
index 000000000..45fb4dabb
Binary files /dev/null and b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-email.imageset/icon-start.pdf differ
diff --git a/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-fingerprint.imageset/Contents.json b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-fingerprint.imageset/Contents.json
new file mode 100644
index 000000000..2c9b5c7ac
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-fingerprint.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images" : [
+ {
+ "filename" : "icon-start.pdf",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-fingerprint.imageset/icon-start.pdf b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-fingerprint.imageset/icon-start.pdf
new file mode 100644
index 000000000..15cbe9f3d
Binary files /dev/null and b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-fingerprint.imageset/icon-start.pdf differ
diff --git a/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-key.imageset/Contents.json b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-key.imageset/Contents.json
new file mode 100644
index 000000000..58ec7a4d0
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-key.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images" : [
+ {
+ "filename" : "key.pdf",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-key.imageset/key.pdf b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-key.imageset/key.pdf
new file mode 100644
index 000000000..c994c861e
Binary files /dev/null and b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-key.imageset/key.pdf differ
diff --git a/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-lock.imageset/Contents.json b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-lock.imageset/Contents.json
new file mode 100644
index 000000000..933b987c1
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-lock.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images" : [
+ {
+ "filename" : "lock.pdf",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-lock.imageset/lock.pdf b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-lock.imageset/lock.pdf
new file mode 100644
index 000000000..a83b226a7
Binary files /dev/null and b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-lock.imageset/lock.pdf differ
diff --git a/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-phone.imageset/Contents.json b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-phone.imageset/Contents.json
new file mode 100644
index 000000000..20f107a31
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-phone.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images" : [
+ {
+ "filename" : "phone.pdf",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-phone.imageset/phone.pdf b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-phone.imageset/phone.pdf
new file mode 100644
index 000000000..8f1b1fa58
Binary files /dev/null and b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-phone.imageset/phone.pdf differ
diff --git a/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-plus.imageset/Contents.json b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-plus.imageset/Contents.json
new file mode 100644
index 000000000..0eb9612b3
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-plus.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images" : [
+ {
+ "filename" : "plus.pdf",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-plus.imageset/plus.pdf b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-plus.imageset/plus.pdf
new file mode 100644
index 000000000..4edf82c16
Binary files /dev/null and b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-plus.imageset/plus.pdf differ
diff --git a/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-profile.imageset/Contents.json b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-profile.imageset/Contents.json
new file mode 100644
index 000000000..c0e564655
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-profile.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images" : [
+ {
+ "filename" : "user-profile-avatar.pdf",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-profile.imageset/user-profile-avatar.pdf b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-profile.imageset/user-profile-avatar.pdf
new file mode 100644
index 000000000..211791953
Binary files /dev/null and b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-profile.imageset/user-profile-avatar.pdf differ
diff --git a/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-security.imageset/Contents.json b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-security.imageset/Contents.json
new file mode 100644
index 000000000..b060a2cc9
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-security.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images" : [
+ {
+ "filename" : "security.pdf",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-security.imageset/security.pdf b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-security.imageset/security.pdf
new file mode 100644
index 000000000..6da44a972
Binary files /dev/null and b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-security.imageset/security.pdf differ
diff --git a/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-sign-out.imageset/Contents.json b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-sign-out.imageset/Contents.json
new file mode 100644
index 000000000..a79f8273e
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-sign-out.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images" : [
+ {
+ "filename" : "sign-out.pdf",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-sign-out.imageset/sign-out.pdf b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-sign-out.imageset/sign-out.pdf
new file mode 100644
index 000000000..e7dd4f0f5
Binary files /dev/null and b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-sign-out.imageset/sign-out.pdf differ
diff --git a/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-sms.imageset/Contents.json b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-sms.imageset/Contents.json
new file mode 100644
index 000000000..2c9b5c7ac
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-sms.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images" : [
+ {
+ "filename" : "icon-start.pdf",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-sms.imageset/icon-start.pdf b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-sms.imageset/icon-start.pdf
new file mode 100644
index 000000000..d7c68ce15
Binary files /dev/null and b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-sms.imageset/icon-start.pdf differ
diff --git a/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-spinner.imageset/Contents.json b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-spinner.imageset/Contents.json
new file mode 100644
index 000000000..1f6115532
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-spinner.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images" : [
+ {
+ "filename" : "spinner.pdf",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-spinner.imageset/spinner.pdf b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-spinner.imageset/spinner.pdf
new file mode 100644
index 000000000..ddbd0b36b
Binary files /dev/null and b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-spinner.imageset/spinner.pdf differ
diff --git a/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-switch.imageset/Contents.json b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-switch.imageset/Contents.json
new file mode 100644
index 000000000..148e971c2
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-switch.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images" : [
+ {
+ "filename" : "switch.pdf",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-switch.imageset/switch.pdf b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-switch.imageset/switch.pdf
new file mode 100644
index 000000000..28f90c17a
Binary files /dev/null and b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-switch.imageset/switch.pdf differ
diff --git a/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-three-dots-vertical.imageset/Contents.json b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-three-dots-vertical.imageset/Contents.json
new file mode 100644
index 000000000..8bc0e2b3f
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-three-dots-vertical.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images" : [
+ {
+ "filename" : "three-dots-vertical.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-three-dots-vertical.imageset/three-dots-vertical.svg b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-three-dots-vertical.imageset/three-dots-vertical.svg
new file mode 100644
index 000000000..80b8f766b
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-three-dots-vertical.imageset/three-dots-vertical.svg
@@ -0,0 +1,5 @@
+
diff --git a/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-triangle-right.imageset/Contents.json b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-triangle-right.imageset/Contents.json
new file mode 100644
index 000000000..06fc3b6df
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-triangle-right.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images" : [
+ {
+ "filename" : "Right Icon.pdf",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-triangle-right.imageset/Right Icon.pdf b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-triangle-right.imageset/Right Icon.pdf
new file mode 100644
index 000000000..d24e47896
Binary files /dev/null and b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-triangle-right.imageset/Right Icon.pdf differ
diff --git a/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-up-down.imageset/Contents.json b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-up-down.imageset/Contents.json
new file mode 100644
index 000000000..c18f33287
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-up-down.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images" : [
+ {
+ "filename" : "Left Add-On Icon.pdf",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-up-down.imageset/Left Add-On Icon.pdf b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-up-down.imageset/Left Add-On Icon.pdf
new file mode 100644
index 000000000..79f396ded
Binary files /dev/null and b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-up-down.imageset/Left Add-On Icon.pdf differ
diff --git a/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-user.imageset/Contents.json b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-user.imageset/Contents.json
new file mode 100644
index 000000000..c2e176c81
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-user.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images" : [
+ {
+ "filename" : "user.pdf",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-user.imageset/user.pdf b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-user.imageset/user.pdf
new file mode 100644
index 000000000..cd57b90ec
Binary files /dev/null and b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-user.imageset/user.pdf differ
diff --git a/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-warning.imageset/Contents.json b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-warning.imageset/Contents.json
new file mode 100644
index 000000000..2c9b5c7ac
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-warning.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images" : [
+ {
+ "filename" : "icon-start.pdf",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-warning.imageset/icon-start.pdf b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-warning.imageset/icon-start.pdf
new file mode 100644
index 000000000..62e4cda06
Binary files /dev/null and b/Sources/Clerk/ClerkUI/Resources/Images.xcassets/icon-warning.imageset/icon-start.pdf differ
diff --git a/Sources/Clerk/ClerkUI/Resources/Localizable.xcstrings b/Sources/Clerk/ClerkUI/Resources/Localizable.xcstrings
new file mode 100644
index 000000000..1e9a3729a
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Resources/Localizable.xcstrings
@@ -0,0 +1,12110 @@
+{
+ "sourceLanguage" : "en",
+ "strings" : {
+ "" : {
+ "shouldTranslate" : false
+ },
+ " " : {
+ "shouldTranslate" : false
+ },
+ "%@ will be removed from this account. You will no longer be able to sign in using this connected account." : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "سيتم إزالة %@ من هذا الحساب. لن تتمكن بعد الآن من تسجيل الدخول باستخدام هذا الحساب المتصل."
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ wird von diesem Konto entfernt. Sie können sich mit diesem verbundenen Konto nicht mehr anmelden."
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Το %@ θα αφαιρεθεί από αυτόν τον λογαριασμό. Δεν θα μπορείτε πλέον να συνδεθείτε χρησιμοποιώντας αυτόν τον συνδεδεμένο λογαριασμό."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ será eliminado de esta cuenta. Ya no podrá iniciar sesión usando esta cuenta conectada."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ sera supprimé de ce compte. Vous ne pourrez plus vous connecter en utilisant ce compte connecté."
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ को इस खाते से हटा दिया जाएगा। आप इस जुड़े हुए खाते का उपयोग करके अब लॉग इन नहीं कर पाएंगे।"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ verrà rimosso da questo account. Non potrai più accedere utilizzando questo account collegato."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@はこのアカウントから削除されます。この接続されたアカウントを使用してサインインできなくなります。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@이(가) 이 계정에서 제거됩니다. 이 연결된 계정을 사용하여 더 이상 로그인할 수 없습니다."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@将从该账户中移除。您将无法再使用此关联账户登录。"
+ }
+ }
+ }
+ },
+ "%@ will be removed from this account. You will no longer be able to sign in using this email address." : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "سيتم إزالة %@ من هذا الحساب. لن تتمكن بعد الآن من تسجيل الدخول باستخدام عنوان البريد الإلكتروني هذا."
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ wird von diesem Konto entfernt. Sie können sich mit dieser E-Mail-Adresse nicht mehr anmelden."
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Το %@ θα αφαιρεθεί από αυτόν τον λογαριασμό. Δεν θα μπορείτε πλέον να συνδεθείτε χρησιμοποιώντας αυτή τη διεύθυνση email."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ será eliminado de esta cuenta. Ya no podrá iniciar sesión usando esta dirección de correo electrónico."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ sera supprimé de ce compte. Vous ne pourrez plus vous connecter en utilisant cette adresse e-mail."
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ को इस खाते से हटा दिया जाएगा। आप इस ईमेल पते का उपयोग करके अब लॉग इन नहीं कर पाएंगे।"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ verrà rimosso da questo account. Non potrai più accedere utilizzando questo indirizzo email."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@はこのアカウントから削除されます。このメールアドレスを使用してサインインできなくなります。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@이(가) 이 계정에서 제거됩니다. 이 이메일 주소를 사용하여 더 이상 로그인할 수 없습니다."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@将从该账户中移除。您将无法再使用此电子邮件地址登录。"
+ }
+ }
+ }
+ },
+ "%@ will be removed from this account. You will no longer be able to sign in using this passkey." : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "سيتم إزالة %@ من هذا الحساب. لن تتمكن بعد الآن من تسجيل الدخول باستخدام مفتاح المرور هذا."
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ wird von diesem Konto entfernt. Sie können sich mit diesem Passkey nicht mehr anmelden."
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Το %@ θα αφαιρεθεί από αυτόν τον λογαριασμό. Δεν θα μπορείτε πλέον να συνδεθείτε χρησιμοποιώντας αυτό το κλειδί πρόσβασης."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ será eliminado de esta cuenta. Ya no podrá iniciar sesión usando esta clave de acceso."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ sera supprimé de ce compte. Vous ne pourrez plus vous connecter en utilisant cette clé d'accès."
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ को इस खाते से हटा दिया जाएगा। आप इस पासकी का उपयोग करके अब लॉग इन नहीं कर पाएंगे।"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ verrà rimosso da questo account. Non potrai più accedere utilizzando questa chiave di accesso."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@はこのアカウントから削除されます。このパスキーを使用してサインインできなくなります。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@이(가) 이 계정에서 제거됩니다. 이 패스키를 사용하여 더 이상 로그인할 수 없습니다."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@将从该账户中移除。您将无法再使用此密码钥匙登录。"
+ }
+ }
+ }
+ },
+ "%@ will be removed from this account. You will no longer be able to sign in using this phone number." : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "سيتم إزالة %@ من هذا الحساب. لن تتمكن بعد الآن من تسجيل الدخول باستخدام رقم الهاتف هذا."
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ wird von diesem Konto entfernt. Sie können sich mit dieser Telefonnummer nicht mehr anmelden."
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Το %@ θα αφαιρεθεί από αυτόν τον λογαριασμό. Δεν θα μπορείτε πλέον να συνδεθείτε χρησιμοποιώντας αυτόν τον αριθμό τηλεφώνου."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ será eliminado de esta cuenta. Ya no podrá iniciar sesión usando este número de teléfono."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ sera supprimé de ce compte. Vous ne pourrez plus vous connecter en utilisant ce numéro de téléphone."
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ को इस खाते से हटा दिया जाएगा। आप इस फोन नंबर का उपयोग करके अब लॉग इन नहीं कर पाएंगे।"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ verrà rimosso da questo account. Non potrai più accedere utilizzando questo numero di telefono."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@はこのアカウントから削除されます。この電話番号を使用してサインインできなくなります。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@이(가) 이 계정에서 제거됩니다. 이 전화번호를 사용하여 더 이상 로그인할 수 없습니다."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@将从该账户中移除。您将无法再使用此电话号码登录。"
+ }
+ }
+ }
+ },
+ "%@ will no longer be receiving verification codes when signing in." : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ لن يعود يتلقى رموز التحقق عند تسجيل الدخول."
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ wird beim Anmelden keine Bestätigungscodes mehr erhalten."
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Το %@ δεν θα λαμβάνει πλέον κωδικούς επαλήθευσης κατά τη σύνδεση."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ ya no recibirá códigos de verificación al iniciar sesión."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ ne recevra plus de codes de vérification lors de la connexion."
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ को साइन इन करते समय अब सत्यापन कोड प्राप्त नहीं होंगे।"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ non riceverà più codici di verifica durante l'accesso."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ はサインイン時に確認コードを受信しなくなります。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@는 로그인 시 더 이상 인증 코드를 받지 않습니다."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ 将不再在登录时接收验证码。"
+ }
+ }
+ }
+ },
+ "A text message containing a verification code will be sent to this phone number. Message and data rates may apply." : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "سيتم إرسال رسالة نصية تحتوي على رمز التحقق إلى رقم الهاتف هذا. قد يتم تطبيق رسوم الرسائل والبيانات."
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "An diese Telefonnummer wird eine SMS mit einem Verifizierungscode gesendet. Es können Kosten für Nachrichten und Daten anfallen."
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Θα αποσταλεί ένα μήνυμα κειμένου που περιέχει κωδικό επαλήθευσης σε αυτόν τον αριθμό τηλεφώνου. Μπορεί να ισχύουν χρεώσεις για μηνύματα και δεδομένα."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Se enviará un mensaje de texto con un código de verificación a este número de teléfono. Pueden aplicarse tarifas de mensajes y datos."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Un message texte contenant un code de vérification sera envoyé à ce numéro de téléphone. Des frais de message et de données peuvent s'appliquer."
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "इस फोन नंबर पर एक पाठ संदेश भेजा जाएगा जिसमें सत्यापन कोड होगा। संदेश और डेटा दरें लागू हो सकती हैं।"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Un messaggio di testo contenente un codice di verifica verrà inviato a questo numero di telefono. Potrebbero essere applicate tariffe per messaggi e dati."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "この電話番号に認証コードを含むテキストメッセージが送信されます。メッセージとデータの料金が適用される場合があります。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "이 전화번호로 인증 코드가 포함된 문자 메시지가 전송됩니다. 메시지 및 데이터 요금이 적용될 수 있습니다."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "将向此电话号码发送包含验证码的短信。可能会收取短信和数据费用。"
+ }
+ }
+ }
+ },
+ "Account" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "الحساب"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Konto"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Λογαριασμός"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cuenta"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Compte"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "खाता"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Account"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "アカウント"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "계정"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "账户"
+ }
+ }
+ }
+ },
+ "ACTIVE DEVICES" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "الأجهزة النشطة"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "AKTIVE GERÄTE"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ΕΝΕΡΓΕΣ ΣΥΣΚΕΥΕΣ"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "DISPOSITIVOS ACTIVOS"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "APPAREILS ACTIFS"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "सक्रिय डिवाइस"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "DISPOSITIVI ATTIVI"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "アクティブなデバイス"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "활성 기기"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "活跃设备"
+ }
+ }
+ }
+ },
+ "Add a passkey" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "إضافة مفتاح مرور"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Passkey hinzufügen"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Προσθήκη κλειδιού πρόσβασης"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Agregar clave de acceso"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ajouter une clé d'accès"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "पासकी जोड़ें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aggiungi passkey"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "パスキーを追加"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "패스키 추가"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "添加通行密钥"
+ }
+ }
+ }
+ },
+ "Add account" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "إضافة حساب"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Konto hinzufügen"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Προσθήκη λογαριασμού"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Añadir cuenta"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ajouter un compte"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "खाता जोड़ें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aggiungi account"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "アカウントを追加"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "계정 추가"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "添加账户"
+ }
+ }
+ }
+ },
+ "Add authenticator application" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "إضافة تطبيق المصادقة"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Authenticator-Anwendung hinzufügen"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Προσθήκη εφαρμογής επαλήθευσης"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Agregar aplicación autenticadora"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ajouter une application d'authentification"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "प्रमाणीकरण एप्लिकेशन जोड़ें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aggiungi applicazione di autenticazione"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "認証アプリを追加"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "인증 애플리케이션 추가"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "添加身份验证应用"
+ }
+ }
+ }
+ },
+ "Add email address" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "إضافة عنوان بريد إلكتروني"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "E-Mail-Adresse hinzufügen"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Προσθήκη διεύθυνσης email"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Agregar dirección de correo electrónico"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ajouter une adresse e-mail"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ईमेल पता जोड़ें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aggiungi indirizzo email"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "メールアドレスを追加"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "이메일 주소 추가"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "添加电子邮箱地址"
+ }
+ }
+ }
+ },
+ "Add password" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "إضافة كلمة مرور"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Passwort hinzufügen"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Προσθήκη κωδικού πρόσβασης"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Agregar contraseña"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ajouter un mot de passe"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "पासवर्ड जोड़ें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aggiungi password"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "パスワードを追加"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "비밀번호 추가"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "添加密码"
+ }
+ }
+ }
+ },
+ "Add phone number" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "إضافة رقم هاتف"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Telefonnummer hinzufügen"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Προσθήκη αριθμού τηλεφώνου"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Agregar número de teléfono"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ajouter un numéro de téléphone"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "फ़ोन नंबर जोड़ें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aggiungi numero di telefono"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "電話番号を追加"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "전화번호 추가"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "添加电话号码"
+ }
+ }
+ }
+ },
+ "Add SMS code verification" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "إضافة التحقق من رمز الرسائل النصية"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "SMS-Code-Verifizierung hinzufügen"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Προσθήκη επαλήθευσης κωδικού SMS"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Agregar verificación de código SMS"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ajouter la vérification par code SMS"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "SMS कोड सत्यापन जोड़ें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aggiungi verifica codice SMS"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "SMSコード認証を追加"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "SMS 코드 인증 추가"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "添加短信验证码验证"
+ }
+ }
+ }
+ },
+ "Add two-step verification" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "إضافة التحقق بخطوتين"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Zwei-Faktor-Authentifizierung hinzufügen"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Προσθήκη επαλήθευσης δύο βημάτων"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Agregar verificación en dos pasos"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ajouter la vérification en deux étapes"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "दो-चरणीय सत्यापन जोड़ें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aggiungi verifica in due passaggi"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "2段階認証を追加"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "2단계 인증 추가"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "添加两步验证"
+ }
+ }
+ }
+ },
+ "Alternatively, if your authenticator supports TOTP URIs, you can also copy the full URI." : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "بدلاً من ذلك، إذا كان المصادق الخاص بك يدعم عناوين TOTP URI، يمكنك أيضاً نسخ العنوان الكامل."
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Alternativ können Sie auch die vollständige URI kopieren, wenn Ihr Authenticator TOTP-URIs unterstützt."
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Εναλλακτικά, εάν ο επαληθευτής σας υποστηρίζει TOTP URIs, μπορείτε επίσης να αντιγράψετε το πλήρες URI."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Alternativamente, si tu autenticador admite URIs TOTP, también puedes copiar la URI completa."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Alternativement, si votre authentificateur prend en charge les URI TOTP, vous pouvez également copier l'URI complète."
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "वैकल्पिक रूप से, यदि आपका प्रमाणीकरण TOTP URIs का समर्थन करता है, तो आप पूरा URI भी कॉपी कर सकते हैं।"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "In alternativa, se il tuo autenticatore supporta gli URI TOTP, puoi anche copiare l'URI completo."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "または、認証アプリがTOTP URIをサポートしている場合は、完全なURIをコピーすることもできます。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "또는 인증기가 TOTP URI를 지원하는 경우 전체 URI를 복사할 수도 있습니다."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "或者,如果您的身份验证器支持TOTP URI,您也可以复制完整的URI。"
+ }
+ }
+ }
+ },
+ "An unknown error occurred." : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "حدث خطأ ما. يرجى المحاولة مرة أخرى."
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ein unbekannter Fehler ist aufgetreten."
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Κάτι πήγε στραβά. Παρακαλώ δοκιμάστε ξανά."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Algo salió mal. Por favor, inténtalo de nuevo."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Une erreur s'est produite. Veuillez réessayer."
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "कुछ गलत हुआ। कृपया पुनः प्रयास करें।"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Qualcosa è andato storto. Per favore riprova."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "不明なエラーが発生しました。もう一度お試しください。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "出现错误。请重试。"
+ }
+ }
+ }
+ },
+ "Are you sure you want to delete your account? This action is permanent and irreversible." : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "هل أنت متأكد من أنك تريد حذف حسابك؟ هذا الإجراء دائم ولا يمكن الرجوع عنه."
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sind Sie sicher, dass Sie Ihr Konto löschen möchten? Diese Aktion ist dauerhaft und nicht rückgängig zu machen."
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Είστε σίγουροι ότι θέλετε να διαγράψετε τον λογαριασμό σας; Αυτή η ενέργεια είναι μόνιμη και μη αναστρέψιμη."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "¿Estás seguro de que quieres eliminar tu cuenta? Esta acción es permanente e irreversible."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Êtes-vous sûr de vouloir supprimer votre compte ? Cette action est permanente et irréversible."
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "क्या आप वाकई अपना खाता हटाना चाहते हैं? यह कार्य स्थायी और अपरिवर्तनीय है।"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sei sicuro di voler eliminare il tuo account? Questa azione è permanente e irreversibile."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "アカウントを削除してもよろしいですか?この操作は永続的で元に戻すことはできません。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "계정을 삭제하시겠습니까? 이 작업은 영구적이며 되돌릴 수 없습니다."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "您确定要删除您的账户吗?此操作是永久性的且不可逆转。"
+ }
+ }
+ }
+ },
+ "Authenticator app" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "تطبيق المصادقة"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Authentifizierungs-App"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Εφαρμογή ελέγχου ταυτότητας"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aplicación de autenticación"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Application d'authentification"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "प्रमाणक ऐप"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "App di autenticazione"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "認証アプリ"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "인증 앱"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "身份验证器应用"
+ }
+ }
+ }
+ },
+ "Authenticator application" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "تطبيق المصادقة"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Authenticator-Anwendung"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Εφαρμογή επαλήθευσης"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aplicación autenticadora"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Application d'authentification"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "प्रमाणीकरण एप्लिकेशन"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Applicazione di autenticazione"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "認証アプリケーション"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "인증 애플리케이션"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "身份验证应用程序"
+ }
+ }
+ }
+ },
+ "Backup code" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "رمز النسخ الاحتياطي"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Backup-Code"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Εφεδρικός κωδικός"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Código de respaldo"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Code de secours"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "बैकअप कोड"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Codice di backup"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "バックアップコード"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "백업 코드"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "备用码"
+ }
+ }
+ }
+ },
+ "Backup codes" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "رموز النسخ الاحتياطي"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Backup-Codes"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Εφεδρικοί κωδικοί"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Códigos de respaldo"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Codes de secours"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "बैकअप कोड"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Codici di backup"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "バックアップコード"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "백업 코드"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "备用码"
+ }
+ }
+ }
+ },
+ "Backup codes are now enabled. You can use one of these to sign in to your account, if you lose access to your authentication device. Each code can only be used once." : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "تم تفعيل رموز النسخ الاحتياطي الآن. يمكنك استخدام أحدها لتسجيل الدخول إلى حسابك إذا فقدت الوصول إلى جهاز المصادقة الخاص بك. يمكن استخدام كل رمز مرة واحدة فقط."
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Backup-Codes sind jetzt aktiviert. Sie können einen davon verwenden, um sich bei Ihrem Konto anzumelden, falls Sie den Zugriff auf Ihr Authentifizierungsgerät verlieren. Jeder Code kann nur einmal verwendet werden."
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Οι εφεδρικοί κωδικοί είναι τώρα ενεργοποιημένοι. Μπορείτε να χρησιμοποιήσετε έναν από αυτούς για να συνδεθείτε στον λογαριασμό σας, αν χάσετε την πρόσβαση στη συσκευή ελέγχου ταυτότητας. Κάθε κωδικός μπορεί να χρησιμοποιηθεί μόνο μία φορά."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Los códigos de respaldo ahora están habilitados. Puedes usar uno de ellos para iniciar sesión en tu cuenta si pierdes el acceso a tu dispositivo de autenticación. Cada código solo se puede usar una vez."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Les codes de secours sont maintenant activés. Vous pouvez en utiliser un pour vous connecter à votre compte si vous perdez l'accès à votre appareil d'authentification. Chaque code ne peut être utilisé qu'une seule fois."
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "बैकअप कोड अब सक्षम हैं। यदि आप अपने प्रमाणीकरण डिवाइस तक पहुँच खो देते हैं, तो आप इनमें से किसी एक का उपयोग अपने खाते में साइन इन करने के लिए कर सकते हैं। प्रत्येक कोड का केवल एक बार उपयोग किया जा सकता है।"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "I codici di backup sono ora abilitati. Puoi usarne uno per accedere al tuo account se perdi l'accesso al tuo dispositivo di autenticazione. Ogni codice può essere utilizzato solo una volta."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "バックアップコードが有効になりました。認証デバイスへのアクセスを失った場合、これらのうちの1つを使ってアカウントにサインインできます。各コードは1回しか使用できません。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "백업 코드가 이제 활성화되었습니다. 인증 장치에 접근할 수 없을 때 이 중 하나를 사용하여 계정에 로그인할 수 있습니다. 각 코드는 한 번만 사용할 수 있습니다."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "备份代码现已启用。如果您无法访问身份验证设备,可以使用其中一个代码登录您的帐户。每个代码只能使用一次。"
+ }
+ }
+ }
+ },
+ "Cancel" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "إلغاء"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Abbrechen"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ακύρωση"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cancelar"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Annuler"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "रद्द करें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Annulla"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "キャンセル"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "취소"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "取消"
+ }
+ }
+ }
+ },
+ "Change password" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "تغيير كلمة لمرور"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Passwort ändern"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Αλλαγή κωδικού πρόσβασης"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cambiar la contraseña"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Changer le mot de passe"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "पासवर्ड बदलें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cambia password"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "パスワードを変更"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "비밀번호 변경"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "更改密码"
+ }
+ }
+ }
+ },
+ "Check your email" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "تحقق من بريدك الإلكتروني"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Überprüfen Sie Ihre E-Mail"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ελέγξτε το email σας"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Revisa tu correo electrónico"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Vérifiez votre e-mail"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "अपना ईमेल जांचें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Controlla la tua email"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "メールを確認してください"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "이메일을 확인하세요"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "请检查您的邮箱"
+ }
+ }
+ }
+ },
+ "Check your phone" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "تحقق من هاتفك"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Überprüfen Sie Ihr Telefon"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ελέγξτε το τηλέφωνό σας"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Revisa tu teléfono"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Vérifiez votre téléphone"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "अपना फोन जांचें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Controlla il tuo telefono"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "電話を確認してください"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "휴대폰을 확인하세요"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "请查看您的手机"
+ }
+ }
+ }
+ },
+ "Choose a username." : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "اختر اسم مستخدم."
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Wählen Sie einen Benutzernamen."
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Επιλέξτε όνομα χρήστη."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Elige un nombre de usuario."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Choisissez un nom d'utilisateur."
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "एक उपयोगकर्ता नाम चुनें।"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Scegli un nome utente."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ユーザー名を選択してください。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "사용자 이름을 선택하세요."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "选择用户名。"
+ }
+ }
+ }
+ },
+ "Choose from photo library" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "اختر من مكتبة الصور"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aus der Fotomediathek wählen"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Επιλογή από τη βιβλιοθήκη φωτογραφιών"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Elegir de la biblioteca de fotos"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Choisir depuis la photothèque"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "फोटो लाइब्रेरी से चुनें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Scegli dalla libreria foto"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "写真ライブラリから選択"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "사진 보관함에서 선택"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "从照片库中选择"
+ }
+ }
+ }
+ },
+ "Choose how you'd like to receive your two-step verification code." : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "اختر كيف تريد استلام رمز التحقق المكون من خطوتين."
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Wählen Sie aus, wie Sie Ihren Zwei-Faktor-Verifizierungscode erhalten möchten."
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Επιλέξτε πώς θέλετε να λάβετε τον κωδικό επαλήθευσης δύο βημάτων."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Elige cómo te gustaría recibir tu código de verificación en dos pasos."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Choisissez comment vous souhaitez recevoir votre code de vérification en deux étapes."
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "चुनें कि आप अपना दो-चरणीय सत्यापन कोड कैसे प्राप्त करना चाहते हैं।"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Scegli come vuoi ricevere il tuo codice di verifica a due passaggi."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "2段階認証コードの受信方法を選択してください。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "2단계 인증 코드를 받을 방법을 선택하세요."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "选择您希望如何接收两步验证码。"
+ }
+ }
+ }
+ },
+ "Choose your password" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "اختر كلمة المرور الخاصة بك"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Wählen Sie Ihr Passwort"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Επιλέξτε τον κωδικό πρόσβασής σας"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Elige tu contraseña"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Choisissez votre mot de passe"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "अपना पासवर्ड चुनें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Scegli la tua password"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "パスワードを選択してください"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "비밀번호를 선택하세요"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "选择您的密码"
+ }
+ }
+ }
+ },
+ "Choose your username" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "اختر اسم المستخدم الخاص بك"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Wählen Sie Ihren Benutzernamen"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Επιλέξτε το όνομα χρήστη σας"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Elige tu nombre de usuario"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Choisissez votre nom d'utilisateur"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "अपना उपयोगकर्ता नाम चुनें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Scegli il tuo nome utente"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ユーザー名を選択してください"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "사용자 이름을 선택하세요"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "选择您的用户名"
+ }
+ }
+ }
+ },
+ "Client ID is unavailble." : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "معرف العميل غير متاح."
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Client-ID ist nicht verfügbar."
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Το Client ID δεν είναι διαθέσιμο."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "El ID del cliente no está disponible."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "L'ID client n'est pas disponible."
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "क्लाइंट आईडी उपलब्ध नहीं है।"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "L'ID client non è disponibile."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "クライアントIDは利用できません。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "클라이언트 ID를 사용할 수 없습니다."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "客户端 ID 不可用。"
+ }
+ }
+ }
+ },
+ "Close" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "إغلاق"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Schließen"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Κλείσιμο"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cerrar"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fermer"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "बंद करें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Chiudi"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "閉じる"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "닫기"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "关闭"
+ }
+ }
+ }
+ },
+ "Complete your profile" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "أكمل ملفك الشخصي"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Vervollständigen Sie Ihr Profil"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Συμπληρώστε το προφίλ σας"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Completa tu perfil"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Complétez votre profil"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "अपनी प्रोफ़ाइल पूरी करें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Completa il tuo profilo"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "プロフィールを完成させてください"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "프로필을 완성하세요"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "完善您的个人资料"
+ }
+ }
+ }
+ },
+ "Confirm password" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "تأكيد كلمة المرور"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Passwort bestätigen"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Επιβεβαίωση κωδικού"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Confirmar contraseña"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Confirmer le mot de passe"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "पासवर्ड की पुष्टि करें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Conferma password"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "パスワードの確認"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "비밀번호 확인"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "确认密码"
+ }
+ }
+ }
+ },
+ "Connect account" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ربط الحساب"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Konto verbinden"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Σύνδεση λογαριασμού"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Conectar cuenta"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Connecter le compte"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "खाता कनेक्ट करें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Collega account"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "アカウントを接続"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "계정 연결"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "连接账户"
+ }
+ }
+ }
+ },
+ "CONNECTED ACCOUNTS" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "الحسابات المتصلة"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "VERBUNDENE KONTEN"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ΣΥΝΔΕΔΕΜΕΝΟΙ ΛΟΓΑΡΙΑΣΜΟΙ"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "CUENTAS CONECTADAS"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "COMPTES CONNECTÉS"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "कनेक्टेड खाते"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ACCOUNT COLLEGATI"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "接続済みアカウント"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "연결된 계정"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "关联账户"
+ }
+ }
+ }
+ },
+ "Continue" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "متابعة"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Weiter"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Συνέχεια"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Continuar"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Continuer"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "जारी रखें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Continua"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "続ける"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "계속"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "继续"
+ }
+ }
+ }
+ },
+ "Continue to %@" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "للمتابعة إلى %@"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Weiter zu %@"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "για να συνεχίσετε στο %@"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Para continuar a %@"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Pour continuer vers %@"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ पर जारी रखने के लिए"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "per continuare a %@"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@に続けるには"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@(으)로 계속하려면"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "继续前往%@"
+ }
+ }
+ }
+ },
+ "Continue with %@" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "متابعة باستخدام %@"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Weiter mit %@"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Συνέχεια με %@"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Continuar con %@"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Continuer avec %@"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ के साथ जारी रखें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Continua con %@"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@で続ける"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@(으)로 계속하기"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "使用%@继续"
+ }
+ }
+ }
+ },
+ "Copy to clipboard" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "نسخ إلى الحافظة"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "In Zwischenablage kopieren"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Αντιγραφή στο πρόχειρο"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Copiar al portapapeles"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Copier dans le presse-papiers"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "क्लिपबोर्ड पर कॉपी करें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Copia negli appunti"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "クリップボードにコピー"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "클립보드에 복사"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "复制到剪贴板"
+ }
+ }
+ }
+ },
+ "Create a unique password." : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "إنشاء كلمة مرور فريدة."
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Erstellen Sie ein einzigartiges Passwort."
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Δημιουργήστε έναν μοναδικό κωδικό πρόσβασης."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Crea una contraseña única."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Créez un mot de passe unique."
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "एक अनोखा पासवर्ड बनाएं।"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Crea una password unica."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "固有のパスワードを作成してください。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "고유한 비밀번호를 만드세요."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "创建一个独特的密码。"
+ }
+ }
+ }
+ },
+ "Create your account" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "إنشاء حسابك"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Erstellen Sie Ihr Konto"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Δημιουργήστε τον λογαριασμό σας"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Crea tu cuenta"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Créez votre compte"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "अपना खाता बनाएं"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Crea il tuo account"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "アカウントを作成"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "계정 만들기"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "创建您的账户"
+ }
+ }
+ }
+ },
+ "Created: %@" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "تم الإنشاء: %@"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Erstellt: %@"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Δημιουργήθηκε: %@"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Creado: %@"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Créé : %@"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "बनाया गया: %@"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Creato: %@"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "作成日: %@"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "생성됨: %@"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "创建时间:%@"
+ }
+ }
+ }
+ },
+ "Current password" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "كلمة المرور الحالية"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aktuelles Passwort"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Τρέχων κωδικός πρόσβασης"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Contraseña actual"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Mot de passe actuel"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "वर्तमान पासवर्ड"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Password attuale"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "現在のパスワード"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "현재 비밀번호"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "当前密码"
+ }
+ }
+ }
+ },
+ "Default" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "افتراضي"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Standard"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Προεπιλογή"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Predeterminado"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Par défaut"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "डिफ़ॉल्ट"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Predefinito"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "デフォルト"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "기본값"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "默认"
+ }
+ }
+ }
+ },
+ "DELETE" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "حذف"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "LÖSCHEN"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ΔΙΑΓΡΑΦΗ"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ELIMINAR"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "SUPPRIMER"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "हटाएं"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ELIMINA"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "削除"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "삭제"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "删除"
+ }
+ }
+ }
+ },
+ "Delete account" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "حذف الحساب"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Konto löschen"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Διαγραφή λογαριασμού"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Eliminar cuenta"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Supprimer le compte"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "खाता हटाएं"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Elimina account"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "アカウントを削除"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "계정 삭제"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "删除账户"
+ }
+ }
+ }
+ },
+ "DELETE ACCOUNT" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "حذف الحساب"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "KONTO LÖSCHEN"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ΔΙΑΓΡΑΦΗ ΛΟΓΑΡΙΑΣΜΟΥ"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ELIMINAR CUENTA"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "SUPPRIMER LE COMPTE"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "खाता हटाएं"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ELIMINA ACCOUNT"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "アカウントを削除"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "계정 삭제"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "删除账户"
+ }
+ }
+ }
+ },
+ "Desktop device" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "جهاز سطح المكتب"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Desktop-Gerät"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Συσκευή υπολογιστή"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Dispositivo de escritorio"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Appareil de bureau"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "डेस्कटॉप डिवाइस"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Dispositivo desktop"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "デスクトップデバイス"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "데스크톱 기기"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "桌面设备"
+ }
+ }
+ }
+ },
+ "Didn't recieve a code? " : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "لم تستلم رمزاً؟ "
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Keinen Code erhalten? "
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Δεν λάβατε κωδικό; "
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "¿No recibiste el código? "
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Vous n'avez pas reçu le code ? "
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "कोड नहीं मिला? "
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Non hai ricevuto il codice? "
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "コードが届きませんでしたか?"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "인증코드를 받지 못하셨나요?"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "没有收到验证码?"
+ }
+ }
+ }
+ },
+ "Done" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "تم"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fertig"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ολοκληρώθηκε"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Listo"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Terminé"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "पूर्ण"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fatto"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "完了"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "완료"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "完成"
+ }
+ }
+ }
+ },
+ "Email address" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "عنوان البريد الإلكتروني"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "E-Mail-Adresse"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Διεύθυνση email"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Dirección de correo electrónico"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Adresse e-mail"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ईमेल पता"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Indirizzo email"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "メールアドレス"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "이메일 주소"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "电子邮箱地址"
+ }
+ }
+ }
+ },
+ "EMAIL ADDRESSES" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "عناوين البريد الإلكتروني"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "E-MAIL-ADRESSEN"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ΔΙΕΥΘΥΝΣΕΙΣ EMAIL"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "DIRECCIONES DE CORREO ELECTRÓNICO"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ADRESSES E-MAIL"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ईमेल पते"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "INDIRIZZI EMAIL"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "メールアドレス"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "이메일 주소"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "电子邮箱地址"
+ }
+ }
+ }
+ },
+ "Email code to %@" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "إرسال رمز إلى %@"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Code per E-Mail an %@ senden"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Αποστολή κωδικού στο %@"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Enviar código a %@"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Envoyer le code à %@"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ पर कोड भेजें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Invia codice a %@"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@にコードを送信"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@에 코드 전송"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "发送验证码至%@"
+ }
+ }
+ }
+ },
+ "Email support" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "الدعم بالبريد الإلكتروني"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "E-Mail-Support"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Υποστήριξη μέσω email"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Soporte por correo electrónico"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Support par e-mail"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ईमेल सहायता"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Supporto via email"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "メールサポート"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "이메일 지원"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "电子邮件支持"
+ }
+ }
+ }
+ },
+ "Enter a backup code" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "أدخل رمز النسخ الاحتياطي"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Backup-Code eingeben"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Εισαγάγετε εφεδρικό κωδικό"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ingrese un código de respaldo"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Entrez un code de secours"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "बैकअप कोड दर्ज करें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Inserisci un codice di backup"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "バックアップコードを入力"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "백업 코드 입력"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "输入备用码"
+ }
+ }
+ }
+ },
+ "Enter password" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "أدخل كلمة المرور الخاصة بك"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Passwort eingeben"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Εισάγετε τον κωδικό σας"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Introduce tu contraseña"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Entrez le mot de passe"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "अपने पासवर्ड से साइन इन करें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Accedi con la tua password"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "パスワードを入力"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "비밀번호 입력"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "使用您的密码登录"
+ }
+ }
+ }
+ },
+ "Enter the email address you'd like to use." : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "أدخل عنوان البريد الإلكتروني الذي تريد استخدامه."
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Geben Sie die E-Mail-Adresse ein, die Sie verwenden möchten."
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Εισαγάγετε τη διεύθυνση email που θα θέλατε να χρησιμοποιήσετε."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Introduce la dirección de correo electrónico que te gustaría usar."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Entrez l'adresse e-mail que vous souhaitez utiliser."
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "वह ईमेल पता दर्ज करें जिसका आप उपयोग करना चाहते हैं।"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Inserisci l'indirizzo email che vorresti usare."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "使用したいメールアドレスを入力してください。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "사용하고 싶은 이메일 주소를 입력하세요."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "输入您想要使用的电子邮箱地址。"
+ }
+ }
+ }
+ },
+ "Enter the password for your account" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "أدخل كلمة المرور لحسابك"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Geben Sie Ihr Passwort für Ihr Konto ein"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Εισάγετε τον κωδικό του λογαριασμού σας"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Introduce la contraseña de tu cuenta"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Entrez le mot de passe de votre compte"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "अपने खाते का पासवर्ड दर्ज करें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Inserisci la password del tuo account"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "アカウントのパスワードを入力"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "계정 비밀번호 입력"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "输入您账户的密码"
+ }
+ }
+ }
+ },
+ "Enter the phone number you'd like to use." : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "أدخل رقم الهاتف الذي تريد استخدامه."
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Geben Sie die Telefonnummer ein, die Sie verwenden möchten."
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Εισαγάγετε τον αριθμό τηλεφώνου που θα θέλατε να χρησιμοποιήσετε."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Introduce el número de teléfono que te gustaría usar."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Entrez le numéro de téléphone que vous souhaitez utiliser."
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "वह फोन नंबर दर्ज करें जिसका आप उपयोग करना चाहते हैं।"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Inserisci il numero di telefono che vorresti usare."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "使用したい電話番号を入力してください。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "사용하고 싶은 전화번호를 입력하세요."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "输入您想要使用的电话号码。"
+ }
+ }
+ }
+ },
+ "Enter the verification code from your authenticator application." : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "أدخل رمز التحقق من تطبيق المصادقة الخاص بك."
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Geben Sie den Bestätigungscode aus Ihrer Authenticator-Anwendung ein."
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Εισαγάγετε τον κωδικό επαλήθευσης από την εφαρμογή επαλήθευσής σας."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ingresa el código de verificación de tu aplicación autenticadora."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Saisissez le code de vérification de votre application d'authentification."
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "अपने प्रमाणीकरण एप्लिकेशन से सत्यापन कोड दर्ज करें।"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Inserisci il codice di verifica dalla tua applicazione di autenticazione."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "認証アプリから確認コードを入力してください。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "인증 애플리케이션에서 확인 코드를 입력하세요."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "输入来自您的身份验证应用的验证码。"
+ }
+ }
+ }
+ },
+ "Enter the verification code sent to %@" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "أدخل رمز التحقق المرسل إلى %@"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Geben Sie den an %@ gesendeten Bestätigungscode ein"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Εισαγάγετε τον κωδικό επαλήθευσης που στάλθηκε στο %@"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ingrese el código de verificación enviado a %@"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Entrez le code de vérification envoyé à %@"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ पर भेजा गया सत्यापन कोड दर्ज करें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Inserisci il codice di verifica inviato a %@"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ に送信された認証コードを入力してください"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@로 전송된 인증 코드를 입력하세요"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "输入发送到%@的验证码"
+ }
+ }
+ }
+ },
+ "Enter your current password to set a new one." : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "أدخل كلمة المرور الحالية لتعيين كلمة مرور جديدة."
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Geben Sie Ihr aktuelles Passwort ein, um ein neues zu setzen."
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Εισαγάγετε τον τρέχοντα κωδικό πρόσβασής σας για να ορίσετε έναν νέο."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Introduce tu contraseña actual para configurar una nueva."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Entrez votre mot de passe actuel pour en définir un nouveau."
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "नया पासवर्ड सेट करने के लिए अपना वर्तमान पासवर्ड दर्ज करें।"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Inserisci la tua password attuale per impostarne una nuova."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "新しいパスワードを設定するには、現在のパスワードを入力してください。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "새 비밀번호를 설정하려면 현재 비밀번호를 입력하세요."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "输入您当前的密码以设置新密码。"
+ }
+ }
+ }
+ },
+ "Enter your email" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "أدخل بريدك الإلكتروني"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "E-Mail-Adresse eingeben"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Εισάγετε το email σας"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Introduce tu correo electrónico"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Entrez votre e-mail"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "अपना ईमेल दर्ज करें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Inserisci la tua email"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "メールアドレスを入力"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "이메일 입력"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "请输入您的邮箱"
+ }
+ }
+ }
+ },
+ "Enter your email or username" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "أدخل بريدك الإلكتروني أو اسم المستخدم"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Geben Sie Ihre E-Mail-Adresse oder Ihren Benutzernamen ein"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Εισάγετε το email ή το όνομα χρήστη σας"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Introduce tu correo electrónico o nombre de usuario"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Entrez votre e-mail ou nom d'utilisateur"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ईमेल पता या यूजरनेम दर्ज करें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Inserisci la tua email o nome utente"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "メールアドレスまたはユーザー名を入力"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "이메일 또는 사용자 이름 입력"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "请输入您的邮箱或用户名"
+ }
+ }
+ }
+ },
+ "Enter your password" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "أدخل كلمة المرور الخاصة بك"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Passwort eingeben"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Εισάγετε τον κωδικό σας"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Introduce tu contraseña"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Entrez votre mot de passe"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "अपने पासवर्ड से साइन इन करें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Accedi con la tua password"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "パスワードを入力"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "비밀번호 입력"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "使用密码登录"
+ }
+ }
+ }
+ },
+ "Enter your phone number" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "أدخل رقم الهاتف"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Telefonnummer eingeben"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Εισάγετε τον αριθμό τηλεφώνου σας"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Introduce tu número de teléfono"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Entrez votre numéro de téléphone"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "अपना फोन नंबर दर्ज करें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Inserisci il tuo numero di telefono"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "電話番号を入力"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "전화번호 입력"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "请输入您的电话号码"
+ }
+ }
+ }
+ },
+ "Enter your username" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "أدخل اسم المستخدم الخاص بك"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Geben Sie Ihren Benutzernamen ein"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Εισάγετε το όνομα χρήστη σας"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Introduce tu nombre de usuario"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Entrez votre nom d'utilisateur"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "अपना यूजरनेम दर्ज करें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Inserisci il tuo nome utente"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ユーザー名を入力"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "사용자 이름 입력"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "请输入您的用户名"
+ }
+ }
+ }
+ },
+ "Facing issues? You can use any of these methods to sign in." : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "تواجه مشاكل؟ يمكنك استخدام أي من هذه الطرق لتسجيل الدخول."
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sie haben Probleme? Sie können eine der folgenden Methoden verwenden, um sich anzumelden."
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Αντιμετωπίζετε προβλήματα; Μπορείτε να χρησιμοποιήσετε οποιαδήποτε από αυτές τις μεθόδους για να συνδεθείτε."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "¿Tienes problemas? Puedes usar cualquiera de estos métodos para iniciar sesión."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Vous rencontrez des problèmes ? Vous pouvez utiliser l'une de ces méthodes pour vous connecter."
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "समस्याएं आ रही हैं? आप साइन इन करने के लिए इनमें से किसी भी विधि का उपयोग कर सकते हैं।"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Hai problemi? Puoi utilizzare uno di questi metodi per accedere."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "問題が発生していますか?これらの方法のいずれかを使用してサインインできます。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "문제가 발생했나요? 다음 방법 중 하나를 사용하여 로그인할 수 있습니다."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "遇到问题?您可以使用以下任一方法登录。"
+ }
+ }
+ }
+ },
+ "First name" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "الاسم الأول"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Vorname"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Όνομα"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nombre"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Prénom"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "पहला नाम"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nome"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "名"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "이름"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "名"
+ }
+ }
+ }
+ },
+ "First, enter the code sent to your email address" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "أولاً، أدخل الرمز المرسل إلى عنوان بريدك الإلكتروني"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Erstens, geben Sie den Code ein, den Sie an Ihre E-Mail-Adresse gesendet haben"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Πρώτα, εισάγετε τον κωδικό που στάλθηκε στη διεύθυνση email σας"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Primero, introduce el código enviado a tu correo electrónico"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "D'abord, entrez le code envoyé à votre adresse e-mail"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "पहले, अपने ईमेल पते पर भेजे गए कोड को दर्ज करें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Prima, inserisci il codice inviato al tuo indirizzo email"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "まず、メールアドレスに送信されたコードを入力してください"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "먼저 이메일 주소로 전송된 코드를 입력하세요"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "首先,输入发送到您邮箱的验证码"
+ }
+ }
+ }
+ },
+ "First, enter the code sent to your phone" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "أولاً، أدخل الرمز المرسل إلى هاتفك"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Erstens, geben Sie die Nummer ein, die Sie an Ihr Telefon gesendet haben"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Πρώτα, εισάγετε τον κωδικό που στάλθηκε στο τηλέφωνό σας"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Primero, introduce el código enviado a tu teléfono"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "D'abord, entrez le code envoyé à votre téléphone"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "पहले, अपने फोन पर भेजे गए कोड को दर्ज करें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Prima, inserisci il codice inviato al tuo telefono"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "まず、電話番号に送信されたコードを入力してください"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "먼저 휴대폰으로 전송된 코드를 입력하세요"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "首先,输入发送到您手机的验证码"
+ }
+ }
+ }
+ },
+ "Forgot password?" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "نسيت كلمة المرور؟"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Passwort vergessen?"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ξεχάσατε τον κωδικό;"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "¿Olvidaste tu contraseña?"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Mot de passe oublié ?"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "पासवर्ड भूल गए?"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Password dimenticata?"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "パスワードをお忘れですか?"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "비밀번호를 잊으셨나요?"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "忘记密码?"
+ }
+ }
+ }
+ },
+ "Get help" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "احصل على المساعدة"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Hilfe erhalten"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Λάβετε βοήθεια"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Obtener ayuda"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Obtenir de l'aide"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "सहायता प्राप्त करें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ottieni aiuto"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ヘルプを取得"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "도움말 보기"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "获取帮助"
+ }
+ }
+ }
+ },
+ "Go back" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ارجع"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Zurück gehen"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Επιστροφή"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Volver"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Retour"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "वापस जाएं"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Torna indietro"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "戻る"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "뒤로 가기"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "返回"
+ }
+ }
+ }
+ },
+ "If you have trouble signing into your account, email us and we will work with you to restore access as soon as possible." : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "إذا واجهت مشكلة في تسجيل الدخول إلى حسابك، أرسل لنا بريداً إلكترونياً وسنعمل معك لاستعادة الوصول في أقرب وقت ممكن."
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Wenn Sie Probleme beim Anmelden auf Ihrem Konto haben, senden Sie uns eine E-Mail und wir werden mit Ihnen zusammenarbeiten, um den Zugang so schnell wie möglich wiederherzustellen."
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Αν έχετε πρόβλημα με την είσοδό σας στον λογαριασμό σας, στείλτε μας email και θα συνεργαστούμε μαζί σας για την αποκατάσταση της πρόσβασης το συντομότερο δυνατό."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Si tienes problemas para iniciar sesión en tu cuenta, envíanos un correo electrónico y trabajaremos contigo para restaurar el acceso lo antes posible."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Si vous avez des difficultés à vous connecter à votre compte, envoyez-nous un e-mail et nous travaillerons avec vous pour rétablir l'accès dès que possible."
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "यदि आपको अपने खाते में साइन इन करने में परेशानी हो रही है, तो हमें ईमेल करें और हम आपके साथ काम करेंगे ताकि जल्द से जल्द पहुंच बहाल की जा सके।"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Se hai problemi ad accedere al tuo account, inviaci una email e lavoreremo con te per ripristinare l'accesso il prima possibile."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "アカウントへのサインインで問題が発生した場合は、メールでご連絡ください。できるだけ早くアクセスを復元できるよう対応いたします。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "계정 로그인에 문제가 있으시면 이메일로 문의해 주세요. 최대한 빨리 접근 권한을 복구하도록 도와드리겠습니다."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "如果您在登录账户时遇到问题,请发送电子邮件给我们,我们将与您合作尽快恢复访问权限。"
+ }
+ }
+ }
+ },
+ "International" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "دولي"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "International"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Διεθνές"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Internacional"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "International"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "अंतरराष्ट्रीय"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Internazionale"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "国際"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "국제"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "国际"
+ }
+ }
+ }
+ },
+ "Invalid credential type." : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "نوع بيانات الاعتماد غير صالح."
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ein ungültiger Anmeldetyp."
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Μη έγκυρος τύπος διαπιστευτηρίων."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Tipo de credencial no válido."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Type d'identifiant invalide."
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "अमान्य प्रमाण-पत्र प्रकार।"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Tipo di credenziali non valido."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "無効な認証情報の種類です。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "유효하지 않은 인증 정보 유형입니다."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "凭证类型无效。"
+ }
+ }
+ }
+ },
+ "It is recommended to sign out of all other devices which may have used your old password." : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "يُنصح بتسجيل الخروج من جميع الأجهزة الأخرى التي قد تكون استخدمت كلمة المرور القديمة."
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Es wird empfohlen, sich von allen anderen Geräten abzumelden, die möglicherweise Ihr altes Passwort verwendet haben."
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Συνιστάται να αποσυνδεθείτε από όλες τις άλλες συσκευές που μπορεί να έχουν χρησιμοποιήσει τον παλιό σας κωδικό πρόσβασης."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Se recomienda cerrar sesión en todos los demás dispositivos que puedan haber usado tu contraseña anterior."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Il est recommandé de vous déconnecter de tous les autres appareils qui ont pu utiliser votre ancien mot de passe."
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "यह अनुशंसा की जाती है कि आप उन सभी अन्य उपकरणों से साइन आउट करें जिन्होंने आपका पुराना पासवर्ड इस्तेमाल किया हो सकता है।"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "È consigliabile disconnettersi da tutti gli altri dispositivi che potrebbero aver utilizzato la tua vecchia password."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "古いパスワードを使用した可能性のある他のすべてのデバイスからサインアウトすることをお勧めします。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "이전 비밀번호를 사용했을 수 있는 모든 다른 기기에서 로그아웃하는 것이 좋습니다."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "建议从所有可能使用过您旧密码的其他设备注销。"
+ }
+ }
+ }
+ },
+ "Last name" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "اسم العائلة"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nachname"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Επώνυμο"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Apellido"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nom de famille"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "अंतिम नाम"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cognome"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "姓"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "성"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "姓"
+ }
+ }
+ }
+ },
+ "Last used: %@" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "آخر استخدام: %@"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Zuletzt verwendet: %@"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Τελευταία χρήση: %@"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Último uso: %@"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Dernière utilisation : %@"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "अंतिम उपयोग: %@"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ultimo utilizzo: %@"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "最終使用: %@"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "마지막 사용: %@"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "最后使用:%@"
+ }
+ }
+ }
+ },
+ "Link another login option to your account. You’ll need to verify it before it can be used." : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ربط خيار تسجيل دخول آخر بحسابك. ستحتاج إلى التحقق منه قبل استخدامه."
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Verknüpfen Sie eine weitere Anmeldemöglichkeit mit Ihrem Konto. Sie müssen sie verifizieren, bevor sie verwendet werden kann."
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Σύνδεση μιας άλλης επιλογής σύνδεσης με τον λογαριασμό σας. Θα χρειαστεί να την επαληθεύσετε πριν μπορεί να χρησιμοποιηθεί."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Vincula otra opción de inicio de sesión a tu cuenta. Deberás verificarla antes de poder usarla."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Associez une autre option de connexion à votre compte. Vous devrez la vérifier avant de pouvoir l'utiliser."
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "अपने खाते से दूसरा लॉगिन विकल्प जोड़ें। इसका उपयोग करने से पहले आपको इसे सत्यापित करना होगा।"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Collega un'altra opzione di accesso al tuo account. Dovrai verificarla prima di poterla utilizzare."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "アカウントに別のログイン方法をリンクします。使用する前に確認が必要です。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "계정에 다른 로그인 옵션을 연결하세요. 사용하기 전에 확인이 필요합니다."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "将其他登录选项链接到您的账户。使用前需要验证。"
+ }
+ }
+ }
+ },
+ "Linked" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "مرتبط"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Verbunden"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Συνδεδεμένο"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Vinculado"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Lié"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "लिंक किया गया"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Collegato"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "リンク済み"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "연결됨"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "已关联"
+ }
+ }
+ }
+ },
+ "Manage account" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "إدارة الحساب"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Konto verwalten"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Διαχείριση λογαριασμού"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Gestionar cuenta"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Gérer le compte"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "खाता प्रबंधित करें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Gestisci account"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "アカウント管理"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "계정 관리"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "管理账户"
+ }
+ }
+ }
+ },
+ "MFA reserved" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "مخصص للمصادقة متعددة العوامل"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Für MFA reserviert"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Κρατημένο για MFA"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Reservado para MFA"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Réservé pour MFA"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "एमएफए के लिए आरक्षित"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Riservato per MFA"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "MFA用に予約済み"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "MFA 예약됨"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "为MFA保留"
+ }
+ }
+ }
+ },
+ "Missing callback URL" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "رابط الاستدعاء مفقود"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Rückruf-URL fehlt"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Λείπει το URL επανάκλησης"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Falta URL de retorno"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "URL de rappel manquant"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "कॉलबैक URL अनुपलब्ध"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "URL di callback mancante"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "コールバックURLがありません"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "콜백 URL이 없습니다"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "缺少回调URL"
+ }
+ }
+ }
+ },
+ "Mobile device" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "جهاز محمول"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Mobiles Gerät"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Κινητή συσκευή"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Dispositivo móvil"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Appareil mobile"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "मोबाइल डिवाइस"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Dispositivo mobile"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "モバイルデバイス"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "모바일 기기"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "移动设备"
+ }
+ }
+ }
+ },
+ "Name of passkey" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "اسم مفتاح المرور"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Name des Passkeys"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Όνομα passkey"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nombre de la llave de acceso"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nom de la clé d'accès"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "पासकी का नाम"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nome della passkey"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "パスキーの名前"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "패스키 이름"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "通行密钥名称"
+ }
+ }
+ }
+ },
+ "New password" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "كلمة المرور الجديدة"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Neues Passwort"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Νέος κωδικός"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nueva contraseña"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nouveau mot de passe"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "नया पासवर्ड"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nuova password"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "新しいパスワード"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "새 비밀번호"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "新密码"
+ }
+ }
+ }
+ },
+ "Next" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "التالي"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Weiter"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Επόμενο"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Siguiente"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Suivant"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "अगला"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Avanti"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "次へ"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "다음"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "下一步"
+ }
+ }
+ }
+ },
+ "or" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "أو"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "oder"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ή"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "o"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ou"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "या"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "o"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "または"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "또는"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "或"
+ }
+ }
+ }
+ },
+ "Or, sign in with another method" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "أو، سجل الدخول بطريقة أخرى"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Oder, melden Sie sich mit einer anderen Methode an"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ή, συνδεθείτε με άλλη μέθοδο"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "O, inicia sesión con otro método"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ou, connectez-vous avec une autre méthode"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "या, दूसरी विधि से साइन इन करें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Oppure, accedi con un altro metodo"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "または、別の方法でサインイン"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "또는 다른 방법으로 로그인"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "或使用其他方式登录"
+ }
+ }
+ }
+ },
+ "PASSKEYS" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "مفاتيح المرور"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "PASSKEYS"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ΚΛΕΙΔΙΑ ΠΡΟΣΒΑΣΗΣ"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "CLAVES DE ACCESO"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "CLÉS D'ACCÈS"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "पासकीज़"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "PASSKEY"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "パスキー"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "패스키"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "通行密钥"
+ }
+ }
+ }
+ },
+ "Password" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "كلمة المرور"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Passwort"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Κωδικός πρόσβασης"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Contraseña"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Mot de passe"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "पासवर्ड"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Password"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "パスワード"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "비밀번호"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "密码"
+ }
+ }
+ }
+ },
+ "PASSWORD" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "كلمة المرور"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "PASSWORT"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ΚΩΔΙΚΟΣ ΠΡΟΣΒΑΣΗΣ"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "CONTRASEÑA"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "MOT DE PASSE"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "पासवर्ड"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "PASSWORD"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "パスワード"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "비밀번호"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "密码"
+ }
+ }
+ }
+ },
+ "Passwords don't match." : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "كلمات المرور غير متطابقة."
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Passwörter stimmen nicht überein."
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Οι κωδικοί δεν ταιριάζουν."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Las contraseñas no coinciden."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Les mots de passe ne correspondent pas."
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "पासवर्ड मेल नहीं खाते।"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Le password non corrispondono."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "パスワードが一致しません。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "비밀번호가 일치하지 않습니다."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "密码不匹配。"
+ }
+ }
+ }
+ },
+ "Phone number" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "رقم الهاتف"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Telefonnummer"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Αριθμός τηλεφώνου"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Número de teléfono"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Numéro de téléphone"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "फोन नंबर"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Numero di telefono"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "電話番号"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "전화번호"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "电话号码"
+ }
+ }
+ }
+ },
+ "PHONE NUMBERS" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "أرقام الهواتف"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "TELEFONNUMMERN"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ΑΡΙΘΜΟΙ ΤΗΛΕΦΩΝΟΥ"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "NÚMEROS DE TELÉFONO"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "NUMÉROS DE TÉLÉPHONE"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "फोन नंबर"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "NUMERI DI TELEFONO"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "電話番号"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "전화번호"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "电话号码"
+ }
+ }
+ }
+ },
+ "Primary" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "رئيسي"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Primär"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Κύριο"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Principal"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Principal"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "प्राथमिक"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Principale"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "主要"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "기본"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "主要"
+ }
+ }
+ }
+ },
+ "Profile" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "الملف الشخصي"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Profil"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Προφίλ"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Perfil"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Profil"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "प्रोफ़ाइल"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Profilo"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "プロフィール"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "프로필"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "个人资料"
+ }
+ }
+ }
+ },
+ "Profile Details" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "تفاصيل الملف الشخصي"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Profil-Details"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Λεπτομέρειες προφίλ"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Detalles del perfil"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Détails du profil"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "प्रोफ़ाइल विवरण"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Dettagli profilo"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "プロフィール詳細"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "프로필 세부정보"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "个人资料详情"
+ }
+ }
+ }
+ },
+ "Reconnect" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "إعادة الاتصال"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Wieder verbinden"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Επανασύνδεση"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Reconectar"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Reconnecter"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "पुनः कनेक्ट करें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Riconnetti"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "再接続"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "다시 연결"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "重新连接"
+ }
+ }
+ }
+ },
+ "Redirect URL is missing or invalid. Unable to start external authentication flow." : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "عنوان URL لإعادة التوجيه مفقود أو غير صالح. لا يمكن بدء تدفق المصادقة الخارجية."
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Die Rückruf-URL fehlt oder ist ungültig. Es ist nicht möglich, den externen Authentifizierungsfluss zu starten."
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Η διεύθυνση URL ανακατεύθυνσης λείπει ή είναι άκυρη. Αδυναμία εκκίνησης της εξωτερικής ροής πιστοποίησης."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Falta la URL de redirección o no es válida. No se puede iniciar el flujo de autenticación externa."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "L'URL de redirection est manquante ou invalide. Impossible de démarrer le flux d'authentification externe."
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "रिडायरेक्ट URL गायब है या अमान्य है। बाहरी प्रमाणीकरण प्रवाह शुरू करने में असमर्थ।"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "L'URL di reindirizzamento è mancante o non valida. Impossibile avviare il flusso di autenticazione esterna."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "リダイレクトURLが不足しているか無効です。外部認証フローを開始できません。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "리디렉션 URL이 없거나 유효하지 않습니다. 외부 인증 흐름을 시작할 수 없습니다。"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "重定向 URL 缺失或无效。无法启动外部身份验证流程。"
+ }
+ }
+ }
+ },
+ "Regenerate" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "إعادة توليد"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Neu generieren"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Αναγέννηση"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Regenerar"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Régénérer"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "पुनर्जनित करें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Rigenera"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "再生成"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "재생성"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "重新生成"
+ }
+ }
+ }
+ },
+ "Remove" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "إزالة"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Entfernen"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Αφαίρεση"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Eliminar"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Supprimer"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "हटाएं"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Rimuovi"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "削除"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "제거"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "移除"
+ }
+ }
+ }
+ },
+ "Remove connected account" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "إزالة الحساب المتصل"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Verbundenes Konto entfernen"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Αφαίρεση συνδεδεμένου λογαριασμού"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Eliminar cuenta conectada"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Supprimer le compte connecté"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "जुड़ा हुआ खाता हटाएं"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Rimuovi account collegato"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "接続されたアカウントを削除"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "연결된 계정 제거"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "删除已连接账户"
+ }
+ }
+ }
+ },
+ "Remove connection" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "إزالة الاتصال"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Verbindung entfernen"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Αφαίρεση σύνδεσης"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Eliminar conexión"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Supprimer la connexion"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "कनेक्शन हटाएं"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Rimuovi connessione"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "接続を削除"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "연결 제거"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "删除连接"
+ }
+ }
+ }
+ },
+ "Remove email" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "إزالة البريد الإلكتروني"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "E-Mail entfernen"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Αφαίρεση email"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Eliminar correo electrónico"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Supprimer l'e-mail"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ईमेल हटाएं"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Rimuovi email"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "メールを削除"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "이메일 제거"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "删除邮箱"
+ }
+ }
+ }
+ },
+ "Remove email address" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "إزالة عنوان البريد الإلكتروني"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "E-Mail-Adresse entfernen"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Αφαίρεση διεύθυνσης email"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Eliminar dirección de correo electrónico"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Supprimer l'adresse e-mail"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ईमेल पता हटाएं"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Rimuovi indirizzo email"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "メールアドレスを削除"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "이메일 주소 제거"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "删除邮箱地址"
+ }
+ }
+ }
+ },
+ "Remove passkey" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "إزالة مفتاح المرور"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Passkey entfernen"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Αφαίρεση passkey"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Eliminar clave de acceso"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Supprimer la clé d'accès"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "पासकी हटाएं"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Rimuovi passkey"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "パスキーを削除"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "패스키 제거"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "删除密码钥匙"
+ }
+ }
+ }
+ },
+ "Remove phone" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "إزالة الهاتف"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Telefon entfernen"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Αφαίρεση τηλεφώνου"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Eliminar teléfono"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Supprimer le téléphone"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "फोन हटाएं"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Rimuovi telefono"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "電話を削除"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "전화 제거"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "删除电话"
+ }
+ }
+ }
+ },
+ "Remove phone number" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "إزالة رقم الهاتف"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Telefonnummer entfernen"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Αφαίρεση αριθμού τηλεφώνου"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Eliminar número de teléfono"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Supprimer le numéro de téléphone"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "फोन नंबर हटाएं"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Rimuovi numero di telefono"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "電話番号を削除"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "전화번호 제거"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "删除电话号码"
+ }
+ }
+ }
+ },
+ "Remove photo" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "إزالة الصورة"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Foto entfernen"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Αφαίρεση φωτογραφίας"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Eliminar foto"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Supprimer la photo"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "फोटो हटाएं"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Rimuovi foto"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "写真を削除"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "사진 제거"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "移除照片"
+ }
+ }
+ }
+ },
+ "Remove two-step verification" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "إزالة التحقق بخطوتين"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Zwei-Schritt-Verifizierung entfernen"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Κατάργηση επαλήθευσης δύο βημάτων"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Eliminar verificación en dos pasos"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Supprimer la vérification en deux étapes"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "दो-चरणीय सत्यापन हटाएं"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Rimuovi verifica a due passaggi"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "2段階認証を削除"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "2단계 인증 제거"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "移除两步验证"
+ }
+ }
+ }
+ },
+ "Rename" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "إعادة تسمية"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Umbenennen"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Μετονομασία"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Renombrar"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Renommer"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "नाम बदलें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Rinomina"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "名前を変更"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "이름 바꾸기"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "重命名"
+ }
+ }
+ }
+ },
+ "Rename passkey" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "إعادة تسمية مفتاح المرور"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Passkey umbenennen"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Μετονομασία passkey"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Renombrar llave de acceso"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Renommer la clé d'accès"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "पासकी का नाम बदलें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Rinomina passkey"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "パスキーの名前を変更"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "패스키 이름 바꾸기"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "重命名通行密钥"
+ }
+ }
+ }
+ },
+ "Resend" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "إعادة الإرسال"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Code erneut senden"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Επαναποστολή"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Reenviar"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Renvoyer"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "पुनः भेजें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Reinvia"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "再送信"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "재전송"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "重新发送"
+ }
+ }
+ }
+ },
+ "Resend (%lld)" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "إعادة الإرسال (%lld)"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Code erneut senden (%lld)"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Επανάληψη αποστολής (%lld)"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Reenviar (%lld)"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Renvoyer (%lld)"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "पुनः भेजें (%lld)"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Rinvia (%lld)"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "再送信 (%lld)"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "재전송 (%lld)"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "重新发送 (%lld)"
+ }
+ }
+ }
+ },
+ "Reset password" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "إعادة تعيين كلمة المرور"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Passwort zurücksetzen"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Επαναφορά κωδικού"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Restablecer contraseña"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Réinitialiser le mot de passe"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "पासवर्ड रीसेट करें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Reimposta password"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "パスワードをリセット"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "비밀번호 재설정"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "重置密码"
+ }
+ }
+ }
+ },
+ "Reset your password" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "إعادة تعيين كلمة المرور الخاصة بك"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ihr Passwort zurücksetzen"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Επαναφορά του κωδικού σας"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Restablece tu contraseña"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Réinitialisez votre mot de passe"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "अपना पासवर्ड रीसेट करें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Reimposta la tua password"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "パスワードをリセット"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "비밀번호 재설정"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "重置您的密码"
+ }
+ }
+ }
+ },
+ "Save" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "حفظ"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Speichern"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Αποθήκευση"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Guardar"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Enregistrer"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "सहेजें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Salva"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "保存"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "저장"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "保存"
+ }
+ }
+ }
+ },
+ "Secured by" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "مؤمن بواسطة"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sichergestellt von"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ασφαλισμένο από"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Protegido por"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sécurisé par"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "द्वारा सुरक्षित"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Protetto da"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "セキュリティ提供"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "보안 제공"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "安全提供"
+ }
+ }
+ }
+ },
+ "Security" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "الأمان"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sicherheit"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ασφάλεια"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Seguridad"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sécurité"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "सुरक्षा"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sicurezza"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "セキュリティ"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "보안"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "安全"
+ }
+ }
+ }
+ },
+ "Select an existing phone number to register for SMS code two-step verification or add a new one." : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "اختر رقم هاتف موجود للتسجيل في التحقق بخطوتين عبر رمز الرسائل النصية أو أضف رقماً جديداً."
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Wählen Sie eine vorhandene Telefonnummer für die SMS-Code-Zwei-Schritt-Verifizierung aus oder fügen Sie eine neue hinzu."
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Επιλέξτε έναν υπάρχοντα αριθμό τηλεφώνου για εγγραφή στην επαλήθευση δύο βημάτων με κωδικό SMS ή προσθέστε έναν νέο."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Selecciona un número de teléfono existente para registrarte en la verificación en dos pasos por código SMS o agrega uno nuevo."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sélectionnez un numéro de téléphone existant pour vous inscrire à la vérification en deux étapes par code SMS ou ajoutez-en un nouveau."
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "SMS कोड दो-चरणीय सत्यापन के लिए पंजीकरण हेतु कोई मौजूदा फ़ोन नंबर चुनें या कोई नया जोड़ें।"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Seleziona un numero di telefono esistente per registrarti alla verifica a due passaggi tramite codice SMS o aggiungine uno nuovo."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "SMSコード2段階認証に登録する既存の電話番号を選択するか、新しい番号を追加してください。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "SMS 코드 2단계 인증에 등록할 기존 전화번호를 선택하거나 새 번호를 추가하세요."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "选择现有电话号码注册短信验证码两步验证,或添加新号码。"
+ }
+ }
+ }
+ },
+ "Send SMS code to %@" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "إرسال رمز SMS إلى %@"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "SMS-Code an %@ senden"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Αποστολή κωδικού SMS στο %@"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Enviar código SMS a %@"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Envoyer le code SMS à %@"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ पर SMS कोड भेजें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Invia codice SMS a %@"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@にSMSコードを送信"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@에 SMS 코드 전송"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "发送验证码至%@"
+ }
+ }
+ }
+ },
+ "Set as default" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "تعيين كافتراضي"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Als Standard festlegen"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ορισμός ως προεπιλογή"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Establecer como predeterminado"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Définir par défaut"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "डिफ़ॉल्ट के रूप में सेट करें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Imposta come predefinito"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "デフォルトに設定"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "기본값으로 설정"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "设为默认"
+ }
+ }
+ }
+ },
+ "Set as primary" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "تعيين كأساسي"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Als primär festlegen"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ορισμός ως κύριο"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Establecer como principal"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Définir comme principal"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "प्राथमिक के रूप में सेट करें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Imposta come principale"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "プライマリに設定"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "기본으로 설정"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "设为主要"
+ }
+ }
+ }
+ },
+ "Set new password" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "تعيين كلمة مرور جديدة"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Neues Passwort festlegen"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ορισμός νέου κωδικού πρόσβασης"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Establecer nueva contraseña"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Définir un nouveau mot de passe"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "नया पासवर्ड सेट करें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Imposta nuova password"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "新しいパスワードを設定"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "새 비밀번호 설정"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "设置新密码"
+ }
+ }
+ }
+ },
+ "Set up a new sign-in method in your authenticator and enter the Key provided below.\n\nMake sure Time-based or One-time passwords is enabled, then finish linking your account." : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "قم بإعداد طريقة تسجيل دخول جديدة في المصادق الخاص بك وأدخل المفتاح المقدم أدناه.\n\nتأكد من تمكين كلمات المرور المستندة إلى الوقت أو لمرة واحدة، ثم أنهِ ربط حسابك."
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Richten Sie eine neue Anmeldemethode in Ihrem Authenticator ein und geben Sie den unten bereitgestellten Schlüssel ein.\n\nStellen Sie sicher, dass zeitbasierte oder einmalige Passwörter aktiviert sind, und schließen Sie dann die Verknüpfung Ihres Kontos ab."
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ρυθμίστε μια νέα μέθοδο σύνδεσης στον επαληθευτή σας και εισαγάγετε το Κλειδί που παρέχεται παρακάτω.\n\nΒεβαιωθείτε ότι οι κωδικοί πρόσβασης βασισμένοι στον χρόνο ή μίας χρήσης είναι ενεργοποιημένοι, στη συνέχεια ολοκληρώστε τη σύνδεση του λογαριασμού σας."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Configura un nuevo método de inicio de sesión en tu autenticador e ingresa la Clave proporcionada a continuación.\n\nAsegúrate de que las contraseñas basadas en tiempo o de un solo uso estén habilitadas, luego termina de vincular tu cuenta."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Configurez une nouvelle méthode de connexion dans votre authentificateur et saisissez la Clé fournie ci-dessous.\n\nAssurez-vous que les mots de passe basés sur le temps ou à usage unique sont activés, puis terminez la liaison de votre compte."
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "अपने प्रमाणीकरण में एक नई साइन-इन विधि सेट करें और नीचे दी गई कुंजी दर्ज करें।\n\nसुनिश्चित करें कि समय-आधारित या एक-बार पासवर्ड सक्षम है, फिर अपने खाते को लिंक करना समाप्त करें।"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Imposta un nuovo metodo di accesso nel tuo autenticatore e inserisci la Chiave fornita di seguito.\n\nAssicurati che le password basate sul tempo o monouso siano abilitate, quindi completa il collegamento del tuo account."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "認証アプリで新しいサインイン方法を設定し、以下に提供されているキーを入力してください。\n\n時間ベースまたはワンタイムパスワードが有効になっていることを確認してから、アカウントのリンクを完了してください。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "인증기에서 새로운 로그인 방법을 설정하고 아래 제공된 키를 입력하세요.\n\n시간 기반 또는 일회용 비밀번호가 활성화되어 있는지 확인한 다음 계정 연결을 완료하세요."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "在您的身份验证器中设置新的登录方法,并输入下面提供的密钥。\n\n确保启用基于时间或一次性密码,然后完成账户链接。"
+ }
+ }
+ }
+ },
+ "Sign in with your passkey" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "تسجيل الدخول باستخدام مفتاح المرور الخاص بك"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Mit Passkey anmelden"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Σύνδεση με το κλειδί πρόσβασης σας"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Inicia sesión con tu clave de acceso"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Connectez-vous avec votre clé d'accès"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "अपने पासकी से साइन इन करें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Accedi con la tua passkey"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "パスキーでサインイン"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "패스키로 로그인"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "使用您的通行密钥登录"
+ }
+ }
+ }
+ },
+ "Sign in with your password" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "تسجيل الدخول باستخدام كلمة المرور"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Passwort verwenden"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Συνδεθείτε με τον κωδικό πρόσβασής σας"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Inicia sesión con tu contraseña"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Connectez-vous avec votre mot de passe"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "अपने पासवर्ड से साइन इन करें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Accedi con la tua password"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "パスワードでサインイン"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "비밀번호로 로그인"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "使用密码登录"
+ }
+ }
+ }
+ },
+ "Sign out" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "تسجيل الخروج"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Abmelden"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Αποσύνδεση"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cerrar sesión"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Se déconnecter"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "साइन आउट"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Esci"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "サインアウト"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "로그아웃"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "退出登录"
+ }
+ }
+ }
+ },
+ "Sign out of all accounts" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "تسجيل الخروج من جميع الحسابات"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Abmelden von allen Konten"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Αποσύνδεση από όλους τους λογαριασμούς"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cerrar sesión en todas las cuentas"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Se déconnecter de tous les comptes"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "सभी खातों से साइन आउट करें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Esci da tutti gli account"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "すべてのアカウントからサインアウト"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "모든 계정에서 로그아웃"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "退出所有账户"
+ }
+ }
+ }
+ },
+ "Sign out of all other devices" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "تسجيل الخروج من جميع الأجهزة الأخرى"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Von allen anderen Geräten abmelden"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Αποσύνδεση από όλες τις άλλες συσκευές"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cerrar sesión en todos los demás dispositivos"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Se déconnecter de tous les autres appareils"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "सभी अन्य डिवाइस से साइन आउट करें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Esci da tutti gli altri dispositivi"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "他のすべてのデバイスからサインアウト"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "다른 모든 기기에서 로그아웃"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "退出所有其他设备"
+ }
+ }
+ }
+ },
+ "Sign out of device" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "تسجيل الخروج من الجهاز"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Von Gerät abmelden"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Αποσύνδεση από συσκευή"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cerrar sesión del dispositivo"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Se déconnecter de l'appareil"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "डिवाइस से साइन आउट करें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Disconnetti dal dispositivo"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "デバイスからサインアウト"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "기기에서 로그아웃"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "从设备注销"
+ }
+ }
+ }
+ },
+ "Sign up" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "إنشاء حساب"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Registrieren"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Εγγραφή"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Registrarse"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "S'inscrire"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "साइन अप करें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Registrati"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "サインアップ"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "가입하기"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "注册"
+ }
+ }
+ }
+ },
+ "Skip" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "تخطي"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Überspringen"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Παράβλεψη"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Omitir"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ignorer"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "छोड़ें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Salta"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "スキップ"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "건너뛰기"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "跳过"
+ }
+ }
+ }
+ },
+ "SMS code" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "رمز الرسائل القصيرة"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "SMS-Code"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Κωδικός SMS"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Código SMS"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Code SMS"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "एसएमएस कोड"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Codice SMS"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "SMSコード"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "SMS 코드"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "短信验证码"
+ }
+ }
+ }
+ },
+ "Something went wrong. Please try again." : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "حدث خطأ ما. يرجى المحاولة مرة أخرى."
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ein unbekannter Fehler ist aufgetreten."
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Κάτι πήγε στραβά. Παρακαλώ δοκιμάστε ξανά."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Algo salió mal. Por favor, inténtalo de nuevo."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Une erreur s'est produite. Veuillez réessayer."
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "कुछ गलत हुआ। कृपया पुनः प्रयास करें।"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Qualcosa è andato storto. Per favore riprova."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "エラーが発生しました。もう一度お試しください。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "문제가 발생했습니다. 다시 시도해 주세요."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "出现错误。请重试。"
+ }
+ }
+ }
+ },
+ "Success" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "نجاح"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Erfolg"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Επιτυχία"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Éxito"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Succès"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "सफलता"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Successo"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "成功"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "성공"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "成功"
+ }
+ }
+ }
+ },
+ "Switch account" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "تبديل الحساب"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Konto wechseln"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Εναλλαγή λογαριασμού"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cambiar de cuenta"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Changer de compte"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "खाता बदलें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cambia account"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "アカウントを切り替え"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "계정 전환"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "切换账户"
+ }
+ }
+ }
+ },
+ "There was an error loading the image from the photos library." : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "حدث خطأ أثناء تحميل الصورة من مكتبة الصور."
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Beim Laden des Bildes aus der Fotobibliothek ist ein Fehler aufgetreten."
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Υπήρξε σφάλμα κατά τη φόρτωση της εικόνας από τη βιβλιοθήκη φωτογραφιών."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Hubo un error al cargar la imagen de la biblioteca de fotos."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Une erreur s'est produite lors du chargement de l'image depuis la photothèque."
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "फोटो लाइब्रेरी से छवि लोड करने में त्रुटि हुई।"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Si è verificato un errore durante il caricamento dell'immagine dalla libreria foto."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "フォトライブラリから画像を読み込む際にエラーが発生しました。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "사진 라이브러리에서 이미지를 로드하는 중 오류가 발생했습니다."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "从照片库加载图像时出错。"
+ }
+ }
+ }
+ },
+ "This device" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "هذا الجهاز"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Dieses Gerät"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Αυτή η συσκευή"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Este dispositivo"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cet appareil"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "इस डिवाइस"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Questo dispositivo"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "このデバイス"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "이 기기"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "这个设备"
+ }
+ }
+ }
+ },
+ "to continue" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "للمتابعة"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Weiter"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "για να συνεχίσετε"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Para continuar"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Pour continuer"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "जारी रखने के लिए"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "per continuare"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "続けるには"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "계속하려면"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "继续"
+ }
+ }
+ }
+ },
+ "to continue to %@" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "للمتابعة إلى %@"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Weiter zu %@"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "για να συνεχίσετε στο %@"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Para continuar a %@"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Pour continuer vers %@"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ पर जारी रखने के लिए"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "per continuare a %@"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@に続けるには"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@(으)로 계속하려면"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "继续前往%@"
+ }
+ }
+ }
+ },
+ "To continue, please enter the verification code generated by your authenticator app" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "للمتابعة، يرجى إدخال رمز التحقق الذي تم إنشاؤه بواسطة تطبيق المصادقة الخاص بك"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Um fortzufahren, geben Sie bitte den von Ihrer Authentifizierungs-App generierten Bestätigungscode ein"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Για να συνεχίσετε, εισαγάγετε τον κωδικό επαλήθευσης που δημιουργήθηκε από την εφαρμογή ελέγχου ταυτότητας σας"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Para continuar, ingrese el código de verificación generado por su aplicación de autenticación"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Pour continuer, veuillez saisir le code de vérification généré par votre application d'authentification"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "जारी रखने के लिए, कृपया अपने प्रमाणक ऐप द्वारा उत्पन्न सत्यापन कोड दर्ज करें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Per continuare, inserisci il codice di verifica generato dalla tua app di autenticazione"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "続行するには、認証アプリで生成された認証コードを入力してください"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "계속하려면 인증 앱에서 생성된 인증 코드를 입력하세요"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "要继续,请输入您的身份验证器应用生成的验证码"
+ }
+ }
+ }
+ },
+ "Two-step verification" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "التحقق بخطوتين"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Zwei-Faktor-Authentifizierung"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Επαλήθευση δύο βημάτων"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Verificación en dos pasos"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Vérification en deux étapes"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "दो-चरणीय सत्यापन"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Verifica in due passaggi"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "2段階認証"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "2단계 인증"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "两步验证"
+ }
+ }
+ }
+ },
+ "TWO-STEP VERIFICATION" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "التحقق بخطوتين"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ZWEI-FACH-VERIFIZIERUNG"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ΕΠΑΛΗΘΕΥΣΗ ΔΥΟ ΒΗΜΑΤΩΝ"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "VERIFICACIÓN EN DOS PASOS"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "VÉRIFICATION EN DEUX ÉTAPES"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "दो-चरणीय सत्यापन"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "VERIFICA IN DUE PASSAGGI"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "2段階認証"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "2단계 인증"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "两步验证"
+ }
+ }
+ }
+ },
+ "Two-step verification is now enabled. When signing in, you will need to enter a verification code from this authenticator app as an additional step.\n\nSave these backup codes and store them somewhere safe. If you lose access to your authentication device, you can use backup codes to sign in." : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "تم تمكين التحقق بخطوتين الآن. عند تسجيل الدخول، ستحتاج إلى إدخال رمز التحقق من تطبيق المصادقة هذا كخطوة إضافية.\n\nاحفظ رموز النسخ الاحتياطي هذه واحتفظ بها في مكان آمن. إذا فقدت الوصول إلى جهاز المصادقة الخاص بك، يمكنك استخدام رموز النسخ الاحتياطي لتسجيل الدخول."
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Die Zwei-Faktor-Authentifizierung ist jetzt aktiviert. Bei der Anmeldung müssen Sie als zusätzlichen Schritt einen Bestätigungscode aus dieser Authenticator-App eingeben.\n\nSpeichern Sie diese Backup-Codes und bewahren Sie sie an einem sicheren Ort auf. Wenn Sie den Zugang zu Ihrem Authentifizierungsgerät verlieren, können Sie Backup-Codes zur Anmeldung verwenden."
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Η επαλήθευση δύο βημάτων είναι πλέον ενεργοποιημένη. Κατά τη σύνδεση, θα χρειαστεί να εισαγάγετε έναν κωδικό επαλήθευσης από αυτήν την εφαρμογή επαλήθευσης ως επιπλέον βήμα.\n\nΑποθηκεύστε αυτούς τους κωδικούς αντιγράφων ασφαλείας και φυλάξτε τους σε ασφαλές μέρος. Εάν χάσετε την πρόσβαση στη συσκευή επαλήθευσής σας, μπορείτε να χρησιμοποιήσετε κωδικούς αντιγράφων ασφαλείας για σύνδεση."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "La verificación en dos pasos ya está habilitada. Al iniciar sesión, necesitarás ingresar un código de verificación de esta aplicación autenticadora como paso adicional.\n\nGuarda estos códigos de respaldo y almacénalos en un lugar seguro. Si pierdes acceso a tu dispositivo de autenticación, puedes usar códigos de respaldo para iniciar sesión."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "La vérification en deux étapes est maintenant activée. Lors de la connexion, vous devrez saisir un code de vérification de cette application d'authentification comme étape supplémentaire.\n\nEnregistrez ces codes de sauvegarde et stockez-les dans un endroit sûr. Si vous perdez l'accès à votre appareil d'authentification, vous pouvez utiliser les codes de sauvegarde pour vous connecter."
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "दो-चरणीय सत्यापन अब सक्षम है। साइन इन करते समय, आपको एक अतिरिक्त चरण के रूप में इस प्रमाणीकरण ऐप से एक सत्यापन कोड दर्ज करना होगा।\n\nइन बैकअप कोड को सहेजें और उन्हें किसी सुरक्षित स्थान पर संग्रहीत करें। यदि आप अपने प्रमाणीकरण डिवाइस तक पहुंच खो देते हैं, तो आप साइन इन करने के लिए बैकअप कोड का उपयोग कर सकते हैं।"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "La verifica in due passaggi è ora abilitata. Durante l'accesso, dovrai inserire un codice di verifica da questa app di autenticazione come passaggio aggiuntivo.\n\nSalva questi codici di backup e conservali in un posto sicuro. Se perdi l'accesso al tuo dispositivo di autenticazione, puoi utilizzare i codici di backup per accedere."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "2段階認証が有効になりました。サインイン時に、追加のステップとしてこの認証アプリから確認コードを入力する必要があります。\n\nこれらのバックアップコードを保存し、安全な場所に保管してください。認証デバイスへのアクセスを失った場合、バックアップコードを使用してサインインできます。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "2단계 인증이 이제 활성화되었습니다. 로그인할 때 추가 단계로 이 인증 앱에서 확인 코드를 입력해야 합니다.\n\n이 백업 코드를 저장하고 안전한 곳에 보관하세요. 인증 기기에 대한 액세스를 잃으면 백업 코드를 사용하여 로그인할 수 있습니다."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "两步验证现已启用。登录时,您需要输入来自此身份验证应用的验证码作为额外步骤。\n\n保存这些备份代码并将其存储在安全的地方。如果您无法访问身份验证设备,可以使用备份代码登录。"
+ }
+ }
+ }
+ },
+ "Type \"DELETE\" to continue" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "اكتب \"حذف\" للمتابعة"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Geben Sie \"LÖSCHEN\" ein, um fortzufahren"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Πληκτρολογήστε \"ΔΙΑΓΡΑΦΗ\" για να συνεχίσετε"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Escribe \"ELIMINAR\" para continuar"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Tapez \"SUPPRIMER\" pour continuer"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "जारी रखने के लिए \"हटाएं\" टाइप करें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Digita \"ELIMINA\" per continuare"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "続行するには\"削除\"と入力してください"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "계속하려면 \"삭제\"를 입력하세요"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "输入\"删除\"以继续"
+ }
+ }
+ }
+ },
+ "Unable to delete membership: missing userId" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "تعذر حذف العضوية: معرف المستخدم مفقود"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Es konnte keine Mitgliedschaft gelöscht werden: userId fehlt"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Αδυναμία διαγραφής συμμετοχής: λείπει το userId"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "No se puede eliminar la membresía: falta el userId"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Impossible de supprimer l'adhésion : userId manquant"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "सदस्यता हटाने में असमर्थ: userId अनुपलब्ध"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Impossibile eliminare l'appartenenza: userId mancante"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "メンバーシップを削除できません:userIdがありません"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "멤버십을 삭제할 수 없습니다: userId가 없습니다"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "无法删除成员资格:缺少userId"
+ }
+ }
+ }
+ },
+ "Unable to evaluate policy." : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "تعذر تقييم السياسة."
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Es konnte keine Richtlinie ausgewertet werden."
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Αδυναμία αξιολόγησης πολιτικής."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "No se puede evaluar la política."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Impossible d'évaluer la politique."
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "नीति का मूल्यांकन करने में असमर्थ।"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Impossibile valutare la policy."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ポリシーを評価できません。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "정책을 평가할 수 없습니다."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "无法评估策略。"
+ }
+ }
+ }
+ },
+ "Unable to get the challenge for the passkey." : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "تعذر الحصول على التحدي لمفتاح المرور."
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Es konnte kein Challenge für die Passkey erhalten werden."
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Αδυναμία λήψης της πρόκλησης για το passkey."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "No se puede obtener el desafío para la clave de acceso."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Impossible d'obtenir le défi pour la clé d'accès."
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "पासकी के लिए चैलेंज प्राप्त करने में असमर्थ।"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Impossibile ottenere la sfida per la passkey."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "パスキーのチャレンジを取得できません。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "패스키의 챌린지를 가져올 수 없습니다."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "无法获取通行密钥的质询。"
+ }
+ }
+ }
+ },
+ "Unable to get the user ID for the passkey." : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "تعذر الحصول على معرف المستخدم لمفتاح المرور."
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Es konnte keine userId für die Passkey erhalten werden."
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Αδυναμία λήψης του αναγνωριστικού χρήστη για το passkey."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "No se puede obtener el ID de usuario para la clave de acceso."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Impossible d'obtenir l'ID utilisateur pour la clé d'accès."
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "पासकी के लिए उपयोगकर्ता ID प्राप्त करने में असमर्थ।"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Impossibile ottenere l'ID utente per la passkey."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "パスキーのユーザーIDを取得できません。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "패스키의 사용자 ID를 가져올 수 없습니다."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "无法获取通行密钥的用户ID。"
+ }
+ }
+ }
+ },
+ "Unable to get the username for the passkey." : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "تعذر الحصول على اسم المستخدم لمفتاح المرور."
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Es konnte kein Benutzername für die Passkey erhalten werden."
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Αδυναμία λήψης του ονόματος χρήστη για το passkey."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "No se puede obtener el nombre de usuario para la clave de acceso."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Impossible d'obtenir le nom d'utilisateur pour la clé d'accès."
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "पासकी के लिए उपयोगकर्ता नाम प्राप्त करने में असमर्थ।"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Impossibile ottenere il nome utente per la passkey."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "パスキーのユーザー名を取得できません。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "패스키의 사용자 이름을 가져올 수 없습니다."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "无法获取通行密钥的用户名。"
+ }
+ }
+ }
+ },
+ "Unable to get your Apple ID credential." : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "تعذر الحصول على بيانات اعتماد Apple ID الخاصة بك."
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Es konnte keine Apple ID-Anmeldedaten erhalten werden."
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Αδυναμία λήψης των διαπιστευτηρίων Apple ID σας."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "No se puede obtener la credencial de Apple ID."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Impossible d'obtenir vos identifiants Apple ID."
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "आपका Apple ID क्रेडेंशियल प्राप्त करने में असमर्थ।"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Impossibile ottenere le credenziali del tuo Apple ID."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Apple IDの認証情報を取得できません。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Apple ID 인증 정보를 가져올 수 없습니다."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "无法获取您的Apple ID凭证。"
+ }
+ }
+ }
+ },
+ "Unable to retrieve the apple identity token." : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "تعذر استرداد رمز هوية Apple."
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Es konnte kein Apple-Identitäts-Token erhalten werden."
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Αδυναμία ανάκτησης του διακριτικού ταυτότητας Apple."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "No se puede recuperar el token de identidad de Apple."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Impossible de récupérer le jeton d'identité Apple."
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Apple पहचान टोकन पुनर्प्राप्त करने में असमर्थ।"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Impossibile recuperare il token di identità Apple."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "AppleのIDトークンを取得できません。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Apple ID 토큰을 검색할 수 없습니다."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "无法检索Apple身份令牌。"
+ }
+ }
+ }
+ },
+ "Unable to update membership: missing userId" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "تعذر تحديث العضوية: معرف المستخدم مفقود"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Es konnte keine Mitgliedschaft aktualisiert werden: userId fehlt"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Αδυναμία ενημέρωσης συμμετοχής: λείπει το userId"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "No se puede actualizar la membresía: falta el userId"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Impossible de mettre à jour l'adhésion : userId manquant"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "सदस्यता अपडेट करने में असमर्थ: userId अनुपलब्ध"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Impossibile aggiornare l'appartenenza: userId mancante"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "メンバーシップを更新できません:userIdがありません"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "멤버십을 업데이트할 수 없습니다: userId가 없습니다"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "无法更新成员资格:缺少userId"
+ }
+ }
+ }
+ },
+ "Unknown code verification method. Please use another method." : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "طريقة تحقق غير معروفة. يرجى استخدام طريقة أخرى."
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Unbekannte Code-Überprüfungsmethode. Bitte verwenden Sie eine andere Methode."
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Άγνωστη μέθοδος επαλήθευσης κωδικού. Παρακαλώ χρησιμοποιήστε άλλη μέθοδο."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Método de verificación de código desconocido. Por favor, use otro método."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Méthode de vérification de code inconnue. Veuillez utiliser une autre méthode."
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "अज्ञात कोड सत्यापन विधि। कृपया कोई अन्य विधि उपयोग करें।"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Metodo di verifica del codice sconosciuto. Si prega di utilizzare un altro metodo."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "不明なコード認証方法です。別の方法を使用してください。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "알 수 없는 코드 인증 방법입니다. 다른 방법을 사용하세요."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "未知的验证码验证方式。请使用其他方式。"
+ }
+ }
+ }
+ },
+ "Unknown device" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "جهاز غير معروف"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Unbekanntes Gerät"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Άγνωστη συσκευή"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Dispositivo desconocido"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Appareil inconnu"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "अज्ञात डिवाइस"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Dispositivo sconosciuto"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "不明なデバイス"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "알 수 없는 기기"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "未知设备"
+ }
+ }
+ }
+ },
+ "Unverified" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "غير موثق"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nicht verifiziert"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Μη επαληθευμένο"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "No verificado"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Non vérifié"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "असत्यापित"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Non verificato"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "未確認"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "인증되지 않음"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "未验证"
+ }
+ }
+ }
+ },
+ "Update password" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "تحديث كلمة المرور"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Passwort aktualisieren"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ενημέρωση κωδικού πρόσβασης"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Actualizar contraseña"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Mettre à jour le mot de passe"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "पासवर्ड अपडेट करें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aggiorna password"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "パスワードを更新"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "비밀번호 업데이트"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "更新密码"
+ }
+ }
+ }
+ },
+ "Update profile" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "تحديث الملف الشخصي"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Profil aktualisieren"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ενημέρωση προφίλ"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Actualizar perfil"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Mettre à jour le profil"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "प्रोफ़ाइल अपडेट करें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aggiorna profilo"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "プロフィールを更新"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "프로필 업데이트"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "更新个人资料"
+ }
+ }
+ }
+ },
+ "Use a backup code" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "استخدام رمز النسخ الاحتياطي"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ein Sicherungscode verwenden"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Χρήση εφεδρικού κωδικού"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Usar un código de respaldo"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Utiliser un code de secours"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "बैकअप कोड का उपयोग करें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Usa un codice di backup"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "バックアップコードを使用"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "백업 코드 사용"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "使用备用码"
+ }
+ }
+ }
+ },
+ "Use another method" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "استخدام طريقة أخرى"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Eine andere Methode verwenden"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Χρήση άλλης μεθόδου"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Usar otro método"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Utiliser une autre méthode"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "दूसरी विधि का उपयोग करें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Usa un altro metodo"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "他の方法を使用"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "다른 방법 사용"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "使用其他方法"
+ }
+ }
+ }
+ },
+ "Use email address" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "استخدام عنوان البريد الإلكتروني"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "E-Mail-Adresse verwenden"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Χρήση διεύθυνσης email"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Usar dirección de correo electrónico"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Utiliser l'adresse e-mail"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ईमेल पता का उपयोग करें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Usa indirizzo email"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "メールアドレスを使用"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "이메일 주소 사용"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "使用电子邮箱"
+ }
+ }
+ }
+ },
+ "Use email address or username" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "استخدام عنوان البريد الإلكتروني أو اسم المستخدم"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Geben Sie Ihre E-Mail-Adresse oder Ihren Benutzernamen ein"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Χρήση διεύθυνσης email ή ονόματος χρήστη"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Usar dirección de correo electrónico o nombre de usuario"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Utiliser l'adresse e-mail ou le nom d'utilisateur"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ईमेल पता या यूजरनेम का उपयोग करें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Usa indirizzo email o nome utente"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "メールアドレスまたはユーザー名を使用"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "이메일 주소 또는 사용자 이름 사용"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "使用电子邮箱或用户名"
+ }
+ }
+ }
+ },
+ "Use phone number" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "استخدام رقم الهاتف"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Telefonnummer eingeben"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Χρήση αριθμού τηλεφώνου"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Usar número de teléfono"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Utiliser le numéro de téléphone"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "फोन नंबर का उपयोग करें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Usa numero di telefono"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "電話番号を使用"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "전화번호 사용"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "使用电话号码"
+ }
+ }
+ }
+ },
+ "Use username" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "استخدام اسم المستخدم"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Benutzernamen verwenden"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Χρήση ονόματος χρήστη"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Usar nombre de usuario"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Utiliser le nom d'utilisateur"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "यूजरनेम का उपयोग करें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Usa nome utente"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ユーザー名を使用"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "사용자 이름 사용"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "使用用户名"
+ }
+ }
+ }
+ },
+ "Use your authenticator app" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "استخدام تطبيق المصادقة الخاص بك"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ihr Authentifizierungs-App verwenden"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Χρήση της εφαρμογής ελέγχου ταυτότητας"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Usar tu aplicación de autenticación"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Utiliser votre application d'authentification"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "अपने प्रमाणीकरण ऐप का उपयोग करें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Usa la tua app di autenticazione"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "認証アプリを使用"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "인증 앱 사용"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "使用您的身份验证器应用"
+ }
+ }
+ }
+ },
+ "Use your passkey" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "استخدام مفتاح المرور الخاص بك"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Passkey verwenden"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Χρήση του passkey σας"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Usar tu clave de acceso"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Utiliser votre clé d'accès"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "अपनी पासकी का उपयोग करें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Usa la tua passkey"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "パスキーを使用"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "패스키 사용"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "使用您的通行密钥"
+ }
+ }
+ }
+ },
+ "Username" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "اسم المستخدم"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Benutzername"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Όνομα χρήστη"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nombre de usuario"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nom d'utilisateur"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "यूजरनेम"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nome utente"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ユーザー名"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "사용자 이름"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "用户名"
+ }
+ }
+ }
+ },
+ "Using your passkey confirms it's you. Your device may ask for your fingerprint, face or screen lock." : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "استخدام مفتاح المرور الخاص بك يؤكد هويتك. قد يطلب جهازك بصمة إصبعك أو وجهك أو قفل الشاشة."
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Die Verwendung Ihrer Passkey bestätigt, dass Sie es sind. Ihr Gerät kann Sie nach Ihrer Fingerabdruck, Gesicht oder Bildschirmsperre fragen."
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Η χρήση του passkey σας επιβεβαιώνει ότι είστε εσείς. Η συσκευή σας μπορεί να ζητήσει το δακτυλικό σας αποτύπωμα, το πρόσωπό σας ή τον κωδικό κλειδώματος οθόνης."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "El uso de tu clave de acceso confirma que eres tú. Tu dispositivo puede solicitar tu huella digital, rostro o bloqueo de pantalla."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "L'utilisation de votre clé d'accès confirme votre identité. Votre appareil peut vous demander votre empreinte digitale, votre visage ou votre code de verrouillage."
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "अपना पासकी उपयोग करने से पुष्टि होती है कि यह आप ही हैं। आपका डिवाइस आपका फिंगरप्रिंट, फेस या स्क्रीन लॉक मांग सकता है।"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "L'uso della tua passkey conferma che sei tu. Il tuo dispositivo potrebbe richiedere l'impronta digitale, il riconoscimento facciale o il blocco schermo."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "パスキーを使用することで本人確認ができます。デバイスが指紋、顔認証、画面ロックを要求する場合があります。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "패스키를 사용하면 본인임을 확인할 수 있습니다. 기기에서 지문, 얼굴 또는 화면 잠금을 요구할 수 있습니다."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "使用您的通行密钥可确认您的身份。您的设备可能会要求您提供指纹、面容或屏幕锁定。"
+ }
+ }
+ }
+ },
+ "Verification codes from this authenticator will no longer be required when signing in." : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "لن تعود رموز التحقق من هذا المصدق مطلوبة عند تسجيل الدخول."
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Bestätigungscodes von diesem Authentifikator werden beim Anmelden nicht mehr benötigt."
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Οι κωδικοί επαλήθευσης από αυτόν τον επαληθευτή δεν θα απαιτούνται πλέον κατά τη σύνδεση."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Los códigos de verificación de este autenticador ya no serán requeridos al iniciar sesión."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Les codes de vérification de cet authentificateur ne seront plus requis lors de la connexion."
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "इस प्रमाणक से सत्यापन कोड अब साइन इन करते समय आवश्यक नहीं होंगे।"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "I codici di verifica da questo autenticatore non saranno più richiesti durante l'accesso."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "この認証機器からの確認コードは、サインイン時に不要になります。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "이 인증기의 인증 코드는 로그인 시 더 이상 필요하지 않습니다."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "登录时将不再需要此身份验证器的验证码。"
+ }
+ }
+ }
+ },
+ "Verify" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "تحقق"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Verifizieren"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Επαληθεύστε"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Verificar"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Vérifier"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "सत्यापित करें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Verifica"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "確認"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "확인"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "验证"
+ }
+ }
+ }
+ },
+ "Verify authenticator app" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "تحقق من تطبيق المصادقة"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Authenticator-App verifizieren"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Επαλήθευση εφαρμογής επαλήθευσης"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Verificar aplicación autenticadora"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Vérifier l'application d'authentification"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "प्रमाणीकरण ऐप सत्यापित करें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Verifica app di autenticazione"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "認証アプリを確認"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "인증 앱 확인"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "验证身份验证应用"
+ }
+ }
+ }
+ },
+ "Verify email address" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "تحقق من عنوان البريد الإلكتروني"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "E-Mail-Adresse verifizieren"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Επαληθεύστε τη διεύθυνση email"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Verificar dirección de correo electrónico"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Vérifier l'adresse e-mail"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ईमेल पता सत्यापित करें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Verifica indirizzo email"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "メールアドレスを確認"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "이메일 주소 확인"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "验证电子邮箱地址"
+ }
+ }
+ }
+ },
+ "Verify phone number" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "تحقق من رقم الهاتف"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Telefonnummer verifizieren"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Επαληθεύστε τον αριθμό τηλεφώνου"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Verificar número de teléfono"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Vérifier le numéro de téléphone"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "फ़ोन नंबर सत्यापित करें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Verifica numero di telefono"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "電話番号を確認"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "전화번호 확인"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "验证电话号码"
+ }
+ }
+ }
+ },
+ "Verifying..." : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "جاري التحقق..."
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Wird überprüft..."
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Επαλήθευση..."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Verificando..."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Vérification..."
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "सत्यापित किया जा रहा है..."
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Verifica in corso..."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "確認中..."
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "확인 중..."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "正在验证..."
+ }
+ }
+ }
+ },
+ "Welcome! Please fill in the details to get started." : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "مرحباً! يرجى ملء التفاصيل للبدء."
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Willkommen! Bitte füllen Sie die Details aus, um loszulegen."
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Καλώς ήρθατε! Παρακαλώ συμπληρώστε τα στοιχεία για να ξεκινήσετε."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "¡Bienvenido! Por favor completa los detalles para comenzar."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Bienvenue ! Veuillez remplir les détails pour commencer."
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "स्वागत है! शुरू करने के लिए कृपया विवरण भरें।"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Benvenuto! Per favore compila i dettagli per iniziare."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ようこそ!開始するには詳細を入力してください。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "환영합니다! 시작하려면 세부 정보를 입력해 주세요."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "欢迎!请填写详细信息以开始使用。"
+ }
+ }
+ }
+ },
+ "Welcome! Sign in to continue" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "مرحباً! سجل دخولك للمتابعة"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Willkommen! Melden Sie sich an, um fortzufahren"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Καλώς ήρθατε! Συνδεθείτε για να συνεχίσετε"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "¡Bienvenido! Inicia sesión para continuar"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Bienvenue ! Connectez-vous pour continuer"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "स्वागत है! जारी रखने के लिए साइन इन करें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Benvenuto! Accedi per continuare"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ようこそ!続けるにはサインインしてください"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "환영합니다! 계속하려면 로그인하세요"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "欢迎!登录以继续"
+ }
+ }
+ }
+ },
+ "When signing in, you will need to enter a verification code sent to this phone number as an additional step.\n\nSave these backup codes and store them somewhere safe. If you lose access to your authentication device, you can use backup codes to sign in." : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "عند تسجيل الدخول، ستحتاج إلى إدخال رمز التحقق المرسل إلى رقم الهاتف هذا كخطوة إضافية.\n\nاحفظ رموز النسخ الاحتياطي هذه واحتفظ بها في مكان آمن. إذا فقدت الوصول إلى جهاز المصادقة الخاص بك، يمكنك استخدام رموز النسخ الاحتياطي لتسجيل الدخول."
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Bei der Anmeldung müssen Sie als zusätzlichen Schritt einen Bestätigungscode eingeben, der an diese Telefonnummer gesendet wird.\n\nSpeichern Sie diese Backup-Codes und bewahren Sie sie an einem sicheren Ort auf. Wenn Sie den Zugang zu Ihrem Authentifizierungsgerät verlieren, können Sie Backup-Codes zur Anmeldung verwenden."
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Κατά τη σύνδεση, θα χρειαστεί να εισαγάγετε έναν κωδικό επαλήθευσης που αποστέλλεται σε αυτόν τον αριθμό τηλεφώνου ως επιπλέον βήμα.\n\nΑποθηκεύστε αυτούς τους κωδικούς αντιγράφων ασφαλείας και φυλάξτε τους σε ασφαλές μέρος. Εάν χάσετε την πρόσβαση στη συσκευή επαλήθευσής σας, μπορείτε να χρησιμοποιήσετε κωδικούς αντιγράφων ασφαλείας για σύνδεση."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Al iniciar sesión, necesitarás ingresar un código de verificación enviado a este número de teléfono como paso adicional.\n\nGuarda estos códigos de respaldo y almacénalos en un lugar seguro. Si pierdes acceso a tu dispositivo de autenticación, puedes usar códigos de respaldo para iniciar sesión."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Lors de la connexion, vous devrez saisir un code de vérification envoyé à ce numéro de téléphone comme étape supplémentaire.\n\nEnregistrez ces codes de sauvegarde et stockez-les dans un endroit sûr. Si vous perdez l'accès à votre appareil d'authentification, vous pouvez utiliser les codes de sauvegarde pour vous connecter."
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "साइन इन करते समय, आपको एक अतिरिक्त चरण के रूप में इस फोन नंबर पर भेजा गया सत्यापन कोड दर्ज करना होगा।\n\nइन बैकअप कोड को सहेजें और उन्हें किसी सुरक्षित स्थान पर संग्रहीत करें। यदि आप अपने प्रमाणीकरण डिवाइस तक पहुंच खो देते हैं, तो आप साइन इन करने के लिए बैकअप कोड का उपयोग कर सकते हैं।"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Durante l'accesso, dovrai inserire un codice di verifica inviato a questo numero di telefono come passaggio aggiuntivo.\n\nSalva questi codici di backup e conservali in un posto sicuro. Se perdi l'accesso al tuo dispositivo di autenticazione, puoi utilizzare i codici di backup per accedere."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "サインイン時に、追加のステップとしてこの電話番号に送信される確認コードを入力する必要があります。\n\nこれらのバックアップコードを保存し、安全な場所に保管してください。認証デバイスへのアクセスを失った場合、バックアップコードを使用してサインインできます。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "로그인할 때 추가 단계로 이 전화번호로 전송된 확인 코드를 입력해야 합니다.\n\n이 백업 코드를 저장하고 안전한 곳에 보관하세요. 인증 기기에 대한 액세스를 잃으면 백업 코드를 사용하여 로그인할 수 있습니다."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "登录时,您需要输入发送到此电话号码的验证码作为额外步骤。\n\n保存这些备份代码并将其存储在安全的地方。如果您无法访问身份验证设备,可以使用备份代码登录。"
+ }
+ }
+ }
+ },
+ "Whoops, something is wrong" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "عذراً، هناك خطأ ما"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Hoppla, etwas ist schiefgelaufen"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ωχ, κάτι πήγε στραβά"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ups, algo salió mal"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Oups, quelque chose ne va pas"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "उफ़्फ़, कुछ गलत है"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ops, qualcosa non va"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "おっと、何か問題が発生しました"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "앗, 문제가 발생했습니다"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "哎呀,出错了"
+ }
+ }
+ }
+ },
+ "You can change the passkey name to make it easier to find." : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "يمكنك تغيير اسم مفتاح المرور لتسهيل العثور عليه."
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sie können den Passkey-Namen ändern, um ihn leichter zu finden."
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Μπορείτε να αλλάξετε το όνομα του passkey για να είναι πιο εύκολο να το βρείτε."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Puedes cambiar el nombre de la llave de acceso para que sea más fácil de encontrar."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Vous pouvez changer le nom de la clé d'accès pour la retrouver plus facilement."
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "आप पासकी का नाम बदल सकते हैं ताकि इसे खोजना आसान हो जाए।"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Puoi cambiare il nome della passkey per renderla più facile da trovare."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "パスキー名を変更することで、見つけやすくすることができます。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "패스키 이름을 변경하여 더 쉽게 찾을 수 있습니다."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "您可以更改通行密钥名称,以便更容易找到。"
+ }
+ }
+ }
+ },
+ "You'll need to verify this email address before it can be added to your account." : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ستحتاج إلى التحقق من عنوان البريد الإلكتروني هذا قبل أن تتمكن من إضافته إلى حسابك."
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sie müssen diese E-Mail-Adresse verifizieren, bevor sie zu Ihrem Konto hinzugefügt werden kann."
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Θα χρειαστεί να επαληθεύσετε αυτή τη διεύθυνση email πριν προστεθεί στον λογαριασμό σας."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Deberá verificar esta dirección de correo electrónico antes de que pueda agregarse a su cuenta."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Vous devrez vérifier cette adresse e-mail avant qu'elle puisse être ajoutée à votre compte."
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "इस ईमेल पते को अपने खाते में जोड़ने से पहले आपको इसे सत्यापित करना होगा।"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Dovrai verificare questo indirizzo email prima che possa essere aggiunto al tuo account."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "このメールアドレスをアカウントに追加する前に、確認が必要です。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "이 이메일 주소를 계정에 추가하기 전에 확인해야 합니다."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "在将此电子邮箱地址添加到您的账户之前,您需要先进行验证。"
+ }
+ }
+ }
+ },
+ "Your backup code is the one you got when setting up two-step authentication." : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "رمز النسخ الاحتياطي الخاص بك هو الرمز الذي حصلت عليه عند إعداد المصادقة بخطوتين."
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ihr Backup-Code ist der, den Sie bei der Einrichtung der Zwei-Faktor-Authentifizierung erhalten haben."
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ο εφεδρικός σας κωδικός είναι αυτός που λάβατε κατά τη ρύθμιση του ελέγχου ταυτότητας δύο βημάτων."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Tu código de respaldo es el que recibiste al configurar la autenticación en dos pasos."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Votre code de secours est celui que vous avez reçu lors de la configuration de l'authentification à deux étapes."
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "आपका बैकअप कोड वही है जो आपने दो-चरणीय प्रमाणीकरण सेट करते समय प्राप्त किया था।"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Il tuo codice di backup è quello che hai ricevuto durante la configurazione dell'autenticazione a due fattori."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "バックアップコードは、2段階認証の設定時に取得したものです。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "백업 코드는 2단계 인증을 설정할 때 받은 코드입니다."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "您的备用码是在设置两步验证时获得的。"
+ }
+ }
+ }
+ }
+ },
+ "version" : "1.0"
+}
\ No newline at end of file
diff --git a/Sources/Clerk/ClerkUI/Theme/Buttons/ClerkButtonConfig.swift b/Sources/Clerk/ClerkUI/Theme/Buttons/ClerkButtonConfig.swift
new file mode 100644
index 000000000..cc23fb353
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Theme/Buttons/ClerkButtonConfig.swift
@@ -0,0 +1,29 @@
+//
+// ClerkButtonConfig.swift
+// Clerk
+//
+// Created by Mike Pitre on 4/17/25.
+//
+
+#if os(iOS)
+
+import Foundation
+
+struct ClerkButtonConfig {
+ var emphasis: Emphasis = .high
+ var size: Size = .large
+
+ enum Emphasis {
+ case none
+ case low
+ case high
+ }
+
+ enum Size {
+ case small
+ case large
+ }
+
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Theme/Buttons/NegativeButtonStyle.swift b/Sources/Clerk/ClerkUI/Theme/Buttons/NegativeButtonStyle.swift
new file mode 100644
index 000000000..856407283
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Theme/Buttons/NegativeButtonStyle.swift
@@ -0,0 +1,204 @@
+//
+// NegativeButtonStyle.swift
+// Clerk
+//
+// Created by Mike Pitre on 4/10/25.
+//
+
+#if os(iOS)
+
+import SwiftUI
+
+struct NegativeButtonStyle: ButtonStyle {
+ @Environment(\.clerkTheme) private var theme
+ @Environment(\.isEnabled) private var isEnabled
+
+ let config: ClerkButtonConfig
+
+ var font: Font {
+ switch config.size {
+ case .small:
+ theme.fonts.subheadline
+ case .large:
+ theme.fonts.body
+ }
+ }
+
+ func backgroundColor(
+ configuration: Configuration
+ ) -> Color {
+ switch config.emphasis {
+ case .none:
+ configuration.isPressed
+ ? theme.colors.backgroundDanger
+ : theme.colors.background
+ case .low:
+ configuration.isPressed
+ ? theme.colors.backgroundDanger
+ : theme.colors.background
+ case .high:
+ configuration.isPressed
+ ? theme.colors.backgroundDanger
+ : theme.colors.danger
+ }
+ }
+
+ var foregroundStyle: Color {
+ switch config.emphasis {
+ case .none:
+ theme.colors.danger
+ case .low:
+ theme.colors.danger
+ case .high:
+ theme.colors.textOnPrimaryBackground
+ }
+ }
+
+ var height: CGFloat {
+ switch config.size {
+ case .small:
+ 32
+ case .large:
+ 48
+ }
+ }
+
+ var borderWidth: CGFloat {
+ switch config.emphasis {
+ case .none:
+ 0
+ case .low:
+ 1
+ case .high:
+ 1
+ }
+ }
+
+ var borderColor: Color {
+ switch config.emphasis {
+ case .none:
+ .clear
+ case .low:
+ theme.colors.buttonBorder
+ case .high:
+ theme.colors.buttonBorder
+ }
+ }
+
+ var hasShadow: Bool {
+ switch config.emphasis {
+ case .none:
+ false
+ case .low:
+ true
+ case .high:
+ true
+ }
+ }
+
+ func makeBody(configuration: Configuration) -> some View {
+ configuration.label
+ .font(font)
+ .foregroundStyle(foregroundStyle)
+ .padding(8)
+ .frame(minHeight: height)
+ .background {
+ RoundedRectangle(cornerRadius: theme.design.borderRadius)
+ .fill(backgroundColor(configuration: configuration))
+ .overlay {
+ RoundedRectangle(cornerRadius: theme.design.borderRadius)
+ .strokeBorder(borderColor, lineWidth: borderWidth)
+ }
+ }
+ .background {
+ if hasShadow {
+ RoundedRectangle(cornerRadius: theme.design.borderRadius)
+ .fill(backgroundColor(configuration: configuration))
+ .shadow(color: theme.colors.inputBorderFocused, radius: 0.5, x: 0, y: 1)
+ }
+ }
+ .opacity(isEnabled ? 1 : 0.5)
+ .animation(.default, value: isEnabled)
+ }
+}
+
+extension ButtonStyle where Self == NegativeButtonStyle {
+ static func negative(
+ config: ClerkButtonConfig = .init()
+ ) -> NegativeButtonStyle {
+ .init(config: config)
+ }
+}
+
+#Preview {
+ @Previewable @Environment(\.clerkTheme) var theme
+
+ struct Content: View {
+ var body: some View {
+ HStack(spacing: 4) {
+ Text("Continue", bundle: .module)
+ Image("icon-triangle-right", bundle: .module)
+ .opacity(0.6)
+ }
+ }
+ }
+
+ return VStack(spacing: 20) {
+
+ Button {
+ } label: {
+ Content()
+ }
+ .buttonStyle(
+ .negative(
+ config: .init(
+ emphasis: .high,
+ size: .large
+ )
+ )
+ )
+
+ Button {
+ } label: {
+ Content()
+ }
+ .buttonStyle(
+ .negative(
+ config: .init(
+ emphasis: .high,
+ size: .small
+ )
+ )
+ )
+
+ Button {
+ } label: {
+ Content()
+ }
+ .buttonStyle(
+ .negative(
+ config: .init(
+ emphasis: .none,
+ size: .large
+ )
+ )
+ )
+
+ Button {
+ } label: {
+ Content()
+ }
+ .buttonStyle(
+ .negative(
+ config: .init(
+ emphasis: .none,
+ size: .small
+ )
+ )
+ )
+ }
+ .padding()
+ .environment(\.clerkTheme, .clerk)
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Theme/Buttons/PressedBackgroundButtonStyle.swift b/Sources/Clerk/ClerkUI/Theme/Buttons/PressedBackgroundButtonStyle.swift
new file mode 100644
index 000000000..8141b9bd2
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Theme/Buttons/PressedBackgroundButtonStyle.swift
@@ -0,0 +1,37 @@
+//
+// PressedBackgroundButtonStyle.swift
+// Clerk
+//
+// Created by Mike Pitre on 5/1/25.
+//
+
+#if os(iOS)
+
+import SwiftUI
+
+struct PressedBackgroundButtonStyle: ButtonStyle {
+ @Environment(\.clerkTheme) private var theme
+
+ func makeBody(configuration: Configuration) -> some View {
+ configuration.label
+ .background(configuration.isPressed ? theme.colors.backgroundSecondary : nil)
+ }
+
+}
+
+extension ButtonStyle where Self == PressedBackgroundButtonStyle {
+ static var pressedBackground: PressedBackgroundButtonStyle {
+ .init()
+ }
+}
+
+#Preview {
+ Button {
+ //
+ } label: {
+ Text("Continue", bundle: .module)
+ }
+ .buttonStyle(.pressedBackground)
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Theme/Buttons/PrimaryButtonStyle.swift b/Sources/Clerk/ClerkUI/Theme/Buttons/PrimaryButtonStyle.swift
new file mode 100644
index 000000000..d8a8ef3e5
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Theme/Buttons/PrimaryButtonStyle.swift
@@ -0,0 +1,230 @@
+//
+// PrimaryButtonStyle.swift
+// Clerk
+//
+// Created by Mike Pitre on 4/10/25.
+//
+
+#if os(iOS)
+
+import SwiftUI
+
+struct PrimaryButtonStyle: ButtonStyle {
+ @Environment(\.clerkTheme) private var theme
+ @Environment(\.isEnabled) private var isEnabled
+
+ let config: ClerkButtonConfig
+
+ var font: Font {
+ switch config.size {
+ case .small:
+ theme.fonts.subheadline
+ case .large:
+ theme.fonts.body
+ }
+ }
+
+ var foregroundStyle: Color {
+ switch config.emphasis {
+ case .none:
+ theme.colors.primary
+ case .low:
+ theme.colors.primary
+ case .high:
+ theme.colors.textOnPrimaryBackground
+ }
+ }
+
+ var height: CGFloat {
+ switch config.size {
+ case .small:
+ 32
+ case .large:
+ 48
+ }
+ }
+
+ func backgroundColor(
+ configuration: Configuration
+ ) -> Color {
+ switch config.emphasis {
+ case .none:
+ configuration.isPressed
+ ? theme.colors.backgroundSecondary
+ : theme.colors.background
+ case .low:
+ configuration.isPressed
+ ? theme.colors.backgroundSecondary
+ : theme.colors.background
+ case .high:
+ configuration.isPressed
+ ? theme.colors.primaryPressed
+ : theme.colors.primary
+ }
+ }
+
+ var borderWidth: CGFloat {
+ switch config.emphasis {
+ case .none:
+ 0
+ case .low:
+ 1
+ case .high:
+ 1
+ }
+ }
+
+ var borderColor: Color {
+ switch config.emphasis {
+ case .none:
+ .clear
+ case .low:
+ theme.colors.buttonBorder
+ case .high:
+ theme.colors.buttonBorder
+ }
+ }
+
+ var hasShadow: Bool {
+ switch config.emphasis {
+ case .none:
+ false
+ case .low:
+ true
+ case .high:
+ true
+ }
+ }
+
+ func makeBody(configuration: Configuration) -> some View {
+ configuration.label
+ .font(font)
+ .foregroundStyle(foregroundStyle)
+ .padding(8)
+ .frame(minHeight: height)
+ .background {
+ RoundedRectangle(cornerRadius: theme.design.borderRadius)
+ .fill(backgroundColor(configuration: configuration))
+ .overlay {
+ RoundedRectangle(cornerRadius: theme.design.borderRadius)
+ .strokeBorder(borderColor, lineWidth: borderWidth)
+ }
+ }
+ .background {
+ if hasShadow {
+ RoundedRectangle(cornerRadius: theme.design.borderRadius)
+ .fill(backgroundColor(configuration: configuration))
+ .shadow(color: theme.colors.inputBorderFocused, radius: 0.5, x: 0, y: 1)
+ }
+ }
+ .opacity(isEnabled ? 1 : 0.5)
+ .animation(.default, value: isEnabled)
+ }
+}
+
+extension ButtonStyle where Self == PrimaryButtonStyle {
+ static func primary(
+ config: ClerkButtonConfig = .init()
+ ) -> PrimaryButtonStyle {
+ .init(config: config)
+ }
+}
+
+#Preview {
+ @Previewable @Environment(\.clerkTheme) var theme
+
+ struct Content: View {
+ var body: some View {
+ HStack(spacing: 4) {
+ Text("Continue", bundle: .module)
+ Image("icon-triangle-right", bundle: .module)
+ .opacity(0.6)
+ }
+ }
+ }
+
+ return VStack(spacing: 20) {
+
+ Button {
+ } label: {
+ Content()
+ }
+ .buttonStyle(
+ .primary(
+ config: .init(
+ emphasis: .high,
+ size: .large
+ )
+ )
+ )
+
+ Button {
+ } label: {
+ Content()
+ }
+ .buttonStyle(
+ .primary(
+ config: .init(
+ emphasis: .high,
+ size: .small
+ )
+ )
+ )
+
+ Button {
+ } label: {
+ Content()
+ }
+ .buttonStyle(
+ .primary(
+ config: .init(
+ emphasis: .none,
+ size: .large
+ )
+ )
+ )
+
+ Button {
+ } label: {
+ Content()
+ }
+ .buttonStyle(
+ .primary(
+ config: .init(
+ emphasis: .none,
+ size: .small
+ )
+ )
+ )
+
+ Button {
+ } label: {
+ Content()
+ }
+ .buttonStyle(
+ .primary(
+ config: .init(
+ emphasis: .low,
+ size: .large
+ )
+ )
+ )
+
+ Button {
+ } label: {
+ Content()
+ }
+ .buttonStyle(
+ .primary(
+ config: .init(
+ emphasis: .low,
+ size: .small
+ )
+ )
+ )
+ }
+ .padding()
+ .environment(\.clerkTheme, .clerk)
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Theme/Buttons/SecondaryButtonStyle.swift b/Sources/Clerk/ClerkUI/Theme/Buttons/SecondaryButtonStyle.swift
new file mode 100644
index 000000000..c4e087ae3
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Theme/Buttons/SecondaryButtonStyle.swift
@@ -0,0 +1,206 @@
+//
+// SecondaryButtonStyle.swift
+// Clerk
+//
+// Created by Mike Pitre on 4/10/25.
+//
+
+#if os(iOS)
+
+import SwiftUI
+
+struct SecondaryButtonStyle: ButtonStyle {
+ @Environment(\.clerkTheme) private var theme
+ @Environment(\.isEnabled) private var isEnabled
+
+ let config: ClerkButtonConfig
+
+ var font: Font {
+ switch config.size {
+ case .small:
+ theme.fonts.subheadline
+ case .large:
+ theme.fonts.body
+ }
+ }
+
+ func foregroundStyle(configuration: Configuration) -> Color {
+ switch config.emphasis {
+ case .none:
+ configuration.isPressed
+ ? theme.colors.text
+ : theme.colors.textSecondary
+ case .low:
+ theme.colors.text
+ case .high:
+ theme.colors.text
+ }
+ }
+
+ var height: CGFloat {
+ switch config.size {
+ case .small:
+ 32
+ case .large:
+ 48
+ }
+ }
+
+ func backgroundColor(
+ configuration: Configuration
+ ) -> Color {
+ switch config.emphasis {
+ case .none:
+ configuration.isPressed
+ ? theme.colors.backgroundSecondary
+ : theme.colors.background
+ case .low:
+ configuration.isPressed
+ ? theme.colors.backgroundSecondary
+ : theme.colors.background
+ case .high:
+ configuration.isPressed
+ ? theme.colors.backgroundSecondary
+ : theme.colors.background
+ }
+ }
+
+ var borderWidth: CGFloat {
+ switch config.emphasis {
+ case .none:
+ 0
+ case .low:
+ 1
+ case .high:
+ 1
+ }
+ }
+
+ var borderColor: Color {
+ switch config.emphasis {
+ case .none:
+ .clear
+ case .low:
+ theme.colors.buttonBorder
+ case .high:
+ theme.colors.buttonBorder
+ }
+ }
+
+ var hasShadow: Bool {
+ switch config.emphasis {
+ case .none:
+ false
+ case .low:
+ true
+ case .high:
+ true
+ }
+ }
+
+ func makeBody(configuration: Configuration) -> some View {
+ configuration.label
+ .font(font)
+ .foregroundStyle(foregroundStyle(configuration: configuration))
+ .padding(8)
+ .frame(minHeight: height)
+ .background {
+ RoundedRectangle(cornerRadius: theme.design.borderRadius)
+ .fill(backgroundColor(configuration: configuration))
+ .overlay {
+ RoundedRectangle(cornerRadius: theme.design.borderRadius)
+ .strokeBorder(borderColor, lineWidth: borderWidth)
+ }
+ }
+ .background {
+ if hasShadow {
+ RoundedRectangle(cornerRadius: theme.design.borderRadius)
+ .fill(backgroundColor(configuration: configuration))
+ .shadow(color: theme.colors.inputBorderFocused, radius: 0.5, x: 0, y: 1)
+ }
+ }
+ .opacity(isEnabled ? 1 : 0.5)
+ .animation(.default, value: isEnabled)
+ }
+}
+
+extension ButtonStyle where Self == SecondaryButtonStyle {
+ static func secondary(
+ config: ClerkButtonConfig = .init()
+ ) -> SecondaryButtonStyle {
+ .init(config: config)
+ }
+}
+
+#Preview {
+ @Previewable @Environment(\.clerkTheme) var theme
+
+ struct Content: View {
+ var body: some View {
+ HStack(spacing: 4) {
+ Text("Continue", bundle: .module)
+ Image("icon-triangle-right", bundle: .module)
+ .opacity(0.6)
+ }
+ }
+ }
+
+ return VStack(spacing: 20) {
+
+ Button {
+ } label: {
+ Content()
+ }
+ .buttonStyle(
+ .secondary(
+ config: .init(
+ emphasis: .high,
+ size: .large
+ )
+ )
+ )
+
+ Button {
+ } label: {
+ Content()
+ }
+ .buttonStyle(
+ .secondary(
+ config: .init(
+ emphasis: .high,
+ size: .small
+ )
+ )
+ )
+
+ Button {
+ } label: {
+ Content()
+ }
+ .buttonStyle(
+ .secondary(
+ config: .init(
+ emphasis: .none,
+ size: .large
+ )
+ )
+ )
+
+ Button {
+ } label: {
+ Content()
+ }
+ .buttonStyle(
+ .secondary(
+ config: .init(
+ emphasis: .none,
+ size: .small
+ )
+ )
+ )
+ }
+ .padding()
+ .environment(\.clerkTheme, .clerk)
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Theme/ClerkColors.swift b/Sources/Clerk/ClerkUI/Theme/ClerkColors.swift
new file mode 100644
index 000000000..38ab60059
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Theme/ClerkColors.swift
@@ -0,0 +1,138 @@
+//
+// ClerkColors.swift
+// Clerk
+//
+// Created by Mike Pitre on 4/9/25.
+//
+
+#if os(iOS)
+
+import SwiftUI
+
+extension ClerkTheme {
+ public struct Colors {
+
+ public var primary: Color
+ public var background: Color
+ public var inputBackground: Color
+ public var danger: Color
+ public var success: Color
+ public var warning: Color
+ public var text: Color
+ public var textSecondary: Color
+ public var textOnPrimaryBackground: Color
+ public var inputText: Color
+ public var neutral: Color
+
+ public init(
+ primary: Color = Self.default.primary,
+ background: Color = Self.default.background,
+ inputBackground: Color = Self.default.inputBackground,
+ danger: Color = Self.default.danger,
+ success: Color = Self.default.success,
+ warning: Color = Self.default.warning,
+ text: Color = Self.default.text,
+ textSecondary: Color = Self.default.textSecondary,
+ textOnPrimaryBackground: Color = Self.default.textOnPrimaryBackground,
+ inputText: Color = Self.default.inputText,
+ neutral: Color = Self.default.neutral
+ ) {
+ self.primary = primary
+ self.background = background
+ self.inputBackground = inputBackground
+ self.danger = danger
+ self.success = success
+ self.warning = warning
+ self.text = text
+ self.textSecondary = textSecondary
+ self.textOnPrimaryBackground = textOnPrimaryBackground
+ self.inputText = inputText
+ self.neutral = neutral
+ }
+
+ // MARK: - Generated Colors
+
+ var primaryPressed: Color {
+ primary.isDark ? primary.lighten(by: 0.06) : primary.darken(by: 0.06)
+ }
+
+ var border: Color {
+ Color(.neutral).opacity(0.06)
+ }
+
+ var buttonBorder: Color {
+ Color(.neutral).opacity(0.08)
+ }
+
+ var backgroundSecondary: Color {
+ Color(.neutral).opacity(0.03)
+ }
+
+ var inputBorder: Color {
+ Color(.neutral).opacity(0.11)
+ }
+
+ var inputBorderFocused: Color {
+ Color(.neutral).opacity(0.28)
+ }
+
+ var dangerInputBorder: Color {
+ Color(.danger).opacity(0.53)
+ }
+
+ var dangerInputBorderFocused: Color {
+ Color(.danger).opacity(0.15)
+ }
+
+ var backgroundTransparent: Color {
+ Color(.background).opacity(0)
+ }
+
+ var backgroundSuccess: Color {
+ Color(.success).opacity(0.12)
+ }
+
+ var borderSuccess: Color {
+ Color(.success).opacity(0.77)
+ }
+
+ var backgroundDanger: Color {
+ Color(.danger).opacity(0.12)
+ }
+
+ var borderDanger: Color {
+ Color(.danger).opacity(0.77)
+ }
+
+ var backgroundWarning: Color {
+ Color(.warning).opacity(0.12)
+ }
+
+ var borderWarning: Color {
+ Color(.warning).opacity(0.77)
+ }
+
+ }
+}
+
+extension ClerkTheme.Colors {
+
+ public nonisolated static var `default`: Self {
+ .init(
+ primary: Color(.primary),
+ background: Color(.background),
+ inputBackground: Color(.inputBackground),
+ danger: Color(.danger),
+ success: Color(.success),
+ warning: Color(.warning),
+ text: Color(.text),
+ textSecondary: Color(.textSecondary),
+ textOnPrimaryBackground: Color(.textOnPrimaryBackground),
+ inputText: Color(.inputText),
+ neutral: Color(.neutral)
+ )
+ }
+
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Theme/ClerkDesign.swift b/Sources/Clerk/ClerkUI/Theme/ClerkDesign.swift
new file mode 100644
index 000000000..242c4502a
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Theme/ClerkDesign.swift
@@ -0,0 +1,35 @@
+//
+// ClerkDesign.swift
+// Clerk
+//
+// Created by Mike Pitre on 4/10/25.
+//
+
+#if os(iOS)
+
+import Foundation
+
+extension ClerkTheme {
+ public struct Design {
+
+ public var borderRadius: CGFloat
+
+ public init(
+ borderRadius: CGFloat = Self.default.borderRadius
+ ) {
+ self.borderRadius = borderRadius
+ }
+ }
+}
+
+extension ClerkTheme.Design {
+
+ public nonisolated static var `default`: Self {
+ .init(
+ borderRadius: 6.0
+ )
+ }
+
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Theme/ClerkFonts.swift b/Sources/Clerk/ClerkUI/Theme/ClerkFonts.swift
new file mode 100644
index 000000000..3ba347e43
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Theme/ClerkFonts.swift
@@ -0,0 +1,91 @@
+//
+// ClerkFonts.swift
+// Clerk
+//
+// Created by Mike Pitre on 4/10/25.
+//
+
+#if os(iOS)
+
+import SwiftUI
+
+extension ClerkTheme {
+ public struct Fonts {
+
+ // Text styles matching iOS system font styles
+ public var largeTitle: Font
+ public var title: Font
+ public var title2: Font
+ public var title3: Font
+ public var headline: Font
+ public var subheadline: Font
+ public var body: Font
+ public var callout: Font
+ public var footnote: Font
+ public var caption: Font
+ public var caption2: Font
+
+ // Initializer with individual fonts
+ public init(
+ largeTitle: Font = Self.default.largeTitle,
+ title: Font = Self.default.title,
+ title2: Font = Self.default.title2,
+ title3: Font = Self.default.title3,
+ headline: Font = Self.default.headline,
+ subheadline: Font = Self.default.subheadline,
+ body: Font = Self.default.body,
+ callout: Font = Self.default.callout,
+ footnote: Font = Self.default.footnote,
+ caption: Font = Self.default.caption,
+ caption2: Font = Self.default.caption2
+ ) {
+ self.largeTitle = largeTitle
+ self.title = title
+ self.title2 = title2
+ self.title3 = title3
+ self.headline = headline
+ self.subheadline = subheadline
+ self.body = body
+ self.callout = callout
+ self.footnote = footnote
+ self.caption = caption
+ self.caption2 = caption2
+ }
+
+ // Convenience initializer with just a font family
+ public init(fontFamily: String) {
+ self.largeTitle = .custom(fontFamily, size: 34, relativeTo: .largeTitle)
+ self.title = .custom(fontFamily, size: 28, relativeTo: .title)
+ self.title2 = .custom(fontFamily, size: 22, relativeTo: .title2)
+ self.title3 = .custom(fontFamily, size: 20, relativeTo: .title3)
+ self.headline = .custom(fontFamily, size: 17, relativeTo: .headline).weight(.semibold)
+ self.subheadline = .custom(fontFamily, size: 15, relativeTo: .subheadline)
+ self.body = .custom(fontFamily, size: 17, relativeTo: .body)
+ self.callout = .custom(fontFamily, size: 16, relativeTo: .callout)
+ self.footnote = .custom(fontFamily, size: 13, relativeTo: .footnote)
+ self.caption = .custom(fontFamily, size: 12, relativeTo: .caption)
+ self.caption2 = .custom(fontFamily, size: 11, relativeTo: .caption2)
+ }
+
+ }
+}
+
+extension ClerkTheme.Fonts {
+ public nonisolated static var `default`: Self {
+ .init(
+ largeTitle: .system(.largeTitle),
+ title: .system(.title),
+ title2: .system(.title2),
+ title3: .system(.title3),
+ headline: .system(.headline),
+ subheadline: .system(.subheadline),
+ body: .system(.body),
+ callout: .system(.callout),
+ footnote: .system(.footnote),
+ caption: .system(.caption),
+ caption2: .system(.caption2)
+ )
+ }
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Theme/ClerkTheme.swift b/Sources/Clerk/ClerkUI/Theme/ClerkTheme.swift
new file mode 100644
index 000000000..fe69f13a4
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Theme/ClerkTheme.swift
@@ -0,0 +1,30 @@
+//
+// ClerkTheme.swift
+// Clerk
+//
+// Created by Mike Pitre on 4/9/25.
+//
+
+#if os(iOS)
+
+import Foundation
+import SwiftUI
+
+@Observable
+public class ClerkTheme {
+ public var colors: Colors
+ public var fonts: Fonts
+ public var design: Design
+
+ public init(
+ colors: Colors = .default,
+ fonts: Fonts = .default,
+ design: Design = .default
+ ) {
+ self.colors = colors
+ self.fonts = fonts
+ self.design = design
+ }
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Theme/ClerkThemes.swift b/Sources/Clerk/ClerkUI/Theme/ClerkThemes.swift
new file mode 100644
index 000000000..3bcf74e16
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Theme/ClerkThemes.swift
@@ -0,0 +1,48 @@
+//
+// ClerkThemes.swift
+// Clerk
+//
+// Created by Mike Pitre on 4/10/25.
+//
+
+#if os(iOS)
+
+import Foundation
+import SwiftUI
+
+extension ClerkTheme {
+
+ @MainActor
+ public static let `default`: ClerkTheme = .init(
+ colors: .default,
+ fonts: .default,
+ design: .default
+ )
+
+ @MainActor
+ public static let clerk: ClerkTheme = .init(
+ colors: .init(
+ primary: Color(.clerkPrimary),
+ danger: Color(.clerkDanger),
+ textOnPrimaryBackground: Color(.clerkTextOnPrimaryBackground),
+ neutral: Color(.clerkNeutral)
+ ),
+ design: .init(
+ borderRadius: 8.0
+ )
+ )
+}
+
+extension EnvironmentValues {
+ public var clerkTheme: ClerkTheme {
+ get { self[ClerkThemeEnvironmentKey.self] }
+ set { self[ClerkThemeEnvironmentKey.self] = newValue }
+ }
+}
+
+// Create a custom environment key
+private struct ClerkThemeEnvironmentKey: @preconcurrency EnvironmentKey {
+ @MainActor static var defaultValue: ClerkTheme = .default
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Utils/RemoveResource.swift b/Sources/Clerk/ClerkUI/Utils/RemoveResource.swift
new file mode 100644
index 000000000..68529b94a
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Utils/RemoveResource.swift
@@ -0,0 +1,72 @@
+//
+// RemoveResource.swift
+// Clerk
+//
+// Created by Mike Pitre on 5/27/25.
+//
+
+#if os(iOS)
+
+import Foundation
+import SwiftUI
+
+enum RemoveResource: Hashable {
+ case email(EmailAddress)
+ case phoneNumber(PhoneNumber)
+ case externalAccount(ExternalAccount)
+ case passkey(Passkey)
+ case totp
+ case secondFactorPhoneNumber(PhoneNumber)
+
+ var title: LocalizedStringKey {
+ switch self {
+ case .email:
+ return "Remove email address"
+ case .phoneNumber:
+ return "Remove phone number"
+ case .externalAccount:
+ return "Remove connected account"
+ case .passkey:
+ return "Remove passkey"
+ case .totp, .secondFactorPhoneNumber:
+ return "Remove two-step verification"
+ }
+ }
+
+ @MainActor
+ var messageLine1: LocalizedStringKey {
+ switch self {
+ case .email(let emailAddress):
+ return "\(emailAddress.emailAddress) will be removed from this account. You will no longer be able to sign in using this email address."
+ case .phoneNumber(let phoneNumber):
+ return "\(phoneNumber.phoneNumber.formattedAsPhoneNumberIfPossible) will be removed from this account. You will no longer be able to sign in using this phone number."
+ case .externalAccount(let externalAccount):
+ return "\(externalAccount.oauthProvider.name) will be removed from this account. You will no longer be able to sign in using this connected account."
+ case .passkey(let passkey):
+ return "\(passkey.name) will be removed from this account. You will no longer be able to sign in using this passkey."
+ case .totp:
+ return "Verification codes from this authenticator will no longer be required when signing in."
+ case .secondFactorPhoneNumber(let phoneNumber):
+ return "\(phoneNumber.phoneNumber.formattedAsPhoneNumberIfPossible) will no longer be receiving verification codes when signing in."
+ }
+ }
+
+ func deleteAction() async throws {
+ switch self {
+ case .email(let emailAddress):
+ try await emailAddress.destroy()
+ case .phoneNumber(let phoneNumber):
+ try await phoneNumber.delete()
+ case .externalAccount(let externalAccount):
+ try await externalAccount.destroy()
+ case .passkey(let passkey):
+ try await passkey.delete()
+ case .totp:
+ try await Clerk.shared.user?.disableTOTP()
+ case .secondFactorPhoneNumber(let phoneNumber):
+ try await phoneNumber.setReservedForSecondFactor(reserved: false)
+ }
+ }
+}
+
+#endif
diff --git a/Sources/Clerk/ClerkUI/Utils/SignInWithAppleUtils.swift b/Sources/Clerk/ClerkUI/Utils/SignInWithAppleUtils.swift
new file mode 100644
index 000000000..374f0a0f5
--- /dev/null
+++ b/Sources/Clerk/ClerkUI/Utils/SignInWithAppleUtils.swift
@@ -0,0 +1,34 @@
+//
+// SignInWithAppleUtils.swift
+// Clerk
+//
+// Created by Mike Pitre on 4/11/25.
+//
+
+#if os(iOS)
+
+import AuthenticationServices
+import Foundation
+
+enum SignInWithAppleUtils {
+
+ @discardableResult @MainActor
+ static func signIn(requestedScopes: [ASAuthorization.Scope] = [.email, .fullName]) async throws -> TransferFlowResult {
+ let credential = try await SignInWithAppleHelper.getAppleIdCredential(requestedScopes: requestedScopes)
+ guard let idToken = credential.identityToken.flatMap({ String(data: $0, encoding: .utf8) }) else {
+ throw ClerkClientError(message: "Unable to retrieve the apple identity token.")
+ }
+ return try await SignIn.authenticateWithIdToken(provider: .apple, idToken: idToken)
+ }
+
+}
+
+extension ASAuthorizationAppleIDCredential {
+
+ var tokenString: String {
+ identityToken.flatMap({ String(data: $0, encoding: .utf8) }) ?? ""
+ }
+
+}
+
+#endif
diff --git a/Sources/Clerk/Helpers/AppAttestHelper.swift b/Sources/Clerk/Helpers/AppAttestHelper.swift
index 6afdba06d..78d050220 100644
--- a/Sources/Clerk/Helpers/AppAttestHelper.swift
+++ b/Sources/Clerk/Helpers/AppAttestHelper.swift
@@ -7,143 +7,159 @@
import CryptoKit
import DeviceCheck
-import Factory
+import FactoryKit
import Foundation
/// A helper struct for handling Apple's DeviceCheck App Attest API.
struct AppAttestHelper {
- /// The key used to store the attestation key ID in the keychain.
- private static let keychainKey = "AttestKeyId"
-
- /// Errors that can occur during the attestation process.
- enum AttestationError: Error {
- case unsupportedDevice
- case unableToGetChallengeFromServer
- case unableToFormatChallengeAsData
- }
-
- /// Retrieves a challenge from the server for attestation.
- /// - Returns: A challenge string received from the server.
- /// - Throws: `AttestationError.unableToGetChallengeFromServer` if the challenge cannot be retrieved.
- private static func getChallenge() async throws -> String {
- let request = ClerkFAPI.v1.client.deviceAttestation.challenges.post
- guard let challenge = try await Container.shared.apiClient().send(request).value["challenge"] else {
- throw AttestationError.unableToGetChallengeFromServer
+ /// The key used to store the attestation key ID in the keychain.
+ private static let keychainKey = "AttestKeyId"
+
+ /// Errors that can occur during the attestation process.
+ enum AttestationError: Error {
+ case unsupportedDevice
+ case unableToGetChallengeFromServer
+ case unableToFormatChallengeAsData
}
- return challenge
- }
-
- /// Performs device attestation using Apple's DeviceCheck framework.
- /// - Returns: The generated key ID.
- /// - Throws: An error if attestation fails.
- @discardableResult
- static func performDeviceAttestation() async throws -> String {
- guard DCAppAttestService.shared.isSupported else {
- throw AttestationError.unsupportedDevice
+
+ /// Retrieves a challenge from the server for attestation.
+ /// - Returns: A challenge string received from the server.
+ /// - Throws: `AttestationError.unableToGetChallengeFromServer` if the challenge cannot be retrieved.
+ private static func getChallenge() async throws -> String {
+ let response = try await Container.shared.apiClient().request()
+ .add(path: "/v1/client/device_attestation/challenges")
+ .method(.post)
+ .data(type: [String: String].self)
+ .async()
+
+ guard let challenge = response["challenge"] else {
+ throw AttestationError.unableToGetChallengeFromServer
+ }
+
+ return challenge
}
- let challenge = try await getChallenge()
- let keyId = try await DCAppAttestService.shared.generateKey()
+ /// Performs device attestation using Apple's DeviceCheck framework.
+ /// - Returns: The generated key ID.
+ /// - Throws: An error if attestation fails.
+ @discardableResult
+ static func performDeviceAttestation() async throws -> String {
+ guard DCAppAttestService.shared.isSupported else {
+ throw AttestationError.unsupportedDevice
+ }
+
+ let challenge = try await getChallenge()
+ let keyId = try await DCAppAttestService.shared.generateKey()
- guard let challengeData = challenge.data(using: .utf8) else {
- throw AttestationError.unableToFormatChallengeAsData
+ guard let challengeData = challenge.data(using: .utf8) else {
+ throw AttestationError.unableToFormatChallengeAsData
+ }
+
+ let clientDataHash = Data(SHA256.hash(data: challengeData))
+ let attestation = try await DCAppAttestService.shared.attestKey(keyId, clientDataHash: clientDataHash)
+ try await verify(keyId: keyId, challenge: challenge, attestation: attestation)
+
+ try Container.shared.keychain().set(keyId, forKey: keychainKey)
+ return keyId
}
- let clientDataHash = Data(SHA256.hash(data: challengeData))
- let attestation = try await DCAppAttestService.shared.attestKey(keyId, clientDataHash: clientDataHash)
- try await verify(keyId: keyId, challenge: challenge, attestation: attestation)
-
- try Container.shared.keychain().set(keyId, forKey: keychainKey)
- return keyId
- }
-
- /// Verifies the attestation key with the server.
- /// - Parameters:
- /// - keyId: The key ID generated during attestation.
- /// - challenge: The challenge string used for attestation.
- /// - attestation: The attestation data.
- /// - Throws: An error if verification fails.
- private static func verify(keyId: String, challenge: String, attestation: Data) async throws {
- let body = [
- "key_id": keyId,
- "challenge": challenge,
- "attestation": attestation.base64EncodedString(),
- "bundle_id": Bundle.main.bundleIdentifier,
- ]
-
- let request = ClerkFAPI.v1.client.deviceAttestation.verify.post(body)
- try await Container.shared.apiClient().send(request)
- }
-
- /// Creates an assertion using the attestation key.
- /// - Parameter payload: The data payload to be signed.
- /// - Returns: A base64-encoded assertion string.
- /// - Throws: An error if assertion generation fails.
- private static func createAssertion(payload: Data) async throws -> String {
- let keyId: String
- if let existingKeyId = Self.keyId {
- keyId = existingKeyId
- } else {
- keyId = try await performDeviceAttestation()
+ /// Verifies the attestation key with the server.
+ /// - Parameters:
+ /// - keyId: The key ID generated during attestation.
+ /// - challenge: The challenge string used for attestation.
+ /// - attestation: The attestation data.
+ /// - Throws: An error if verification fails.
+ private static func verify(keyId: String, challenge: String, attestation: Data) async throws {
+ let body = [
+ "key_id": keyId,
+ "challenge": challenge,
+ "attestation": attestation.base64EncodedString(),
+ "bundle_id": Bundle.main.bundleIdentifier
+ ]
+
+ let _: (Data?, HTTPURLResponse?) = try await Container.shared.apiClient().request()
+ .add(path: "/v1/client/device_attestation/verify")
+ .method(.post)
+ .body(formEncode: body)
+ .data()
+ .async()
}
- let hash = Data(SHA256.hash(data: payload))
- let assertion = try await DCAppAttestService.shared.generateAssertion(keyId, clientDataHash: hash)
- return assertion.base64EncodedString()
- }
+ /// Creates an assertion using the attestation key.
+ /// - Parameter payload: The data payload to be signed.
+ /// - Returns: A base64-encoded assertion string.
+ /// - Throws: An error if assertion generation fails.
+ private static func createAssertion(payload: Data) async throws -> String {
+ let keyId: String
+ if let existingKeyId = Self.keyId {
+ keyId = existingKeyId
+ } else {
+ keyId = try await performDeviceAttestation()
+ }
+
+ let hash = Data(SHA256.hash(data: payload))
+ let assertion = try await DCAppAttestService.shared.generateAssertion(keyId, clientDataHash: hash)
+ return assertion.base64EncodedString()
+ }
- /// Performs assertion verification with the server.
- /// - Throws: An error if the assertion verification fails.
- static func performAssertion() async throws {
- guard DCAppAttestService.shared.isSupported else {
- throw AttestationError.unsupportedDevice
+ /// Performs assertion verification with the server.
+ /// - Throws: An error if the assertion verification fails.
+ @MainActor
+ static func performAssertion() async throws {
+ guard DCAppAttestService.shared.isSupported else {
+ throw AttestationError.unsupportedDevice
+ }
+
+ let challenge = try await getChallenge()
+ guard let clientId = clientId else {
+ throw ClerkClientError(message: "Client ID is unavailble.")
+ }
+ let payload = try JSONEncoder().encode(["client_id": clientId, "challenge": challenge])
+ let assertion = try await createAssertion(payload: payload)
+
+ let body = [
+ "client_data": String(decoding: payload, as: UTF8.self),
+ "assertion": assertion,
+ "challenge": challenge,
+ "platform": "ios",
+ "bundle_id": Bundle.main.bundleIdentifier
+ ]
+
+ let _: (Data?, HTTPURLResponse?) = try await Container.shared.apiClient().request()
+ .add(path: "/v1/client/verify")
+ .method(.post)
+ .body(formEncode: body)
+ .data()
+ .async()
}
- let challenge = try await getChallenge()
- let clientId = try clientId
- let payload = try JSONEncoder().encode(["client_id": clientId, "challenge": challenge])
- let assertion = try await createAssertion(payload: payload)
-
- let body = [
- "client_data": String(decoding: payload, as: UTF8.self),
- "assertion": assertion,
- "challenge": challenge,
- "platform": "ios",
- "bundle_id": Bundle.main.bundleIdentifier,
- ]
-
- let request = ClerkFAPI.v1.client.verify.post(body)
- try await Container.shared.apiClient().send(request)
- }
-
- /// Checks whether a key ID is stored in the keychain.
- static var hasKeyId: Bool {
- do {
- return try Container.shared.keychain().hasItem(forKey: keychainKey)
- } catch {
- return false
+ /// Checks whether a key ID is stored in the keychain.
+ static var hasKeyId: Bool {
+ do {
+ return try Container.shared.keychain().hasItem(forKey: keychainKey)
+ } catch {
+ return false
+ }
}
- }
-
- /// Retrieves the stored attestation key ID from the keychain.
- private static var keyId: String? {
- try? Container.shared.keychain().string(forKey: keychainKey)
- }
-
- /// Removes the stored attestation key ID from the keychain.
- /// - Throws: An error if key deletion fails.
- static func removeKeyId() throws {
- try Container.shared.keychain().deleteItem(forKey: keychainKey)
- }
-
- /// Retrieves the stored attestation client ID from the keychain.
- ///
- /// This needs to come from the keychain, because if the initial client request is blocked on app load,
- /// the app wont have a client yet
- static var clientId: String {
- get throws {
- try Container.shared.keychain().string(forKey: "clientId")
+
+ /// Retrieves the stored attestation key ID from the keychain.
+ private static var keyId: String? {
+ try? Container.shared.keychain().string(forKey: keychainKey)
+ }
+
+ /// Removes the stored attestation key ID from the keychain.
+ /// - Throws: An error if key deletion fails.
+ static func removeKeyId() throws {
+ try Container.shared.keychain().deleteItem(forKey: keychainKey)
+ }
+
+ /// Retrieves the stored attestation client ID from the keychain.
+ ///
+ /// This needs to come from the keychain, because if the initial client request is blocked on app load,
+ /// the app wont have a client yet
+ @MainActor
+ static var clientId: String? {
+ try? Container.shared.clerkService().loadClientFromKeychain()?.id
}
- }
}
diff --git a/Sources/Clerk/Helpers/LocalAuthHelper.swift b/Sources/Clerk/Helpers/LocalAuthHelper.swift
deleted file mode 100644
index 141f1b0c0..000000000
--- a/Sources/Clerk/Helpers/LocalAuthHelper.swift
+++ /dev/null
@@ -1,100 +0,0 @@
-//
-// LocalAuth.swift
-//
-//
-// Created by Mike Pitre on 3/25/24.
-//
-
-#if !os(tvOS) && !os(watchOS)
-
- import Foundation
- import SwiftUI
- import LocalAuthentication
-
- /// A utility class for handling local authentication mechanisms, including biometrics and device owner authentication.
- final public class LocalAuthHelper {
-
- /// A structure representing user credentials with an identifier and password.
- public struct Credentials {
- /// The unique identifier for the credentials, typically a username or email.
- let identifier: String
-
- /// The associated password for the credentials.
- let password: String
- }
-
- /// A shared authentication context used for UI updates during authentication.
- ///
- /// - Note: The context is refreshed before each authentication attempt to avoid unintended reuse of previously successful authentication results.
- nonisolated(unsafe) public static var context = LAContext()
-
- /// The type of biometry available on the device.
- ///
- /// This property uses a temporary `LAContext` instance to determine the available biometry type.
- /// - Returns: An `LABiometryType` indicating the available biometric capability, such as `.faceID`, `.touchID`, or `.none`.
- static var availableBiometryType: LABiometryType {
- let biometryContext = LAContext()
- biometryContext.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil)
- return biometryContext.biometryType
- }
-
- /// Attempts to authenticate the user using biometric or device owner authentication.
- public static func authenticateWithBiometrics() async throws {
-
- // Get a fresh context for each login. If you use the same context on multiple attempts
- // (by commenting out the next line), then a previously successful authentication
- // causes the next policy evaluation to succeed without testing biometry again.
- // That's usually not what you want.
- context = LAContext()
-
- context.localizedCancelTitle = "Cancel"
-
- // First check if we have the needed hardware support.
- var error: NSError?
- guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) else {
- throw ClerkClientError(message: "Unable to evaluate policy.")
- }
-
- do {
- try await context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: "Authenticate")
- } catch {
- throw error
- }
- }
- }
-
- extension LABiometryType {
-
- var displayName: String {
- switch self {
- case .none:
- "None"
- case .touchID:
- "Touch ID"
- case .faceID:
- "Face ID"
- case .opticID:
- "Optic ID"
- @unknown default:
- "Biometrics"
- }
- }
-
- var systemImageName: String? {
- switch self {
- case .none:
- nil
- case .touchID:
- "touchid"
- case .faceID:
- "faceid"
- case .opticID:
- "opticid"
- @unknown default:
- nil
- }
- }
-
- }
-
-#endif
diff --git a/Sources/Clerk/Helpers/PasskeyHelper.swift b/Sources/Clerk/Helpers/PasskeyHelper.swift
index 49ce322a7..8eb986d07 100644
--- a/Sources/Clerk/Helpers/PasskeyHelper.swift
+++ b/Sources/Clerk/Helpers/PasskeyHelper.swift
@@ -7,164 +7,164 @@
#if canImport(AuthenticationServices) && !os(watchOS)
- import Foundation
- import AuthenticationServices
- import os
+import Foundation
+import AuthenticationServices
+import os
- final class PasskeyHelper: NSObject {
+final class PasskeyHelper: NSObject {
@MainActor
public static var controller: ASAuthorizationController?
@MainActor
var domain: String {
- guard let urlComponents = URLComponents(string: Clerk.shared.frontendApiUrl) else {
- return ""
- }
+ guard let urlComponents = URLComponents(string: Clerk.shared.frontendApiUrl) else {
+ return ""
+ }
- let host = urlComponents.host
- return host?.replacingOccurrences(of: "www.", with: "") ?? ""
+ let host = urlComponents.host ?? ""
+ return host.replacingOccurrences(of: "www.", with: "")
}
private var continuation: CheckedContinuation?
@MainActor
func signIn(challenge: Data, preferImmediatelyAvailableCredentials: Bool) async throws -> ASAuthorization {
- return try await withCheckedThrowingContinuation { continuation in
- self.continuation = continuation
-
- let publicKeyCredentialProvider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: domain)
-
- let assertionRequest = publicKeyCredentialProvider.createCredentialAssertionRequest(challenge: challenge)
-
- // Pass in any mix of supported sign-in request types.
- let authController = ASAuthorizationController(authorizationRequests: [assertionRequest])
- authController.delegate = self
- authController.presentationContextProvider = self
- Self.controller = authController
-
- #if !os(tvOS)
-
- if preferImmediatelyAvailableCredentials {
- // If credentials are available, presents a modal sign-in sheet.
- // If there are no locally saved credentials, no UI appears and
- // the system passes ASAuthorizationError.Code.canceled to call
- // `AccountManager.authorizationController(controller:didCompleteWithError:)`.
- authController.performRequests(options: .preferImmediatelyAvailableCredentials)
- } else {
- // If credentials are available, presents a modal sign-in sheet.
- // If there are no locally saved credentials, the system presents a QR code to allow signing in with a
- // passkey from a nearby device.
- authController.performRequests()
- }
+ return try await withCheckedThrowingContinuation { continuation in
+ self.continuation = continuation
- #else
+ let publicKeyCredentialProvider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: domain)
- authController.performRequests()
+ let assertionRequest = publicKeyCredentialProvider.createCredentialAssertionRequest(challenge: challenge)
- #endif
- }
+ // Pass in any mix of supported sign-in request types.
+ let authController = ASAuthorizationController(authorizationRequests: [assertionRequest])
+ authController.delegate = self
+ authController.presentationContextProvider = self
+ Self.controller = authController
+
+ #if !os(tvOS)
+
+ if preferImmediatelyAvailableCredentials {
+ // If credentials are available, presents a modal sign-in sheet.
+ // If there are no locally saved credentials, no UI appears and
+ // the system passes ASAuthorizationError.Code.canceled to call
+ // `AccountManager.authorizationController(controller:didCompleteWithError:)`.
+ authController.performRequests(options: .preferImmediatelyAvailableCredentials)
+ } else {
+ // If credentials are available, presents a modal sign-in sheet.
+ // If there are no locally saved credentials, the system presents a QR code to allow signing in with a
+ // passkey from a nearby device.
+ authController.performRequests()
+ }
+
+ #else
+
+ authController.performRequests()
+
+ #endif
+ }
}
#if os(iOS) && !targetEnvironment(macCatalyst)
- @MainActor
- func beginAutoFillAssistedPasskeySignIn(challenge: Data) async throws -> ASAuthorization {
+ @MainActor
+ func beginAutoFillAssistedPasskeySignIn(challenge: Data) async throws -> ASAuthorization {
return try await withCheckedThrowingContinuation { continuation in
- self.continuation = continuation
+ self.continuation = continuation
- let publicKeyCredentialProvider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: domain)
+ let publicKeyCredentialProvider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: domain)
- let assertionRequest = publicKeyCredentialProvider.createCredentialAssertionRequest(challenge: challenge)
+ let assertionRequest = publicKeyCredentialProvider.createCredentialAssertionRequest(challenge: challenge)
- // AutoFill-assisted requests only support ASAuthorizationPlatformPublicKeyCredentialAssertionRequest.
- let authController = ASAuthorizationController(authorizationRequests: [assertionRequest])
- authController.delegate = self
- authController.presentationContextProvider = self
- Self.controller = authController
+ // AutoFill-assisted requests only support ASAuthorizationPlatformPublicKeyCredentialAssertionRequest.
+ let authController = ASAuthorizationController(authorizationRequests: [assertionRequest])
+ authController.delegate = self
+ authController.presentationContextProvider = self
+ Self.controller = authController
- authController.performAutoFillAssistedRequests()
+ authController.performAutoFillAssistedRequests()
}
- }
+ }
#endif
@MainActor
func createPasskey(challenge: Data, name: String, userId: Data) async throws -> ASAuthorization {
- return try await withCheckedThrowingContinuation { continuation in
- self.continuation = continuation
-
- let publicKeyCredentialProvider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: domain)
-
- let registrationRequest = publicKeyCredentialProvider.createCredentialRegistrationRequest(
- challenge: challenge,
- name: name,
- userID: userId
- )
-
- // Use only ASAuthorizationPlatformPublicKeyCredentialRegistrationRequests or
- // ASAuthorizationSecurityKeyPublicKeyCredentialRegistrationRequests here.
- let authController = ASAuthorizationController(authorizationRequests: [registrationRequest])
- authController.delegate = self
- authController.presentationContextProvider = self
- Self.controller = authController
-
- authController.performRequests()
- }
+ return try await withCheckedThrowingContinuation { continuation in
+ self.continuation = continuation
+
+ let publicKeyCredentialProvider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: domain)
+
+ let registrationRequest = publicKeyCredentialProvider.createCredentialRegistrationRequest(
+ challenge: challenge,
+ name: name,
+ userID: userId
+ )
+
+ // Use only ASAuthorizationPlatformPublicKeyCredentialRegistrationRequests or
+ // ASAuthorizationSecurityKeyPublicKeyCredentialRegistrationRequests here.
+ let authController = ASAuthorizationController(authorizationRequests: [registrationRequest])
+ authController.delegate = self
+ authController.presentationContextProvider = self
+ Self.controller = authController
+
+ authController.performRequests()
+ }
}
- }
+}
- extension PasskeyHelper: ASAuthorizationControllerDelegate {
+extension PasskeyHelper: ASAuthorizationControllerDelegate {
public func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {
- let logger = Logger()
- switch authorization.credential {
- case let credentialRegistration as ASAuthorizationPlatformPublicKeyCredentialRegistration:
- logger.log("A new passkey was registered: \(credentialRegistration)")
- // Verify the attestationObject and clientDataJSON with your service.
- // The attestationObject contains the user's new public key to store and use for subsequent sign-ins.
- // let attestationObject = credentialRegistration.rawAttestationObject
- // let clientDataJSON = credentialRegistration.rawClientDataJSON
-
- // After the server verifies the registration and creates the user account, sign in the user with the new account.
- continuation?.resume(returning: authorization)
- case let credentialAssertion as ASAuthorizationPlatformPublicKeyCredentialAssertion:
- logger.log("A passkey was used to sign in: \(credentialAssertion)")
- // Verify the below signature and clientDataJSON with your service for the given userID.
- // let signature = credentialAssertion.signature
- // let clientDataJSON = credentialAssertion.rawClientDataJSON
- // let userID = credentialAssertion.userID
-
- // After the server verifies the assertion, sign in the user.
- continuation?.resume(returning: authorization)
- case let passwordCredential as ASPasswordCredential:
- logger.log("A password was provided: \(passwordCredential)")
- // Verify the userName and password with your service.
- // let userName = passwordCredential.user
- // let password = passwordCredential.password
-
- // After the server verifies the userName and password, sign in the user.
- continuation?.resume(returning: authorization)
- default:
- fatalError("Received unknown authorization type.")
- }
+ let logger = Logger()
+ switch authorization.credential {
+ case let credentialRegistration as ASAuthorizationPlatformPublicKeyCredentialRegistration:
+ logger.log("A new passkey was registered: \(credentialRegistration)")
+ // Verify the attestationObject and clientDataJSON with your service.
+ // The attestationObject contains the user's new public key to store and use for subsequent sign-ins.
+ // let attestationObject = credentialRegistration.rawAttestationObject
+ // let clientDataJSON = credentialRegistration.rawClientDataJSON
+
+ // After the server verifies the registration and creates the user account, sign in the user with the new account.
+ continuation?.resume(returning: authorization)
+ case let credentialAssertion as ASAuthorizationPlatformPublicKeyCredentialAssertion:
+ logger.log("A passkey was used to sign in: \(credentialAssertion)")
+ // Verify the below signature and clientDataJSON with your service for the given userID.
+ // let signature = credentialAssertion.signature
+ // let clientDataJSON = credentialAssertion.rawClientDataJSON
+ // let userID = credentialAssertion.userID
+
+ // After the server verifies the assertion, sign in the user.
+ continuation?.resume(returning: authorization)
+ case let passwordCredential as ASPasswordCredential:
+ logger.log("A password was provided: \(passwordCredential)")
+ // Verify the userName and password with your service.
+ // let userName = passwordCredential.user
+ // let password = passwordCredential.password
+
+ // After the server verifies the userName and password, sign in the user.
+ continuation?.resume(returning: authorization)
+ default:
+ fatalError("Received unknown authorization type.")
+ }
}
public func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: any Error) {
- continuation?.resume(throwing: error)
+ continuation?.resume(throwing: error)
}
- }
+}
- extension PasskeyHelper: ASAuthorizationControllerPresentationContextProviding {
+extension PasskeyHelper: ASAuthorizationControllerPresentationContextProviding {
@MainActor
public func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
- #if os(iOS)
+ #if os(iOS)
UIApplication.shared.windows.first(where: { $0.isKeyWindow }) ?? ASPresentationAnchor()
- #else
+ #else
ASPresentationAnchor()
- #endif
+ #endif
}
- }
+}
#endif
diff --git a/Sources/Clerk/Helpers/SignInWithAppleHelper.swift b/Sources/Clerk/Helpers/SignInWithAppleHelper.swift
index 92fddc3c1..079f38e2c 100644
--- a/Sources/Clerk/Helpers/SignInWithAppleHelper.swift
+++ b/Sources/Clerk/Helpers/SignInWithAppleHelper.swift
@@ -7,36 +7,59 @@
#if canImport(AuthenticationServices) && !os(watchOS)
- import Foundation
- import AuthenticationServices
-
- /// A helper class for managing the Sign in with Apple process.
- ///
- /// This class simplifies the process of requesting user credentials using Sign in with Apple
- /// by wrapping the necessary functionality in an async-await compatible API.
- ///
- /// ### Example Usage
- /// ```swift
- /// do {
- /// // Create an instance of the helper and get the Apple ID credential.
- /// let appleIdCredential = try await SignInWithAppleHelper.getAppleIdCredential()
- ///
- /// // Extract the ID token from the credential.
- /// guard let idToken = appleIdCredential.identityToken.flatMap({ String(data: $0, encoding: .utf8) }) else {
- /// // throw an error
- /// }
- ///
- /// // Authenticate with the extracted ID token.
- /// try await SignIn.authenticateWithIdToken(provider: .apple, idToken: idToken)
- /// } catch {
- /// // Handle any errors.
- /// }
- /// ```
- final public class SignInWithAppleHelper: NSObject {
+import Foundation
+import AuthenticationServices
+import CryptoKit
+
+/// A helper class for managing the Sign in with Apple process.
+///
+/// This class simplifies the process of requesting user credentials using Sign in with Apple
+/// by wrapping the necessary functionality in an async-await compatible API.
+///
+/// ### Example Usage
+/// ```swift
+/// do {
+/// // Create an instance of the helper and get the Apple ID credential.
+/// let appleIdCredential = try await SignInWithAppleHelper.getAppleIdCredential()
+///
+/// // Extract the ID token from the credential.
+/// guard let idToken = appleIdCredential.identityToken.flatMap({ String(data: $0, encoding: .utf8) }) else {
+/// // throw an error
+/// }
+///
+/// // Authenticate with the extracted ID token.
+/// try await SignIn.authenticateWithIdToken(provider: .apple, idToken: idToken)
+/// } catch {
+/// // Handle any errors.
+/// }
+/// ```
+final public class SignInWithAppleHelper: NSObject {
/// A continuation to handle the result of the authorization process.
private var continuation: CheckedContinuation?
+ /// Generates a cryptographically secure nonce for Sign in with Apple requests.
+ ///
+ /// This method creates a nonce using CryptoKit's SHA256 hash function with cryptographically
+ /// secure random data, following Apple's best practices for Sign in with Apple authentication.
+ ///
+ /// - Returns: A Base64 URL-safe encoded nonce string.
+ private func generateNonce() -> String {
+ // Generate 32 bytes (256 bits) of cryptographically secure random data
+ let data = Data((0..<32).map { _ in UInt8.random(in: UInt8.min...UInt8.max) })
+
+ // Hash the random data using SHA256
+ let hashedData = SHA256.hash(data: data)
+
+ // Convert to Base64 URL-safe encoding (replacing + with -, / with _, and removing padding)
+ let base64String = Data(hashedData).base64EncodedString()
+ return
+ base64String
+ .replacingOccurrences(of: "+", with: "-")
+ .replacingOccurrences(of: "/", with: "_")
+ .replacingOccurrences(of: "=", with: "")
+ }
+
/// Starts the Sign in with Apple authorization flow with the requested scopes.
///
/// This method presents the Sign in with Apple UI and waits for the user to complete the sign-in flow.
@@ -49,19 +72,19 @@
/// - Throws: An error if the authorization fails or if the user cancels the process.
@MainActor
func start(requestedScopes: [ASAuthorization.Scope]) async throws -> ASAuthorization {
- return try await withCheckedThrowingContinuation { continuation in
- self.continuation = continuation
-
- let appleIDProvider = ASAuthorizationAppleIDProvider()
- let appleRequest = appleIDProvider.createRequest()
- appleRequest.requestedScopes = requestedScopes
- appleRequest.nonce = UUID().uuidString
-
- let authorizationController = ASAuthorizationController(authorizationRequests: [appleRequest])
- authorizationController.delegate = self
- authorizationController.presentationContextProvider = self
- authorizationController.performRequests()
- }
+ return try await withCheckedThrowingContinuation { continuation in
+ self.continuation = continuation
+
+ let appleIDProvider = ASAuthorizationAppleIDProvider()
+ let appleRequest = appleIDProvider.createRequest()
+ appleRequest.requestedScopes = requestedScopes
+ appleRequest.nonce = generateNonce()
+
+ let authorizationController = ASAuthorizationController(authorizationRequests: [appleRequest])
+ authorizationController.delegate = self
+ authorizationController.presentationContextProvider = self
+ authorizationController.performRequests()
+ }
}
/// Fetches an Apple ID credential using Sign in with Apple.
@@ -76,40 +99,40 @@
/// - Throws: An error if the authorization fails or if the credential cannot be retrieved.
@MainActor
public static func getAppleIdCredential(requestedScopes: [ASAuthorization.Scope] = [.email]) async throws -> ASAuthorizationAppleIDCredential {
- let authManager = SignInWithAppleHelper()
- let authorization = try await authManager.start(requestedScopes: requestedScopes)
+ let authManager = SignInWithAppleHelper()
+ let authorization = try await authManager.start(requestedScopes: requestedScopes)
- guard let appleIdCredential = authorization.credential as? ASAuthorizationAppleIDCredential else {
- throw ClerkClientError(message: "Unable to get your Apple ID credential.")
- }
+ guard let appleIdCredential = authorization.credential as? ASAuthorizationAppleIDCredential else {
+ throw ClerkClientError(message: "Unable to get your Apple ID credential.")
+ }
- return appleIdCredential
+ return appleIdCredential
}
- }
+}
- extension SignInWithAppleHelper: ASAuthorizationControllerDelegate {
+extension SignInWithAppleHelper: ASAuthorizationControllerDelegate {
/// Called when the authorization process completes successfully.
public func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {
- continuation?.resume(returning: authorization)
+ continuation?.resume(returning: authorization)
}
/// Called when the authorization process fails.
public func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: any Error) {
- continuation?.resume(throwing: error)
+ continuation?.resume(throwing: error)
}
- }
+}
- extension SignInWithAppleHelper: ASAuthorizationControllerPresentationContextProviding {
+extension SignInWithAppleHelper: ASAuthorizationControllerPresentationContextProviding {
@MainActor
public func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
- #if os(iOS)
+ #if os(iOS)
UIApplication.shared.windows.first(where: { $0.isKeyWindow }) ?? ASPresentationAnchor()
- #else
+ #else
ASPresentationAnchor()
- #endif
+ #endif
}
- }
+}
#endif
diff --git a/Sources/Clerk/Models/BackupCodeResource.swift b/Sources/Clerk/Models/BackupCodeResource.swift
index 1095634f6..5e7be03ad 100644
--- a/Sources/Clerk/Models/BackupCodeResource.swift
+++ b/Sources/Clerk/Models/BackupCodeResource.swift
@@ -8,38 +8,38 @@
import Foundation
/// An interface that represents a backup code.
-public struct BackupCodeResource: Codable, Hashable, Equatable, Sendable {
-
- /// The unique identifier for the set of backup codes.
- public let id: String
-
- /// The generated set of backup codes.
- public let codes: [String]
-
- /// The date when the backup codes were created.
- public let createdAt: Date
-
- /// The date when the backup codes were last updated.
- public let updatedAt: Date
+public struct BackupCodeResource: Identifiable, Codable, Hashable, Equatable, Sendable {
+
+ /// The unique identifier for the set of backup codes.
+ public let id: String
+
+ /// The generated set of backup codes.
+ public let codes: [String]
+
+ /// The date when the backup codes were created.
+ public let createdAt: Date
+
+ /// The date when the backup codes were last updated.
+ public let updatedAt: Date
}
extension BackupCodeResource {
-
- static var mock: Self {
- .init(
- id: "1",
- codes: [
- "abcd",
- "efgh",
- "ijkl",
- "mnop",
- "qrst",
- "uvwx",
- "yz"
- ],
- createdAt: .distantPast,
- updatedAt: .distantPast
- )
- }
-
+
+ static var mock: Self {
+ .init(
+ id: "1",
+ codes: [
+ "abcd",
+ "efgh",
+ "ijkl",
+ "mnop",
+ "qrst",
+ "uvwx",
+ "yz"
+ ],
+ createdAt: .distantPast,
+ updatedAt: .distantPast
+ )
+ }
+
}
diff --git a/Sources/Clerk/Models/Clerk/Clerk.swift b/Sources/Clerk/Models/Clerk/Clerk.swift
index 1de0ded97..2d03cb057 100644
--- a/Sources/Clerk/Models/Clerk/Clerk.swift
+++ b/Sources/Clerk/Models/Clerk/Clerk.swift
@@ -5,14 +5,14 @@
// Created by Mike Pitre on 10/2/23.
//
-import Factory
+import FactoryKit
import Foundation
-import Get
import RegexBuilder
+import RequestBuilder
import SimpleKeychain
#if canImport(UIKit)
- import UIKit
+import UIKit
#endif
/**
@@ -22,281 +22,352 @@ import SimpleKeychain
@Observable
final public class Clerk {
- /// The shared Clerk instance.
- public static let shared = Container.shared.clerk()
+ /// The shared Clerk instance.
+ public nonisolated static let shared = Container.shared.clerk()
- /// A getter to see if the Clerk object is ready for use or not.
- private(set) public var isLoaded: Bool = false
+ /// A getter to see if the Clerk object is ready for use or not.
+ private(set) public var isLoaded: Bool = false
- /// A getter to see if a Clerk instance is running in production or development mode.
- public var instanceType: InstanceEnvironmentType {
- if publishableKey.starts(with: "pk_live_") {
- return .production
+ /// A getter to see if a Clerk instance is running in production or development mode.
+ public var instanceType: InstanceEnvironmentType {
+ if publishableKey.starts(with: "pk_live_") {
+ return .production
+ }
+ return .development
+ }
+
+ /// The Client object for the current device.
+ internal(set) public var client: Client? {
+ didSet {
+ if let client = client {
+ try? Container.shared.clerkService().saveClientToKeychain(client)
+ } else {
+ try? Container.shared.keychain().deleteItem(forKey: "cachedClient")
+ }
+ }
}
- return .development
- }
-
- /// The Client object for the current device.
- internal(set) public var client: Client? {
- didSet {
- if let clientId = client?.id {
- try? Container.shared.clerkService().saveClientIdToKeychain(clientId)
- }
+
+ /// The currently active Session, which is guaranteed to be one of the sessions in Client.sessions. If there is no active session, this field will be nil.
+ public var session: Session? {
+ guard let client else { return nil }
+ return client.activeSessions.first(where: { $0.id == client.lastActiveSessionId })
+ }
+
+ /// A shortcut to Session.user which holds the currently active User object. If the session is nil, the user field will match.
+ public var user: User? {
+ session?.user
}
- }
-
- /// The currently active Session, which is guaranteed to be one of the sessions in Client.sessions. If there is no active session, this field will be nil.
- public var session: Session? {
- guard let client else { return nil }
- return client.activeSessions.first(where: { $0.id == client.lastActiveSessionId })
- }
-
- /// A shortcut to Session.user which holds the currently active User object. If the session is nil, the user field will match.
- public var user: User? {
- session?.user
- }
-
- /// A dictionary of a user's active sessions on all devices.
- internal(set) public var sessionsByUserId: [String: [Session]] = [:]
-
- /// The publishable key from your Clerk Dashboard, used to connect to Clerk.
- private(set) public var publishableKey: String = "" {
- didSet {
- let liveRegex = Regex {
- "pk_live_"
- Capture {
- OneOrMore(.any)
+
+ /// A dictionary of a user's active sessions on all devices.
+ internal(set) public var sessionsByUserId: [String: [Session]] = [:]
+
+ /// The publishable key from your Clerk Dashboard, used to connect to Clerk.
+ private(set) public var publishableKey: String = "" {
+ didSet {
+ let liveRegex = Regex {
+ "pk_live_"
+ Capture {
+ OneOrMore(.any)
+ }
+ }
+
+ let testRegex = Regex {
+ "pk_test_"
+ Capture {
+ OneOrMore(.any)
+ }
+ }
+
+ if let match = publishableKey.firstMatch(of: liveRegex)?.output.1 ?? publishableKey.firstMatch(of: testRegex)?.output.1,
+ let apiUrl = String(match).base64String()
+ {
+ frontendApiUrl = "https://\(apiUrl.dropLast())"
+ }
}
- }
+ }
+
+ /// The event emitter for auth events.
+ public let authEventEmitter = EventEmitter()
- let testRegex = Regex {
- "pk_test_"
- Capture {
- OneOrMore(.any)
+ /// The Clerk environment for the instance.
+ var environment = Environment() {
+ didSet {
+ try? Container.shared.clerkService().saveEnvironmentToKeychain(environment)
}
- }
+ }
+
+ // MARK: - Private Properties
- if let match = publishableKey.firstMatch(of: liveRegex)?.output.1 ?? publishableKey.firstMatch(of: testRegex)?.output.1,
- let apiUrl = String(match).base64String()
- {
- frontendApiUrl = "https://\(apiUrl.dropLast())"
- }
+ nonisolated init() {
+ Task { @MainActor in
+ loadCachedClient()
+ loadCachedEnvironment()
+ }
}
- }
-
- /// The event emitter for auth events.
- public let authEventEmitter = EventEmitter()
-
- /// Enable for additional debugging signals.
- private(set) public var debugMode: Bool = false
-
- /// The Clerk environment for the instance.
- var environment = Environment()
-
- // MARK: - Private Properties
-
- nonisolated init() {}
-
- /// Frontend API URL.
- private(set) var frontendApiUrl: String = "" {
- didSet {
- Container.shared.apiClient.register { [frontendApiUrl] in
- APIClient(baseURL: URL(string: frontendApiUrl)) { configuration in
- configuration.delegate = ClerkAPIClientDelegate()
- configuration.decoder = .clerkDecoder
- configuration.encoder = .clerkEncoder
- configuration.sessionConfiguration.httpAdditionalHeaders = [
- "Content-Type": "application/x-www-form-urlencoded",
- "clerk-api-version": "2024-10-01",
- "x-ios-sdk-version": Clerk.version,
- "x-mobile": "1",
- ]
+
+ /// Frontend API URL.
+ private(set) var frontendApiUrl: String = "" {
+ didSet {
+ Container.shared.apiClient.register { [frontendApiUrl] in
+ let base = URL(string: frontendApiUrl)
+ let session = URLSession(configuration: URLSessionConfiguration.ephemeral)
+ return BaseSessionManager(base: base, session: session)
+ .set(encoder: JSONEncoder.clerkEncoder)
+ .set(decoder: JSONDecoder.clerkDecoder)
+ .interceptor(URLRequestInterceptorMock())
+ .interceptor(URLRequestInterceptorClerkHeaders())
+ .interceptor(URLRequestInterceptorQueryItems())
+ .interceptor(URLRequestInterceptorInvalidAuth())
+ .interceptor(URLRequestInterceptorDeviceAssertion())
+ .interceptor(URLRequestInterceptorDeviceTokenSaving())
+ .interceptor(URLRequestInterceptorClientSync())
+ .interceptor(URLRequestInterceptorEventEmitter())
+ .interceptor(URLRequestInterceptorClerkErrorThrowing())
+ }
}
- }
}
- }
-
- private var keychainConfig = KeychainConfig() {
- didSet {
- Container.shared.keychain.register { [keychainConfig] in
- SimpleKeychain(
- service: keychainConfig.service,
- accessGroup: keychainConfig.accessGroup,
- accessibility: .afterFirstUnlockThisDeviceOnly
- )
- }
+
+ /// The configuration settings for this Clerk instance.
+ var settings: Settings = .init() {
+ didSet {
+ Container.shared.clerkSettings.register { [settings] in
+ settings
+ }
+
+ Container.shared.keychain.register { [keychainConfig = settings.keychainConfig] in
+ SimpleKeychain(
+ service: keychainConfig.service,
+ accessGroup: keychainConfig.accessGroup,
+ accessibility: .afterFirstUnlockThisDeviceOnly
+ )
+ }
+ }
}
- }
- /// Holds a reference to the task performed when the app will enter the foreground.
- private var willEnterForegroundTask: Task?
+ /// Holds a reference to the task performed when the app will enter the foreground.
+ private var willEnterForegroundTask: Task?
- /// Holds a reference to the task performed when the app entered the background.
- private var didEnterBackgroundTask: Task?
+ /// Holds a reference to the task performed when the app entered the background.
+ private var didEnterBackgroundTask: Task?
- /// Holds a reference to the session polling task.
- private var sessionPollingTask: Task?
+ /// Holds a reference to the session polling task.
+ private var sessionPollingTask: Task?
}
extension Clerk {
- /// Configures the shared clerk instance.
- /// - Parameters:
- /// - publishableKey: The publishable key from your Clerk Dashboard, used to connect to Clerk.
- /// - debugMode: Enable for additional debugging signals.
- /// - keychainConfig: Options that Clerk will use when accessing the keychain.
- public func configure(
- publishableKey: String,
- debugMode: Bool = false,
- keychainConfig: KeychainConfig = KeychainConfig()
- ) {
- self.publishableKey = publishableKey
- self.debugMode = debugMode
- self.keychainConfig = keychainConfig
- }
-
- /// Loads all necessary environment configuration and instance settings from the Frontend API.
- /// It is absolutely necessary to call this method before using the Clerk object in your code.
- public func load() async throws {
- if publishableKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
- throw ClerkClientError(
- message: """
- Clerk loaded without a publishable key.
- Please call configure() with a valid publishable key first.
- """
- )
+ /// Configures the shared clerk instance.
+ /// - Parameters:
+ /// - publishableKey: The publishable key from your Clerk Dashboard, used to connect to Clerk.
+ public func configure(
+ publishableKey: String,
+ settings: Settings = .init()
+ ) {
+ self.publishableKey = publishableKey
+ self.settings = settings
}
- do {
- startSessionTokenPolling()
- setupNotificationObservers()
+ /// Loads all necessary environment configuration and instance settings from the Frontend API.
+ /// It is absolutely necessary to call this method before using the Clerk object in your code.
+ public func load() async throws {
+ if publishableKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ ClerkLogger.error("Clerk loaded without a publishable key. Please call configure() with a valid publishable key first.")
+ return
+ }
- async let client = Client.get()
- async let environment = Environment.get()
- _ = try await client
- self.environment = try await environment
+ do {
+ startSessionTokenPolling()
+ setupNotificationObservers()
+
+ // Both of these are automatically applied to the shared instance:
+ async let client = Client.get() // via middleware
+ async let environment = Environment.get() // via the function itself
- attestDeviceIfNeeded(environment: self.environment)
+ _ = try await client
+ attestDeviceIfNeeded(environment: try await environment)
- isLoaded = true
- } catch {
- throw error
+ isLoaded = true
+ } catch {
+ throw error
+ }
+ }
+
+ /// Signs out the active user.
+ ///
+ /// - In a **multi-session** application: Signs out the active user from all sessions.
+ /// - In a **single-session** context: Signs out the active user from the current session.
+ /// - You can specify a specific session to sign out by passing the `sessionId` parameter.
+ ///
+ /// - Parameter sessionId: An optional session ID to specify a particular session to sign out.
+ /// Useful for multi-session applications.
+ ///
+ /// - Throws: An error if the sign-out process fails.
+ ///
+ /// - Example:
+ /// ```swift
+ /// try await clerk.signOut()
+ /// ```
+ public func signOut(sessionId: String? = nil) async throws {
+ try await Container.shared.clerkService().signOut(sessionId)
+ }
+
+ /// A method used to set the active session.
+ ///
+ /// Useful for multi-session applications.
+ ///
+ /// - Parameter sessionId: The session ID to be set as active.
+ /// - Parameter organizationId: The organization ID to be set as active in the current session. If nil, the currently active organization is removed as active.
+ public func setActive(sessionId: String, organizationId: String? = nil) async throws {
+ try await Container.shared.clerkService().setActive(sessionId, organizationId)
}
- }
-
- /// Signs out the active user.
- ///
- /// - In a **multi-session** application: Signs out the active user from all sessions.
- /// - In a **single-session** context: Signs out the active user from the current session.
- /// - You can specify a specific session to sign out by passing the `sessionId` parameter.
- ///
- /// - Parameter sessionId: An optional session ID to specify a particular session to sign out.
- /// Useful for multi-session applications.
- ///
- /// - Throws: An error if the sign-out process fails.
- ///
- /// - Example:
- /// ```swift
- /// try await clerk.signOut()
- /// ```
- public func signOut(sessionId: String? = nil) async throws {
- try await Container.shared.clerkService().signOut(sessionId)
- }
-
- /// A method used to set the active session.
- ///
- /// Useful for multi-session applications.
- ///
- /// - Parameter sessionId: The session ID to be set as active.
- /// - Parameter organizationId: The organization ID to be set as active in the current session. If nil, the currently active organization is removed as active.
- public func setActive(sessionId: String, organizationId: String? = nil) async throws {
- try await Container.shared.clerkService().setActive(sessionId, organizationId)
- }
}
extension Clerk {
- // MARK: - Private Properties
+ // MARK: - Private Properties
- private func setupNotificationObservers() {
- #if !os(watchOS) && !os(macOS)
+ private func setupNotificationObservers() {
+ #if !os(watchOS) && !os(macOS)
- // cancel existing tasks if they exist (switching instances)
- willEnterForegroundTask?.cancel()
- didEnterBackgroundTask?.cancel()
+ // cancel existing tasks if they exist (switching instances)
+ willEnterForegroundTask?.cancel()
+ didEnterBackgroundTask?.cancel()
- willEnterForegroundTask = Task {
- for await _ in NotificationCenter.default.notifications(
- named: UIApplication.willEnterForegroundNotification
- ).map({ _ in () }) {
- self.startSessionTokenPolling()
+ willEnterForegroundTask = Task {
+ for await _ in NotificationCenter.default.notifications(
+ named: UIApplication.willEnterForegroundNotification
+ ).map({ _ in () }) {
+ self.startSessionTokenPolling()
- // Start both functions concurrently without waiting for them
- Task {
- _ = try? await Client.get()
- }
+ // Start both functions concurrently without waiting for them
+ Task {
+ try? await Client.get()
+ }
- Task {
- self.environment = try await Environment.get()
- }
+ Task {
+ try? await Environment.get()
+ }
+ }
}
- }
- didEnterBackgroundTask = Task {
- for await _ in NotificationCenter.default.notifications(
- named: UIApplication.didEnterBackgroundNotification
- ).map({ _ in () }) {
- stopSessionTokenPolling()
+ didEnterBackgroundTask = Task {
+ for await _ in NotificationCenter.default.notifications(
+ named: UIApplication.didEnterBackgroundNotification
+ ).map({ _ in () }) {
+ stopSessionTokenPolling()
+ }
}
- }
- #endif
- }
+ #endif
+ }
- private func startSessionTokenPolling() {
- guard sessionPollingTask == nil || sessionPollingTask?.isCancelled == true else {
- return
+ private func startSessionTokenPolling() {
+ guard sessionPollingTask == nil || sessionPollingTask?.isCancelled == true else {
+ return
+ }
+
+ sessionPollingTask = Task(priority: .background) {
+ repeat {
+ if let session = session {
+ _ = try? await session.getToken()
+ }
+ try await Task.sleep(for: .seconds(5), tolerance: .seconds(0.1))
+ } while !Task.isCancelled
+ }
}
- sessionPollingTask = Task(priority: .background) {
- repeat {
- if let session = session {
- try? await session.getToken()
+ private func stopSessionTokenPolling() {
+ sessionPollingTask?.cancel()
+ sessionPollingTask = nil
+ }
+
+ private func attestDeviceIfNeeded(environment: Environment) {
+ if !AppAttestHelper.hasKeyId, [.onboarding, .enforced].contains(environment.fraudSettings?.native.deviceAttestationMode) {
+ Task.detached {
+ do {
+ try await AppAttestHelper.performDeviceAttestation()
+ } catch {
+ ClerkLogger.logError(error, message: "Device attestation failed")
+ }
+ }
}
- try await Task.sleep(for: .seconds(5), tolerance: .seconds(0.1))
- } while !Task.isCancelled
}
- }
- private func stopSessionTokenPolling() {
- sessionPollingTask?.cancel()
- sessionPollingTask = nil
- }
+ private func loadCachedClient() {
+ do {
+ if let cachedClient = try Container.shared.clerkService().loadClientFromKeychain() {
+ // Only set cached client if we don't already have one
+ // This prevents overwriting fresh data during load()
+ if self.client == nil {
+ self.client = cachedClient
+ }
+ }
+ } catch {
+ ClerkLogger.logError(error, message: "Failed to load cached client")
+ }
+ }
- private func attestDeviceIfNeeded(environment: Environment) {
- if !AppAttestHelper.hasKeyId, [.onboarding, .enforced].contains(environment.fraudSettings?.native.deviceAttestationMode) {
- Task.detached {
+ private func loadCachedEnvironment() {
do {
- try await AppAttestHelper.performDeviceAttestation()
+ if let cachedEnvironment = try Container.shared.clerkService().loadEnvironmentFromKeychain() {
+ // Only set cached environment if we don't already have fresh data
+ // This prevents overwriting fresh data during load()
+ if self.environment.isEmpty {
+ self.environment = cachedEnvironment
+ }
+ }
} catch {
- dump(error)
+ ClerkLogger.logError(error, message: "Failed to load cached environment")
}
- }
}
- }
}
extension Container {
- var clerk: Factory {
- self { Clerk() }
- .singleton
- }
+ var clerk: Factory {
+ self { Clerk() }
+ .singleton
+ }
+
+ var keychain: Factory {
+ self { SimpleKeychain(accessibility: .afterFirstUnlockThisDeviceOnly) }
+ .cached
+ }
- var keychain: Factory {
- self { SimpleKeychain(accessibility: .afterFirstUnlockThisDeviceOnly) }
- .cached
- }
+ var clerkSettings: Factory {
+ self { .init() }
+ .cached
+ }
}
+
+extension Clerk {
+
+ @_spi(Internal)
+ public static var mock: Clerk {
+ let clerk = Clerk()
+ clerk.client = .mock
+ clerk.environment = .mock
+ clerk.sessionsByUserId = [User.mock.id: [.mock, .mock2]]
+ return clerk
+ }
+
+ @_spi(Internal)
+ public static var mockSignedOut: Clerk {
+ let clerk = Clerk()
+ clerk.client = .mockSignedOut
+ clerk.environment = .mock
+ clerk.sessionsByUserId = [:]
+ return clerk
+ }
+
+}
+
+#if canImport(SwiftUI)
+import SwiftUI
+
+extension EnvironmentValues {
+ @Entry public var clerk = Clerk.shared
+}
+#endif
diff --git a/Sources/Clerk/Models/Clerk/ClerkService.swift b/Sources/Clerk/Models/Clerk/ClerkService.swift
index b251b76e6..9f76bfa9b 100644
--- a/Sources/Clerk/Models/Clerk/ClerkService.swift
+++ b/Sources/Clerk/Models/Clerk/ClerkService.swift
@@ -2,52 +2,75 @@
// ClerkService.swift
// Clerk
//
-// Created by Mike Pitre on 2/26/25.
+// Created by Mike Pitre on 7/28/25.
//
-import Factory
+import FactoryKit
import Foundation
-import Get
-struct ClerkService {
- var saveClientIdToKeychain: (_ clientId: String) throws -> Void
- var signOut: (_ sessionId: String?) async throws -> Void
- var setActive: (_ sessionId: String, _ organizationId: String?) async throws -> Void
+extension Container {
+
+ var clerkService: Factory {
+ self { @MainActor in ClerkService() }
+ }
+
}
-extension ClerkService {
+@MainActor
+struct ClerkService {
- static var liveValue: Self {
- .init(
- saveClientIdToKeychain: { clientId in
- try Container.shared.keychain().set(clientId, forKey: "clientId")
- },
- signOut: { sessionId in
+ var signOut: (_ sessionId: String?) async throws -> Void = { sessionId in
if let sessionId {
- let request = ClerkFAPI.v1.client.sessions.id(sessionId).remove.post
- try await Container.shared.apiClient().send(request)
+ _ = try await Container.shared.apiClient().request()
+ .add(path: "/v1/client/sessions/\(sessionId)/remove")
+ .method(.post)
+ .data(type: ClientResponse.self)
+ .async()
} else {
- let request = ClerkFAPI.v1.client.sessions.delete
- try await Container.shared.apiClient().send(request)
+ _ = try await Container.shared.apiClient().request()
+ .add(path: "/v1/client/sessions")
+ .method(.delete)
+ .data(type: ClientResponse.self)
+ .async()
}
- },
- setActive: { sessionId, organizationId in
- let request = Request>(
- path: "v1/client/sessions/\(sessionId)/touch",
- method: .post,
- body: ["active_organization_id": organizationId ?? ""] // nil key/values get dropped, use an empty string to set no active org
- )
- try await Container.shared.apiClient().send(request)
- }
- )
- }
+ }
-}
+ var setActive: (_ sessionId: String, _ organizationId: String?) async throws -> Void = { sessionId, organizationId in
+ _ = try await Container.shared.apiClient().request()
+ .add(path: "/v1/client/sessions/\(sessionId)/touch")
+ .method(.post)
+ .body(formEncode: ["active_organization_id": organizationId ?? ""])
+ .data(type: ClientResponse.self)
+ .async()
+ }
-extension Container {
+ // MARK: - Keychain Utilities
+
+ var saveClientToKeychain: (_ client: Client) throws -> Void = { client in
+ let clientData = try JSONEncoder.clerkEncoder.encode(client)
+ try Container.shared.keychain().set(clientData, forKey: "cachedClient")
+ }
+
+ var loadClientFromKeychain: () throws -> Client? = {
+ guard let clientData = try? Container.shared.keychain().data(forKey: "cachedClient") else {
+ return nil
+ }
+ let decoder = JSONDecoder.clerkDecoder
+ return try decoder.decode(Client.self, from: clientData)
+ }
+
+ var saveEnvironmentToKeychain: (_ environment: Clerk.Environment) throws -> Void = { environment in
+ let encoder = JSONEncoder.clerkEncoder
+ let environmentData = try encoder.encode(environment)
+ try Container.shared.keychain().set(environmentData, forKey: "cachedEnvironment")
+ }
- var clerkService: Factory {
- self { .liveValue }
- }
+ var loadEnvironmentFromKeychain: () throws -> Clerk.Environment? = {
+ guard let environmentData = try? Container.shared.keychain().data(forKey: "cachedEnvironment") else {
+ return nil
+ }
+ let decoder = JSONDecoder.clerkDecoder
+ return try decoder.decode(Clerk.Environment.self, from: environmentData)
+ }
}
diff --git a/Sources/Clerk/Models/Clerk/Settings/ClerkSettings.swift b/Sources/Clerk/Models/Clerk/Settings/ClerkSettings.swift
new file mode 100644
index 000000000..71fc5db81
--- /dev/null
+++ b/Sources/Clerk/Models/Clerk/Settings/ClerkSettings.swift
@@ -0,0 +1,40 @@
+//
+// ClerkSettings.swift
+// Clerk
+//
+// Created by Mike Pitre on 6/30/25.
+//
+
+import Foundation
+
+extension Clerk {
+
+ /// A configuration object that can be passed to `Clerk.configure()` to customize various aspects of the Clerk SDK behavior.
+ public struct Settings: Sendable {
+
+ /// Enable additional debugging signals and logging. Defaults to false.
+ public let debugMode: Bool
+
+ /// Configuration for keychain storage behavior.
+ public let keychainConfig: KeychainConfig
+
+ /// Configuration for OAuth redirect URLs and callback handling.
+ public let redirectConfig: RedirectConfig
+
+ /// Initializes a ``Settings`` instance.
+ /// - Parameters:
+ /// - debugMode: Enable additional debugging signals and logging. Defaults to false.
+ /// - keychainConfig: Configuration for keychain storage behavior. Defaults to a new KeychainConfig instance.
+ /// - redirectConfig: Configuration for OAuth redirect URLs and callback handling. Defaults to a new RedirectConfig instance.
+ public init(
+ debugMode: Bool = false,
+ keychainConfig: KeychainConfig = .init(),
+ redirectConfig: RedirectConfig = .init()
+ ) {
+ self.debugMode = debugMode
+ self.keychainConfig = keychainConfig
+ self.redirectConfig = redirectConfig
+ }
+ }
+
+}
diff --git a/Sources/Clerk/Models/Clerk/Settings/KeychainConfig.swift b/Sources/Clerk/Models/Clerk/Settings/KeychainConfig.swift
new file mode 100644
index 000000000..a38641b69
--- /dev/null
+++ b/Sources/Clerk/Models/Clerk/Settings/KeychainConfig.swift
@@ -0,0 +1,30 @@
+//
+// KeychainConfig.swift
+// Clerk
+//
+// Created by Mike Pitre on 3/24/25.
+//
+
+import Foundation
+
+/// A configuration object that can be passed to `Clerk.configure()` to customize keychain behavior.
+public struct KeychainConfig: Sendable {
+
+ /// Name of the service under which to save items. Defaults to the bundle identifier.
+ public let service: String
+
+ /// Access group for sharing Keychain items.
+ public let accessGroup: String?
+
+ /// Initializes a ``KeychainConfig`` instance.
+ /// - Parameters:
+ /// - service: Name of the service under which to save items. Defaults to the bundle identifier.
+ /// - accessGroup: Access group for sharing Keychain items.
+ public init(
+ service: String = Bundle.main.bundleIdentifier ?? "",
+ accessGroup: String? = nil
+ ) {
+ self.service = service
+ self.accessGroup = accessGroup
+ }
+}
diff --git a/Sources/Clerk/Models/Clerk/Settings/RedirectConfig.swift b/Sources/Clerk/Models/Clerk/Settings/RedirectConfig.swift
new file mode 100644
index 000000000..ef8375ec3
--- /dev/null
+++ b/Sources/Clerk/Models/Clerk/Settings/RedirectConfig.swift
@@ -0,0 +1,30 @@
+//
+// RedirectConfig.swift
+//
+//
+// Created by Mike Pitre on 3/19/24.
+//
+
+import Foundation
+
+/// A configuration object that can be passed to `Clerk.configure()` to customize redirect behavior for OAuth flows and deep linking.
+public struct RedirectConfig: Sendable {
+
+ /// The URL that OAuth providers should redirect to after authentication. Defaults to "{bundleIdentifier}://callback".
+ public let redirectUrl: String
+
+ /// The URL scheme used for handling callbacks from OAuth providers. Defaults to the bundle identifier.
+ public let callbackUrlScheme: String
+
+ /// Initializes a ``RedirectConfig`` instance.
+ /// - Parameters:
+ /// - redirectUrl: The URL that OAuth providers should redirect to after authentication. Defaults to "{bundleIdentifier}://callback".
+ /// - callbackUrlScheme: The URL scheme used for handling callbacks from OAuth providers. Defaults to the bundle identifier.
+ public init(
+ redirectUrl: String = "\(Bundle.main.bundleIdentifier ?? "")://callback",
+ callbackUrlScheme: String = Bundle.main.bundleIdentifier ?? ""
+ ) {
+ self.redirectUrl = redirectUrl
+ self.callbackUrlScheme = callbackUrlScheme
+ }
+}
diff --git a/Sources/Clerk/Models/ClerkAPIError.swift b/Sources/Clerk/Models/ClerkAPIError.swift
index a0d1d8109..e804eb189 100644
--- a/Sources/Clerk/Models/ClerkAPIError.swift
+++ b/Sources/Clerk/Models/ClerkAPIError.swift
@@ -10,24 +10,24 @@ import Foundation
/// An object that represents an error returned by the Clerk API.
public struct ClerkAPIError: Error, LocalizedError, Codable, Equatable, Hashable {
- /// A string code that represents the error, such as `username_exists_code`.
- public let code: String
+ /// A string code that represents the error, such as `username_exists_code`.
+ public let code: String
- /// A message that describes the error.
- public let message: String?
+ /// A message that describes the error.
+ public let message: String?
- /// A more detailed message that describes the error.
- public let longMessage: String?
+ /// A more detailed message that describes the error.
+ public let longMessage: String?
- /// Additional information about the error.
- public let meta: JSON?
+ /// Additional information about the error.
+ public let meta: JSON?
- /// A unique identifier for tracing the specific request, useful for debugging.
- public var clerkTraceId: String?
+ /// A unique identifier for tracing the specific request, useful for debugging.
+ public var clerkTraceId: String?
}
extension ClerkAPIError {
- public var errorDescription: String? { longMessage ?? message }
+ public var errorDescription: String? { longMessage ?? message }
}
/// Represents the body of Clerk API error responses.
@@ -36,23 +36,23 @@ extension ClerkAPIError {
/// It also includes a unique trace ID for debugging purposes.
public struct ClerkErrorResponse: Codable, Equatable {
- /// An array of `ClerkAPIError` objects, each describing an individual error.
- public let errors: [ClerkAPIError]
+ /// An array of `ClerkAPIError` objects, each describing an individual error.
+ public let errors: [ClerkAPIError]
- /// A unique identifier for tracing the specific request, useful for debugging.
- public let clerkTraceId: String?
+ /// A unique identifier for tracing the specific request, useful for debugging.
+ public let clerkTraceId: String?
}
extension ClerkAPIError {
- static var mock: ClerkAPIError {
- .init(
- code: "error",
- message: "An unknown error occurred.",
- longMessage: "An unknown error occurred. Please try again or contact support.",
- meta: nil,
- clerkTraceId: "1"
- )
- }
+ static var mock: ClerkAPIError {
+ .init(
+ code: "error",
+ message: "An unknown error occurred.",
+ longMessage: "An unknown error occurred. Please try again or contact support.",
+ meta: nil,
+ clerkTraceId: "1"
+ )
+ }
}
diff --git a/Sources/Clerk/Models/ClerkClientError.swift b/Sources/Clerk/Models/ClerkClientError.swift
index 1a1c0b0ff..186d74bae 100644
--- a/Sources/Clerk/Models/ClerkClientError.swift
+++ b/Sources/Clerk/Models/ClerkClientError.swift
@@ -6,25 +6,29 @@
//
import Foundation
+import SwiftUI
/// An object that represents an error created by Clerk on the client.
public struct ClerkClientError: Error, LocalizedError {
- /// A message that describes the error.
- public let message: String?
+ /// A message that describes the error.
+ public let message: String.LocalizationValue?
- public init(message: String? = nil) {
- self.message = message
- }
+ public init(message: String.LocalizationValue? = nil) {
+ self.message = message
+ }
}
extension ClerkClientError {
- public var errorDescription: String? { message }
+ public var errorDescription: String? {
+ guard let message else { return nil }
+ return String(localized: message)
+ }
}
extension ClerkClientError {
- static var mock: ClerkClientError {
- .init(message: "An unknown error occurred.")
- }
+ static var mock: ClerkClientError {
+ .init(message: "An unknown error occurred.")
+ }
}
diff --git a/Sources/Clerk/Models/ClerkPaginatedResponse.swift b/Sources/Clerk/Models/ClerkPaginatedResponse.swift
index a46312190..2303fefd2 100644
--- a/Sources/Clerk/Models/ClerkPaginatedResponse.swift
+++ b/Sources/Clerk/Models/ClerkPaginatedResponse.swift
@@ -10,9 +10,9 @@ import Foundation
/// An interface that describes the response of a method that returns a paginated list of resources.
public struct ClerkPaginatedResponse: Codable, Sendable {
- /// An array that contains the fetched data.
- public let data: [T]
+ /// An array that contains the fetched data.
+ public let data: [T]
- /// The total count of data that exists remotely.
- public let totalCount: Int
+ /// The total count of data that exists remotely.
+ public let totalCount: Int
}
diff --git a/Sources/Clerk/Models/Client/Client.swift b/Sources/Clerk/Models/Client/Client.swift
index 2901e951e..4697f32d0 100644
--- a/Sources/Clerk/Models/Client/Client.swift
+++ b/Sources/Clerk/Models/Client/Client.swift
@@ -5,7 +5,7 @@
// Created by Mike Pitre on 10/2/23.
//
-import Factory
+import FactoryKit
import Foundation
/// The Client object keeps track of the authenticated sessions in the current device. The device can be a browser, a native application or any other medium that is usually the requesting part in a request/response architecture.
@@ -13,67 +13,78 @@ import Foundation
/// The Client object also holds information about any sign in or sign up attempts that might be in progress, tracking the sign in or sign up progress.
public struct Client: Codable, Sendable, Equatable {
- /// Unique identifier for this client.
- public let id: String
-
- /// The current sign in attempt, or nil if there is none.
- public let signIn: SignIn?
-
- /// The current sign up attempt, or nil if there is none.
- public let signUp: SignUp?
-
- /// A list of sessions that have been created on this client.
- public let sessions: [Session]
-
- /// A list of active sessions on this client.
- public var activeSessions: [Session] {
- sessions.filter { $0.status == .active }
- }
-
- /// The ID of the last active Session on this client.
- public let lastActiveSessionId: String?
-
- /// Timestamp of last update for the client.
- public let updatedAt: Date
-
- public init(
- id: String,
- signIn: SignIn? = nil,
- signUp: SignUp? = nil,
- sessions: [Session],
- lastActiveSessionId: String? = nil,
- updatedAt: Date
- ) {
- self.id = id
- self.signIn = signIn
- self.signUp = signUp
- self.sessions = sessions
- self.lastActiveSessionId = lastActiveSessionId
- self.updatedAt = updatedAt
- }
+ /// Unique identifier for this client.
+ public let id: String
+
+ /// The current sign in attempt, or nil if there is none.
+ public let signIn: SignIn?
+
+ /// The current sign up attempt, or nil if there is none.
+ public let signUp: SignUp?
+
+ /// A list of sessions that have been created on this client.
+ public let sessions: [Session]
+
+ /// A list of active sessions on this client.
+ public var activeSessions: [Session] {
+ sessions.filter { $0.status == .active }
+ }
+
+ /// The ID of the last active Session on this client.
+ public let lastActiveSessionId: String?
+
+ /// Timestamp of last update for the client.
+ public let updatedAt: Date
+
+ public init(
+ id: String,
+ signIn: SignIn? = nil,
+ signUp: SignUp? = nil,
+ sessions: [Session],
+ lastActiveSessionId: String? = nil,
+ updatedAt: Date
+ ) {
+ self.id = id
+ self.signIn = signIn
+ self.signUp = signUp
+ self.sessions = sessions
+ self.lastActiveSessionId = lastActiveSessionId
+ self.updatedAt = updatedAt
+ }
}
extension Client {
- /// Retrieves the current client.
- @discardableResult @MainActor
- public static func get() async throws -> Client? {
- try await Container.shared.clientService().get()
- }
+ /// Retrieves the current client.
+ @discardableResult @MainActor
+ public static func get() async throws -> Client? {
+ try await Container.shared.clientService().get()
+ }
}
extension Client {
- static var mock: Client {
- return Client(
- id: "sess_1",
- signIn: .mock,
- signUp: .mock,
- sessions: [.mock],
- lastActiveSessionId: "1",
- updatedAt: Date(timeIntervalSinceReferenceDate: 1234567890)
- )
- }
+ static var mock: Client {
+ return Client(
+ id: "1",
+ signIn: .mock,
+ signUp: .mock,
+ sessions: [.mock, .mock2],
+ lastActiveSessionId: "1",
+ updatedAt: Date(timeIntervalSinceReferenceDate: 1234567890)
+ )
+ }
+
+ static var mockSignedOut: Client {
+ return Client(
+ id: "2",
+ signIn: .mock,
+ signUp: .mock,
+ sessions: [],
+ lastActiveSessionId: nil,
+ updatedAt: Date(timeIntervalSinceReferenceDate: 1234567890)
+ )
+ }
}
diff --git a/Sources/Clerk/Models/Client/ClientResponse.swift b/Sources/Clerk/Models/Client/ClientResponse.swift
index e8512c437..eec57a683 100644
--- a/Sources/Clerk/Models/Client/ClientResponse.swift
+++ b/Sources/Clerk/Models/Client/ClientResponse.swift
@@ -15,6 +15,6 @@ import Foundation
/// func post(_ params: SignUp.CreateParams) -> Request>
/// ```
struct ClientResponse: Codable, Sendable {
- let response: Response
- let client: Client?
+ let response: Response
+ let client: Client?
}
diff --git a/Sources/Clerk/Models/Client/ClientService.swift b/Sources/Clerk/Models/Client/ClientService.swift
index 7bfc17e1f..7846de4d7 100644
--- a/Sources/Clerk/Models/Client/ClientService.swift
+++ b/Sources/Clerk/Models/Client/ClientService.swift
@@ -2,33 +2,29 @@
// ClientService.swift
// Clerk
//
-// Created by Mike Pitre on 2/26/25.
+// Created by Mike Pitre on 7/28/25.
//
-import Factory
+import FactoryKit
import Foundation
-struct ClientService {
- var get: () async throws -> Client?
-}
-
-extension ClientService {
+extension Container {
- static var liveValue: Self {
- .init(
- get: {
- let request = ClerkFAPI.v1.client.get
- return try await Container.shared.apiClient().send(request).value.response
- }
- )
- }
+ var clientService: Factory {
+ self { @MainActor in ClientService() }
+ }
}
-extension Container {
+@MainActor
+struct ClientService {
- var clientService: Factory {
- self { .liveValue }
- }
+ var get: () async throws -> Client? = {
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/client")
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
}
diff --git a/Sources/Clerk/Models/DeletedObject.swift b/Sources/Clerk/Models/DeletedObject.swift
index c274f2e84..200f5e831 100644
--- a/Sources/Clerk/Models/DeletedObject.swift
+++ b/Sources/Clerk/Models/DeletedObject.swift
@@ -10,34 +10,34 @@ import Foundation
/// The DeletedObject class represents an item that has been deleted from the database.
public struct DeletedObject: Codable, Sendable {
- /// The object type that has been deleted.
- public let object: String?
-
- /// The ID of the deleted item.
- public let id: String?
-
- /// A boolean checking if the item has been deleted or not.
- public let deleted: Bool?
-
- public init(
- object: String? = nil,
- id: String? = nil,
- deleted: Bool? = nil
- ) {
- self.object = object
- self.id = id
- self.deleted = deleted
- }
+ /// The object type that has been deleted.
+ public let object: String?
+
+ /// The ID of the deleted item.
+ public let id: String?
+
+ /// A boolean checking if the item has been deleted or not.
+ public let deleted: Bool?
+
+ public init(
+ object: String? = nil,
+ id: String? = nil,
+ deleted: Bool? = nil
+ ) {
+ self.object = object
+ self.id = id
+ self.deleted = deleted
+ }
}
extension DeletedObject {
- static var mock: DeletedObject {
- .init(
- object: "object",
- id: "1",
- deleted: true
- )
- }
+ static var mock: DeletedObject {
+ .init(
+ object: "object",
+ id: "1",
+ deleted: true
+ )
+ }
}
diff --git a/Sources/Clerk/Models/EmailAddress/EmailAddress.swift b/Sources/Clerk/Models/EmailAddress/EmailAddress.swift
index 7634a0ab9..e5fb3668b 100644
--- a/Sources/Clerk/Models/EmailAddress/EmailAddress.swift
+++ b/Sources/Clerk/Models/EmailAddress/EmailAddress.swift
@@ -5,7 +5,7 @@
// Created by Mike Pitre on 10/20/23.
//
-import Factory
+import FactoryKit
import Foundation
/// The `EmailAddress` object is a model around an email address.
@@ -22,94 +22,108 @@ import Foundation
/// passing the one-time code as a parameter.
public struct EmailAddress: Codable, Equatable, Hashable, Identifiable, Sendable {
- /// The unique identifier for this email address.
- public let id: String
-
- /// The value of this email address.
- public let emailAddress: String
-
- /// An object holding information on the verification of this email address.
- public let verification: Verification?
-
- /// An array of objects containing information about any identifications
- /// that might be linked to this email address.
- public let linkedTo: [JSON]?
-
- public init(
- id: String,
- emailAddress: String,
- verification: Verification? = nil,
- linkedTo: [JSON]? = nil
- ) {
- self.id = id
- self.emailAddress = emailAddress
- self.verification = verification
- self.linkedTo = linkedTo
- }
+ /// The unique identifier for this email address.
+ public let id: String
+
+ /// The value of this email address.
+ public let emailAddress: String
+
+ /// An object holding information on the verification of this email address.
+ public let verification: Verification?
+
+ /// An array of objects containing information about any identifications
+ /// that might be linked to this email address.
+ public let linkedTo: [JSON]?
+
+ /// The date the email was created.
+ public let createdAt: Date
+
+ public init(
+ id: String,
+ emailAddress: String,
+ verification: Verification? = nil,
+ linkedTo: [JSON]? = nil,
+ createdAt: Date = .now
+ ) {
+ self.id = id
+ self.emailAddress = emailAddress
+ self.verification = verification
+ self.linkedTo = linkedTo
+ self.createdAt = createdAt
+ }
}
extension EmailAddress {
- /// Creates a new email address for the current user.
- /// - Parameters:
- /// - email: The email address to add to the current user.
- @discardableResult @MainActor
- public static func create(_ email: String) async throws -> EmailAddress {
- try await Container.shared.userService().createEmailAddress(email)
- }
-
- /// Prepares the verification process for this email address.
- ///
- /// An email message with a one-time code or an email link will be sent to the email address box.
- ///
- /// - Parameters:
- /// - strategy: The verification strategy to use. See ``EmailAddress/PrepareStrategy`` for available strategies.
- /// - Returns: ``EmailAddress``
- /// - Throws: An error if the verification preparation fails.
- ///
- /// Example usage:
- /// ```swift
- /// let emailAddress = try await emailAddress.prepareVerification(strategy: .emailCode)
- /// ```
- @discardableResult @MainActor
- public func prepareVerification(strategy: PrepareStrategy) async throws -> EmailAddress {
- try await Container.shared.emailAddressService().prepareVerification(id, strategy)
- }
-
- /// Attempts to verify this email address, passing the one-time code that was sent as an email message.
- /// The code will be sent when calling the ``EmailAddress/prepareVerification(strategy:)`` method.
- ///
- /// - Parameters:
- /// - strategy: The verification strategy to use. See ``EmailAddress/AttemptStrategy`` for available strategies.
- /// - Returns: ``EmailAddress``
- /// - Throws: An error if the verification attempt fails.
- ///
- /// Example usage:
- /// ```swift
- /// let emailAddress = try await emailAddress.attemptVerification(strategy: .emailCode(code: "123456"))
- /// ```
- @discardableResult @MainActor
- public func attemptVerification(strategy: AttemptStrategy) async throws -> EmailAddress {
- try await Container.shared.emailAddressService().attemptVerification(id, strategy)
- }
-
- /// Deletes this email address.
- @discardableResult @MainActor
- public func destroy() async throws -> DeletedObject {
- try await Container.shared.emailAddressService().destroy(id)
- }
+ /// Creates a new email address for the current user.
+ /// - Parameters:
+ /// - email: The email address to add to the current user.
+ @discardableResult @MainActor
+ public static func create(_ email: String) async throws -> EmailAddress {
+ try await Container.shared.emailAddressService().create(email)
+ }
+
+ /// Prepares the verification process for this email address.
+ ///
+ /// An email message with a one-time code or an email link will be sent to the email address box.
+ ///
+ /// - Parameters:
+ /// - strategy: The verification strategy to use. See ``EmailAddress/PrepareStrategy`` for available strategies.
+ /// - Returns: ``EmailAddress``
+ /// - Throws: An error if the verification preparation fails.
+ ///
+ /// Example usage:
+ /// ```swift
+ /// let emailAddress = try await emailAddress.prepareVerification(strategy: .emailCode)
+ /// ```
+ @discardableResult @MainActor
+ public func prepareVerification(strategy: PrepareStrategy) async throws -> EmailAddress {
+ try await Container.shared.emailAddressService().prepareVerification(id, strategy)
+ }
+
+ /// Attempts to verify this email address, passing the one-time code that was sent as an email message.
+ /// The code will be sent when calling the ``EmailAddress/prepareVerification(strategy:)`` method.
+ ///
+ /// - Parameters:
+ /// - strategy: The verification strategy to use. See ``EmailAddress/AttemptStrategy`` for available strategies.
+ /// - Returns: ``EmailAddress``
+ /// - Throws: An error if the verification attempt fails.
+ ///
+ /// Example usage:
+ /// ```swift
+ /// let emailAddress = try await emailAddress.attemptVerification(strategy: .emailCode(code: "123456"))
+ /// ```
+ @discardableResult @MainActor
+ public func attemptVerification(strategy: AttemptStrategy) async throws -> EmailAddress {
+ try await Container.shared.emailAddressService().attemptVerification(id, strategy)
+ }
+
+ /// Deletes this email address.
+ @discardableResult @MainActor
+ public func destroy() async throws -> DeletedObject {
+ try await Container.shared.emailAddressService().destroy(id)
+ }
}
extension EmailAddress {
- static var mock: EmailAddress {
- EmailAddress(
- id: "1",
- emailAddress: "user@email.com",
- verification: .mockEmailCodeVerifiedVerification,
- linkedTo: nil
- )
- }
+ static var mock: EmailAddress {
+ EmailAddress(
+ id: "1",
+ emailAddress: "user@email.com",
+ verification: .mockEmailCodeVerifiedVerification,
+ linkedTo: nil
+ )
+ }
+
+ static var mock2: EmailAddress {
+ EmailAddress(
+ id: "12",
+ emailAddress: "user2@email.com",
+ verification: .mockEmailCodeVerifiedVerification,
+ linkedTo: nil
+ )
+ }
}
diff --git a/Sources/Clerk/Models/EmailAddress/EmailAddressAttemptVerificationParams.swift b/Sources/Clerk/Models/EmailAddress/EmailAddressAttemptVerificationParams.swift
index e26b489f7..13cbd396f 100644
--- a/Sources/Clerk/Models/EmailAddress/EmailAddressAttemptVerificationParams.swift
+++ b/Sources/Clerk/Models/EmailAddress/EmailAddressAttemptVerificationParams.swift
@@ -9,30 +9,30 @@ import SwiftUI
extension EmailAddress {
- /// Represents the strategy for attempting email address verification.
- ///
- /// Use this enum to specify the method of verification when calling the ``EmailAddress/attemptVerification(strategy:)`` function.
- public enum AttemptStrategy: Sendable {
-
- /// The strategy for email verification using a one-time code.
+ /// Represents the strategy for attempting email address verification.
///
- /// - Parameter code: The one-time code that was sent to the user's email address when calling ``EmailAddress/prepareVerification(strategy:)``.
- case emailCode(code: String)
+ /// Use this enum to specify the method of verification when calling the ``EmailAddress/attemptVerification(strategy:)`` function.
+ public enum AttemptStrategy: Sendable {
- /// The request body that will be sent with the verification attempt.
- ///
- /// This computed property returns the appropriate `RequestBody` struct based on the selected strategy.
- internal var requestBody: RequestBody {
- switch self {
- case .emailCode(let code):
- return .init(code: code)
- }
- }
+ /// The strategy for email verification using a one-time code.
+ ///
+ /// - Parameter code: The one-time code that was sent to the user's email address when calling ``EmailAddress/prepareVerification(strategy:)``.
+ case emailCode(code: String)
+
+ /// The request body that will be sent with the verification attempt.
+ ///
+ /// This computed property returns the appropriate `RequestBody` struct based on the selected strategy.
+ internal var requestBody: RequestBody {
+ switch self {
+ case .emailCode(let code):
+ return .init(code: code)
+ }
+ }
- /// A struct that represents the request body for attempting email address verification.
- internal struct RequestBody: Encodable {
- /// The one-time code that the user enters to verify their email address.
- let code: String
+ /// A struct that represents the request body for attempting email address verification.
+ internal struct RequestBody: Encodable {
+ /// The one-time code that the user enters to verify their email address.
+ let code: String
+ }
}
- }
}
diff --git a/Sources/Clerk/Models/EmailAddress/EmailAddressPrepareVerificationParams.swift b/Sources/Clerk/Models/EmailAddress/EmailAddressPrepareVerificationParams.swift
index fc6a787fa..d6c72b82c 100644
--- a/Sources/Clerk/Models/EmailAddress/EmailAddressPrepareVerificationParams.swift
+++ b/Sources/Clerk/Models/EmailAddress/EmailAddressPrepareVerificationParams.swift
@@ -7,28 +7,28 @@
extension EmailAddress {
- /// Represents the strategy for preparing the verification process for an email address.
- ///
- /// Use this enum to specify how the verification email will be sent to the user.
- public enum PrepareStrategy: Sendable {
+ /// Represents the strategy for preparing the verification process for an email address.
+ ///
+ /// Use this enum to specify how the verification email will be sent to the user.
+ public enum PrepareStrategy: Sendable {
- /// User will receive a one-time authentication code via email.
- case emailCode
+ /// User will receive a one-time authentication code via email.
+ case emailCode
- /// Converts the strategy into the required request body for the verification process.
- var requestBody: RequestBody {
- switch self {
- case .emailCode:
- return .init(strategy: "email_code")
- }
- }
+ /// Converts the strategy into the required request body for the verification process.
+ var requestBody: RequestBody {
+ switch self {
+ case .emailCode:
+ return .init(strategy: "email_code")
+ }
+ }
- /// Represents the body of the request used to prepare the email address verification.
- struct RequestBody: Encodable {
+ /// Represents the body of the request used to prepare the email address verification.
+ struct RequestBody: Encodable {
- /// The verification strategy.
- let strategy: String
+ /// The verification strategy.
+ let strategy: String
+ }
}
- }
}
diff --git a/Sources/Clerk/Models/EmailAddress/EmailAddressService.swift b/Sources/Clerk/Models/EmailAddress/EmailAddressService.swift
index 81c3d62e5..f62572e6e 100644
--- a/Sources/Clerk/Models/EmailAddress/EmailAddressService.swift
+++ b/Sources/Clerk/Models/EmailAddress/EmailAddressService.swift
@@ -2,51 +2,64 @@
// EmailAddressService.swift
// Clerk
//
-// Created by Mike Pitre on 2/26/25.
+// Created by Mike Pitre on 7/28/25.
//
-import Factory
+import FactoryKit
import Foundation
-struct EmailAddressService {
- var prepareVerification: @MainActor (_ emailAddressId: String, _ strategy: EmailAddress.PrepareStrategy) async throws -> EmailAddress
- var attemptVerification: @MainActor (_ emailAddressId: String, _ strategy: EmailAddress.AttemptStrategy) async throws -> EmailAddress
- var destroy: @MainActor (_ emailAddressId: String) async throws -> DeletedObject
-}
+extension Container {
-extension EmailAddressService {
-
- static var liveValue: Self {
- .init(
- prepareVerification: { id, strategy in
- let request = ClerkFAPI.v1.me.emailAddresses.id(id).prepareVerification.post(
- queryItems: [.init(name: "_clerk_session_id", value: Clerk.shared.session?.id)],
- body: strategy.requestBody
- )
- return try await Container.shared.apiClient().send(request).value.response
- },
- attemptVerification: { id, strategy in
- let request = ClerkFAPI.v1.me.emailAddresses.id(id).attemptVerification.post(
- queryItems: [.init(name: "_clerk_session_id", value: Clerk.shared.session?.id)],
- body: strategy.requestBody
- )
- return try await Container.shared.apiClient().send(request).value.response
- },
- destroy: { id in
- let request = ClerkFAPI.v1.me.emailAddresses.id(id).delete(
- queryItems: [.init(name: "_clerk_session_id", value: Clerk.shared.session?.id)]
- )
- return try await Container.shared.apiClient().send(request).value.response
- }
- )
- }
+ var emailAddressService: Factory {
+ self { @MainActor in EmailAddressService() }
+ }
}
-extension Container {
+@MainActor
+struct EmailAddressService {
+
+ var create: (_ email: String) async throws -> EmailAddress = { email in
+ try await Container.shared.apiClient().request()
+ .add(path: "v1/me/email_addresses")
+ .addClerkSessionId()
+ .method(.post)
+ .body(formEncode: ["email_address": email])
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
+
+ var prepareVerification: (_ emailAddressId: String, _ strategy: EmailAddress.PrepareStrategy) async throws -> EmailAddress = { emailAddressId, strategy in
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/me/email_addresses/\(emailAddressId)/prepare_verification")
+ .addClerkSessionId()
+ .method(.post)
+ .body(formEncode: strategy.requestBody)
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
+
+ var attemptVerification: (_ emailAddressId: String, _ strategy: EmailAddress.AttemptStrategy) async throws -> EmailAddress = { emailAddressId, strategy in
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/me/email_addresses/\(emailAddressId)/attempt_verification")
+ .addClerkSessionId()
+ .method(.post)
+ .body(formEncode: strategy.requestBody)
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
- var emailAddressService: Factory {
- self { .liveValue }
- }
+ var destroy: (_ emailAddressId: String) async throws -> DeletedObject = { emailAddressId in
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/me/email_addresses/\(emailAddressId)")
+ .addClerkSessionId()
+ .method(.delete)
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
}
diff --git a/Sources/Clerk/Models/EnterpriseAccount.swift b/Sources/Clerk/Models/EnterpriseAccount.swift
index 3051abe17..d7101dd0b 100644
--- a/Sources/Clerk/Models/EnterpriseAccount.swift
+++ b/Sources/Clerk/Models/EnterpriseAccount.swift
@@ -12,145 +12,145 @@ import Foundation
/// `EnterpriseAccount` encapsulates the details of a user's enterprise account.
public struct EnterpriseAccount: Codable, Hashable, Equatable, Sendable {
- // MARK: - Properties
-
- /// The unique identifier for the enterprise account.
- public let id: String
-
- /// The type of object, typically a string identifier indicating the object type.
- public let object: String
-
- /// The authentication protocol used (e.g., SAML, OpenID).
- public let `protocol`: String
-
- /// The name of the provider (e.g., Okta, Google).
- public let provider: String
-
- /// A flag indicating whether the enterprise account is active.
- public let active: Bool
-
- /// The email address associated with the enterprise account.
- public let emailAddress: String
-
- /// The first name of the account holder, if available.
- public let firstName: String?
-
- /// The last name of the account holder, if available.
- public let lastName: String?
-
- /// The unique user identifier assigned by the provider, if available.
- public let providerUserId: String?
-
- /// Public metadata associated with the enterprise account.
- public let publicMetadata: JSON
-
- /// Verification information for the enterprise account, if available.
- public let verification: Verification?
-
- /// Details about the enterprise connection associated with this account.
- public let enterpriseConnection: EnterpriseConnection
-
- public init(
- id: String,
- object: String,
- `protocol`: String,
- provider: String,
- active: Bool,
- emailAddress: String,
- firstName: String? = nil,
- lastName: String? = nil,
- providerUserId: String? = nil,
- publicMetadata: JSON,
- verification: Verification? = nil,
- enterpriseConnection: EnterpriseAccount.EnterpriseConnection
- ) {
- self.id = id
- self.object = object
- self.`protocol` = `protocol`
- self.provider = provider
- self.active = active
- self.emailAddress = emailAddress
- self.firstName = firstName
- self.lastName = lastName
- self.providerUserId = providerUserId
- self.publicMetadata = publicMetadata
- self.verification = verification
- self.enterpriseConnection = enterpriseConnection
- }
-
- /// A model representing the connection details for an enterprise account.
- ///
- /// `EnterpriseConnection` contains the configuration and metadata for the connection
- /// between the enterprise account and the identity provider.
- public struct EnterpriseConnection: Codable, Hashable, Equatable, Sendable {
-
- /// The unique identifier for the enterprise connection.
+ // MARK: - Properties
+
+ /// The unique identifier for the enterprise account.
public let id: String
+ /// The type of object, typically a string identifier indicating the object type.
+ public let object: String
+
/// The authentication protocol used (e.g., SAML, OpenID).
public let `protocol`: String
- /// The name of the provider (e.g., Okta, Google Workspace).
+ /// The name of the provider (e.g., Okta, Google).
public let provider: String
- /// The display name of the enterprise connection.
- public let name: String
-
- /// The public URL of the provider's logo.
- public let logoPublicUrl: String
-
- /// The domain associated with the enterprise connection (e.g., example.com).
- public let domain: String
-
- /// A flag indicating whether the enterprise connection is active.
+ /// A flag indicating whether the enterprise account is active.
public let active: Bool
- /// A flag indicating whether user attributes are synchronized with the provider.
- public let syncUserAttributes: Bool
+ /// The email address associated with the enterprise account.
+ public let emailAddress: String
+
+ /// The first name of the account holder, if available.
+ public let firstName: String?
- /// A flag indicating whether additional user identifications are disabled for this connection.
- public let disableAdditionalIdentifications: Bool
+ /// The last name of the account holder, if available.
+ public let lastName: String?
- /// The date and time when the enterprise connection was created.
- public let createdAt: Date
+ /// The unique user identifier assigned by the provider, if available.
+ public let providerUserId: String?
- /// The date and time when the enterprise connection was last updated.
- public let updatedAt: Date
+ /// Public metadata associated with the enterprise account.
+ public let publicMetadata: JSON
- /// A flag indicating whether subdomains are allowed for the enterprise connection.
- public let allowSubdomains: Bool
+ /// Verification information for the enterprise account, if available.
+ public let verification: Verification?
- /// A flag indicating whether IDP-initiated flows are allowed.
- public let allowIdpInitiated: Bool
+ /// Details about the enterprise connection associated with this account.
+ public let enterpriseConnection: EnterpriseConnection
public init(
- id: String,
- `protocol`: String,
- provider: String,
- name: String,
- logoPublicUrl: String,
- domain: String,
- active: Bool,
- syncUserAttributes: Bool,
- disableAdditionalIdentifications: Bool,
- createdAt: Date,
- updatedAt: Date,
- allowSubdomains: Bool,
- allowIdpInitiated: Bool
+ id: String,
+ object: String,
+ `protocol`: String,
+ provider: String,
+ active: Bool,
+ emailAddress: String,
+ firstName: String? = nil,
+ lastName: String? = nil,
+ providerUserId: String? = nil,
+ publicMetadata: JSON,
+ verification: Verification? = nil,
+ enterpriseConnection: EnterpriseAccount.EnterpriseConnection
) {
- self.id = id
- self.`protocol` = `protocol`
- self.provider = provider
- self.name = name
- self.logoPublicUrl = logoPublicUrl
- self.domain = domain
- self.active = active
- self.syncUserAttributes = syncUserAttributes
- self.disableAdditionalIdentifications = disableAdditionalIdentifications
- self.createdAt = createdAt
- self.updatedAt = updatedAt
- self.allowSubdomains = allowSubdomains
- self.allowIdpInitiated = allowIdpInitiated
+ self.id = id
+ self.object = object
+ self.`protocol` = `protocol`
+ self.provider = provider
+ self.active = active
+ self.emailAddress = emailAddress
+ self.firstName = firstName
+ self.lastName = lastName
+ self.providerUserId = providerUserId
+ self.publicMetadata = publicMetadata
+ self.verification = verification
+ self.enterpriseConnection = enterpriseConnection
+ }
+
+ /// A model representing the connection details for an enterprise account.
+ ///
+ /// `EnterpriseConnection` contains the configuration and metadata for the connection
+ /// between the enterprise account and the identity provider.
+ public struct EnterpriseConnection: Codable, Hashable, Equatable, Sendable {
+
+ /// The unique identifier for the enterprise connection.
+ public let id: String
+
+ /// The authentication protocol used (e.g., SAML, OpenID).
+ public let `protocol`: String
+
+ /// The name of the provider (e.g., Okta, Google Workspace).
+ public let provider: String
+
+ /// The display name of the enterprise connection.
+ public let name: String
+
+ /// The public URL of the provider's logo.
+ public let logoPublicUrl: String
+
+ /// The domain associated with the enterprise connection (e.g., example.com).
+ public let domain: String
+
+ /// A flag indicating whether the enterprise connection is active.
+ public let active: Bool
+
+ /// A flag indicating whether user attributes are synchronized with the provider.
+ public let syncUserAttributes: Bool
+
+ /// A flag indicating whether additional user identifications are disabled for this connection.
+ public let disableAdditionalIdentifications: Bool
+
+ /// The date and time when the enterprise connection was created.
+ public let createdAt: Date
+
+ /// The date and time when the enterprise connection was last updated.
+ public let updatedAt: Date
+
+ /// A flag indicating whether subdomains are allowed for the enterprise connection.
+ public let allowSubdomains: Bool
+
+ /// A flag indicating whether IDP-initiated flows are allowed.
+ public let allowIdpInitiated: Bool
+
+ public init(
+ id: String,
+ `protocol`: String,
+ provider: String,
+ name: String,
+ logoPublicUrl: String,
+ domain: String,
+ active: Bool,
+ syncUserAttributes: Bool,
+ disableAdditionalIdentifications: Bool,
+ createdAt: Date,
+ updatedAt: Date,
+ allowSubdomains: Bool,
+ allowIdpInitiated: Bool
+ ) {
+ self.id = id
+ self.`protocol` = `protocol`
+ self.provider = provider
+ self.name = name
+ self.logoPublicUrl = logoPublicUrl
+ self.domain = domain
+ self.active = active
+ self.syncUserAttributes = syncUserAttributes
+ self.disableAdditionalIdentifications = disableAdditionalIdentifications
+ self.createdAt = createdAt
+ self.updatedAt = updatedAt
+ self.allowSubdomains = allowSubdomains
+ self.allowIdpInitiated = allowIdpInitiated
+ }
}
- }
}
diff --git a/Sources/Clerk/Models/Environment/AuthConfig.swift b/Sources/Clerk/Models/Environment/AuthConfig.swift
index 1cd63b504..965e0ca7f 100644
--- a/Sources/Clerk/Models/Environment/AuthConfig.swift
+++ b/Sources/Clerk/Models/Environment/AuthConfig.swift
@@ -9,8 +9,18 @@ import Foundation
extension Clerk.Environment {
- struct AuthConfig: Codable, Sendable {
- let singleSessionMode: Bool
- }
+ struct AuthConfig: Codable, Sendable, Equatable {
+ let singleSessionMode: Bool
+ }
+
+}
+
+extension Clerk.Environment.AuthConfig {
+
+ static var mock: Self {
+ .init(
+ singleSessionMode: false
+ )
+ }
}
diff --git a/Sources/Clerk/Models/Environment/CommerceSettings.swift b/Sources/Clerk/Models/Environment/CommerceSettings.swift
new file mode 100644
index 000000000..27068970c
--- /dev/null
+++ b/Sources/Clerk/Models/Environment/CommerceSettings.swift
@@ -0,0 +1,32 @@
+//
+// CommerceSettings.swift
+// Clerk
+//
+// Created by Mike Pitre on 5/9/25.
+//
+
+import Foundation
+
+struct CommerceSettings: Codable, Sendable, Equatable {
+ let billing: Billing
+
+ struct Billing: Codable, Sendable, Equatable {
+ let enabled: Bool
+ let hasPaidUserPlans: Bool
+ let hasPaidOrgPlans: Bool
+ }
+}
+
+extension CommerceSettings {
+
+ static var mock: Self {
+ .init(
+ billing: .init(
+ enabled: true,
+ hasPaidUserPlans: true,
+ hasPaidOrgPlans: true
+ )
+ )
+ }
+
+}
diff --git a/Sources/Clerk/Models/Environment/DisplayConfig.swift b/Sources/Clerk/Models/Environment/DisplayConfig.swift
index 2e56af820..216c33e06 100644
--- a/Sources/Clerk/Models/Environment/DisplayConfig.swift
+++ b/Sources/Clerk/Models/Environment/DisplayConfig.swift
@@ -9,25 +9,44 @@ import Foundation
extension Clerk.Environment {
- struct DisplayConfig: Codable, Sendable {
- let instanceEnvironmentType: InstanceEnvironmentType
- let applicationName: String
- let preferredSignInStrategy: PreferredSignInStrategy
- let branded: Bool
- let logoImageUrl: String
- let homeUrl: String
- let privacyPolicyUrl: String?
- let termsUrl: String?
-
- enum PreferredSignInStrategy: String, Codable, CodingKeyRepresentable, Sendable {
- case password
- case otp
- case unknown
-
- init(from decoder: Decoder) throws {
- self = try .init(rawValue: decoder.singleValueContainer().decode(RawValue.self)) ?? .unknown
- }
+ struct DisplayConfig: Codable, Sendable, Equatable {
+ let instanceEnvironmentType: InstanceEnvironmentType
+ let applicationName: String
+ let preferredSignInStrategy: PreferredSignInStrategy
+ let supportEmail: String?
+ let branded: Bool
+ let logoImageUrl: String
+ let homeUrl: String
+ let privacyPolicyUrl: String?
+ let termsUrl: String?
+
+ enum PreferredSignInStrategy: String, Codable, CodingKeyRepresentable, Sendable, Equatable {
+ case password
+ case otp
+ case unknown
+
+ init(from decoder: Decoder) throws {
+ self = try .init(rawValue: decoder.singleValueContainer().decode(RawValue.self)) ?? .unknown
+ }
+ }
+ }
+
+}
+
+extension Clerk.Environment.DisplayConfig {
+
+ static var mock: Self {
+ .init(
+ instanceEnvironmentType: .development,
+ applicationName: "Acme Co",
+ preferredSignInStrategy: .otp,
+ supportEmail: "support@example.com",
+ branded: true,
+ logoImageUrl: "",
+ homeUrl: "",
+ privacyPolicyUrl: "privacy",
+ termsUrl: "terms"
+ )
}
- }
}
diff --git a/Sources/Clerk/Models/Environment/Environment.swift b/Sources/Clerk/Models/Environment/Environment.swift
index 25fbaadb3..e87555ee1 100644
--- a/Sources/Clerk/Models/Environment/Environment.swift
+++ b/Sources/Clerk/Models/Environment/Environment.swift
@@ -5,25 +5,44 @@
// Created by Mike Pitre on 10/5/23.
//
-import Factory
+import FactoryKit
import Foundation
extension Clerk {
- struct Environment: Codable, Sendable {
- var authConfig: AuthConfig?
- var userSettings: UserSettings?
- var displayConfig: DisplayConfig?
- var fraudSettings: FraudSettings?
- }
+ struct Environment: Codable, Sendable, Equatable {
+ var authConfig: AuthConfig?
+ var userSettings: UserSettings?
+ var displayConfig: DisplayConfig?
+ var fraudSettings: FraudSettings?
+ var commerceSettings: CommerceSettings?
+
+ var isEmpty: Bool {
+ authConfig == nil && userSettings == nil && displayConfig == nil && fraudSettings == nil && commerceSettings == nil
+ }
+ }
+
+}
+
+extension Clerk.Environment {
+
+ @MainActor
+ static func get() async throws -> Clerk.Environment {
+ try await Container.shared.environmentService().get()
+ }
}
extension Clerk.Environment {
- @MainActor
- static func get() async throws -> Clerk.Environment {
- return try await Container.shared.environmentService().get()
- }
+ static var mock: Self {
+ .init(
+ authConfig: .mock,
+ userSettings: .mock,
+ displayConfig: .mock,
+ fraudSettings: nil,
+ commerceSettings: .mock
+ )
+ }
}
diff --git a/Sources/Clerk/Models/Environment/EnvironmentService.swift b/Sources/Clerk/Models/Environment/EnvironmentService.swift
index b93e2fff6..fe97abfbf 100644
--- a/Sources/Clerk/Models/Environment/EnvironmentService.swift
+++ b/Sources/Clerk/Models/Environment/EnvironmentService.swift
@@ -2,33 +2,31 @@
// EnvironmentService.swift
// Clerk
//
-// Created by Mike Pitre on 2/26/25.
+// Created by Mike Pitre on 7/28/25.
//
-import Factory
+import FactoryKit
import Foundation
-struct EnvironmentService {
- var get: () async throws -> Clerk.Environment
-}
-
-extension EnvironmentService {
+extension Container {
- static var liveValue: Self {
- .init(
- get: {
- let request = ClerkFAPI.v1.environment.get
- return try await Container.shared.apiClient().send(request).value
- }
- )
- }
+ var environmentService: Factory {
+ self { @MainActor in EnvironmentService() }
+ }
}
-extension Container {
+@MainActor
+struct EnvironmentService {
+
+ var get: () async throws -> Clerk.Environment = {
+ let environment = try await Container.shared.apiClient().request()
+ .add(path: "/v1/environment")
+ .data(type: Clerk.Environment.self)
+ .async()
- var environmentService: Factory {
- self { .liveValue }
- }
+ Clerk.shared.environment = environment
+ return environment
+ }
}
diff --git a/Sources/Clerk/Models/Environment/FraudSettings.swift b/Sources/Clerk/Models/Environment/FraudSettings.swift
index 22ef0e398..65a3e8b19 100644
--- a/Sources/Clerk/Models/Environment/FraudSettings.swift
+++ b/Sources/Clerk/Models/Environment/FraudSettings.swift
@@ -9,24 +9,24 @@ import Foundation
extension Clerk.Environment {
- struct FraudSettings: Codable, Sendable {
+ struct FraudSettings: Codable, Sendable, Equatable {
- let native: Native
+ let native: Native
- struct Native: Codable, Sendable {
+ struct Native: Codable, Sendable, Equatable {
- let deviceAttestationMode: DeviceAttestationMode
+ let deviceAttestationMode: DeviceAttestationMode
- enum DeviceAttestationMode: String, Codable, CodingKeyRepresentable, Sendable {
- case disabled
- case onboarding
- case enforced
- case unknown
+ enum DeviceAttestationMode: String, Codable, CodingKeyRepresentable, Sendable, Equatable {
+ case disabled
+ case onboarding
+ case enforced
+ case unknown
- public init(from decoder: Decoder) throws {
- self = try .init(rawValue: decoder.singleValueContainer().decode(RawValue.self)) ?? .unknown
+ public init(from decoder: Decoder) throws {
+ self = try .init(rawValue: decoder.singleValueContainer().decode(RawValue.self)) ?? .unknown
+ }
+ }
}
- }
}
- }
}
diff --git a/Sources/Clerk/Models/Environment/UserSettings.swift b/Sources/Clerk/Models/Environment/UserSettings.swift
index 816251e54..bf1eeed14 100644
--- a/Sources/Clerk/Models/Environment/UserSettings.swift
+++ b/Sources/Clerk/Models/Environment/UserSettings.swift
@@ -9,51 +9,195 @@ import Foundation
extension Clerk.Environment {
- struct UserSettings: Codable, Equatable, Sendable {
-
- let attributes: [String: AttributesConfig]
- let signUp: SignUp
- let social: [String: SocialConfig]
- let actions: Actions
- let passkeySettings: PasskeySettings?
-
- struct AttributesConfig: Codable, Equatable, Sendable {
- let enabled: Bool
- let required: Bool
- let usedForFirstFactor: Bool
- let firstFactors: [String]?
- let usedForSecondFactor: Bool
- let secondFactors: [String]?
- let verifications: [String]?
- let verifyAtSignUp: Bool
- }
+ struct UserSettings: Codable, Equatable, Sendable {
- struct SignUp: Codable, Equatable, Sendable {
- let customActionRequired: Bool
- let progressive: Bool
- let mode: String
- let legalConsentEnabled: Bool
- }
+ let attributes: [String: AttributesConfig]
+ let signUp: SignUp
+ let social: [String: SocialConfig]
+ let actions: Actions
+ let passkeySettings: PasskeySettings?
- struct SocialConfig: Codable, Equatable, Sendable {
- let enabled: Bool
- let required: Bool
- let authenticatable: Bool
- let strategy: String
- let notSelectable: Bool
- let name: String
- let logoUrl: String?
- }
+ struct AttributesConfig: Codable, Equatable, Sendable {
+ let enabled: Bool
+ let required: Bool
+ let usedForFirstFactor: Bool
+ let firstFactors: [String]?
+ let usedForSecondFactor: Bool
+ let secondFactors: [String]?
+ let verifications: [String]?
+ let verifyAtSignUp: Bool
+ }
+
+ struct SignUp: Codable, Equatable, Sendable {
+ let customActionRequired: Bool
+ let progressive: Bool
+ let mode: String
+ let legalConsentEnabled: Bool
+ }
+
+ struct SocialConfig: Codable, Equatable, Sendable {
+ let enabled: Bool
+ let required: Bool
+ let authenticatable: Bool
+ let strategy: String
+ let notSelectable: Bool
+ let name: String
+ let logoUrl: String?
+ }
- struct Actions: Codable, Equatable, Sendable {
- var deleteSelf: Bool = false
- var createOrganization: Bool = false
+ struct Actions: Codable, Equatable, Sendable {
+ var deleteSelf: Bool = false
+ var createOrganization: Bool = false
+ }
+
+ struct PasskeySettings: Codable, Equatable, Sendable {
+ let allowAutofill: Bool
+ let showSignInButton: Bool
+ }
}
- struct PasskeySettings: Codable, Equatable, Sendable {
- let allowAutofill: Bool
- let showSignInButton: Bool
+}
+
+extension Clerk.Environment.UserSettings {
+
+ static var mock: Self {
+ .init(
+ attributes: [
+ "email_address": .init(
+ enabled: true,
+ required: false,
+ usedForFirstFactor: true,
+ firstFactors: nil,
+ usedForSecondFactor: false,
+ secondFactors: nil,
+ verifications: nil,
+ verifyAtSignUp: true
+ ),
+ "phone_number": .init(
+ enabled: true,
+ required: false,
+ usedForFirstFactor: true,
+ firstFactors: nil,
+ usedForSecondFactor: true,
+ secondFactors: nil,
+ verifications: nil,
+ verifyAtSignUp: true
+ ),
+ "username": .init(
+ enabled: true,
+ required: false,
+ usedForFirstFactor: true,
+ firstFactors: nil,
+ usedForSecondFactor: false,
+ secondFactors: nil,
+ verifications: nil,
+ verifyAtSignUp: false
+ ),
+ "first_name": .init(
+ enabled: true,
+ required: false,
+ usedForFirstFactor: false,
+ firstFactors: nil,
+ usedForSecondFactor: false,
+ secondFactors: nil,
+ verifications: nil,
+ verifyAtSignUp: false
+ ),
+ "last_name": .init(
+ enabled: true,
+ required: false,
+ usedForFirstFactor: false,
+ firstFactors: nil,
+ usedForSecondFactor: false,
+ secondFactors: nil,
+ verifications: nil,
+ verifyAtSignUp: false
+ ),
+ "password": .init(
+ enabled: true,
+ required: false,
+ usedForFirstFactor: true,
+ firstFactors: nil,
+ usedForSecondFactor: false,
+ secondFactors: nil,
+ verifications: nil,
+ verifyAtSignUp: true
+ ),
+ "passkey": .init(
+ enabled: true,
+ required: false,
+ usedForFirstFactor: true,
+ firstFactors: nil,
+ usedForSecondFactor: false,
+ secondFactors: nil,
+ verifications: nil,
+ verifyAtSignUp: true
+ ),
+ "authenticator_app": .init(
+ enabled: true,
+ required: false,
+ usedForFirstFactor: false,
+ firstFactors: nil,
+ usedForSecondFactor: true,
+ secondFactors: nil,
+ verifications: nil,
+ verifyAtSignUp: true
+ ),
+ "backup_code": .init(
+ enabled: true,
+ required: false,
+ usedForFirstFactor: false,
+ firstFactors: nil,
+ usedForSecondFactor: true,
+ secondFactors: nil,
+ verifications: nil,
+ verifyAtSignUp: true
+ )
+ ],
+ signUp: .init(
+ customActionRequired: false,
+ progressive: false,
+ mode: "",
+ legalConsentEnabled: true
+ ),
+ social: [
+ "oauth_google": .init(
+ enabled: true,
+ required: false,
+ authenticatable: true,
+ strategy: "oauth_google",
+ notSelectable: false,
+ name: "Google",
+ logoUrl: ""
+ ),
+ "oauth_apple": .init(
+ enabled: true,
+ required: false,
+ authenticatable: true,
+ strategy: "oauth_apple",
+ notSelectable: false,
+ name: "Apple",
+ logoUrl: ""
+ ),
+ "oauth_slack": .init(
+ enabled: true,
+ required: false,
+ authenticatable: true,
+ strategy: "oauth_slack",
+ notSelectable: false,
+ name: "Slack",
+ logoUrl: ""
+ )
+ ],
+ actions: .init(
+ deleteSelf: true,
+ createOrganization: true
+ ),
+ passkeySettings: .init(
+ allowAutofill: true,
+ showSignInButton: true
+ )
+ )
}
- }
}
diff --git a/Sources/Clerk/Models/ExternalAccount/ExternalAccount.swift b/Sources/Clerk/Models/ExternalAccount/ExternalAccount.swift
index 577f51187..4773e0b08 100644
--- a/Sources/Clerk/Models/ExternalAccount/ExternalAccount.swift
+++ b/Sources/Clerk/Models/ExternalAccount/ExternalAccount.swift
@@ -5,7 +5,7 @@
// Created by Mike Pitre on 10/20/23.
//
-import Factory
+import FactoryKit
import Foundation
///The `ExternalAccount` object is a model around an identification obtained by an external provider (e.g. a social provider such as Google).
@@ -13,133 +13,153 @@ import Foundation
///External account must be verified, so that you can make sure they can be assigned to their rightful owners. The `ExternalAccount` object holds all necessary state around the verification process.
public struct ExternalAccount: Codable, Identifiable, Sendable, Hashable, Equatable {
- /// The unique identifier for this external account.
- public let id: String
-
- /// The identification with which this external account is associated.
- public let identificationId: String
-
- /// The provider name e.g. google
- public let provider: String
-
- /// The unique ID of the user in the provider.
- public let providerUserId: String
-
- /// The provided email address of the user.
- public let emailAddress: String
-
- /// The scopes that the user has granted access to.
- public let approvedScopes: String
-
- /// The user's first name.
- public let firstName: String?
-
- /// The user's last name.
- public let lastName: String?
-
- /// The user's image URL.
- public let imageUrl: String?
-
- /// The user's username.
- public let username: String?
-
- /// Metadata that can be read from the Frontend API and Backend API and can be set only from the Backend API.
- public let publicMetadata: JSON
-
- /// A descriptive label to differentiate multiple external accounts of the same user for the same provider.
- public let label: String?
-
- /// An object holding information on the verification of this external account.
- public let verification: Verification?
-
- public init(
- id: String,
- identificationId: String,
- provider: String,
- providerUserId: String,
- emailAddress: String,
- approvedScopes: String,
- firstName: String? = nil,
- lastName: String? = nil,
- imageUrl: String? = nil,
- username: String? = nil,
- publicMetadata: JSON,
- label: String? = nil,
- verification: Verification? = nil
- ) {
- self.id = id
- self.identificationId = identificationId
- self.provider = provider
- self.providerUserId = providerUserId
- self.emailAddress = emailAddress
- self.approvedScopes = approvedScopes
- self.firstName = firstName
- self.lastName = lastName
- self.imageUrl = imageUrl
- self.username = username
- self.publicMetadata = publicMetadata
- self.label = label
- self.verification = verification
- }
+ /// The unique identifier for this external account.
+ public let id: String
+
+ /// The identification with which this external account is associated.
+ public let identificationId: String
+
+ /// The provider name e.g. google
+ public let provider: String
+
+ /// The unique ID of the user in the provider.
+ public let providerUserId: String
+
+ /// The provided email address of the user.
+ public let emailAddress: String
+
+ /// The scopes that the user has granted access to.
+ public let approvedScopes: String
+
+ /// The user's first name.
+ public let firstName: String?
+
+ /// The user's last name.
+ public let lastName: String?
+
+ /// The user's image URL.
+ public let imageUrl: String?
+
+ /// The user's username.
+ public let username: String?
+
+ /// Metadata that can be read from the Frontend API and Backend API and can be set only from the Backend API.
+ public let publicMetadata: JSON
+
+ /// A descriptive label to differentiate multiple external accounts of the same user for the same provider.
+ public let label: String?
+
+ /// An object holding information on the verification of this external account.
+ public let verification: Verification?
+
+ /// The date when the external account was created.
+ public let createdAt: Date
+
+ public init(
+ id: String,
+ identificationId: String,
+ provider: String,
+ providerUserId: String,
+ emailAddress: String,
+ approvedScopes: String,
+ firstName: String? = nil,
+ lastName: String? = nil,
+ imageUrl: String? = nil,
+ username: String? = nil,
+ publicMetadata: JSON,
+ label: String? = nil,
+ verification: Verification? = nil,
+ createdAt: Date = .now
+ ) {
+ self.id = id
+ self.identificationId = identificationId
+ self.provider = provider
+ self.providerUserId = providerUserId
+ self.emailAddress = emailAddress
+ self.approvedScopes = approvedScopes
+ self.firstName = firstName
+ self.lastName = lastName
+ self.imageUrl = imageUrl
+ self.username = username
+ self.publicMetadata = publicMetadata
+ self.label = label
+ self.verification = verification
+ self.createdAt = createdAt
+ }
}
extension ExternalAccount {
- /// Invokes a re-authorization flow for an existing external account.
- ///
- /// - Parameters:
- /// - prefersEphemeralWebBrowserSession: A Boolean indicating whether to prefer an ephemeral web
- /// browser session (default is `false`). When `true`, the session
- /// does not persist cookies or other data between sessions, ensuring
- /// a private browsing experience.
- @discardableResult @MainActor
- public func reauthorize(prefersEphemeralWebBrowserSession: Bool = false) async throws -> ExternalAccount {
- try await Container.shared.externalAccountService().reauthorize(self, prefersEphemeralWebBrowserSession)
- }
-
- /// Deletes this external account.
- @discardableResult @MainActor
- public func destroy() async throws -> DeletedObject {
- try await Container.shared.externalAccountService().destroy(self)
- }
+ /// Invokes a re-authorization flow for an existing external account.
+ ///
+ /// - Parameters:
+ /// - prefersEphemeralWebBrowserSession: A Boolean indicating whether to prefer an ephemeral web
+ /// browser session (default is `false`). When `true`, the session
+ /// does not persist cookies or other data between sessions, ensuring
+ /// a private browsing experience.
+ @discardableResult @MainActor
+ public func reauthorize(prefersEphemeralWebBrowserSession: Bool = false) async throws -> ExternalAccount {
+ guard
+ let redirectUrl = verification?.externalVerificationRedirectUrl,
+ let url = URL(string: redirectUrl)
+ else {
+ throw ClerkClientError(message: "Redirect URL is missing or invalid. Unable to start external authentication flow.")
+ }
+
+ let authSession = WebAuthentication(url: url, prefersEphemeralWebBrowserSession: prefersEphemeralWebBrowserSession)
+
+ _ = try await authSession.start()
+
+ try await Client.get()
+ guard let externalAccount = Clerk.shared.user?.externalAccounts.first(where: { $0.id == id }) else {
+ throw ClerkClientError(message: "Something went wrong. Please try again.")
+ }
+ return externalAccount
+ }
+
+ /// Deletes this external account.
+ @discardableResult @MainActor
+ public func destroy() async throws -> DeletedObject {
+ try await Container.shared.externalAccountService().destroy(id)
+ }
}
extension ExternalAccount {
- static var mockVerified: ExternalAccount {
- .init(
- id: "1",
- identificationId: "1",
- provider: "oauth_google",
- providerUserId: "1",
- emailAddress: "user@gmail.com",
- approvedScopes: "email openid profile",
- firstName: "First",
- lastName: "Last",
- imageUrl: nil,
- username: "username",
- publicMetadata: "{}",
- label: nil,
- verification: .mockExternalAccountVerifiedVerification
- )
- }
-
- static var mockUnverified: ExternalAccount {
- .init(
- id: "1",
- identificationId: "1",
- provider: "oauth_google",
- providerUserId: "1",
- emailAddress: "user@gmail.com",
- approvedScopes: "email openid profile",
- firstName: "First",
- lastName: "Last",
- imageUrl: nil,
- username: "username",
- publicMetadata: "{}",
- label: nil,
- verification: .mockExternalAccountUnverifiedVerification
- )
- }
+ static var mockVerified: ExternalAccount {
+ .init(
+ id: "1",
+ identificationId: "1",
+ provider: "oauth_google",
+ providerUserId: "1",
+ emailAddress: "user@gmail.com",
+ approvedScopes: "email openid profile",
+ firstName: "First",
+ lastName: "Last",
+ imageUrl: nil,
+ username: "username",
+ publicMetadata: "{}",
+ label: nil,
+ verification: .mockExternalAccountVerifiedVerification
+ )
+ }
+
+ static var mockUnverified: ExternalAccount {
+ .init(
+ id: "1",
+ identificationId: "1",
+ provider: "oauth_google",
+ providerUserId: "1",
+ emailAddress: "user@gmail.com",
+ approvedScopes: "email openid profile",
+ firstName: "First",
+ lastName: "Last",
+ imageUrl: nil,
+ username: "username",
+ publicMetadata: "{}",
+ label: nil,
+ verification: .mockExternalAccountUnverifiedVerification
+ )
+ }
}
diff --git a/Sources/Clerk/Models/ExternalAccount/ExternalAccountService.swift b/Sources/Clerk/Models/ExternalAccount/ExternalAccountService.swift
index b4046ca67..c894dabc7 100644
--- a/Sources/Clerk/Models/ExternalAccount/ExternalAccountService.swift
+++ b/Sources/Clerk/Models/ExternalAccount/ExternalAccountService.swift
@@ -2,54 +2,31 @@
// ExternalAccountService.swift
// Clerk
//
-// Created by Mike Pitre on 2/26/25.
+// Created by Mike Pitre on 7/28/25.
//
-import Factory
+import FactoryKit
import Foundation
-struct ExternalAccountService {
- var reauthorize: (_ externalAccount: ExternalAccount, _ prefersEphemeralWebBrowserSession: Bool) async throws -> ExternalAccount
- var destroy: @MainActor (_ externalAccount: ExternalAccount) async throws -> DeletedObject
-}
-
-extension ExternalAccountService {
-
- static var liveValue: Self {
- .init(
- reauthorize: { externalAccount, prefersEphemeralWebBrowserSession in
- guard
- let redirectUrl = externalAccount.verification?.externalVerificationRedirectUrl,
- let url = URL(string: redirectUrl)
- else {
- throw ClerkClientError(message: "Redirect URL is missing or invalid. Unable to start external authentication flow.")
- }
-
- let authSession = await WebAuthentication(url: url, prefersEphemeralWebBrowserSession: prefersEphemeralWebBrowserSession)
-
- _ = try await authSession.start()
+extension Container {
- try await Client.get()
- guard let externalAccount = await Clerk.shared.user?.externalAccounts.first(where: { $0.id == externalAccount.id }) else {
- throw ClerkClientError(message: "Something went wrong. Please try again.")
- }
- return externalAccount
- },
- destroy: { externalAccount in
- let request = ClerkFAPI.v1.me.externalAccounts.id(externalAccount.id).delete(
- queryItems: [.init(name: "_clerk_session_id", value: Clerk.shared.session?.id)]
- )
- return try await Container.shared.apiClient().send(request).value.response
- }
- )
- }
+ var externalAccountService: Factory {
+ self { @MainActor in ExternalAccountService() }
+ }
}
-extension Container {
+@MainActor
+struct ExternalAccountService {
- var externalAccountService: Factory {
- self { .liveValue }
- }
+ var destroy: (_ externalAccountId: String) async throws -> DeletedObject = { externalAccountId in
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/me/external_accounts/\(externalAccountId)")
+ .method(.delete)
+ .addClerkSessionId()
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
}
diff --git a/Sources/Clerk/Models/Factor.swift b/Sources/Clerk/Models/Factor.swift
index b735add6b..052fc50dd 100644
--- a/Sources/Clerk/Models/Factor.swift
+++ b/Sources/Clerk/Models/Factor.swift
@@ -10,52 +10,81 @@ import Foundation
/// The Factor type represents the factor verification strategy that can be used in the sign-in process.
public struct Factor: Codable, Equatable, Hashable, Sendable {
- /// The strategy of the factor.
- public let strategy: String
-
- /// The ID of the email address that a code or link will be sent to.
- public let emailAddressId: String?
-
- /// The ID of the phone number that a code will be sent to.
- public let phoneNumberId: String?
-
- /// The ID of the Web3 wallet that will be used to sign a message.
- public let web3WalletId: String?
-
- /// The safe identifier of the factor.
- public let safeIdentifier: String?
-
- /// Whether the factor is the primary factor.
- public let primary: Bool?
-
- public init(
- strategy: String,
- emailAddressId: String? = nil,
- phoneNumberId: String? = nil,
- web3WalletId: String? = nil,
- safeIdentifier: String? = nil,
- primary: Bool? = nil
- ) {
- self.strategy = strategy
- self.emailAddressId = emailAddressId
- self.phoneNumberId = phoneNumberId
- self.web3WalletId = web3WalletId
- self.safeIdentifier = safeIdentifier
- self.primary = primary
- }
+ /// The strategy of the factor.
+ public let strategy: String
+
+ /// The ID of the email address that a code or link will be sent to.
+ public let emailAddressId: String?
+
+ /// The ID of the phone number that a code will be sent to.
+ public let phoneNumberId: String?
+
+ /// The ID of the Web3 wallet that will be used to sign a message.
+ public let web3WalletId: String?
+
+ /// The safe identifier of the factor.
+ public let safeIdentifier: String?
+
+ /// Whether the factor is the primary factor.
+ public let primary: Bool?
+
+ public init(
+ strategy: String,
+ emailAddressId: String? = nil,
+ phoneNumberId: String? = nil,
+ web3WalletId: String? = nil,
+ safeIdentifier: String? = nil,
+ primary: Bool? = nil
+ ) {
+ self.strategy = strategy
+ self.emailAddressId = emailAddressId
+ self.phoneNumberId = phoneNumberId
+ self.web3WalletId = web3WalletId
+ self.safeIdentifier = safeIdentifier
+ self.primary = primary
+ }
}
extension Factor {
- static var mock: Factor {
- Factor(
- strategy: "email_code",
- emailAddressId: "1",
- phoneNumberId: "1",
- web3WalletId: nil,
- safeIdentifier: User.mock.emailAddresses.first?.emailAddress,
- primary: true
- )
- }
+ static var mockEmailCode: Factor {
+ Factor(strategy: "email_code")
+ }
+
+ static var mockPhoneCode: Factor {
+ Factor(strategy: "phone_code")
+ }
+
+ static var mockGoogle: Factor {
+ Factor(strategy: "oauth_google")
+ }
+
+ static var mockApple: Factor {
+ Factor(strategy: "oauth_apple")
+ }
+
+ static var mockPassword: Factor {
+ Factor(strategy: "password")
+ }
+
+ static var mockPasskey: Factor {
+ Factor(strategy: "passkey")
+ }
+
+ static var mockResetPasswordEmailCode: Factor {
+ Factor(strategy: "reset_password_email_code")
+ }
+
+ static var mockResetPasswordPhoneCode: Factor {
+ Factor(strategy: "reset_password_phone_code")
+ }
+
+ static var mockTotp: Factor {
+ Factor(strategy: "totp")
+ }
+
+ static var mockBackupCode: Factor {
+ Factor(strategy: "backup_code")
+ }
}
diff --git a/Sources/Clerk/Models/IDTokenProvider.swift b/Sources/Clerk/Models/IDTokenProvider.swift
index 85559cc82..446ddd50b 100644
--- a/Sources/Clerk/Models/IDTokenProvider.swift
+++ b/Sources/Clerk/Models/IDTokenProvider.swift
@@ -12,28 +12,28 @@ import Foundation
/// This enum provides different identity providers that can be used for ID token authentication.
public enum IDTokenProvider: CaseIterable, Codable, Sendable {
- /// The identity provider for Sign in with Apple.
- case apple
+ /// The identity provider for Sign in with Apple.
+ case apple
- /// Returns the corresponding strategy string for the identity provider.
- ///
- /// This property converts the identity provider into a string that can be used for ID token authentication
- /// or passed as a strategy parameter.
- var strategy: String {
- switch self {
- case .apple:
- return "oauth_token_apple"
+ /// Returns the corresponding strategy string for the identity provider.
+ ///
+ /// This property converts the identity provider into a string that can be used for ID token authentication
+ /// or passed as a strategy parameter.
+ var strategy: String {
+ switch self {
+ case .apple:
+ return "oauth_token_apple"
+ }
}
- }
- /// Initializes an `IDTokenProvider` instance from a strategy string.
- ///
- /// - Parameter strategy: The strategy string representing the identity provider.
- init?(strategy: String) {
- if let provider = Self.allCases.first(where: { $0.strategy == strategy }) {
- self = provider
- } else {
- return nil
+ /// Initializes an `IDTokenProvider` instance from a strategy string.
+ ///
+ /// - Parameter strategy: The strategy string representing the identity provider.
+ init?(strategy: String) {
+ if let provider = Self.allCases.first(where: { $0.strategy == strategy }) {
+ self = provider
+ } else {
+ return nil
+ }
}
- }
}
diff --git a/Sources/Clerk/Models/ImageResource.swift b/Sources/Clerk/Models/ImageResource.swift
index b72b51404..56f3927f7 100644
--- a/Sources/Clerk/Models/ImageResource.swift
+++ b/Sources/Clerk/Models/ImageResource.swift
@@ -10,22 +10,22 @@ import Foundation
/// Represents information about an image.
public struct ImageResource: Codable, Sendable {
- /// The unique identifier of the image.
- public let id: String
+ /// The unique identifier of the image.
+ public let id: String
- /// The name of the image.
- public let name: String?
+ /// The name of the image.
+ public let name: String?
- /// The publicly accessible URL for the image.
- public let publicUrl: String?
+ /// The publicly accessible URL for the image.
+ public let publicUrl: String?
- public init(
- id: String,
- name: String? = nil,
- publicUrl: String? = nil
- ) {
- self.id = id
- self.name = name
- self.publicUrl = publicUrl
- }
+ public init(
+ id: String,
+ name: String? = nil,
+ publicUrl: String? = nil
+ ) {
+ self.id = id
+ self.name = name
+ self.publicUrl = publicUrl
+ }
}
diff --git a/Sources/Clerk/Models/InstanceEnvironmentType.swift b/Sources/Clerk/Models/InstanceEnvironmentType.swift
index a2e003595..308d487c1 100644
--- a/Sources/Clerk/Models/InstanceEnvironmentType.swift
+++ b/Sources/Clerk/Models/InstanceEnvironmentType.swift
@@ -11,18 +11,18 @@
/// environment-specific behavior or configurations.
public enum InstanceEnvironmentType: String, Codable, CodingKeyRepresentable, Sendable {
- /// Represents a production environment.
- case production
+ /// Represents a production environment.
+ case production
- /// Represents a development environment.
- case development
+ /// Represents a development environment.
+ case development
- /// Represents an unknown environment.
- ///
- /// Used as a fallback in case of decoding error.
- case unknown
+ /// Represents an unknown environment.
+ ///
+ /// Used as a fallback in case of decoding error.
+ case unknown
- public init(from decoder: Decoder) throws {
- self = try .init(rawValue: decoder.singleValueContainer().decode(RawValue.self)) ?? .unknown
- }
+ public init(from decoder: Decoder) throws {
+ self = try .init(rawValue: decoder.singleValueContainer().decode(RawValue.self)) ?? .unknown
+ }
}
diff --git a/Sources/Clerk/Models/OAuthProvider.swift b/Sources/Clerk/Models/OAuthProvider.swift
index 2dbbed474..2c681aaba 100644
--- a/Sources/Clerk/Models/OAuthProvider.swift
+++ b/Sources/Clerk/Models/OAuthProvider.swift
@@ -10,330 +10,330 @@ import RegexBuilder
/// A type that represents the OAuth provider.
public enum OAuthProvider: CaseIterable, Codable, Sendable, Equatable, Identifiable, Hashable {
- case facebook
- case google
- case hubspot
- case github
- case tiktok
- case gitlab
- case discord
- case twitter
- case twitch
- case linkedin
- case linkedin_oidc
- case dropbox
- case atlassian
- case bitbucket
- case microsoft
- case notion
- case apple
- case line
- case instagram
- case coinbase
- case spotify
- case xero
- case box
- case slack
- case linear
- case huggingFace
- case custom(_ strategy: String)
+ case facebook
+ case google
+ case hubspot
+ case github
+ case tiktok
+ case gitlab
+ case discord
+ case twitter
+ case twitch
+ case linkedin
+ case linkedin_oidc
+ case dropbox
+ case atlassian
+ case bitbucket
+ case microsoft
+ case notion
+ case apple
+ case line
+ case instagram
+ case coinbase
+ case spotify
+ case xero
+ case box
+ case slack
+ case linear
+ case huggingFace
+ case custom(_ strategy: String)
- // **
- // When adding a new case, make sure to add it to the all cases array
- // (.custom SHOULD NOT be included)
- // **
+ // **
+ // When adding a new case, make sure to add it to the all cases array
+ // (.custom SHOULD NOT be included)
+ // **
- static public var allCases: [OAuthProvider] {
- [
- .facebook,
- .google,
- .hubspot,
- .github,
- .tiktok,
- .gitlab,
- .discord,
- .twitter,
- .twitch,
- .linkedin,
- .linkedin_oidc,
- .dropbox,
- .atlassian,
- .bitbucket,
- .microsoft,
- .notion,
- .apple,
- .line,
- .instagram,
- .coinbase,
- .spotify,
- .xero,
- .box,
- .slack,
- .linear,
- .huggingFace,
- ]
- }
+ static public var allCases: [OAuthProvider] {
+ [
+ .facebook,
+ .google,
+ .hubspot,
+ .github,
+ .tiktok,
+ .gitlab,
+ .discord,
+ .twitter,
+ .twitch,
+ .linkedin,
+ .linkedin_oidc,
+ .dropbox,
+ .atlassian,
+ .bitbucket,
+ .microsoft,
+ .notion,
+ .apple,
+ .line,
+ .instagram,
+ .coinbase,
+ .spotify,
+ .xero,
+ .box,
+ .slack,
+ .linear,
+ .huggingFace
+ ]
+ }
- @_documentation(visibility: internal)
- public var id: String { providerData.strategy }
+ @_documentation(visibility: internal)
+ public var id: String { providerData.strategy }
- public init(strategy: String) {
- if let provider = Self.allCases.first(where: { $0.providerData.strategy == strategy }) {
- self = provider
- } else {
- self = .custom(strategy)
+ public init(strategy: String) {
+ if let provider = Self.allCases.first(where: { $0.providerData.strategy == strategy }) {
+ self = provider
+ } else {
+ self = .custom(strategy)
+ }
}
- }
- /// Returns the string value of strategy for the OAuth provider.
- public var strategy: String {
- switch self {
- case .custom(let strategy):
- return strategy
- default:
- return providerData.strategy
+ /// Returns the string value of strategy for the OAuth provider.
+ public var strategy: String {
+ switch self {
+ case .custom(let strategy):
+ return strategy
+ default:
+ return providerData.strategy
+ }
}
- }
- /// Returns the name for a built in OAuth provider.
- @MainActor
- public var name: String {
- switch self {
- case .custom(let strategy):
- if let socialConfig = Clerk.shared.environment.userSettings?.social.first(where: { socialConfig in
- socialConfig.value.strategy == strategy
- }) {
- return socialConfig.value.name
- }
+ /// Returns the name for a built in OAuth provider.
+ @MainActor
+ public var name: String {
+ switch self {
+ case .custom(let strategy):
+ if let socialConfig = Clerk.shared.environment.userSettings?.social.first(where: { socialConfig in
+ socialConfig.value.strategy == strategy
+ }) {
+ return socialConfig.value.name
+ }
- fallthrough
- default:
- return providerData.name
+ fallthrough
+ default:
+ return providerData.name
+ }
}
- }
- /// The url to an the icon for the provider.
- ///
- /// - Parameters:
- /// - darkMode: Will return the dark mode variant of the image. Does not apply to custom providers.
- @MainActor
- public func iconImageUrl(darkMode: Bool = false) -> URL? {
- switch self {
- case .custom(let strategy):
- if let socialConfig = Clerk.shared.environment.userSettings?.social.first(where: { socialConfig in
- socialConfig.value.strategy == strategy && socialConfig.value.logoUrl?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
- }) {
- return URL(string: socialConfig.value.logoUrl ?? "")
- }
+ /// The url to an the icon for the provider.
+ ///
+ /// - Parameters:
+ /// - darkMode: Will return the dark mode variant of the image. Does not apply to custom providers.
+ @MainActor
+ public func iconImageUrl(darkMode: Bool = false) -> URL? {
+ switch self {
+ case .custom(let strategy):
+ if let socialConfig = Clerk.shared.environment.userSettings?.social.first(where: { socialConfig in
+ socialConfig.value.strategy == strategy && socialConfig.value.logoUrl?.isEmptyTrimmed == false
+ }) {
+ return URL(string: socialConfig.value.logoUrl ?? "")
+ }
- return nil
+ return nil
- default:
+ default:
- if let socialConfig = Clerk.shared.environment.userSettings?.social.first(where: { socialConfig in
- socialConfig.value.strategy == strategy && socialConfig.value.logoUrl?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
- }) {
- if var logoUrl = socialConfig.value.logoUrl {
- if darkMode {
- logoUrl = logoUrl.replacingOccurrences(of: ".png", with: "-dark.png")
- }
+ if let socialConfig = Clerk.shared.environment.userSettings?.social.first(where: { socialConfig in
+ socialConfig.value.strategy == strategy && socialConfig.value.logoUrl?.isEmptyTrimmed == false
+ }) {
+ if var logoUrl = socialConfig.value.logoUrl {
+ if darkMode {
+ logoUrl = logoUrl.replacingOccurrences(of: ".png", with: "-dark.png")
+ }
- return URL(string: logoUrl)
- }
- }
+ return URL(string: logoUrl)
+ }
+ }
- return nil
+ return nil
+ }
}
- }
- private struct OAuthProviderData {
- public var provider: String
- public let strategy: String
- public let name: String
- }
+ private struct OAuthProviderData {
+ public var provider: String
+ public let strategy: String
+ public let name: String
+ }
- private var providerData: OAuthProviderData {
- switch self {
- case .custom(let strategy):
- return .init(
- provider: "",
- strategy: strategy,
- name: ""
- )
- case .facebook:
- return .init(
- provider: "facebook",
- strategy: "oauth_facebook",
- name: "Facebook"
- )
- case .google:
- return .init(
- provider: "google",
- strategy: "oauth_google",
- name: "Google"
- )
- case .hubspot:
- return .init(
- provider: "hubspot",
- strategy: "oauth_hubspot",
- name: "HubSpot"
- )
- case .github:
- return .init(
- provider: "github",
- strategy: "oauth_github",
- name: "GitHub"
- )
- case .tiktok:
- return .init(
- provider: "tiktok",
- strategy: "oauth_tiktok",
- name: "TikTok"
- )
- case .gitlab:
- return .init(
- provider: "gitlab",
- strategy: "oauth_gitlab",
- name: "GitLab"
- )
- case .discord:
- return .init(
- provider: "discord",
- strategy: "oauth_discord",
- name: "Discord"
- )
- case .twitter:
- return .init(
- provider: "twitter",
- strategy: "oauth_twitter",
- name: "Twitter"
- )
- case .twitch:
- return .init(
- provider: "twitch",
- strategy: "oauth_twitch",
- name: "Twitch"
- )
- case .linkedin:
- return .init(
- provider: "linkedin",
- strategy: "oauth_linkedin",
- name: "LinkedIn"
- )
- case .linkedin_oidc:
- return .init(
- provider: "linkedin_oidc",
- strategy: "oauth_linkedin_oidc",
- name: "LinkedIn"
- )
- case .dropbox:
- return .init(
- provider: "dropbox",
- strategy: "oauth_dropbox",
- name: "Dropbox"
- )
- case .atlassian:
- return .init(
- provider: "atlassian",
- strategy: "oauth_atlassian",
- name: "Atlassian"
- )
- case .bitbucket:
- return .init(
- provider: "bitbucket",
- strategy: "oauth_bitbucket",
- name: "Bitbucket"
- )
- case .microsoft:
- return .init(
- provider: "microsoft",
- strategy: "oauth_microsoft",
- name: "Microsoft"
- )
- case .notion:
- return .init(
- provider: "notion",
- strategy: "oauth_notion",
- name: "Notion"
- )
- case .apple:
- return .init(
- provider: "apple",
- strategy: "oauth_apple",
- name: "Apple"
- )
- case .line:
- return .init(
- provider: "line",
- strategy: "oauth_line",
- name: "LINE"
- )
- case .instagram:
- return .init(
- provider: "instagram",
- strategy: "oauth_instagram",
- name: "Instagram"
- )
- case .coinbase:
- return .init(
- provider: "coinbase",
- strategy: "oauth_coinbase",
- name: "Coinbase"
- )
- case .spotify:
- return .init(
- provider: "spotify",
- strategy: "oauth_spotify",
- name: "Spotify"
- )
- case .xero:
- return .init(
- provider: "xero",
- strategy: "oauth_xero",
- name: "Xero"
- )
- case .box:
- return .init(
- provider: "box",
- strategy: "oauth_box",
- name: "Box"
- )
- case .slack:
- return .init(
- provider: "slack",
- strategy: "oauth_slack",
- name: "Slack"
- )
- case .linear:
- return .init(
- provider: "linear",
- strategy: "oauth_linear",
- name: "Linear"
- )
- case .huggingFace:
- return .init(
- provider: "huggingface",
- strategy: "oauth_huggingface",
- name: "Hugging Face"
- )
+ private var providerData: OAuthProviderData {
+ switch self {
+ case .custom(let strategy):
+ return .init(
+ provider: "",
+ strategy: strategy,
+ name: ""
+ )
+ case .facebook:
+ return .init(
+ provider: "facebook",
+ strategy: "oauth_facebook",
+ name: "Facebook"
+ )
+ case .google:
+ return .init(
+ provider: "google",
+ strategy: "oauth_google",
+ name: "Google"
+ )
+ case .hubspot:
+ return .init(
+ provider: "hubspot",
+ strategy: "oauth_hubspot",
+ name: "HubSpot"
+ )
+ case .github:
+ return .init(
+ provider: "github",
+ strategy: "oauth_github",
+ name: "GitHub"
+ )
+ case .tiktok:
+ return .init(
+ provider: "tiktok",
+ strategy: "oauth_tiktok",
+ name: "TikTok"
+ )
+ case .gitlab:
+ return .init(
+ provider: "gitlab",
+ strategy: "oauth_gitlab",
+ name: "GitLab"
+ )
+ case .discord:
+ return .init(
+ provider: "discord",
+ strategy: "oauth_discord",
+ name: "Discord"
+ )
+ case .twitter:
+ return .init(
+ provider: "twitter",
+ strategy: "oauth_twitter",
+ name: "Twitter"
+ )
+ case .twitch:
+ return .init(
+ provider: "twitch",
+ strategy: "oauth_twitch",
+ name: "Twitch"
+ )
+ case .linkedin:
+ return .init(
+ provider: "linkedin",
+ strategy: "oauth_linkedin",
+ name: "LinkedIn"
+ )
+ case .linkedin_oidc:
+ return .init(
+ provider: "linkedin_oidc",
+ strategy: "oauth_linkedin_oidc",
+ name: "LinkedIn"
+ )
+ case .dropbox:
+ return .init(
+ provider: "dropbox",
+ strategy: "oauth_dropbox",
+ name: "Dropbox"
+ )
+ case .atlassian:
+ return .init(
+ provider: "atlassian",
+ strategy: "oauth_atlassian",
+ name: "Atlassian"
+ )
+ case .bitbucket:
+ return .init(
+ provider: "bitbucket",
+ strategy: "oauth_bitbucket",
+ name: "Bitbucket"
+ )
+ case .microsoft:
+ return .init(
+ provider: "microsoft",
+ strategy: "oauth_microsoft",
+ name: "Microsoft"
+ )
+ case .notion:
+ return .init(
+ provider: "notion",
+ strategy: "oauth_notion",
+ name: "Notion"
+ )
+ case .apple:
+ return .init(
+ provider: "apple",
+ strategy: "oauth_apple",
+ name: "Apple"
+ )
+ case .line:
+ return .init(
+ provider: "line",
+ strategy: "oauth_line",
+ name: "LINE"
+ )
+ case .instagram:
+ return .init(
+ provider: "instagram",
+ strategy: "oauth_instagram",
+ name: "Instagram"
+ )
+ case .coinbase:
+ return .init(
+ provider: "coinbase",
+ strategy: "oauth_coinbase",
+ name: "Coinbase"
+ )
+ case .spotify:
+ return .init(
+ provider: "spotify",
+ strategy: "oauth_spotify",
+ name: "Spotify"
+ )
+ case .xero:
+ return .init(
+ provider: "xero",
+ strategy: "oauth_xero",
+ name: "Xero"
+ )
+ case .box:
+ return .init(
+ provider: "box",
+ strategy: "oauth_box",
+ name: "Box"
+ )
+ case .slack:
+ return .init(
+ provider: "slack",
+ strategy: "oauth_slack",
+ name: "Slack"
+ )
+ case .linear:
+ return .init(
+ provider: "linear",
+ strategy: "oauth_linear",
+ name: "Linear"
+ )
+ case .huggingFace:
+ return .init(
+ provider: "huggingface",
+ strategy: "oauth_huggingface",
+ name: "Hugging Face"
+ )
+ }
}
- }
}
extension OAuthProvider: Comparable {
- public static func < (lhs: Self, rhs: Self) -> Bool {
- let lhsName = lhs.providerData.name
- let rhsName = rhs.providerData.name
+ public static func < (lhs: Self, rhs: Self) -> Bool {
+ let lhsName = lhs.providerData.name
+ let rhsName = rhs.providerData.name
- if lhsName.isEmpty && rhsName.isEmpty {
- return false
- } else if lhsName.isEmpty {
- return false
- } else if rhsName.isEmpty {
- return true
- }
+ if lhsName.isEmpty && rhsName.isEmpty {
+ return false
+ } else if lhsName.isEmpty {
+ return false
+ } else if rhsName.isEmpty {
+ return true
+ }
- return lhsName < rhsName
- }
+ return lhsName < rhsName
+ }
}
diff --git a/Sources/Clerk/Models/Organizations/Organization.swift b/Sources/Clerk/Models/Organizations/Organization.swift
index 7196edb0e..2651d60b0 100644
--- a/Sources/Clerk/Models/Organizations/Organization.swift
+++ b/Sources/Clerk/Models/Organizations/Organization.swift
@@ -5,440 +5,325 @@
// Created by Mike Pitre on 2/6/25.
//
-import Factory
+import FactoryKit
import Foundation
-import Get
/// The Organization object holds information about an organization, as well as methods for managing it.
public struct Organization: Codable, Equatable, Sendable, Hashable, Identifiable {
- /// The unique identifier of the related organization.
- public let id: String
-
- /// The name of the related organization.
- public let name: String
-
- /// The organization slug. If supplied, it must be unique for the instance.
- public let slug: String?
-
- /// Holds the organization logo or default logo. Compatible with Clerk's Image Optimization.
- public let imageUrl: String
-
- /// A getter boolean to check if the organization has an uploaded image.
- ///
- /// Returns false if Clerk is displaying an avatar for the organization.
- public let hasImage: Bool
-
- /// The number of members the associated organization contains.
- public let membersCount: Int?
-
- /// The number of pending invitations to users to join the organization.
- public let pendingInvitationsCount: Int?
-
- /// The maximum number of memberships allowed for the organization.
- public let maxAllowedMemberships: Int
-
- /// A getter boolean to check if the admin of the organization can delete it.
- public let adminDeleteEnabled: Bool
-
- /// The date when the organization was created.
- public let createdAt: Date
-
- /// The date when the organization was last updated.
- public let updatedAt: Date
-
- /// Metadata that can be read from the Frontend API and Backend API
- /// and can be set only from the Backend API.
- public let publicMetadata: JSON?
-
- public init(
- id: String,
- name: String,
- slug: String? = nil,
- imageUrl: String,
- hasImage: Bool,
- membersCount: Int? = nil,
- pendingInvitationsCount: Int? = nil,
- maxAllowedMemberships: Int,
- adminDeleteEnabled: Bool,
- createdAt: Date,
- updatedAt: Date,
- publicMetadata: JSON? = nil
- ) {
- self.id = id
- self.name = name
- self.slug = slug
- self.imageUrl = imageUrl
- self.hasImage = hasImage
- self.membersCount = membersCount
- self.pendingInvitationsCount = pendingInvitationsCount
- self.maxAllowedMemberships = maxAllowedMemberships
- self.adminDeleteEnabled = adminDeleteEnabled
- self.createdAt = createdAt
- self.updatedAt = updatedAt
- self.publicMetadata = publicMetadata
- }
+ /// The unique identifier of the related organization.
+ public let id: String
+
+ /// The name of the related organization.
+ public let name: String
+
+ /// The organization slug. If supplied, it must be unique for the instance.
+ public let slug: String?
+
+ /// Holds the organization logo or default logo. Compatible with Clerk's Image Optimization.
+ public let imageUrl: String
+
+ /// A getter boolean to check if the organization has an uploaded image.
+ ///
+ /// Returns false if Clerk is displaying an avatar for the organization.
+ public let hasImage: Bool
+
+ /// The number of members the associated organization contains.
+ public let membersCount: Int?
+
+ /// The number of pending invitations to users to join the organization.
+ public let pendingInvitationsCount: Int?
+
+ /// The maximum number of memberships allowed for the organization.
+ public let maxAllowedMemberships: Int
+
+ /// A getter boolean to check if the admin of the organization can delete it.
+ public let adminDeleteEnabled: Bool
+
+ /// The date when the organization was created.
+ public let createdAt: Date
+
+ /// The date when the organization was last updated.
+ public let updatedAt: Date
+
+ /// Metadata that can be read from the Frontend API and Backend API
+ /// and can be set only from the Backend API.
+ public let publicMetadata: JSON?
+
+ public init(
+ id: String,
+ name: String,
+ slug: String? = nil,
+ imageUrl: String,
+ hasImage: Bool,
+ membersCount: Int? = nil,
+ pendingInvitationsCount: Int? = nil,
+ maxAllowedMemberships: Int,
+ adminDeleteEnabled: Bool,
+ createdAt: Date,
+ updatedAt: Date,
+ publicMetadata: JSON? = nil
+ ) {
+ self.id = id
+ self.name = name
+ self.slug = slug
+ self.imageUrl = imageUrl
+ self.hasImage = hasImage
+ self.membersCount = membersCount
+ self.pendingInvitationsCount = pendingInvitationsCount
+ self.maxAllowedMemberships = maxAllowedMemberships
+ self.adminDeleteEnabled = adminDeleteEnabled
+ self.createdAt = createdAt
+ self.updatedAt = updatedAt
+ self.publicMetadata = publicMetadata
+ }
}
extension Organization {
- /// Updates an organization's attributes. Returns an Organization object.
- ///
- /// - Parameters:
- /// - name: The organization name.
- /// - slug: (Optional) The organization slug.
- @discardableResult @MainActor
- public func update(
- name: String,
- slug: String? = nil
- ) async throws -> Organization {
- let request = Request>(
- path: "/v1/organizations/\(id)",
- method: .patch,
- query: [("_clerk_session_id", Clerk.shared.session?.id)],
- body: [
- "name": name,
- "slug": slug,
- ]
- )
- return try await Container.shared.apiClient().send(request).value.response
- }
-
- /// Deletes the organization. Only administrators can delete an organization.
- ///
- /// Deleting an organization will also delete all memberships and invitations. This is **not reversible**.
- @discardableResult @MainActor
- public func destroy() async throws -> DeletedObject {
- let request = Request>(
- path: "/v1/organizations/\(id)",
- method: .delete,
- query: [("_clerk_session_id", Clerk.shared.session?.id)]
- )
- return try await Container.shared.apiClient().send(request).value.response
- }
-
- /// Sets or replaces an organization's logo.
- ///
- /// The logo must be an image and its size cannot exceed 10MB.
- /// - Returns: ``Organization``
- @discardableResult @MainActor
- public func setLogo(imageData: Data) async throws -> Organization {
- let boundary = UUID().uuidString
- var data = Data()
- data.append("\r\n--\(boundary)\r\n".data(using: .utf8)!)
- data.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(UUID().uuidString)\"\r\n".data(using: .utf8)!)
- data.append("Content-Type: image/jpeg\r\n\r\n".data(using: .utf8)!)
- data.append(imageData)
- data.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
-
- let request = Request>(
- path: "/v1/organizations/\(id)/logo",
- method: .put,
- query: [("_clerk_session_id", Clerk.shared.session?.id)],
- headers: ["Content-Type": "multipart/form-data; boundary=\(boundary)"]
- )
- return try await Container.shared.apiClient().send(request).value.response
- }
-
- /// Returns a ClerkPaginatedResponse of RoleResource objects.
- ///
- /// - Parameters:
- /// - initialPage: A number that can be used to skip the first n-1 pages. For example, if initialPage is set to 10, it is will skip the first 9 pages and will fetch the 10th page.
- /// - pageSize: A number that indicates the maximum number of results that should be returned for a specific page.
- /// - Returns:
- /// A ``ClerkPaginatedResponse`` of ``RoleResource`` objects.
- @MainActor
- public func getRoles(
- initialPage: Int = 0,
- pageSize: Int = 20
- ) async throws -> ClerkPaginatedResponse {
- let request = Request>>(
- path: "/v1/organizations/\(id)/roles",
- query: [
- ("offset", String(initialPage)),
- ("limit", String(pageSize)),
- ("_clerk_session_id", Clerk.shared.session?.id),
- ]
- )
- return try await Container.shared.apiClient().send(request).value.response
- }
-
- /// Retrieves the list of memberships for the currently active organization.
- ///
- /// - Parameters:
- /// - query: Returns members that match the given query. For possible matches, we check for any of the user's identifier, usernames, user ids, first and last names. The query value doesn't need to match the exact value you are looking for, it is capable of partial matches as well.
- /// - role: Filter by roles. This can be one of the predefined roles or a custom role.
- /// - initialPage: A number that can be used to skip the first n-1 pages. For example, if initialPage is set to 10, it is will skip the first 9 pages and will fetch the 10th page.
- /// - pageSize: A number that indicates the maximum number of results that should be returned for a specific page.
- ///
- /// - Returns:
- /// A ``ClerkPaginatedResponse`` of ``OrganizationMembership`` objects.
- @MainActor
- public func getMemberships(
- query: String? = nil,
- role: [String]? = nil,
- initialPage: Int = 0,
- pageSize: Int = 20
- ) async throws -> ClerkPaginatedResponse {
- let roleQueries = role?.map { ("role[]", $0) } ?? []
- let request = Request>>(
- path: "/v1/organizations/\(id)/memberships",
- query: ([
- ("query", query),
- ("offset", String(initialPage)),
- ("limit", String(pageSize)),
- ("paginated", String(true)),
- ("_clerk_session_id", Clerk.shared.session?.id),
- ] + roleQueries)
- .filter { $1 != nil }
- )
- return try await Container.shared.apiClient().send(request).value.response
- }
-
- /// Adds a user as a member to an organization.
- ///
- /// A user can only be added to an organization if they are not already a member of it
- /// and if they already exist in the same instance as the organization.
- ///
- /// Only administrators can add members to an organization.
- ///
- /// - Parameters:
- /// - userId: The ID of the user to be added as a member to the organization.
- /// - role: The role that the user will have in the organization.
- ///
- /// - Returns:
- /// An ``OrganizationMembership`` object.
- @discardableResult @MainActor
- public func addMember(
- userId: String,
- role: String
- ) async throws -> OrganizationMembership {
- let request = Request>(
- path: "/v1/organizations/\(id)/memberships",
- method: .post,
- query: [("_clerk_session_id", Clerk.shared.session?.id)],
- body: [
- "user_id": userId,
- "role": role,
- ]
- )
- return try await Container.shared.apiClient().send(request).value.response
- }
-
- /// Updates a member of an organization.
- ///
- /// Currently, only a user's role can be updated.
- ///
- /// - Parameters:
- /// - userId: The ID of the user to update.
- /// - role: The new role for the member.
- ///
- /// - Returns:
- /// An ``OrganizationMembership`` object.
- @discardableResult @MainActor
- public func updateMember(
- userId: String,
- role: String
- ) async throws -> OrganizationMembership {
- let request = Request>(
- path: "/v1/organizations/\(id)/memberships/\(userId)",
- method: .patch,
- query: [("_clerk_session_id", Clerk.shared.session?.id)],
- body: ["role": role]
- )
- return try await Container.shared.apiClient().send(request).value.response
- }
-
- /// Removes a member from the organization based on the user ID.
- ///
- /// - Parameter userId:
- /// The ID of the user to remove from the organization.
- ///
- /// - Returns:
- /// An ``OrganizationMembership`` object.
- @discardableResult @MainActor
- public func removeMember(userId: String) async throws -> OrganizationMembership {
- let request = Request>(
- path: "/v1/organizations/\(id)/memberships/\(userId)",
- method: .delete,
- query: [("_clerk_session_id", Clerk.shared.session?.id)]
- )
- return try await Container.shared.apiClient().send(request).value.response
- }
-
- /// Retrieves the list of invitations for the currently active organization.
- ///
- /// - Parameters:
- /// - initialPage: A number that can be used to skip the first n-1 pages.
- /// For example, if `initialPage` is set to 10, it will skip the first 9 pages and fetch the 10th page.
- /// - pageSize: A number that indicates the maximum number of results that should be returned for a specific page.
- /// - status: The status an invitation can have.
- ///
- /// - Returns:
- /// A ``ClerkPaginatedResponse`` of ``OrganizationInvitation`` objects.
- @MainActor
- public func getInvitations(
- initialPage: Int = 0,
- pageSize: Int = 20,
- status: String? = nil
- ) async throws -> ClerkPaginatedResponse {
- let request = Request>>(
- path: "/v1/organizations/\(id)/invitations",
- query: [
- ("offset", String(initialPage)),
- ("limit", String(pageSize)),
- ("status", status),
- ("_clerk_session_id", Clerk.shared.session?.id),
- ].filter { $1 != nil }
- )
- return try await Container.shared.apiClient().send(request).value.response
- }
-
- /// Creates and sends an invitation to the target email address to become a member with the specified role.
- ///
- /// - Parameters:
- /// - emailAddress: The email address to invite.
- /// - role: The role of the new member.
- ///
- /// - Returns:
- /// An ``OrganizationInvitation`` object.
- @discardableResult @MainActor
- public func inviteMember(
- emailAddress: String,
- role: String
- ) async throws -> OrganizationInvitation {
- let request = Request>(
- path: "/v1/organizations/\(id)/invitations",
- method: .post,
- query: [("_clerk_session_id", Clerk.shared.session?.id)],
- body: [
- "email_address": emailAddress,
- "role": role,
- ]
- )
- return try await Container.shared.apiClient().send(request).value.response
- }
-
- // /// Creates and sends an invitation to the target email addresses for becoming a member with the role passed in the parameters.
- // ///
- // /// - Parameters:
- // /// - params: ``InviteMembersParams``
- // ///
- // /// - Returns:
- // /// An array of ``OrganizationInvitation`` objects.
- // @discardableResult @MainActor
- // public func inviteMembers(params: InviteMembersParams) async throws -> [OrganizationInvitation] {
- // let request = Request>(
- // path: "/v1/organizations/\(id)/invitations/bulk",
- // method: .post,
- // query: [("_clerk_session_id", Clerk.shared.session?.id)],
- // body: params
- // )
- // return try await Container.shared.apiClient().send(request).value.response
- // }
-
- /// Creates a new domain for the currently active organization.
- ///
- /// - Parameters:
- /// - domainName: The domain name that will be added to the organization.
- /// - Returns: An ``OrganizationDomain`` object.
- @discardableResult @MainActor
- public func createDomain(domainName: String) async throws -> OrganizationDomain {
- let request = Request>(
- path: "/v1/organizations/\(id)/domains",
- method: .post,
- query: [("_clerk_session_id", Clerk.shared.session?.id)],
- body: ["name": domainName]
- )
- return try await Container.shared.apiClient().send(request).value.response
- }
-
- /// Retrieves the list of domains for the currently active organization.
- ///
- /// Returns a `ClerkPaginatedResponse` of `OrganizationDomain` objects.
- ///
- /// - Parameters:
- /// - initialPage: A number that can be used to skip the first n-1 pages.
- /// For example, if `initialPage` is set to 10, it will skip the first 9 pages and fetch the 10th page.
- /// - pageSize: A number that indicates the maximum number of results that should be returned for a specific page.
- /// - enrollmentMode: An enrollment mode will change how new users join an organization.
- /// - Returns: A ``ClerkPaginatedResponse`` of ``OrganizationDomain`` objects.
- @MainActor
- public func getDomains(
- initialPage: Int = 0,
- pageSize: Int = 20,
- enrollmentMode: String? = nil
- ) async throws -> ClerkPaginatedResponse {
- let request = Request>>(
- path: "/v1/organizations/\(id)/domains",
- query: [
- ("offset", String(initialPage)),
- ("limit", String(pageSize)),
- ("enrollment_mode", enrollmentMode),
- ("_clerk_session_id", Clerk.shared.session?.id),
- ].filter { $1 != nil }
- )
- return try await Container.shared.apiClient().send(request).value.response
- }
-
- /// Retrieves a domain for an organization based on the given domain ID.
- ///
- /// - Parameters:
- /// - domainId: The ID of the domain that will be fetched.
- /// - Returns: An ``OrganizationDomain`` object.
- @MainActor
- public func getDomain(domainId: String) async throws -> OrganizationDomain {
- let request = Request>(
- path: "/v1/organizations/\(id)/domains/\(domainId)",
- query: [("_clerk_session_id", Clerk.shared.session?.id)]
- )
- return try await Container.shared.apiClient().send(request).value.response
- }
-
- /// Retrieves the list of membership requests for the currently active organization.
- ///
- /// - Parameters:
- /// - initialPage: A number that can be used to skip the first n-1 pages.
- /// For example, if `initialPage` is set to 10, it will skip the first 9 pages and fetch the 10th page.
- /// - pageSize: A number that indicates the maximum number of results that should be returned for a specific page.
- /// - status: The status of the membership requests that will be included in the response.
- /// - Returns: A ``ClerkPaginatedResponse`` of ``OrganizationMembershipRequest`` objects.
- @MainActor
- public func getMembershipRequests(
- initialPage: Int = 0,
- pageSize: Int = 20,
- status: String? = nil
- ) async throws -> ClerkPaginatedResponse {
- let request = Request>>(
- path: "/v1/organizations/\(id)/membership_requests",
- query: [
- ("offset", String(initialPage)),
- ("limit", String(pageSize)),
- ("status", status),
- ("_clerk_session_id", Clerk.shared.session?.id),
- ].filter { $1 != nil }
- )
- return try await Container.shared.apiClient().send(request).value.response
- }
+ /// Updates an organization's attributes. Returns an Organization object.
+ ///
+ /// - Parameters:
+ /// - name: The organization name.
+ /// - slug: (Optional) The organization slug.
+ @discardableResult @MainActor
+ public func update(
+ name: String,
+ slug: String? = nil
+ ) async throws -> Organization {
+ try await Container.shared.organizationService().updateOrganization(id, name, slug)
+ }
+
+ /// Deletes the organization. Only administrators can delete an organization.
+ ///
+ /// Deleting an organization will also delete all memberships and invitations. This is **not reversible**.
+ @discardableResult @MainActor
+ public func destroy() async throws -> DeletedObject {
+ try await Container.shared.organizationService().destroyOrganization(id)
+ }
+
+ /// Sets or replaces an organization's logo.
+ ///
+ /// The logo must be an image and its size cannot exceed 10MB.
+ /// - Returns: ``Organization``
+ @discardableResult @MainActor
+ public func setLogo(imageData: Data) async throws -> Organization {
+ try await Container.shared.organizationService().setOrganizationLogo(id, imageData)
+ }
+
+ /// Returns a ClerkPaginatedResponse of RoleResource objects.
+ ///
+ /// - Parameters:
+ /// - initialPage: A number that can be used to skip the first n-1 pages. For example, if initialPage is set to 10, it is will skip the first 9 pages and will fetch the 10th page.
+ /// - pageSize: A number that indicates the maximum number of results that should be returned for a specific page.
+ /// - Returns:
+ /// A ``ClerkPaginatedResponse`` of ``RoleResource`` objects.
+ @MainActor
+ public func getRoles(
+ initialPage: Int = 0,
+ pageSize: Int = 20
+ ) async throws -> ClerkPaginatedResponse {
+ try await Container.shared.organizationService().getOrganizationRoles(id, initialPage, pageSize)
+ }
+
+ /// Retrieves the list of memberships for the currently active organization.
+ ///
+ /// - Parameters:
+ /// - query: Returns members that match the given query. For possible matches, we check for any of the user's identifier, usernames, user ids, first and last names. The query value doesn't need to match the exact value you are looking for, it is capable of partial matches as well.
+ /// - role: Filter by roles. This can be one of the predefined roles or a custom role.
+ /// - initialPage: A number that can be used to skip the first n-1 pages. For example, if initialPage is set to 10, it is will skip the first 9 pages and will fetch the 10th page.
+ /// - pageSize: A number that indicates the maximum number of results that should be returned for a specific page.
+ ///
+ /// - Returns:
+ /// A ``ClerkPaginatedResponse`` of ``OrganizationMembership`` objects.
+ @MainActor
+ public func getMemberships(
+ query: String? = nil,
+ role: [String]? = nil,
+ initialPage: Int = 0,
+ pageSize: Int = 20
+ ) async throws -> ClerkPaginatedResponse {
+ try await Container.shared.organizationService().getOrganizationMemberships(id, query, role, initialPage, pageSize)
+ }
+
+ /// Adds a user as a member to an organization.
+ ///
+ /// A user can only be added to an organization if they are not already a member of it
+ /// and if they already exist in the same instance as the organization.
+ ///
+ /// Only administrators can add members to an organization.
+ ///
+ /// - Parameters:
+ /// - userId: The ID of the user to be added as a member to the organization.
+ /// - role: The role that the user will have in the organization.
+ ///
+ /// - Returns:
+ /// An ``OrganizationMembership`` object.
+ @discardableResult @MainActor
+ public func addMember(
+ userId: String,
+ role: String
+ ) async throws -> OrganizationMembership {
+ try await Container.shared.organizationService().addOrganizationMember(id, userId, role)
+ }
+
+ /// Updates a member of an organization.
+ ///
+ /// Currently, only a user's role can be updated.
+ ///
+ /// - Parameters:
+ /// - userId: The ID of the user to update.
+ /// - role: The new role for the member.
+ ///
+ /// - Returns:
+ /// An ``OrganizationMembership`` object.
+ @discardableResult @MainActor
+ public func updateMember(
+ userId: String,
+ role: String
+ ) async throws -> OrganizationMembership {
+ try await Container.shared.organizationService().updateOrganizationMember(id, userId, role)
+ }
+
+ /// Removes a member from the organization based on the user ID.
+ ///
+ /// - Parameter userId:
+ /// The ID of the user to remove from the organization.
+ ///
+ /// - Returns:
+ /// An ``OrganizationMembership`` object.
+ @discardableResult @MainActor
+ public func removeMember(userId: String) async throws -> OrganizationMembership {
+ try await Container.shared.organizationService().removeOrganizationMember(id, userId)
+ }
+
+ /// Retrieves the list of invitations for the currently active organization.
+ ///
+ /// - Parameters:
+ /// - initialPage: A number that can be used to skip the first n-1 pages.
+ /// For example, if `initialPage` is set to 10, it will skip the first 9 pages and fetch the 10th page.
+ /// - pageSize: A number that indicates the maximum number of results that should be returned for a specific page.
+ /// - status: The status an invitation can have.
+ ///
+ /// - Returns:
+ /// A ``ClerkPaginatedResponse`` of ``OrganizationInvitation`` objects.
+ @MainActor
+ public func getInvitations(
+ initialPage: Int = 0,
+ pageSize: Int = 20,
+ status: String? = nil
+ ) async throws -> ClerkPaginatedResponse {
+ try await Container.shared.organizationService().getOrganizationInvitations(id, initialPage, pageSize, status)
+ }
+
+ /// Creates and sends an invitation to the target email address to become a member with the specified role.
+ ///
+ /// - Parameters:
+ /// - emailAddress: The email address to invite.
+ /// - role: The role of the new member.
+ ///
+ /// - Returns:
+ /// An ``OrganizationInvitation`` object.
+ @discardableResult @MainActor
+ public func inviteMember(
+ emailAddress: String,
+ role: String
+ ) async throws -> OrganizationInvitation {
+ try await Container.shared.organizationService().inviteOrganizationMember(id, emailAddress, role)
+ }
+
+ // /// Creates and sends an invitation to the target email addresses for becoming a member with the role passed in the parameters.
+ // ///
+ // /// - Parameters:
+ // /// - params: ``InviteMembersParams``
+ // ///
+ // /// - Returns:
+ // /// An array of ``OrganizationInvitation`` objects.
+ // @discardableResult @MainActor
+ // public func inviteMembers(params: InviteMembersParams) async throws -> [OrganizationInvitation] {
+ // let request = Request>(
+ // path: "/v1/organizations/\(id)/invitations/bulk",
+ // method: .post,
+ // query: [("_clerk_session_id", Clerk.shared.session?.id)],
+ // body: params
+ // )
+ // return try await Container.shared.apiClient().send(request).value.response
+ // }
+
+ /// Creates a new domain for the currently active organization.
+ ///
+ /// - Parameters:
+ /// - domainName: The domain name that will be added to the organization.
+ /// - Returns: An ``OrganizationDomain`` object.
+ @discardableResult @MainActor
+ public func createDomain(domainName: String) async throws -> OrganizationDomain {
+ try await Container.shared.organizationService().createOrganizationDomain(id, domainName)
+ }
+
+ /// Retrieves the list of domains for the currently active organization.
+ ///
+ /// Returns a `ClerkPaginatedResponse` of `OrganizationDomain` objects.
+ ///
+ /// - Parameters:
+ /// - initialPage: A number that can be used to skip the first n-1 pages.
+ /// For example, if `initialPage` is set to 10, it will skip the first 9 pages and fetch the 10th page.
+ /// - pageSize: A number that indicates the maximum number of results that should be returned for a specific page.
+ /// - enrollmentMode: An enrollment mode will change how new users join an organization.
+ /// - Returns: A ``ClerkPaginatedResponse`` of ``OrganizationDomain`` objects.
+ @MainActor
+ public func getDomains(
+ initialPage: Int = 0,
+ pageSize: Int = 20,
+ enrollmentMode: String? = nil
+ ) async throws -> ClerkPaginatedResponse {
+ try await Container.shared.organizationService().getOrganizationDomains(id, initialPage, pageSize, enrollmentMode)
+ }
+
+ /// Retrieves a domain for an organization based on the given domain ID.
+ ///
+ /// - Parameters:
+ /// - domainId: The ID of the domain that will be fetched.
+ /// - Returns: An ``OrganizationDomain`` object.
+ @MainActor
+ public func getDomain(domainId: String) async throws -> OrganizationDomain {
+ try await Container.shared.organizationService().getOrganizationDomain(id, domainId)
+ }
+
+ /// Retrieves the list of membership requests for the currently active organization.
+ ///
+ /// - Parameters:
+ /// - initialPage: A number that can be used to skip the first n-1 pages.
+ /// For example, if `initialPage` is set to 10, it will skip the first 9 pages and fetch the 10th page.
+ /// - pageSize: A number that indicates the maximum number of results that should be returned for a specific page.
+ /// - status: The status of the membership requests that will be included in the response.
+ /// - Returns: A ``ClerkPaginatedResponse`` of ``OrganizationMembershipRequest`` objects.
+ @MainActor
+ public func getMembershipRequests(
+ initialPage: Int = 0,
+ pageSize: Int = 20,
+ status: String? = nil
+ ) async throws -> ClerkPaginatedResponse {
+ try await Container.shared.organizationService().getOrganizationMembershipRequests(id, initialPage, pageSize, status)
+ }
}
extension Organization {
- static var mock: Self {
- .init(
- id: "1",
- name: "Organization Name",
- slug: "org-slug",
- imageUrl: "",
- hasImage: false,
- membersCount: 3,
- pendingInvitationsCount: 1,
- maxAllowedMemberships: 100,
- adminDeleteEnabled: true,
- createdAt: Date.distantPast,
- updatedAt: .now,
- publicMetadata: nil
- )
- }
+ static var mock: Self {
+ .init(
+ id: "1",
+ name: "Organization Name",
+ slug: "org-slug",
+ imageUrl: "",
+ hasImage: false,
+ membersCount: 3,
+ pendingInvitationsCount: 1,
+ maxAllowedMemberships: 100,
+ adminDeleteEnabled: true,
+ createdAt: Date.distantPast,
+ updatedAt: .now,
+ publicMetadata: nil
+ )
+ }
}
diff --git a/Sources/Clerk/Models/Organizations/OrganizationDomain.swift b/Sources/Clerk/Models/Organizations/OrganizationDomain.swift
index b491e19a0..2b71ebd4e 100644
--- a/Sources/Clerk/Models/Organizations/OrganizationDomain.swift
+++ b/Sources/Clerk/Models/Organizations/OrganizationDomain.swift
@@ -5,170 +5,155 @@
// Created by Mike Pitre on 2/11/25.
//
-import Factory
+import FactoryKit
import Foundation
-import Get
/// The model representing an organization domain.
public struct OrganizationDomain: Codable, Identifiable, Hashable, Sendable {
- /// The unique identifier for this organization domain.
- public let id: String
-
- /// The name for this organization domain (e.g. example.com).
- public let name: String
-
- /// The organization ID of the organization this domain is for.
- public let organizationId: String
-
- /// The enrollment mode for new users joining the organization.
- public let enrollmentMode: String
-
- /// The object that describes the status of the verification process of the domain.
- public let verification: Verification
-
- /// The email address that was used to verify this organization domain, or `nil` if not available.
- public let affiliationEmailAddress: String?
-
- /// The number of total pending invitations sent to emails that match the domain name.
- public let totalPendingInvitations: Int
-
- /// The number of total pending suggestions sent to emails that match the domain name.
- public let totalPendingSuggestions: Int
-
- /// The date when the organization domain was created.
- public let createdAt: Date
-
- /// The date when the organization domain was last updated.
- public let updatedAt: Date
-
- public init(
- id: String,
- name: String,
- organizationId: String,
- enrollmentMode: String,
- verification: OrganizationDomain.Verification,
- affiliationEmailAddress: String? = nil,
- totalPendingInvitations: Int,
- totalPendingSuggestions: Int,
- createdAt: Date,
- updatedAt: Date
- ) {
- self.id = id
- self.name = name
- self.organizationId = organizationId
- self.enrollmentMode = enrollmentMode
- self.verification = verification
- self.affiliationEmailAddress = affiliationEmailAddress
- self.totalPendingInvitations = totalPendingInvitations
- self.totalPendingSuggestions = totalPendingSuggestions
- self.createdAt = createdAt
- self.updatedAt = updatedAt
- }
-
- /// The model representing the verification details of an organization domain.
- public struct Verification: Codable, Sendable, Hashable {
-
- /// The status of the verification process.
- public let status: String
-
- /// The strategy used for the verification process.
- public let strategy: String
-
- /// The number of attempts that have occurred to verify the domain.
- ///
- /// This value tracks how many verification attempts have been made for this domain.
- public let attempts: Int
+ /// The unique identifier for this organization domain.
+ public let id: String
- /// The expiration date and time of the verification.
- ///
- /// Once the expiration date has passed, the verification process may need to be restarted.
- public let expireAt: Date?
+ /// The name for this organization domain (e.g. example.com).
+ public let name: String
+
+ /// The organization ID of the organization this domain is for.
+ public let organizationId: String
+
+ /// The enrollment mode for new users joining the organization.
+ public let enrollmentMode: String
+
+ /// The object that describes the status of the verification process of the domain.
+ public let verification: Verification
+
+ /// The email address that was used to verify this organization domain, or `nil` if not available.
+ public let affiliationEmailAddress: String?
+
+ /// The number of total pending invitations sent to emails that match the domain name.
+ public let totalPendingInvitations: Int
+
+ /// The number of total pending suggestions sent to emails that match the domain name.
+ public let totalPendingSuggestions: Int
+
+ /// The date when the organization domain was created.
+ public let createdAt: Date
+
+ /// The date when the organization domain was last updated.
+ public let updatedAt: Date
public init(
- status: String,
- strategy: String,
- attempts: Int,
- expireAt: Date? = nil
+ id: String,
+ name: String,
+ organizationId: String,
+ enrollmentMode: String,
+ verification: OrganizationDomain.Verification,
+ affiliationEmailAddress: String? = nil,
+ totalPendingInvitations: Int,
+ totalPendingSuggestions: Int,
+ createdAt: Date,
+ updatedAt: Date
) {
- self.status = status
- self.strategy = strategy
- self.attempts = attempts
- self.expireAt = expireAt
+ self.id = id
+ self.name = name
+ self.organizationId = organizationId
+ self.enrollmentMode = enrollmentMode
+ self.verification = verification
+ self.affiliationEmailAddress = affiliationEmailAddress
+ self.totalPendingInvitations = totalPendingInvitations
+ self.totalPendingSuggestions = totalPendingSuggestions
+ self.createdAt = createdAt
+ self.updatedAt = updatedAt
+ }
+
+ /// The model representing the verification details of an organization domain.
+ public struct Verification: Codable, Sendable, Hashable {
+
+ /// The status of the verification process.
+ public let status: String
+
+ /// The strategy used for the verification process.
+ public let strategy: String
+
+ /// The number of attempts that have occurred to verify the domain.
+ ///
+ /// This value tracks how many verification attempts have been made for this domain.
+ public let attempts: Int
+
+ /// The expiration date and time of the verification.
+ ///
+ /// Once the expiration date has passed, the verification process may need to be restarted.
+ public let expireAt: Date?
+
+ public init(
+ status: String,
+ strategy: String,
+ attempts: Int,
+ expireAt: Date? = nil
+ ) {
+ self.status = status
+ self.strategy = strategy
+ self.attempts = attempts
+ self.expireAt = expireAt
+ }
}
- }
}
extension OrganizationDomain {
- /// Deletes the organization domain and removes it from the organization.
- @discardableResult @MainActor
- public func delete() async throws -> DeletedObject {
- let request = Request>(
- path: "/v1/organizations/\(organizationId)/domains/\(id)",
- method: .delete
- )
- return try await Container.shared.apiClient().send(request).value.response
- }
-
- /// Begins the verification process of a created organization domain.
- ///
- /// This is a required step to complete the registration of the domain under the organization.
- ///
- /// - Parameter affiliationEmailAddress: An email address affiliated with the domain name (e.g., `user@example.com`).
- /// - Returns: The unverified ``OrganizationDomain`` object.
- /// - Throws: An error if the verification process cannot be initiated.
- @discardableResult @MainActor
- public func prepareAffiliationVerification(affiliationEmailAddress: String) async throws -> OrganizationDomain {
- let request = Request>(
- path: "/v1/organizations/\(organizationId)/domains/\(id)/prepare_affiliation_verification",
- method: .post,
- body: ["affiliation_email_address": affiliationEmailAddress]
- )
- return try await Container.shared.apiClient().send(request).value.response
- }
-
- /// Attempts to complete the domain verification process.
- ///
- /// This is a required step to complete the registration of a domain under an organization, as the administrator should be verified as a person affiliated with that domain.
- ///
- /// Make sure that an ``OrganizationDomain`` object already exists before calling this method by first calling ``prepareAffiliationVerification(affiliationEmailAddress:)``.
- ///
- /// - Parameter code: The one-time code sent to the user as part of this verification step.
- /// - Returns: The verified ``OrganizationDomain`` object.
- /// - Throws: An error if the verification process cannot be completed.
- @discardableResult @MainActor
- public func attemptAffiliationVerification(code: String) async throws -> OrganizationDomain {
- let request = Request>(
- path: "/v1/organizations/\(organizationId)/domains/\(id)/attempt_affiliation_verification",
- method: .post,
- body: ["code": code]
- )
- return try await Container.shared.apiClient().send(request).value.response
- }
+ /// Deletes the organization domain and removes it from the organization.
+ @discardableResult @MainActor
+ public func delete() async throws -> DeletedObject {
+ try await Container.shared.organizationService().deleteOrganizationDomain(organizationId, id)
+ }
+
+ /// Begins the verification process of a created organization domain.
+ ///
+ /// This is a required step to complete the registration of the domain under the organization.
+ ///
+ /// - Parameter affiliationEmailAddress: An email address affiliated with the domain name (e.g., `user@example.com`).
+ /// - Returns: The unverified ``OrganizationDomain`` object.
+ /// - Throws: An error if the verification process cannot be initiated.
+ @discardableResult @MainActor
+ public func prepareAffiliationVerification(affiliationEmailAddress: String) async throws -> OrganizationDomain {
+ try await Container.shared.organizationService().prepareOrganizationDomainAffiliationVerification(organizationId, id, affiliationEmailAddress)
+ }
+
+ /// Attempts to complete the domain verification process.
+ ///
+ /// This is a required step to complete the registration of a domain under an organization, as the administrator should be verified as a person affiliated with that domain.
+ ///
+ /// Make sure that an ``OrganizationDomain`` object already exists before calling this method by first calling ``prepareAffiliationVerification(affiliationEmailAddress:)``.
+ ///
+ /// - Parameter code: The one-time code sent to the user as part of this verification step.
+ /// - Returns: The verified ``OrganizationDomain`` object.
+ /// - Throws: An error if the verification process cannot be completed.
+ @discardableResult @MainActor
+ public func attemptAffiliationVerification(code: String) async throws -> OrganizationDomain {
+ try await Container.shared.organizationService().attemptOrganizationDomainAffiliationVerification(organizationId, id, code)
+ }
}
extension OrganizationDomain {
- static var mock: Self {
- .init(
- id: "1",
- name: "name",
- organizationId: "1",
- enrollmentMode: "enrollment_mode",
- verification: .init(
- status: "status",
- strategy: "strategy",
- attempts: 1,
- expireAt: .distantFuture
- ),
- affiliationEmailAddress: nil,
- totalPendingInvitations: 3,
- totalPendingSuggestions: 3,
- createdAt: .distantPast,
- updatedAt: .now
- )
- }
+ static var mock: Self {
+ .init(
+ id: "1",
+ name: "name",
+ organizationId: "1",
+ enrollmentMode: "enrollment_mode",
+ verification: .init(
+ status: "status",
+ strategy: "strategy",
+ attempts: 1,
+ expireAt: .distantFuture
+ ),
+ affiliationEmailAddress: nil,
+ totalPendingInvitations: 3,
+ totalPendingSuggestions: 3,
+ createdAt: .distantPast,
+ updatedAt: .now
+ )
+ }
}
diff --git a/Sources/Clerk/Models/Organizations/OrganizationInvitation.swift b/Sources/Clerk/Models/Organizations/OrganizationInvitation.swift
index c4cf3c16f..c87defce4 100644
--- a/Sources/Clerk/Models/Organizations/OrganizationInvitation.swift
+++ b/Sources/Clerk/Models/Organizations/OrganizationInvitation.swift
@@ -5,87 +5,82 @@
// Created by Mike Pitre on 2/11/25.
//
-import Factory
+import FactoryKit
import Foundation
-import Get
/// Represents an organization invitation and its associated details.
public struct OrganizationInvitation: Codable, Sendable, Hashable, Identifiable {
- /// The unique identifier for this organization invitation.
- public let id: String
-
- /// The email address the invitation has been sent to.
- public let emailAddress: String
-
- /// The organization ID of the organization this invitation is for.
- public let organizationId: String
-
- /// Metadata that can be read from the Frontend API and Backend API and can be set only from the Backend API.
- public let publicMetadata: JSON
-
- /// The role of the user in the organization.
- ///
- /// Clerk provides the default roles org:admin and org:member. However, you can create custom roles as well.
- public let role: String
-
- /// The status of the invitation.
- public let status: String
-
- /// The date when the invitation was created.
- public let createdAt: Date
-
- /// The date when the invitation was last updated.
- public let updatedAt: Date
-
- public init(
- id: String,
- emailAddress: String,
- organizationId: String,
- publicMetadata: JSON,
- role: String,
- status: String,
- createdAt: Date,
- updatedAt: Date
- ) {
- self.id = id
- self.emailAddress = emailAddress
- self.organizationId = organizationId
- self.publicMetadata = publicMetadata
- self.role = role
- self.status = status
- self.createdAt = createdAt
- self.updatedAt = updatedAt
- }
+ /// The unique identifier for this organization invitation.
+ public let id: String
+
+ /// The email address the invitation has been sent to.
+ public let emailAddress: String
+
+ /// The organization ID of the organization this invitation is for.
+ public let organizationId: String
+
+ /// Metadata that can be read from the Frontend API and Backend API and can be set only from the Backend API.
+ public let publicMetadata: JSON
+
+ /// The role of the user in the organization.
+ ///
+ /// Clerk provides the default roles org:admin and org:member. However, you can create custom roles as well.
+ public let role: String
+
+ /// The status of the invitation.
+ public let status: String
+
+ /// The date when the invitation was created.
+ public let createdAt: Date
+
+ /// The date when the invitation was last updated.
+ public let updatedAt: Date
+
+ public init(
+ id: String,
+ emailAddress: String,
+ organizationId: String,
+ publicMetadata: JSON,
+ role: String,
+ status: String,
+ createdAt: Date,
+ updatedAt: Date
+ ) {
+ self.id = id
+ self.emailAddress = emailAddress
+ self.organizationId = organizationId
+ self.publicMetadata = publicMetadata
+ self.role = role
+ self.status = status
+ self.createdAt = createdAt
+ self.updatedAt = updatedAt
+ }
}
extension OrganizationInvitation {
- /// Revokes the invitation for the email it corresponds to.
- @discardableResult @MainActor
- public func revoke() async throws -> OrganizationInvitation {
- let request = Request>(
- path: "/v1/organizations/\(organizationId)/invitations/\(id)/revoke",
- method: .post
- )
- return try await Container.shared.apiClient().send(request).value.response
- }
+ /// Revokes the invitation for the email it corresponds to.
+ @discardableResult @MainActor
+ public func revoke() async throws -> OrganizationInvitation {
+ try await Container.shared.organizationService().revokeOrganizationInvitation(organizationId, id)
+ }
}
extension OrganizationInvitation {
- static var mock: Self {
- .init(
- id: "1",
- emailAddress: EmailAddress.mock.emailAddress,
- organizationId: "1",
- publicMetadata: "{}",
- role: "org:member",
- status: "pending",
- createdAt: .distantPast,
- updatedAt: .now
- )
- }
+ static var mock: Self {
+ .init(
+ id: "1",
+ emailAddress: EmailAddress.mock.emailAddress,
+ organizationId: "1",
+ publicMetadata: "{}",
+ role: "org:member",
+ status: "pending",
+ createdAt: .distantPast,
+ updatedAt: .now
+ )
+ }
}
diff --git a/Sources/Clerk/Models/Organizations/OrganizationMembership.swift b/Sources/Clerk/Models/Organizations/OrganizationMembership.swift
index 6062fb579..78b1936dc 100644
--- a/Sources/Clerk/Models/Organizations/OrganizationMembership.swift
+++ b/Sources/Clerk/Models/Organizations/OrganizationMembership.swift
@@ -5,131 +5,123 @@
// Created by Mike Pitre on 2/6/25.
//
-import Factory
+import FactoryKit
import Foundation
-import Get
/// The `OrganizationMembership` object is the model around an organization membership entity
/// and describes the relationship between users and organizations.
public struct OrganizationMembership: Codable, Equatable, Sendable, Hashable, Identifiable {
- /// The unique identifier for this organization membership.
- public let id: String
-
- /// Metadata that can be read from the Frontend API and Backend API
- /// and can be set only from the Backend API.
- public let publicMetadata: JSON
-
- /// The role of the current user in the organization.
- public let role: String
-
- /// The permissions associated with the role.
- public let permissions: [String]?
-
- /// Public information about the user that this membership belongs to.
- public let publicUserData: PublicUserData?
-
- /// The `Organization` object the membership belongs to.
- public let organization: Organization
-
- /// The date when the membership was created.
- public let createdAt: Date
-
- /// The date when the membership was last updated.
- public let updatedAt: Date
-
- public init(
- id: String,
- publicMetadata: JSON,
- role: String,
- permissions: [String]?,
- publicUserData: PublicUserData? = nil,
- organization: Organization,
- createdAt: Date,
- updatedAt: Date
- ) {
- self.id = id
- self.publicMetadata = publicMetadata
- self.role = role
- self.permissions = permissions
- self.publicUserData = publicUserData
- self.organization = organization
- self.createdAt = createdAt
- self.updatedAt = updatedAt
- }
+ /// The unique identifier for this organization membership.
+ public let id: String
+
+ /// Metadata that can be read from the Frontend API and Backend API
+ /// and can be set only from the Backend API.
+ public let publicMetadata: JSON
+
+ /// The role of the current user in the organization.
+ public let role: String
+
+ /// The permissions associated with the role.
+ public let permissions: [String]?
+
+ /// Public information about the user that this membership belongs to.
+ public let publicUserData: PublicUserData?
+
+ /// The `Organization` object the membership belongs to.
+ public let organization: Organization
+
+ /// The date when the membership was created.
+ public let createdAt: Date
+
+ /// The date when the membership was last updated.
+ public let updatedAt: Date
+
+ public init(
+ id: String,
+ publicMetadata: JSON,
+ role: String,
+ permissions: [String]?,
+ publicUserData: PublicUserData? = nil,
+ organization: Organization,
+ createdAt: Date,
+ updatedAt: Date
+ ) {
+ self.id = id
+ self.publicMetadata = publicMetadata
+ self.role = role
+ self.permissions = permissions
+ self.publicUserData = publicUserData
+ self.organization = organization
+ self.createdAt = createdAt
+ self.updatedAt = updatedAt
+ }
}
extension OrganizationMembership {
- /// Deletes the membership from the organization it belongs to.
- ///
- /// - Returns: ``OrganizationMembership``
- /// - Throws: An error if the membership deletion fails.
- @discardableResult @MainActor
- public func destroy() async throws -> OrganizationMembership {
- guard let userId = publicUserData?.userId else {
- throw ClerkClientError(message: "Unable to delete membership: missing userId")
+ /// Deletes the membership from the organization it belongs to.
+ ///
+ /// - Returns: ``OrganizationMembership``
+ /// - Throws: An error if the membership deletion fails.
+ @discardableResult @MainActor
+ public func destroy() async throws -> OrganizationMembership {
+ guard let userId = publicUserData?.userId else {
+ throw ClerkClientError(message: "Unable to delete membership: missing userId")
+ }
+
+ return try await Container.shared.organizationService().destroyOrganizationMembership(organization.id, userId)
}
- let request = Request>(
- path: "/v1/organizations/\(organization.id)/memberships/\(userId)",
- method: .delete
- )
- return try await Container.shared.apiClient().send(request).value.response
- }
-
- /// Updates the member's role in the organization.
- ///
- /// - Parameter role: The role to assign to the member.
- /// - Throws: An error if the membership update fails.
- /// - Returns: ``OrganizationMembership``
- @discardableResult @MainActor
- public func update(role: String) async throws -> OrganizationMembership {
- guard let userId = publicUserData?.userId else {
- throw ClerkClientError(message: "Unable to update membership: missing userId")
+
+ /// Updates the member's role in the organization.
+ ///
+ /// - Parameter role: The role to assign to the member.
+ /// - Throws: An error if the membership update fails.
+ /// - Returns: ``OrganizationMembership``
+ @discardableResult @MainActor
+ public func update(role: String) async throws -> OrganizationMembership {
+ guard let userId = publicUserData?.userId else {
+ throw ClerkClientError(message: "Unable to update membership: missing userId")
+ }
+
+ return try await Container.shared.organizationService().updateOrganizationMembership(organization.id, userId, role)
}
- let request = Request>(
- path: "/v1/organizations/\(organization.id)/memberships/\(userId)",
- method: .patch,
- body: ["role": role]
- )
- return try await Container.shared.apiClient().send(request).value.response
- }
}
extension OrganizationMembership {
- static var mockWithUserData: Self {
- .init(
- id: "1",
- publicMetadata: "{}",
- role: "org:role",
- permissions: ["org:sys_memberships:read"],
- publicUserData: .init(
- firstName: "First",
- lastName: "Last",
- imageUrl: "",
- hasImage: false,
- identifier: "identifier",
- userId: "1"
- ),
- organization: .mock,
- createdAt: Date.distantPast,
- updatedAt: .now
- )
- }
-
- static var mockWithoutUserData: Self {
- .init(
- id: "1",
- publicMetadata: "{}",
- role: "org:role",
- permissions: ["org:sys_memberships:read"],
- publicUserData: nil,
- organization: .mock,
- createdAt: Date.distantPast,
- updatedAt: .now
- )
- }
+ static var mockWithUserData: Self {
+ .init(
+ id: "1",
+ publicMetadata: "{}",
+ role: "org:role",
+ permissions: ["org:sys_memberships:read"],
+ publicUserData: .init(
+ firstName: "First",
+ lastName: "Last",
+ imageUrl: "",
+ hasImage: false,
+ identifier: "identifier",
+ userId: "1"
+ ),
+ organization: .mock,
+ createdAt: Date.distantPast,
+ updatedAt: .now
+ )
+ }
+
+ static var mockWithoutUserData: Self {
+ .init(
+ id: "1",
+ publicMetadata: "{}",
+ role: "org:role",
+ permissions: ["org:sys_memberships:read"],
+ publicUserData: nil,
+ organization: .mock,
+ createdAt: Date.distantPast,
+ updatedAt: .now
+ )
+ }
}
diff --git a/Sources/Clerk/Models/Organizations/OrganizationMembershipRequest.swift b/Sources/Clerk/Models/Organizations/OrganizationMembershipRequest.swift
index 1337b9f35..aa01597f4 100644
--- a/Sources/Clerk/Models/Organizations/OrganizationMembershipRequest.swift
+++ b/Sources/Clerk/Models/Organizations/OrganizationMembershipRequest.swift
@@ -5,82 +5,73 @@
// Created by Mike Pitre on 2/11/25.
//
-import Factory
+import FactoryKit
import Foundation
-import Get
/// The model that describes the request of a user to join an organization.
public struct OrganizationMembershipRequest: Codable, Sendable, Hashable, Identifiable {
- /// The unique identifier for this membership request.
- public let id: String
+ /// The unique identifier for this membership request.
+ public let id: String
- /// The organization ID of the organization this request is for.
- public let organizationId: String
+ /// The organization ID of the organization this request is for.
+ public let organizationId: String
- /// The status of the request.
- public let status: String
+ /// The status of the request.
+ public let status: String
- /// Public information about the user that this request belongs to.
- public let publicUserData: PublicUserData?
+ /// Public information about the user that this request belongs to.
+ public let publicUserData: PublicUserData?
- /// The date when the membership request was created.
- public let createdAt: Date
+ /// The date when the membership request was created.
+ public let createdAt: Date
- /// The date when the membership request was last updated.
- public let updatedAt: Date
+ /// The date when the membership request was last updated.
+ public let updatedAt: Date
- public init(
- id: String,
- organizationId: String,
- status: String,
- publicUserData: PublicUserData? = nil,
- createdAt: Date,
- updatedAt: Date
- ) {
- self.id = id
- self.organizationId = organizationId
- self.status = status
- self.publicUserData = publicUserData
- self.createdAt = createdAt
- self.updatedAt = updatedAt
- }
+ public init(
+ id: String,
+ organizationId: String,
+ status: String,
+ publicUserData: PublicUserData? = nil,
+ createdAt: Date,
+ updatedAt: Date
+ ) {
+ self.id = id
+ self.organizationId = organizationId
+ self.status = status
+ self.publicUserData = publicUserData
+ self.createdAt = createdAt
+ self.updatedAt = updatedAt
+ }
}
extension OrganizationMembershipRequest {
- /// Accepts the request of a user to join the organization the request refers to.
- @discardableResult @MainActor
- public func accept() async throws -> OrganizationMembershipRequest {
- let request = Request>(
- path: "/v1/organizations/\(organizationId)/membership_requests/\(id)/accept",
- method: .post
- )
- return try await Container.shared.apiClient().send(request).value.response
- }
+ /// Accepts the request of a user to join the organization the request refers to.
+ @discardableResult @MainActor
+ public func accept() async throws -> OrganizationMembershipRequest {
+ try await Container.shared.organizationService().acceptOrganizationMembershipRequest(organizationId, id)
+ }
- /// Rejects the request of a user to join the organization the request refers to.
- @discardableResult @MainActor
- public func reject() async throws -> OrganizationMembershipRequest {
- let request = Request>(
- path: "/v1/organizations/\(organizationId)/membership_requests/\(id)/reject",
- method: .post
- )
- return try await Container.shared.apiClient().send(request).value.response
- }
+ /// Rejects the request of a user to join the organization the request refers to.
+ @discardableResult @MainActor
+ public func reject() async throws -> OrganizationMembershipRequest {
+ try await Container.shared.organizationService().rejectOrganizationMembershipRequest(organizationId, id)
+ }
}
extension OrganizationMembershipRequest {
- static var mock: Self {
- .init(
- id: "1",
- organizationId: "1",
- status: "pending",
- publicUserData: nil,
- createdAt: .distantPast,
- updatedAt: .now
- )
- }
+ static var mock: Self {
+ .init(
+ id: "1",
+ organizationId: "1",
+ status: "pending",
+ publicUserData: nil,
+ createdAt: .distantPast,
+ updatedAt: .now
+ )
+ }
}
diff --git a/Sources/Clerk/Models/Organizations/OrganizationService.swift b/Sources/Clerk/Models/Organizations/OrganizationService.swift
new file mode 100644
index 000000000..8e7593000
--- /dev/null
+++ b/Sources/Clerk/Models/Organizations/OrganizationService.swift
@@ -0,0 +1,331 @@
+//
+// OrganizationService.swift
+// Clerk
+//
+// Created by Mike Pitre on 7/28/25.
+//
+
+import FactoryKit
+import Foundation
+
+extension Container {
+
+ var organizationService: Factory {
+ self { @MainActor in OrganizationService() }
+ }
+
+}
+
+@MainActor
+struct OrganizationService {
+
+ // MARK: - Organization Methods
+
+ var updateOrganization: (_ organizationId: String, _ name: String, _ slug: String?) async throws -> Organization = { organizationId, name, slug in
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/organizations/\(organizationId)")
+ .method(.patch)
+ .addClerkSessionId()
+ .body(formEncode: [
+ "name": name,
+ "slug": slug
+ ])
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
+
+ var destroyOrganization: (_ organizationId: String) async throws -> DeletedObject = { organizationId in
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/organizations/\(organizationId)")
+ .method(.delete)
+ .addClerkSessionId()
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
+
+ var setOrganizationLogo: (_ organizationId: String, _ imageData: Data) async throws -> Organization = { organizationId, imageData in
+ let boundary = UUID().uuidString
+ var data = Data()
+ data.append("\r\n--\(boundary)\r\n".data(using: .utf8)!)
+ data.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(UUID().uuidString)\"\r\n".data(using: .utf8)!)
+ data.append("Content-Type: image/jpeg\r\n\r\n".data(using: .utf8)!)
+ data.append(imageData)
+ data.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
+
+ return try await Container.shared.apiClient().request()
+ .add(path: "/v1/organizations/\(organizationId)/logo")
+ .body(data: data)
+ .method(.put)
+ .addClerkSessionId()
+ .with {
+ $0.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
+ }
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
+
+ var getOrganizationRoles: (_ organizationId: String, _ initialPage: Int, _ pageSize: Int) async throws -> ClerkPaginatedResponse = { organizationId, initialPage, pageSize in
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/organizations/\(organizationId)/roles")
+ .addClerkSessionId()
+ .add(queryItems: [
+ .init(name: "offset", value: String(initialPage)),
+ .init(name: "limit", value: String(pageSize))
+ ])
+ .data(type: ClientResponse>.self)
+ .async()
+ .response
+ }
+
+ var getOrganizationMemberships: (_ organizationId: String, _ query: String?, _ role: [String]?, _ initialPage: Int, _ pageSize: Int) async throws -> ClerkPaginatedResponse = { organizationId, query, role, initialPage, pageSize in
+ var queryItems = [
+ URLQueryItem(name: "query", value: query),
+ .init(name: "offset", value: String(initialPage)),
+ .init(name: "limit", value: String(pageSize)),
+ .init(name: "paginated", value: String(true))
+ ]
+
+ queryItems += role?.map { URLQueryItem(name: "role[]", value: $0) } ?? []
+
+ return try await Container.shared.apiClient().request()
+ .add(path: "/v1/organizations/\(organizationId)/memberships")
+ .addClerkSessionId()
+ .add(queryItems: queryItems.filter({ $0.value != nil }))
+ .data(type: ClientResponse>.self)
+ .async()
+ .response
+ }
+
+ var addOrganizationMember: (_ organizationId: String, _ userId: String, _ role: String) async throws -> OrganizationMembership = { organizationId, userId, role in
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/organizations/\(organizationId)/memberships")
+ .method(.post)
+ .addClerkSessionId()
+ .body(formEncode: [
+ "user_id": userId,
+ "role": role
+ ])
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
+
+ var updateOrganizationMember: (_ organizationId: String, _ userId: String, _ role: String) async throws -> OrganizationMembership = { organizationId, userId, role in
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/organizations/\(organizationId)/memberships/\(userId)")
+ .method(.patch)
+ .addClerkSessionId()
+ .body(formEncode: [
+ "role": role
+ ])
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
+
+ var removeOrganizationMember: (_ organizationId: String, _ userId: String) async throws -> OrganizationMembership = { organizationId, userId in
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/organizations/\(organizationId)/memberships/\(userId)")
+ .method(.delete)
+ .addClerkSessionId()
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
+
+ var getOrganizationInvitations: (_ organizationId: String, _ initialPage: Int, _ pageSize: Int, _ status: String?) async throws -> ClerkPaginatedResponse = { organizationId, initialPage, pageSize, status in
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/organizations/\(organizationId)/invitations")
+ .addClerkSessionId()
+ .add(
+ queryItems: [
+ .init(name: "offset", value: String(initialPage)),
+ .init(name: "limit", value: String(pageSize)),
+ .init(name: "status", value: status),
+ .init(name: "paginated", value: String(true))
+ ].filter({ $0.value != nil })
+ )
+ .data(type: ClientResponse>.self)
+ .async()
+ .response
+ }
+
+ var inviteOrganizationMember: (_ organizationId: String, _ emailAddress: String, _ role: String) async throws -> OrganizationInvitation = { organizationId, emailAddress, role in
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/organizations/\(organizationId)/invitations")
+ .method(.post)
+ .addClerkSessionId()
+ .body(formEncode: [
+ "email_address": emailAddress,
+ "role": role
+ ])
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
+
+ var createOrganizationDomain: (_ organizationId: String, _ domainName: String) async throws -> OrganizationDomain = { organizationId, domainName in
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/organizations/\(organizationId)/domains")
+ .method(.post)
+ .addClerkSessionId()
+ .body(formEncode: [
+ "name": domainName
+ ])
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
+
+ var getOrganizationDomains: (_ organizationId: String, _ initialPage: Int, _ pageSize: Int, _ enrollmentMode: String?) async throws -> ClerkPaginatedResponse = { organizationId, initialPage, pageSize, enrollmentMode in
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/organizations/\(organizationId)/domains")
+ .addClerkSessionId()
+ .add(
+ queryItems: [
+ .init(name: "offset", value: String(initialPage)),
+ .init(name: "limit", value: String(pageSize)),
+ .init(name: "enrollment_mode", value: enrollmentMode)
+ ].filter({ $0.value != nil })
+ )
+ .data(type: ClientResponse>.self)
+ .async()
+ .response
+ }
+
+ var getOrganizationDomain: (_ organizationId: String, _ domainId: String) async throws -> OrganizationDomain = { organizationId, domainId in
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/organizations/\(organizationId)/domains/\(domainId)")
+ .addClerkSessionId()
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
+
+ var getOrganizationMembershipRequests: (_ organizationId: String, _ initialPage: Int, _ pageSize: Int, _ status: String?) async throws -> ClerkPaginatedResponse = { organizationId, initialPage, pageSize, status in
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/organizations/\(organizationId)/membership_requests")
+ .addClerkSessionId()
+ .add(
+ queryItems: [
+ .init(name: "offset", value: String(initialPage)),
+ .init(name: "limit", value: String(pageSize)),
+ .init(name: "status", value: status)
+ ].filter({ $0.value != nil })
+ )
+ .data(type: ClientResponse>.self)
+ .async()
+ .response
+ }
+
+ // MARK: - Organization Domain Methods
+
+ var deleteOrganizationDomain: (_ organizationId: String, _ domainId: String) async throws -> DeletedObject = { organizationId, domainId in
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/organizations/\(organizationId)/domains/\(domainId)")
+ .method(.delete)
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
+
+ var prepareOrganizationDomainAffiliationVerification: (_ organizationId: String, _ domainId: String, _ affiliationEmailAddress: String) async throws -> OrganizationDomain = { organizationId, domainId, affiliationEmailAddress in
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/organizations/\(organizationId)/domains/\(domainId)/prepare_affiliation_verification")
+ .method(.post)
+ .body(formEncode: ["affiliation_email_address": affiliationEmailAddress])
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
+
+ var attemptOrganizationDomainAffiliationVerification: (_ organizationId: String, _ domainId: String, _ code: String) async throws -> OrganizationDomain = { organizationId, domainId, code in
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/organizations/\(organizationId)/domains/\(domainId)/attempt_affiliation_verification")
+ .method(.post)
+ .body(formEncode: ["code": code])
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
+
+ // MARK: - Organization Invitation Methods
+
+ var revokeOrganizationInvitation: (_ organizationId: String, _ invitationId: String) async throws -> OrganizationInvitation = { organizationId, invitationId in
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/organizations/\(organizationId)/invitations/\(invitationId)/revoke")
+ .method(.post)
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
+
+ // MARK: - Organization Membership Methods
+
+ var destroyOrganizationMembership: (_ organizationId: String, _ userId: String) async throws -> OrganizationMembership = { organizationId, userId in
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/organizations/\(organizationId)/memberships/\(userId)")
+ .method(.delete)
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
+
+ var updateOrganizationMembership: (_ organizationId: String, _ userId: String, _ role: String) async throws -> OrganizationMembership = { organizationId, userId, role in
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/organizations/\(organizationId)/memberships/\(userId)")
+ .method(.patch)
+ .body(formEncode: ["role": role])
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
+
+ // MARK: - User Organization Invitation Methods
+
+ var acceptUserOrganizationInvitation: (_ invitationId: String) async throws -> UserOrganizationInvitation = { invitationId in
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/me/organization_invitations/\(invitationId)/accept")
+ .method(.post)
+ .addClerkSessionId()
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
+
+ // MARK: - Organization Suggestion Methods
+
+ var acceptOrganizationSuggestion: (_ suggestionId: String) async throws -> OrganizationSuggestion = { suggestionId in
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/me/organization_suggestions/\(suggestionId)/accept")
+ .method(.post)
+ .addClerkSessionId()
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
+
+ // MARK: - Organization Membership Request Methods
+
+ var acceptOrganizationMembershipRequest: (_ organizationId: String, _ requestId: String) async throws -> OrganizationMembershipRequest = { organizationId, requestId in
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/organizations/\(organizationId)/membership_requests/\(requestId)/accept")
+ .method(.post)
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
+
+ var rejectOrganizationMembershipRequest: (_ organizationId: String, _ requestId: String) async throws -> OrganizationMembershipRequest = { organizationId, requestId in
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/organizations/\(organizationId)/membership_requests/\(requestId)/reject")
+ .method(.post)
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
+
+}
diff --git a/Sources/Clerk/Models/Organizations/OrganizationSuggestion.swift b/Sources/Clerk/Models/Organizations/OrganizationSuggestion.swift
index 2bf8a348e..5ba62dce8 100644
--- a/Sources/Clerk/Models/Organizations/OrganizationSuggestion.swift
+++ b/Sources/Clerk/Models/Organizations/OrganizationSuggestion.swift
@@ -5,111 +5,103 @@
// Created by Mike Pitre on 3/14/25.
//
-import Factory
+import FactoryKit
import Foundation
-import Get
/// An interface representing an organization suggestion.
public struct OrganizationSuggestion: Codable, Equatable, Sendable, Hashable, Identifiable {
- /// An interface representing an organization suggestion.
- /// The ID of the organization suggestion.
- public let id: String
-
- /// The public data of the organization.
- public let publicOrganizationData: PublicOrganizationData
-
- /// The status of the organization suggestion.
- public let status: String
-
- /// The date and time when the organization suggestion was created.
- public let createdAt: Date
-
- /// The date and time when the organization suggestion was last updated.
- public let updatedAt: Date
-
- public init(
- id: String,
- publicOrganizationData: OrganizationSuggestion.PublicOrganizationData,
- status: String,
- createdAt: Date,
- updatedAt: Date
- ) {
- self.id = id
- self.publicOrganizationData = publicOrganizationData
- self.status = status
- self.createdAt = createdAt
- self.updatedAt = updatedAt
- }
-
- /// The public data of the organization.
- public struct PublicOrganizationData: Codable, Equatable, Sendable, Hashable {
-
- /// Whether the organization has an image.
- public let hasImage: Bool
+ /// An interface representing an organization suggestion.
+ /// The ID of the organization suggestion.
+ public let id: String
- /// Holds the organization logo. Compatible with Clerk's Image Optimization.
- public let imageUrl: String
+ /// The public data of the organization.
+ public let publicOrganizationData: PublicOrganizationData
- /// The name of the organization.
- public let name: String
+ /// The status of the organization suggestion.
+ public let status: String
- /// The ID of the organization.
- public let id: String
+ /// The date and time when the organization suggestion was created.
+ public let createdAt: Date
- /// The slug of the organization.
- public let slug: String?
+ /// The date and time when the organization suggestion was last updated.
+ public let updatedAt: Date
public init(
- hasImage: Bool,
- imageUrl: String,
- name: String,
- id: String,
- slug: String? = nil
+ id: String,
+ publicOrganizationData: OrganizationSuggestion.PublicOrganizationData,
+ status: String,
+ createdAt: Date,
+ updatedAt: Date
) {
- self.hasImage = hasImage
- self.imageUrl = imageUrl
- self.name = name
- self.id = id
- self.slug = slug
+ self.id = id
+ self.publicOrganizationData = publicOrganizationData
+ self.status = status
+ self.createdAt = createdAt
+ self.updatedAt = updatedAt
+ }
+
+ /// The public data of the organization.
+ public struct PublicOrganizationData: Codable, Equatable, Sendable, Hashable {
+
+ /// Whether the organization has an image.
+ public let hasImage: Bool
+
+ /// Holds the organization logo. Compatible with Clerk's Image Optimization.
+ public let imageUrl: String
+
+ /// The name of the organization.
+ public let name: String
+
+ /// The ID of the organization.
+ public let id: String
+
+ /// The slug of the organization.
+ public let slug: String?
+
+ public init(
+ hasImage: Bool,
+ imageUrl: String,
+ name: String,
+ id: String,
+ slug: String? = nil
+ ) {
+ self.hasImage = hasImage
+ self.imageUrl = imageUrl
+ self.name = name
+ self.id = id
+ self.slug = slug
+ }
}
- }
}
extension OrganizationSuggestion {
- /// Accepts the organization suggestion.
- /// - Returns: The accepted ``OrganizationSuggestion``.
- @discardableResult @MainActor
- public func accept() async throws -> OrganizationSuggestion {
- let request = Request>(
- path: "/v1/me/organization_suggestions/\(id)/accept",
- method: .post,
- query: [
- ("_clerk_session_id", Clerk.shared.session?.id)
- ].filter { $1 != nil }
- )
- return try await Container.shared.apiClient().send(request).value.response
- }
+ /// Accepts the organization suggestion.
+ /// - Returns: The accepted ``OrganizationSuggestion``.
+ @discardableResult @MainActor
+ public func accept() async throws -> OrganizationSuggestion {
+ try await Container.shared.organizationService().acceptOrganizationSuggestion(id)
+ }
}
extension OrganizationSuggestion {
- static var mock: Self {
- .init(
- id: "1",
- publicOrganizationData: .init(
- hasImage: false,
- imageUrl: "",
- name: "name",
- id: "1",
- slug: "slug"
- ),
- status: "pending",
- createdAt: .distantPast,
- updatedAt: .now
- )
- }
+ static var mock: Self {
+ .init(
+ id: "1",
+ publicOrganizationData: .init(
+ hasImage: false,
+ imageUrl: "",
+ name: "name",
+ id: "1",
+ slug: "slug"
+ ),
+ status: "pending",
+ createdAt: .distantPast,
+ updatedAt: .now
+ )
+ }
}
diff --git a/Sources/Clerk/Models/Organizations/PermissionResource.swift b/Sources/Clerk/Models/Organizations/PermissionResource.swift
index 46b79486d..6598b985d 100644
--- a/Sources/Clerk/Models/Organizations/PermissionResource.swift
+++ b/Sources/Clerk/Models/Organizations/PermissionResource.swift
@@ -10,58 +10,58 @@ import Foundation
/// An experimental interface that includes information about a user's permission.
public struct PermissionResource: Codable, Identifiable, Sendable, Hashable {
- /// The unique identifier of the permission.
- public let id: String
+ /// The unique identifier of the permission.
+ public let id: String
- /// The unique key of the permission.
- public let key: String
+ /// The unique key of the permission.
+ public let key: String
- /// The name of the permission.
- public let name: String
+ /// The name of the permission.
+ public let name: String
- /// The type of the permission.
- public let type: String
+ /// The type of the permission.
+ public let type: String
- /// A description of the permission.
- public let description: String
+ /// A description of the permission.
+ public let description: String
- /// The date when the permission was created.
- public let createdAt: Date
+ /// The date when the permission was created.
+ public let createdAt: Date
- /// The date when the permission was last updated.
- public let updatedAt: Date
+ /// The date when the permission was last updated.
+ public let updatedAt: Date
- public init(
- id: String,
- key: String,
- name: String,
- type: String,
- description: String,
- createdAt: Date,
- updatedAt: Date
- ) {
- self.id = id
- self.key = key
- self.name = name
- self.type = type
- self.description = description
- self.createdAt = createdAt
- self.updatedAt = updatedAt
- }
+ public init(
+ id: String,
+ key: String,
+ name: String,
+ type: String,
+ description: String,
+ createdAt: Date,
+ updatedAt: Date
+ ) {
+ self.id = id
+ self.key = key
+ self.name = name
+ self.type = type
+ self.description = description
+ self.createdAt = createdAt
+ self.updatedAt = updatedAt
+ }
}
extension PermissionResource {
- static var mock: Self {
- .init(
- id: "1",
- key: "key",
- name: "name",
- type: "type",
- description: "description",
- createdAt: .distantPast,
- updatedAt: .now
- )
- }
+ static var mock: Self {
+ .init(
+ id: "1",
+ key: "key",
+ name: "name",
+ type: "type",
+ description: "description",
+ createdAt: .distantPast,
+ updatedAt: .now
+ )
+ }
}
diff --git a/Sources/Clerk/Models/Organizations/RoleResource.swift b/Sources/Clerk/Models/Organizations/RoleResource.swift
index 0ebbc723f..ba9b91647 100644
--- a/Sources/Clerk/Models/Organizations/RoleResource.swift
+++ b/Sources/Clerk/Models/Organizations/RoleResource.swift
@@ -10,58 +10,58 @@ import Foundation
/// Represents a role with associated permissions and metadata about its creation and updates.
public struct RoleResource: Codable, Sendable, Identifiable, Hashable {
- /// The unique identifier of the role.
- public let id: String
+ /// The unique identifier of the role.
+ public let id: String
- /// The unique key of the role.
- public let key: String
+ /// The unique key of the role.
+ public let key: String
- /// The name of the role.
- public let name: String
+ /// The name of the role.
+ public let name: String
- /// The description of the role.
- public let description: String
+ /// The description of the role.
+ public let description: String
- /// The permissions associated with the role.
- public let permissions: [PermissionResource]
+ /// The permissions associated with the role.
+ public let permissions: [PermissionResource]
- /// The date when the role was created.
- public let createdAt: Date
+ /// The date when the role was created.
+ public let createdAt: Date
- /// The date when the role was last updated.
- public let updatedAt: Date
+ /// The date when the role was last updated.
+ public let updatedAt: Date
- public init(
- id: String,
- key: String,
- name: String,
- description: String,
- permissions: [PermissionResource],
- createdAt: Date,
- updatedAt: Date
- ) {
- self.id = id
- self.key = key
- self.name = name
- self.description = description
- self.permissions = permissions
- self.createdAt = createdAt
- self.updatedAt = updatedAt
- }
+ public init(
+ id: String,
+ key: String,
+ name: String,
+ description: String,
+ permissions: [PermissionResource],
+ createdAt: Date,
+ updatedAt: Date
+ ) {
+ self.id = id
+ self.key = key
+ self.name = name
+ self.description = description
+ self.permissions = permissions
+ self.createdAt = createdAt
+ self.updatedAt = updatedAt
+ }
}
extension RoleResource {
- static var mock: Self {
- .init(
- id: "1",
- key: "key",
- name: "name",
- description: "description",
- permissions: [.mock],
- createdAt: .distantPast,
- updatedAt: .now
- )
- }
+ static var mock: Self {
+ .init(
+ id: "1",
+ key: "key",
+ name: "name",
+ description: "description",
+ permissions: [.mock],
+ createdAt: .distantPast,
+ updatedAt: .now
+ )
+ }
}
diff --git a/Sources/Clerk/Models/Organizations/UserOrganizationInvitation.swift b/Sources/Clerk/Models/Organizations/UserOrganizationInvitation.swift
index 4dbef8b2a..230a79098 100644
--- a/Sources/Clerk/Models/Organizations/UserOrganizationInvitation.swift
+++ b/Sources/Clerk/Models/Organizations/UserOrganizationInvitation.swift
@@ -5,130 +5,122 @@
// Created by Mike Pitre on 2/13/25.
//
-import Factory
+import FactoryKit
import Foundation
-import Get
/// The `UserOrganizationInvitation` object is the model around a user's invitation to an organization.
public struct UserOrganizationInvitation: Codable, Sendable, Identifiable {
- /// The unique identifier for this organization invitation.
- public let id: String
-
- /// The email address the invitation has been sent to.
- public let emailAddress: String
-
- /// The public data of the organization.
- public let publicOrganizationData: PublicOrganizationData
-
- /// The public metadata of the organization invitation.
- public let publicMetadata: JSON
-
- /// The role of the current user in the organization.
- /// - Note: This is a string that represents the user's role. Clerk provides the default roles `org:admin` and `org:member`, but custom roles can also be used.
- public let role: String
-
- /// The status of the invitation.
- /// - Possible values: `pending`, `accepted`, `revoked`.
- public let status: String
-
- /// The date when the invitation was created.
- public let createdAt: Date
-
- /// The date when the invitation was last updated.
- public let updatedAt: Date
-
- public init(
- id: String,
- emailAddress: String,
- publicOrganizationData: UserOrganizationInvitation.PublicOrganizationData,
- publicMetadata: JSON,
- role: String,
- status: String,
- createdAt: Date,
- updatedAt: Date
- ) {
- self.id = id
- self.emailAddress = emailAddress
- self.publicOrganizationData = publicOrganizationData
- self.publicMetadata = publicMetadata
- self.role = role
- self.status = status
- self.createdAt = createdAt
- self.updatedAt = updatedAt
- }
-
- /// The public data of the organization.
- public struct PublicOrganizationData: Codable, Sendable {
-
- /// Whether the organization has an image.
- public let hasImage: Bool
-
- /// Holds the organization logo. Compatible with Clerk's Image Optimization.
- public let imageUrl: String
-
- /// The name of the organization.
- public let name: String
-
- /// The ID of the organization.
+ /// The unique identifier for this organization invitation.
public let id: String
- /// The slug of the organization.
- public let slug: String?
+ /// The email address the invitation has been sent to.
+ public let emailAddress: String
+
+ /// The public data of the organization.
+ public let publicOrganizationData: PublicOrganizationData
+
+ /// The public metadata of the organization invitation.
+ public let publicMetadata: JSON
+
+ /// The role of the current user in the organization.
+ /// - Note: This is a string that represents the user's role. Clerk provides the default roles `org:admin` and `org:member`, but custom roles can also be used.
+ public let role: String
+
+ /// The status of the invitation.
+ /// - Possible values: `pending`, `accepted`, `revoked`.
+ public let status: String
+
+ /// The date when the invitation was created.
+ public let createdAt: Date
+
+ /// The date when the invitation was last updated.
+ public let updatedAt: Date
public init(
- hasImage: Bool,
- imageUrl: String,
- name: String,
- id: String,
- slug: String? = nil
+ id: String,
+ emailAddress: String,
+ publicOrganizationData: UserOrganizationInvitation.PublicOrganizationData,
+ publicMetadata: JSON,
+ role: String,
+ status: String,
+ createdAt: Date,
+ updatedAt: Date
) {
- self.hasImage = hasImage
- self.imageUrl = imageUrl
- self.name = name
- self.id = id
- self.slug = slug
+ self.id = id
+ self.emailAddress = emailAddress
+ self.publicOrganizationData = publicOrganizationData
+ self.publicMetadata = publicMetadata
+ self.role = role
+ self.status = status
+ self.createdAt = createdAt
+ self.updatedAt = updatedAt
+ }
+
+ /// The public data of the organization.
+ public struct PublicOrganizationData: Codable, Sendable {
+
+ /// Whether the organization has an image.
+ public let hasImage: Bool
+
+ /// Holds the organization logo. Compatible with Clerk's Image Optimization.
+ public let imageUrl: String
+
+ /// The name of the organization.
+ public let name: String
+
+ /// The ID of the organization.
+ public let id: String
+
+ /// The slug of the organization.
+ public let slug: String?
+
+ public init(
+ hasImage: Bool,
+ imageUrl: String,
+ name: String,
+ id: String,
+ slug: String? = nil
+ ) {
+ self.hasImage = hasImage
+ self.imageUrl = imageUrl
+ self.name = name
+ self.id = id
+ self.slug = slug
+ }
}
- }
}
extension UserOrganizationInvitation {
- /// Accepts the organization invitation.
- /// - Returns: The accepted ``UserOrganizationInvitation``.
- @discardableResult @MainActor
- public func accept() async throws -> UserOrganizationInvitation {
- let request = Request>(
- path: "/v1/me/organization_invitations/\(id)/accept",
- method: .post,
- query: [
- ("_clerk_session_id", Clerk.shared.session?.id)
- ].filter { $1 != nil }
- )
- return try await Container.shared.apiClient().send(request).value.response
- }
+ /// Accepts the organization invitation.
+ /// - Returns: The accepted ``UserOrganizationInvitation``.
+ @discardableResult @MainActor
+ public func accept() async throws -> UserOrganizationInvitation {
+ try await Container.shared.organizationService().acceptUserOrganizationInvitation(id)
+ }
}
extension UserOrganizationInvitation {
- static var mock: Self {
- .init(
- id: "1",
- emailAddress: "user@email.com",
- publicOrganizationData: .init(
- hasImage: true,
- imageUrl: "",
- name: "name",
- id: "1",
- slug: "slug"
- ),
- publicMetadata: "{}",
- role: "org:member",
- status: "pending",
- createdAt: .distantPast,
- updatedAt: .now
- )
- }
+ static var mock: Self {
+ .init(
+ id: "1",
+ emailAddress: "user@email.com",
+ publicOrganizationData: .init(
+ hasImage: true,
+ imageUrl: "",
+ name: "name",
+ id: "1",
+ slug: "slug"
+ ),
+ publicMetadata: "{}",
+ role: "org:member",
+ status: "pending",
+ createdAt: .distantPast,
+ updatedAt: .now
+ )
+ }
}
diff --git a/Sources/Clerk/Models/Passkey/Passkey.swift b/Sources/Clerk/Models/Passkey/Passkey.swift
index 63345b69e..2b530ee76 100644
--- a/Sources/Clerk/Models/Passkey/Passkey.swift
+++ b/Sources/Clerk/Models/Passkey/Passkey.swift
@@ -5,109 +5,109 @@
// Created by Mike Pitre on 9/6/24.
//
-import Factory
+import FactoryKit
import Foundation
/// An object that represents a passkey associated with a user.
public struct Passkey: Codable, Identifiable, Equatable, Sendable, Hashable {
- /// The unique identifier of the passkey.
- public let id: String
-
- /// The passkey's name.
- public let name: String
-
- /// The verification details for the passkey.
- public let verification: Verification?
-
- /// The date when the passkey was created.
- public let createdAt: Date
-
- /// The date when the passkey was last updated.
- public let updatedAt: Date
-
- /// The date when the passkey was last used.
- public let lastUsedAt: Date?
-
- public init(
- id: String,
- name: String,
- verification: Verification? = nil,
- createdAt: Date,
- updatedAt: Date,
- lastUsedAt: Date? = nil
- ) {
- self.id = id
- self.name = name
- self.verification = verification
- self.createdAt = createdAt
- self.updatedAt = updatedAt
- self.lastUsedAt = lastUsedAt
- }
+ /// The unique identifier of the passkey.
+ public let id: String
+
+ /// The passkey's name.
+ public let name: String
+
+ /// The verification details for the passkey.
+ public let verification: Verification?
+
+ /// The date when the passkey was created.
+ public let createdAt: Date
+
+ /// The date when the passkey was last updated.
+ public let updatedAt: Date
+
+ /// The date when the passkey was last used.
+ public let lastUsedAt: Date?
+
+ public init(
+ id: String,
+ name: String,
+ verification: Verification? = nil,
+ createdAt: Date,
+ updatedAt: Date,
+ lastUsedAt: Date? = nil
+ ) {
+ self.id = id
+ self.name = name
+ self.verification = verification
+ self.createdAt = createdAt
+ self.updatedAt = updatedAt
+ self.lastUsedAt = lastUsedAt
+ }
}
extension Passkey {
- // MARK: - Private Properties
+ // MARK: - Private Properties
- var nonceJSON: JSON? {
- verification?.nonce?.toJSON()
- }
+ var nonceJSON: JSON? {
+ verification?.nonce?.toJSON()
+ }
- var challenge: Data? {
- let challengeString = nonceJSON?.challenge?.stringValue
- return challengeString?.dataFromBase64URL()
- }
+ var challenge: Data? {
+ let challengeString = nonceJSON?.challenge?.stringValue
+ return challengeString?.dataFromBase64URL()
+ }
- var username: String? {
- nonceJSON?.user?.name?.stringValue
- }
+ var username: String? {
+ nonceJSON?.user?.name?.stringValue
+ }
- var userId: Data? {
- nonceJSON?.user?.id?.stringValue?.base64URLFromBase64String().dataFromBase64URL()
- }
+ var userId: Data? {
+ nonceJSON?.user?.id?.stringValue?.base64URLFromBase64String().dataFromBase64URL()
+ }
}
extension Passkey {
- /// Creates a new passkey
- @discardableResult @MainActor
- public static func create() async throws -> Passkey {
- try await Container.shared.passkeyService().create()
- }
-
- /// Updates the name of the associated passkey for the signed-in user.
- @discardableResult @MainActor
- public func update(name: String) async throws -> Passkey {
- try await Container.shared.passkeyService().update(self, name)
- }
-
- /// Attempts to verify the passkey with a credential.
- @discardableResult @MainActor
- public func attemptVerification(credential: String) async throws -> Passkey {
- try await Container.shared.passkeyService().attemptVerification(self, credential)
- }
-
- /// Deletes the associated passkey for the signed-in user.
- @discardableResult @MainActor
- public func delete() async throws -> DeletedObject {
- try await Container.shared.passkeyService().delete(self)
- }
+ /// Creates a new passkey
+ @discardableResult @MainActor
+ public static func create() async throws -> Passkey {
+ try await Container.shared.passkeyService().create()
+ }
+
+ /// Updates the name of the associated passkey for the signed-in user.
+ @discardableResult @MainActor
+ public func update(name: String) async throws -> Passkey {
+ try await Container.shared.passkeyService().update(id, name)
+ }
+
+ /// Attempts to verify the passkey with a credential.
+ @discardableResult @MainActor
+ public func attemptVerification(credential: String) async throws -> Passkey {
+ try await Container.shared.passkeyService().attemptVerification(id, credential)
+ }
+
+ /// Deletes the associated passkey for the signed-in user.
+ @discardableResult @MainActor
+ public func delete() async throws -> DeletedObject {
+ try await Container.shared.passkeyService().delete(id)
+ }
}
extension Passkey {
- static var mock: Passkey {
- Passkey(
- id: "1",
- name: "iCloud Keychain",
- verification: .mockPasskeyVerifiedVerification,
- createdAt: Date(timeIntervalSinceReferenceDate: 1234567890),
- updatedAt: Date(timeIntervalSinceReferenceDate: 1234567890),
- lastUsedAt: Date(timeIntervalSinceReferenceDate: 1234567890)
- )
- }
+ static var mock: Passkey {
+ Passkey(
+ id: "1",
+ name: "iCloud Keychain",
+ verification: .mockPasskeyVerifiedVerification,
+ createdAt: Date(timeIntervalSinceReferenceDate: 1234567890),
+ updatedAt: Date(timeIntervalSinceReferenceDate: 1234567890),
+ lastUsedAt: Date(timeIntervalSinceReferenceDate: 1234567890)
+ )
+ }
}
diff --git a/Sources/Clerk/Models/Passkey/PasskeyService.swift b/Sources/Clerk/Models/Passkey/PasskeyService.swift
index e738a6a02..ba6c94d77 100644
--- a/Sources/Clerk/Models/Passkey/PasskeyService.swift
+++ b/Sources/Clerk/Models/Passkey/PasskeyService.swift
@@ -2,61 +2,66 @@
// PasskeyService.swift
// Clerk
//
-// Created by Mike Pitre on 3/10/25.
+// Created by Mike Pitre on 7/28/25.
//
-import Factory
+import FactoryKit
import Foundation
-struct PasskeyService {
- var create: @MainActor () async throws -> Passkey
- var update: @MainActor (_ passkey: Passkey, _ name: String) async throws -> Passkey
- var attemptVerification: @MainActor (_ passkey: Passkey, _ credential: String) async throws -> Passkey
- var delete: @MainActor (_ passkey: Passkey) async throws -> DeletedObject
-}
+extension Container {
-extension PasskeyService {
-
- static var liveValue: Self {
- .init(
- create: {
- let request = ClerkFAPI.v1.me.passkeys.post(
- queryItems: [.init(name: "_clerk_session_id", value: Clerk.shared.session?.id)]
- )
- return try await Container.shared.apiClient().send(request).value.response
- },
- update: { passkey, name in
- let request = ClerkFAPI.v1.me.passkeys.withId(passkey.id).patch(
- queryItems: [.init(name: "_clerk_session_id", value: Clerk.shared.session?.id)],
- body: ["name": name]
- )
- return try await Container.shared.apiClient().send(request).value.response
- },
- attemptVerification: { passkey, credential in
- let request = ClerkFAPI.v1.me.passkeys.withId(passkey.id).attemptVerification.post(
- queryItems: [.init(name: "_clerk_session_id", value: Clerk.shared.session?.id)],
- body: [
- "strategy": "passkey",
- "public_key_credential": credential,
- ]
- )
- return try await Container.shared.apiClient().send(request).value.response
- },
- delete: { passkey in
- let request = ClerkFAPI.v1.me.passkeys.withId(passkey.id).delete(
- queryItems: [.init(name: "_clerk_session_id", value: Clerk.shared.session?.id)]
- )
- return try await Container.shared.apiClient().send(request).value.response
- }
- )
- }
+ var passkeyService: Factory {
+ self { @MainActor in PasskeyService() }
+ }
}
-extension Container {
+@MainActor
+struct PasskeyService {
+
+ var create: () async throws -> Passkey = {
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/me/passkeys")
+ .method(.post)
+ .addClerkSessionId()
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
+
+ var update: (_ passkeyId: String, _ name: String) async throws -> Passkey = { passkeyId, name in
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/me/passkeys/\(passkeyId)")
+ .method(.patch)
+ .addClerkSessionId()
+ .body(formEncode: ["name": name])
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
+
+ var attemptVerification: (_ passkeyId: String, _ credential: String) async throws -> Passkey = { passkeyId, credential in
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/me/passkeys/\(passkeyId)/attempt_verification")
+ .method(.post)
+ .addClerkSessionId()
+ .body(formEncode: [
+ "strategy": "passkey",
+ "public_key_credential": credential
+ ])
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
- var passkeyService: Factory {
- self { .liveValue }
- }
+ var delete: (_ passkeyId: String) async throws -> DeletedObject = { passkeyId in
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/me/passkeys/\(passkeyId)")
+ .method(.delete)
+ .addClerkSessionId()
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
}
diff --git a/Sources/Clerk/Models/PhoneNumber/PhoneNumber.swift b/Sources/Clerk/Models/PhoneNumber/PhoneNumber.swift
index 21955fd26..07118b725 100644
--- a/Sources/Clerk/Models/PhoneNumber/PhoneNumber.swift
+++ b/Sources/Clerk/Models/PhoneNumber/PhoneNumber.swift
@@ -5,7 +5,7 @@
// Created by Mike Pitre on 10/20/23.
//
-import Factory
+import FactoryKit
import Foundation
/// The `PhoneNumber` object describes a phone number.
@@ -25,105 +25,134 @@ import Foundation
/// the sign-in process.
public struct PhoneNumber: Codable, Equatable, Hashable, Identifiable, Sendable {
- /// The unique identifier for this phone number.
- public let id: String
-
- /// The value of this phone number, in E.164 format.
- public let phoneNumber: String
-
- /// Set to true if this phone number is reserved for multi-factor authentication (2FA). Set to false otherwise.
- public let reservedForSecondFactor: Bool
-
- /// Set to true if this phone number is the default second factor. Set to false otherwise. A user must have exactly one default second factor, if multi-factor authentication (2FA) is enabled.
- public let defaultSecondFactor: Bool
-
- /// An object holding information on the verification of this phone number.
- public let verification: Verification?
-
- /// An object containing information about any other identification that might be linked to this phone number.
- public let linkedTo: JSON?
-
- /// A list of backup codes in case of lost phone number access.
- public let backupCodes: [String]?
-
- public init(
- id: String,
- phoneNumber: String,
- reservedForSecondFactor: Bool,
- defaultSecondFactor: Bool,
- verification: Verification? = nil,
- linkedTo: JSON? = nil,
- backupCodes: [String]? = nil
- ) {
- self.id = id
- self.phoneNumber = phoneNumber
- self.reservedForSecondFactor = reservedForSecondFactor
- self.defaultSecondFactor = defaultSecondFactor
- self.verification = verification
- self.linkedTo = linkedTo
- self.backupCodes = backupCodes
- }
+ /// The unique identifier for this phone number.
+ public let id: String
+
+ /// The value of this phone number, in E.164 format.
+ public let phoneNumber: String
+
+ /// Set to true if this phone number is reserved for multi-factor authentication (2FA). Set to false otherwise.
+ public let reservedForSecondFactor: Bool
+
+ /// Set to true if this phone number is the default second factor. Set to false otherwise. A user must have exactly one default second factor, if multi-factor authentication (2FA) is enabled.
+ public let defaultSecondFactor: Bool
+
+ /// An object holding information on the verification of this phone number.
+ public let verification: Verification?
+
+ /// An object containing information about any other identification that might be linked to this phone number.
+ public let linkedTo: JSON?
+
+ /// A list of backup codes in case of lost phone number access.
+ public let backupCodes: [String]?
+
+ /// The date when the phone number was created.
+ public let createdAt: Date
+
+ public init(
+ id: String,
+ phoneNumber: String,
+ reservedForSecondFactor: Bool,
+ defaultSecondFactor: Bool,
+ verification: Verification? = nil,
+ linkedTo: JSON? = nil,
+ backupCodes: [String]? = nil,
+ createdAt: Date = .now
+ ) {
+ self.id = id
+ self.phoneNumber = phoneNumber
+ self.reservedForSecondFactor = reservedForSecondFactor
+ self.defaultSecondFactor = defaultSecondFactor
+ self.verification = verification
+ self.linkedTo = linkedTo
+ self.backupCodes = backupCodes
+ self.createdAt = createdAt
+ }
}
extension PhoneNumber {
- /// Creates a new phone number for the current user.
- /// - Parameters:
- /// - phoneNumber: The phone number to add to the current user.
- @discardableResult @MainActor
- public static func create(_ phoneNumber: String) async throws -> PhoneNumber {
- try await Container.shared.userService().createPhoneNumber(phoneNumber)
- }
-
- /// Deletes this phone number.
- @discardableResult @MainActor
- public func delete() async throws -> DeletedObject {
- try await Container.shared.phoneNumberService().delete(self)
- }
-
- /// Kick off the verification process for this phone number.
- ///
- /// An SMS message with a one-time code will be sent to the phone number value.
- @discardableResult @MainActor
- public func prepareVerification() async throws -> PhoneNumber {
- try await Container.shared.phoneNumberService().prepareVerification(self)
- }
-
- /// Attempts to verify this phone number, passing the one-time code that was sent as an SMS message.
- ///
- /// The code will be sent when calling the ``PhoneNumber/prepareVerification()`` method.
- @discardableResult @MainActor
- public func attemptVerification(code: String) async throws -> PhoneNumber {
- try await Container.shared.phoneNumberService().attemptVerification(self, code)
- }
-
- /// Marks this phone number as the default second factor for multi-factor authentication(2FA). A user can have exactly one default second factor.
- @discardableResult @MainActor
- public func makeDefaultSecondFactor() async throws -> PhoneNumber {
- try await Container.shared.phoneNumberService().makeDefaultSecondFactor(self)
- }
-
- /// Marks this phone number as reserved for multi-factor authentication (2FA) or not.
- /// - Parameter reserved: Pass true to mark this phone number as reserved for 2FA, or false to disable 2FA for this phone number.
- @discardableResult @MainActor
- public func setReservedForSecondFactor(reserved: Bool = true) async throws -> PhoneNumber {
- try await Container.shared.phoneNumberService().setReservedForSecondFactor(self, reserved)
- }
+ /// Creates a new phone number for the current user.
+ /// - Parameters:
+ /// - phoneNumber: The phone number to add to the current user.
+ @discardableResult @MainActor
+ public static func create(_ phoneNumber: String) async throws -> PhoneNumber {
+ try await Container.shared.phoneNumberService().create(phoneNumber)
+ }
+
+ /// Deletes this phone number.
+ @discardableResult @MainActor
+ public func delete() async throws -> DeletedObject {
+ try await Container.shared.phoneNumberService().delete(id)
+ }
+
+ /// Kick off the verification process for this phone number.
+ ///
+ /// An SMS message with a one-time code will be sent to the phone number value.
+ @discardableResult @MainActor
+ public func prepareVerification() async throws -> PhoneNumber {
+ try await Container.shared.phoneNumberService().prepareVerification(id)
+ }
+
+ /// Attempts to verify this phone number, passing the one-time code that was sent as an SMS message.
+ ///
+ /// The code will be sent when calling the ``PhoneNumber/prepareVerification()`` method.
+ @discardableResult @MainActor
+ public func attemptVerification(code: String) async throws -> PhoneNumber {
+ try await Container.shared.phoneNumberService().attemptVerification(id, code)
+ }
+
+ /// Marks this phone number as the default second factor for multi-factor authentication(2FA). A user can have exactly one default second factor.
+ @discardableResult @MainActor
+ public func makeDefaultSecondFactor() async throws -> PhoneNumber {
+ try await Container.shared.phoneNumberService().makeDefaultSecondFactor(id)
+ }
+
+ /// Marks this phone number as reserved for multi-factor authentication (2FA) or not.
+ /// - Parameter reserved: Pass true to mark this phone number as reserved for 2FA, or false to disable 2FA for this phone number.
+ @discardableResult @MainActor
+ public func setReservedForSecondFactor(reserved: Bool = true) async throws -> PhoneNumber {
+ try await Container.shared.phoneNumberService().setReservedForSecondFactor(id, reserved)
+ }
}
extension PhoneNumber {
- static var mock: PhoneNumber {
- PhoneNumber(
- id: "1",
- phoneNumber: "15555550100",
- reservedForSecondFactor: false,
- defaultSecondFactor: false,
- verification: .mockPhoneCodeVerifiedVerification,
- linkedTo: nil,
- backupCodes: nil
- )
- }
+ static var mock: PhoneNumber {
+ PhoneNumber(
+ id: "1",
+ phoneNumber: "+15555550100",
+ reservedForSecondFactor: false,
+ defaultSecondFactor: false,
+ verification: .mockPhoneCodeVerifiedVerification,
+ linkedTo: nil,
+ backupCodes: nil
+ )
+ }
+
+ static var mock2: PhoneNumber {
+ PhoneNumber(
+ id: "2",
+ phoneNumber: "+15555550101",
+ reservedForSecondFactor: false,
+ defaultSecondFactor: false,
+ verification: .mockPhoneCodeVerifiedVerification,
+ linkedTo: nil,
+ backupCodes: nil
+ )
+ }
+
+ static var mockMfa: PhoneNumber {
+ PhoneNumber(
+ id: "3",
+ phoneNumber: "+15555550102",
+ reservedForSecondFactor: true,
+ defaultSecondFactor: true,
+ verification: .mockPhoneCodeVerifiedVerification,
+ linkedTo: nil,
+ backupCodes: nil
+ )
+ }
}
diff --git a/Sources/Clerk/Models/PhoneNumber/PhoneNumberService.swift b/Sources/Clerk/Models/PhoneNumber/PhoneNumberService.swift
index 5fff39554..c594ecad7 100644
--- a/Sources/Clerk/Models/PhoneNumber/PhoneNumberService.swift
+++ b/Sources/Clerk/Models/PhoneNumber/PhoneNumberService.swift
@@ -2,66 +2,86 @@
// PhoneNumberService.swift
// Clerk
//
-// Created by Mike Pitre on 3/10/25.
+// Created by Mike Pitre on 7/28/25.
//
-import Factory
+import FactoryKit
import Foundation
-struct PhoneNumberService {
- var delete: @MainActor (_ phoneNumber: PhoneNumber) async throws -> DeletedObject
- var prepareVerification: @MainActor (_ phoneNumber: PhoneNumber) async throws -> PhoneNumber
- var attemptVerification: @MainActor (_ phoneNumber: PhoneNumber, _ code: String) async throws -> PhoneNumber
- var makeDefaultSecondFactor: @MainActor (_ phoneNumber: PhoneNumber) async throws -> PhoneNumber
- var setReservedForSecondFactor: @MainActor (_ phoneNumber: PhoneNumber, _ reserved: Bool) async throws -> PhoneNumber
+extension Container {
+
+ var phoneNumberService: Factory {
+ self { @MainActor in PhoneNumberService() }
+ }
+
}
-extension PhoneNumberService {
+@MainActor
+struct PhoneNumberService {
- static var liveValue: Self {
- .init(
- delete: { phoneNumber in
- let request = ClerkFAPI.v1.me.phoneNumbers.id(phoneNumber.id).delete(
- queryItems: [.init(name: "_clerk_session_id", value: Clerk.shared.session?.id)]
- )
- return try await Container.shared.apiClient().send(request).value.response
- },
- prepareVerification: { phoneNumber in
- let request = ClerkFAPI.v1.me.phoneNumbers.id(phoneNumber.id).prepareVerification.post(
- queryItems: [.init(name: "_clerk_session_id", value: Clerk.shared.session?.id)]
- )
- return try await Container.shared.apiClient().send(request).value.response
- },
- attemptVerification: { phoneNumber, code in
- let request = ClerkFAPI.v1.me.phoneNumbers.id(phoneNumber.id).attemptVerification.post(
- queryItems: [.init(name: "_clerk_session_id", value: Clerk.shared.session?.id)],
- body: ["code": code]
- )
- return try await Container.shared.apiClient().send(request).value.response
- },
- makeDefaultSecondFactor: { phoneNumber in
- let request = ClerkFAPI.v1.me.phoneNumbers.id(phoneNumber.id).patch(
- queryItems: [.init(name: "_clerk_session_id", value: Clerk.shared.session?.id)],
- body: ["default_second_factor": true]
- )
- return try await Container.shared.apiClient().send(request).value.response
- },
- setReservedForSecondFactor: { phoneNumber, reserved in
- let request = ClerkFAPI.v1.me.phoneNumbers.id(phoneNumber.id).patch(
- queryItems: [.init(name: "_clerk_session_id", value: Clerk.shared.session?.id)],
- body: ["reserved_for_second_factor": reserved]
- )
- return try await Container.shared.apiClient().send(request).value.response
- }
- )
- }
+ var create: (_ phoneNumber: String) async throws -> PhoneNumber = { phoneNumber in
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/me/phone_numbers")
+ .method(.post)
+ .addClerkSessionId()
+ .body(formEncode: ["phone_number": phoneNumber])
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
-}
+ var delete: (_ phoneNumberId: String) async throws -> DeletedObject = { phoneNumberId in
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/me/phone_numbers/\(phoneNumberId)")
+ .method(.delete)
+ .addClerkSessionId()
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
-extension Container {
+ var prepareVerification: (_ phoneNumberId: String) async throws -> PhoneNumber = { phoneNumberId in
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/me/phone_numbers/\(phoneNumberId)/prepare_verification")
+ .method(.post)
+ .addClerkSessionId()
+ .body(formEncode: ["strategy": "phone_code"])
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
+
+ var attemptVerification: (_ phoneNumberId: String, _ code: String) async throws -> PhoneNumber = { phoneNumberId, code in
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/me/phone_numbers/\(phoneNumberId)/attempt_verification")
+ .method(.post)
+ .addClerkSessionId()
+ .body(formEncode: ["code": code])
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
+
+ var makeDefaultSecondFactor: (_ phoneNumberId: String) async throws -> PhoneNumber = { phoneNumberId in
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/me/phone_numbers/\(phoneNumberId)")
+ .method(.patch)
+ .addClerkSessionId()
+ .body(formEncode: ["default_second_factor": true])
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
- var phoneNumberService: Factory {
- self { .liveValue }
- }
+ var setReservedForSecondFactor: (_ phoneNumberId: String, _ reserved: Bool) async throws -> PhoneNumber = { phoneNumberId, reserved in
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/me/phone_numbers/\(phoneNumberId)")
+ .method(.patch)
+ .addClerkSessionId()
+ .body(formEncode: ["reserved_for_second_factor": reserved])
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
}
diff --git a/Sources/Clerk/Models/PublicUserData.swift b/Sources/Clerk/Models/PublicUserData.swift
index 44819912c..5177b349e 100644
--- a/Sources/Clerk/Models/PublicUserData.swift
+++ b/Sources/Clerk/Models/PublicUserData.swift
@@ -10,39 +10,39 @@ import Foundation
/// Represents publicly available information about a user.
public struct PublicUserData: Codable, Sendable, Hashable, Equatable {
- /// The user's first name.
- public let firstName: String?
-
- /// The user's last name.
- public let lastName: String?
-
- /// Holds the default avatar or user's uploaded profile image.
- /// Compatible with Clerk's Image Optimization.
- public let imageUrl: String
-
- /// A boolean indicating whether the user has uploaded an image or one was copied from OAuth.
- /// Returns `false` if Clerk is displaying a default avatar for the user.
- public let hasImage: Bool
-
- /// The user's identifier.
- public let identifier: String
-
- /// The user's ID.
- public let userId: String?
-
- public init(
- firstName: String? = nil,
- lastName: String? = nil,
- imageUrl: String,
- hasImage: Bool,
- identifier: String,
- userId: String? = nil
- ) {
- self.firstName = firstName
- self.lastName = lastName
- self.imageUrl = imageUrl
- self.hasImage = hasImage
- self.identifier = identifier
- self.userId = userId
- }
+ /// The user's first name.
+ public let firstName: String?
+
+ /// The user's last name.
+ public let lastName: String?
+
+ /// Holds the default avatar or user's uploaded profile image.
+ /// Compatible with Clerk's Image Optimization.
+ public let imageUrl: String
+
+ /// A boolean indicating whether the user has uploaded an image or one was copied from OAuth.
+ /// Returns `false` if Clerk is displaying a default avatar for the user.
+ public let hasImage: Bool
+
+ /// The user's identifier.
+ public let identifier: String
+
+ /// The user's ID.
+ public let userId: String?
+
+ public init(
+ firstName: String? = nil,
+ lastName: String? = nil,
+ imageUrl: String,
+ hasImage: Bool,
+ identifier: String,
+ userId: String? = nil
+ ) {
+ self.firstName = firstName
+ self.lastName = lastName
+ self.imageUrl = imageUrl
+ self.hasImage = hasImage
+ self.identifier = identifier
+ self.userId = userId
+ }
}
diff --git a/Sources/Clerk/Models/Session/Session.swift b/Sources/Clerk/Models/Session/Session.swift
index 37edb9f3b..866ab924d 100644
--- a/Sources/Clerk/Models/Session/Session.swift
+++ b/Sources/Clerk/Models/Session/Session.swift
@@ -5,7 +5,7 @@
// Created by Mike Pitre on 10/5/23.
//
-import Factory
+import FactoryKit
import Foundation
/**
@@ -27,244 +27,278 @@ import Foundation
*/
public struct Session: Codable, Identifiable, Equatable, Sendable {
- /// A unique identifier for the session.
- public let id: String
-
- /// The current state of the session.
- public let status: SessionStatus
-
- /// The time the session expires and will cease to be active.
- public let expireAt: Date
-
- /// The time when the session was abandoned by the user.
- public let abandonAt: Date
-
- /// The time the session was last active on the client.
- public let lastActiveAt: Date
-
- /// The latest activity associated with the session.
- public let latestActivity: SessionActivity?
-
- /// The last active organization identifier.
- public let lastActiveOrganizationId: String?
-
- /// The JWT actor for the session.
- public let actor: String?
-
- /// The user associated with the session.
- public let user: User?
-
- /// Public information about the user that this session belongs to.
- public let publicUserData: PublicUserData?
-
- /// The time the session was created.
- public let createdAt: Date
-
- /// The last time the session recorded activity of any kind.
- public let updatedAt: Date
-
- /// The last active token for the session.
- public let lastActiveToken: TokenResource?
-
- public init(
- id: String,
- status: Session.SessionStatus,
- expireAt: Date,
- abandonAt: Date,
- lastActiveAt: Date,
- latestActivity: SessionActivity? = nil,
- lastActiveOrganizationId: String? = nil,
- actor: String? = nil,
- user: User? = nil,
- publicUserData: PublicUserData? = nil,
- createdAt: Date,
- updatedAt: Date,
- lastActiveToken: TokenResource? = nil
- ) {
- self.id = id
- self.status = status
- self.expireAt = expireAt
- self.abandonAt = abandonAt
- self.lastActiveAt = lastActiveAt
- self.latestActivity = latestActivity
- self.lastActiveOrganizationId = lastActiveOrganizationId
- self.actor = actor
- self.user = user
- self.publicUserData = publicUserData
- self.createdAt = createdAt
- self.updatedAt = updatedAt
- self.lastActiveToken = lastActiveToken
- }
-
- /// Represents the status of a session.
- public enum SessionStatus: String, Codable, Sendable {
- /// The session was abandoned client-side.
- case abandoned
-
- /// The session is valid, and all activity is allowed.
- case active
-
- /// The user signed out of the session, but the Session remains in the Client object.
- case ended
-
- /// The period of allowed activity for this session has passed.
- case expired
-
- /// The user signed out of the session, and the Session was removed from the Client object.
- case removed
-
- /// The session has been replaced by another one, but the Session remains in the Client object.
- case replaced
-
- /// The application ended the session, and the Session was removed from the Client object.
- case revoked
-
- case unknown
-
- public init(from decoder: Decoder) throws {
- self = try .init(rawValue: decoder.singleValueContainer().decode(RawValue.self)) ?? .unknown
+ /// A unique identifier for the session.
+ public let id: String
+
+ /// The current state of the session.
+ public let status: SessionStatus
+
+ /// The time the session expires and will cease to be active.
+ public let expireAt: Date
+
+ /// The time when the session was abandoned by the user.
+ public let abandonAt: Date
+
+ /// The time the session was last active on the client.
+ public let lastActiveAt: Date
+
+ /// The latest activity associated with the session.
+ public let latestActivity: SessionActivity?
+
+ /// The last active organization identifier.
+ public let lastActiveOrganizationId: String?
+
+ /// The JWT actor for the session.
+ public let actor: String?
+
+ /// The user associated with the session.
+ public let user: User?
+
+ /// Public information about the user that this session belongs to.
+ public let publicUserData: PublicUserData?
+
+ /// The time the session was created.
+ public let createdAt: Date
+
+ /// The last time the session recorded activity of any kind.
+ public let updatedAt: Date
+
+ /// The last active token for the session.
+ public let lastActiveToken: TokenResource?
+
+ public init(
+ id: String,
+ status: Session.SessionStatus,
+ expireAt: Date,
+ abandonAt: Date,
+ lastActiveAt: Date,
+ latestActivity: SessionActivity? = nil,
+ lastActiveOrganizationId: String? = nil,
+ actor: String? = nil,
+ user: User? = nil,
+ publicUserData: PublicUserData? = nil,
+ createdAt: Date,
+ updatedAt: Date,
+ lastActiveToken: TokenResource? = nil
+ ) {
+ self.id = id
+ self.status = status
+ self.expireAt = expireAt
+ self.abandonAt = abandonAt
+ self.lastActiveAt = lastActiveAt
+ self.latestActivity = latestActivity
+ self.lastActiveOrganizationId = lastActiveOrganizationId
+ self.actor = actor
+ self.user = user
+ self.publicUserData = publicUserData
+ self.createdAt = createdAt
+ self.updatedAt = updatedAt
+ self.lastActiveToken = lastActiveToken
+ }
+
+ /// Represents the status of a session.
+ public enum SessionStatus: String, Codable, Sendable {
+ /// The session was abandoned client-side.
+ case abandoned
+
+ /// The session is valid, and all activity is allowed.
+ case active
+
+ /// The user signed out of the session, but the Session remains in the Client object.
+ case ended
+
+ /// The period of allowed activity for this session has passed.
+ case expired
+
+ /// The user signed out of the session, and the Session was removed from the Client object.
+ case removed
+
+ /// The session has been replaced by another one, but the Session remains in the Client object.
+ case replaced
+
+ /// The application ended the session, and the Session was removed from the Client object.
+ case revoked
+
+ case unknown
+
+ public init(from decoder: Decoder) throws {
+ self = try .init(rawValue: decoder.singleValueContainer().decode(RawValue.self)) ?? .unknown
+ }
}
- }
}
/// A `SessionActivity` object will provide information about the user's location, device and browser.
public struct SessionActivity: Codable, Equatable, Sendable {
- /// A unique identifier for the session activity record.
- let id: String
-
- /// The name of the browser from which this session activity occurred.
- public let browserName: String?
-
- /// The version of the browser from which this session activity occurred.
- public let browserVersion: String?
-
- /// The type of the device which was used in this session activity.
- public let deviceType: String?
-
- /// The IP address from which this session activity originated.
- public let ipAddress: String?
-
- /// The city from which this session activity occurred. Resolved by IP address geo-location.
- public let city: String?
-
- /// The country from which this session activity occurred. Resolved by IP address geo-location.
- public let country: String?
-
- /// Will be set to true if the session activity came from a mobile device. Set to false otherwise.
- public let isMobile: Bool?
-
- public init(
- id: String,
- browserName: String? = nil,
- browserVersion: String? = nil,
- deviceType: String? = nil,
- ipAddress: String? = nil,
- city: String? = nil,
- country: String? = nil,
- isMobile: Bool? = nil
- ) {
- self.id = id
- self.browserName = browserName
- self.browserVersion = browserVersion
- self.deviceType = deviceType
- self.ipAddress = ipAddress
- self.city = city
- self.country = country
- self.isMobile = isMobile
- }
+ /// A unique identifier for the session activity record.
+ let id: String
+
+ /// The name of the browser from which this session activity occurred.
+ public let browserName: String?
+
+ /// The version of the browser from which this session activity occurred.
+ public let browserVersion: String?
+
+ /// The type of the device which was used in this session activity.
+ public let deviceType: String?
+
+ /// The IP address from which this session activity originated.
+ public let ipAddress: String?
+
+ /// The city from which this session activity occurred. Resolved by IP address geo-location.
+ public let city: String?
+
+ /// The country from which this session activity occurred. Resolved by IP address geo-location.
+ public let country: String?
+
+ /// Will be set to true if the session activity came from a mobile device. Set to false otherwise.
+ public let isMobile: Bool?
+
+ public init(
+ id: String,
+ browserName: String? = nil,
+ browserVersion: String? = nil,
+ deviceType: String? = nil,
+ ipAddress: String? = nil,
+ city: String? = nil,
+ country: String? = nil,
+ isMobile: Bool? = nil
+ ) {
+ self.id = id
+ self.browserName = browserName
+ self.browserVersion = browserVersion
+ self.deviceType = deviceType
+ self.ipAddress = ipAddress
+ self.city = city
+ self.country = country
+ self.isMobile = isMobile
+ }
}
extension Session {
- /// Format for the session token cache key
- ///
- /// For example:
- /// - If the template is null, the key will be 'sess_abc12345'
- /// - If the template is 'supabase', the key will be 'sess_abc12345-supabase'
- func tokenCacheKey(template: String?) -> String {
- var tokenCacheKey = id
- if let template = template {
- tokenCacheKey += "-\(template)"
+ /// Format for the session token cache key
+ ///
+ /// For example:
+ /// - If the template is null, the key will be 'sess_abc12345'
+ /// - If the template is 'supabase', the key will be 'sess_abc12345-supabase'
+ func tokenCacheKey(template: String?) -> String {
+ var tokenCacheKey = id
+ if let template = template {
+ tokenCacheKey += "-\(template)"
+ }
+ return tokenCacheKey
}
- return tokenCacheKey
- }
}
extension Session {
- /// Marks this session as revoked. If this is the active session, the attempt to revoke it will fail. Users can revoke only their own sessions.
- @discardableResult @MainActor
- public func revoke() async throws -> Session {
- try await Container.shared.sessionService().revoke(self)
- }
+ /// Marks this session as revoked. If this is the active session, the attempt to revoke it will fail. Users can revoke only their own sessions.
+ @discardableResult @MainActor
+ public func revoke() async throws -> Session {
+ try await Container.shared.sessionService().revoke(id)
+ }
- /**
- Retrieves the user's session token for the given template or the default clerk token.
- This method uses a cache so a network request will only be made if the token in memory is expired.
- The TTL for clerk token is one minute.
- */
- @discardableResult
- public func getToken(_ options: GetTokenOptions = .init()) async throws -> TokenResource? {
- return try await SessionTokenFetcher.shared.getToken(self, options: options)
- }
+ /**
+ Retrieves the user's session token for the given template or the default clerk token.
+ This method uses a cache so a network request will only be made if the token in memory is expired.
+ The TTL for clerk token is one minute.
+ */
+ @discardableResult
+ public func getToken(_ options: GetTokenOptions = .init()) async throws -> TokenResource? {
+ return try await SessionTokenFetcher.shared.getToken(self, options: options)
+ }
- /// Options that can be passed as parameters to the `getToken()` function.
- public struct GetTokenOptions: Hashable, Sendable {
+ /// Options that can be passed as parameters to the `getToken()` function.
+ public struct GetTokenOptions: Hashable, Sendable {
- /// The name of the JWT template from the Clerk Dashboard to generate a new token from. E.g. 'firebase', 'grafbase', or your custom template's name.
- public let template: String?
+ /// The name of the JWT template from the Clerk Dashboard to generate a new token from. E.g. 'firebase', 'grafbase', or your custom template's name.
+ public let template: String?
- /// If the cached token will expire within X seconds (the buffer), fetch a new token instead. Max is 60 seconds.
- public let expirationBuffer: Double
+ /// If the cached token will expire within X seconds (the buffer), fetch a new token instead. Max is 60 seconds.
+ public let expirationBuffer: Double
- /// Whether to skip the cache lookup and force a call to the server instead, even within the TTL. Useful if the token claims are time-sensitive or depend on data that can be updated (e.g. user fields). Defaults to false.
- public let skipCache: Bool
+ /// Whether to skip the cache lookup and force a call to the server instead, even within the TTL. Useful if the token claims are time-sensitive or depend on data that can be updated (e.g. user fields). Defaults to false.
+ public let skipCache: Bool
- public init(
- template: String? = nil,
- expirationBuffer: Double = 10,
- skipCache: Bool = false
- ) {
- self.template = template
- self.expirationBuffer = min(expirationBuffer, 60)
- self.skipCache = skipCache
+ public init(
+ template: String? = nil,
+ expirationBuffer: Double = 10,
+ skipCache: Bool = false
+ ) {
+ self.template = template
+ self.expirationBuffer = min(expirationBuffer, 60)
+ self.skipCache = skipCache
+ }
}
- }
}
extension Session {
- static let mock = Session(
- id: "1",
- status: .active,
- expireAt: Date(timeIntervalSinceReferenceDate: 1234567890),
- abandonAt: Date(timeIntervalSinceReferenceDate: 1234567890),
- lastActiveAt: Date(timeIntervalSinceReferenceDate: 1234567890),
- latestActivity: nil,
- lastActiveOrganizationId: nil,
- actor: nil,
- user: .mock,
- publicUserData: nil,
- createdAt: Date(timeIntervalSinceReferenceDate: 1234567890),
- updatedAt: Date(timeIntervalSinceReferenceDate: 1234567890),
- lastActiveToken: nil
- )
-
- static let mockExpired = Session(
- id: "1",
- status: .expired,
- expireAt: Date(timeIntervalSinceReferenceDate: 1234567890),
- abandonAt: Date(timeIntervalSinceReferenceDate: 1234567890),
- lastActiveAt: Date(timeIntervalSinceReferenceDate: 1234567890),
- latestActivity: nil,
- lastActiveOrganizationId: nil,
- actor: nil,
- user: .mock,
- publicUserData: nil,
- createdAt: Date(timeIntervalSinceReferenceDate: 1234567890),
- updatedAt: Date(timeIntervalSinceReferenceDate: 1234567890),
- lastActiveToken: nil
- )
+ static let mock = Session(
+ id: "1",
+ status: .active,
+ expireAt: Date(timeIntervalSinceReferenceDate: 1234567890),
+ abandonAt: Date(timeIntervalSinceReferenceDate: 1234567890),
+ lastActiveAt: Date(timeIntervalSinceReferenceDate: 1234567890),
+ latestActivity: .init(
+ id: "1",
+ browserName: "Safari",
+ browserVersion: "17.1.1",
+ deviceType: "iPhone",
+ ipAddress: "196.172.122.88",
+ city: "Detroit",
+ country: "US",
+ isMobile: true
+ ),
+ lastActiveOrganizationId: nil,
+ actor: nil,
+ user: .mock,
+ publicUserData: nil,
+ createdAt: Date(timeIntervalSinceReferenceDate: 1234567890),
+ updatedAt: Date(timeIntervalSinceReferenceDate: 1234567890),
+ lastActiveToken: nil
+ )
+
+ static let mock2 = Session(
+ id: "2",
+ status: .active,
+ expireAt: Date(timeIntervalSinceReferenceDate: 1234567890),
+ abandonAt: Date(timeIntervalSinceReferenceDate: 1234567890),
+ lastActiveAt: Date(timeIntervalSinceReferenceDate: 1234567890),
+ latestActivity: .init(
+ id: "2",
+ browserName: "Chrome",
+ browserVersion: "119.0.0",
+ deviceType: "Macintosh",
+ ipAddress: "196.172.122.88",
+ city: "Detroit",
+ country: "US",
+ isMobile: false
+ ),
+ lastActiveOrganizationId: nil,
+ actor: nil,
+ user: .mock2,
+ publicUserData: nil,
+ createdAt: Date(timeIntervalSinceReferenceDate: 1234567890),
+ updatedAt: Date(timeIntervalSinceReferenceDate: 1234567890),
+ lastActiveToken: nil
+ )
+
+ static let mockExpired = Session(
+ id: "1",
+ status: .expired,
+ expireAt: Date(timeIntervalSinceReferenceDate: 1234567890),
+ abandonAt: Date(timeIntervalSinceReferenceDate: 1234567890),
+ lastActiveAt: Date(timeIntervalSinceReferenceDate: 1234567890),
+ latestActivity: nil,
+ lastActiveOrganizationId: nil,
+ actor: nil,
+ user: .mock,
+ publicUserData: nil,
+ createdAt: Date(timeIntervalSinceReferenceDate: 1234567890),
+ updatedAt: Date(timeIntervalSinceReferenceDate: 1234567890),
+ lastActiveToken: nil
+ )
}
diff --git a/Sources/Clerk/Models/Session/SessionService.swift b/Sources/Clerk/Models/Session/SessionService.swift
index eef09bc49..8fd2eafa0 100644
--- a/Sources/Clerk/Models/Session/SessionService.swift
+++ b/Sources/Clerk/Models/Session/SessionService.swift
@@ -2,35 +2,31 @@
// SessionService.swift
// Clerk
//
-// Created by Mike Pitre on 3/11/25.
+// Created by Mike Pitre on 7/28/25.
//
-import Factory
+import FactoryKit
import Foundation
-struct SessionService {
- var revoke: @MainActor (_ session: Session) async throws -> Session
-}
-
-extension SessionService {
+extension Container {
- static var liveValue: Self {
- .init(
- revoke: { session in
- let request = ClerkFAPI.v1.me.sessions.withId(session.id).revoke.post(
- queryItems: [.init(name: "_clerk_session_id", value: Clerk.shared.session?.id)]
- )
- return try await Container.shared.apiClient().send(request).value.response
- }
- )
- }
+ var sessionService: Factory {
+ self { @MainActor in SessionService() }
+ }
}
-extension Container {
+@MainActor
+struct SessionService {
- var sessionService: Factory {
- self { .liveValue }
- }
+ var revoke: (_ sessionId: String) async throws -> Session = { sessionId in
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/me/sessions/\(sessionId)/revoke")
+ .method(.post)
+ .addClerkSessionId()
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
}
diff --git a/Sources/Clerk/Models/Session/SessionTokenFetcher.swift b/Sources/Clerk/Models/Session/SessionTokenFetcher.swift
index b1471784f..18ced721b 100644
--- a/Sources/Clerk/Models/Session/SessionTokenFetcher.swift
+++ b/Sources/Clerk/Models/Session/SessionTokenFetcher.swift
@@ -5,94 +5,98 @@
// Created by Mike Pitre on 1/19/25.
//
-import Factory
+import FactoryKit
import Foundation
// The purpose of this actor is to NOT trigger refreshes of tokens if a refresh is already in progress.
// This is not a token cache. It is only responsible to returning in progress tasks to refresh a token.
actor SessionTokenFetcher {
- static var shared = SessionTokenFetcher()
+ static let shared = SessionTokenFetcher()
- // Key is `tokenCacheKey` property of a `session`
- var tokenTasks: [String: Task] = [:]
+ // Key is `tokenCacheKey` property of a `session`
+ var tokenTasks: [String: Task] = [:]
- func getToken(_ session: Session, options: Session.GetTokenOptions = .init()) async throws -> TokenResource? {
+ func getToken(_ session: Session, options: Session.GetTokenOptions = .init()) async throws -> TokenResource? {
- let cacheKey = session.tokenCacheKey(template: options.template)
+ let cacheKey = session.tokenCacheKey(template: options.template)
- if let inProgressTask = tokenTasks[cacheKey] {
- return try await inProgressTask.value
- }
-
- let task: Task = Task {
- return try await fetchToken(session, options: options)
- }
+ if let inProgressTask = tokenTasks[cacheKey] {
+ return try await inProgressTask.value
+ }
- tokenTasks[cacheKey] = task
+ let task: Task = Task {
+ return try await fetchToken(session, options: options)
+ }
- let result = await task.result
+ tokenTasks[cacheKey] = task
- // clear the inProgressTask on success AND failure
- tokenTasks[cacheKey] = nil
+ let result = await task.result
- return try result.get()
- }
+ // clear the inProgressTask on success AND failure
+ tokenTasks[cacheKey] = nil
- /**
- Internal function to get the session token. Checks the cache first.
- */
- @discardableResult @MainActor
- func fetchToken(_ session: Session, options: Session.GetTokenOptions = .init()) async throws -> TokenResource? {
- let cacheKey = session.tokenCacheKey(template: options.template)
+ return try result.get()
+ }
- if options.skipCache == false,
- let token = await SessionTokensCache.shared.getToken(cacheKey: cacheKey),
- let expiresAt = token.decodedJWT?.expiresAt,
- Date.now.distance(to: expiresAt) > options.expirationBuffer
- {
- return token
+ /**
+ Internal function to get the session token. Checks the cache first.
+ */
+ @discardableResult @MainActor
+ func fetchToken(_ session: Session, options: Session.GetTokenOptions = .init()) async throws -> TokenResource? {
+ let cacheKey = session.tokenCacheKey(template: options.template)
+
+ if options.skipCache == false,
+ let token = await SessionTokensCache.shared.getToken(cacheKey: cacheKey),
+ let expiresAt = token.decodedJWT?.expiresAt,
+ Date.now.distance(to: expiresAt) > options.expirationBuffer
+ {
+ return token
+ }
+
+ var token: TokenResource?
+
+ if let template = options.template {
+ token = try await Container.shared.apiClient().request()
+ .add(path: "/v1/client/sessions/\(session.id)/tokens/\(template)")
+ .method(.post)
+ .data(type: TokenResource?.self)
+ .async()
+ } else {
+ token = try await Container.shared.apiClient().request()
+ .add(path: "/v1/client/sessions/\(session.id)/tokens")
+ .method(.post)
+ .data(type: TokenResource?.self)
+ .async()
+ }
+
+ if let token {
+ await SessionTokensCache.shared.insertToken(token, cacheKey: cacheKey)
+ }
+
+ return token
}
- var token: TokenResource?
+}
+
+actor SessionTokensCache {
+ static let shared = SessionTokensCache()
- let tokensRequest = ClerkFAPI.v1.client.sessions.id(session.id).tokens
+ private var cache: [String: TokenResource] = [:]
- if let template = options.template {
- let templateTokenRequest = tokensRequest.template(template).post()
- token = try await Container.shared.apiClient().send(templateTokenRequest).value
- } else {
- let defaultTokenRequest = tokensRequest.post()
- token = try await Container.shared.apiClient().send(defaultTokenRequest).value
+ /// Returns a session token from the cache.
+ /// - Parameter cacheKey: cacheKey is the session id + template name if there is one.
+ /// For example, `sess_abc12345` or `sess_abc12345-supabase`.
+ /// - Returns: ``TokenResource``
+ func getToken(cacheKey: String) -> TokenResource? {
+ cache[cacheKey]
}
- if let token {
- await SessionTokensCache.shared.insertToken(token, cacheKey: cacheKey)
+ /// Inserts a session token into the cache.
+ /// - Parameters:
+ /// - token: ``TokenResource``
+ /// - cacheKey: cacheKey is the session id + template name if there is one.
+ /// For example, `sess_abc12345` or `sess_abc12345-supabase`.
+ func insertToken(_ token: TokenResource, cacheKey: String) {
+ cache[cacheKey] = token
}
-
- return token
- }
-
-}
-
-actor SessionTokensCache {
- static let shared = SessionTokensCache()
-
- private var cache: [String: TokenResource] = [:]
-
- /// Returns a session token from the cache.
- /// - Parameter cacheKey: cacheKey is the session id + template name if there is one.
- /// For example, `sess_abc12345` or `sess_abc12345-supabase`.
- /// - Returns: ``TokenResource``
- func getToken(cacheKey: String) -> TokenResource? {
- cache[cacheKey]
- }
-
- /// Inserts a session token into the cache.
- /// - Parameters:
- /// - token: ``TokenResource``
- /// - cacheKey: cacheKey is the session id + template name if there is one.
- /// For example, `sess_abc12345` or `sess_abc12345-supabase`.
- func insertToken(_ token: TokenResource, cacheKey: String) {
- cache[cacheKey] = token
- }
}
diff --git a/Sources/Clerk/Models/SignIn/SignIn.swift b/Sources/Clerk/Models/SignIn/SignIn.swift
index 3a737d866..e36087b2c 100644
--- a/Sources/Clerk/Models/SignIn/SignIn.swift
+++ b/Sources/Clerk/Models/SignIn/SignIn.swift
@@ -5,7 +5,8 @@
// Created by Mike Pitre on 1/30/24.
//
-import Factory
+import AuthenticationServices
+import FactoryKit
import Foundation
/// The `SignIn` object holds the state of the current sign-in process and provides helper methods
@@ -42,200 +43,200 @@ import Foundation
public struct SignIn: Codable, Sendable, Equatable, Hashable {
- /// Unique identifier for this sign in.
- public let id: String
-
- /// The status of the current sign-in.
- ///
- /// See ``SignIn/Status-swift.enum`` for supported values.
- public let status: Status
-
- /// Array of all the authentication identifiers that are supported for this sign in.
- public let supportedIdentifiers: [Identifier]?
-
- /// The authentication identifier value for the current sign-in.
- public let identifier: String?
-
- /// Array of the first factors that are supported in the current sign-in.
- ///
- /// Each factor contains information about the verification strategy that can be used. See the `SignInFirstFactor` type reference for more information.
- public let supportedFirstFactors: [Factor]?
-
- /// Array of the second factors that are supported in the current sign-in.
- ///
- /// Each factor contains information about the verification strategy that can be used. This property is populated only when the first factor is verified. See the `SignInSecondFactor` type reference for more information.
- public let supportedSecondFactors: [Factor]?
-
- /// The state of the verification process for the selected first factor.
- ///
- /// Initially, this property contains an empty verification object, since there is no first factor selected. You need to call the `prepareFirstFactor` method in order to start the verification process.
- public let firstFactorVerification: Verification?
-
- /// The state of the verification process for the selected second factor.
- ///
- /// Initially, this property contains an empty verification object, since there is no second factor selected. For the `phone_code` strategy, you need to call the `prepareSecondFactor` method in order to start the verification process. For the `totp` strategy, you can directly attempt.
- public let secondFactorVerification: Verification?
-
- /// An object containing information about the user of the current sign-in.
- ///
- /// This property is populated only once an identifier is given to the SignIn object.
- public let userData: UserData?
-
- /// The identifier of the session that was created upon completion of the current sign-in.
- ///
- /// The value of this property is `nil` if the sign-in status is not `complete`.
- public let createdSessionId: String?
-
- public init(
- id: String,
- status: SignIn.Status,
- supportedIdentifiers: [SignIn.Identifier]? = nil,
- identifier: String? = nil,
- supportedFirstFactors: [Factor]? = nil,
- supportedSecondFactors: [Factor]? = nil,
- firstFactorVerification: Verification? = nil,
- secondFactorVerification: Verification? = nil,
- userData: SignIn.UserData? = nil,
- createdSessionId: String? = nil
- ) {
- self.id = id
- self.status = status
- self.supportedIdentifiers = supportedIdentifiers
- self.identifier = identifier
- self.supportedFirstFactors = supportedFirstFactors
- self.supportedSecondFactors = supportedSecondFactors
- self.firstFactorVerification = firstFactorVerification
- self.secondFactorVerification = secondFactorVerification
- self.userData = userData
- self.createdSessionId = createdSessionId
- }
+ /// Unique identifier for this sign in.
+ public let id: String
+
+ /// The status of the current sign-in.
+ ///
+ /// See ``SignIn/Status-swift.enum`` for supported values.
+ public let status: Status
+
+ /// Array of all the authentication identifiers that are supported for this sign in.
+ public let supportedIdentifiers: [Identifier]?
+
+ /// The authentication identifier value for the current sign-in.
+ public let identifier: String?
+
+ /// Array of the first factors that are supported in the current sign-in.
+ ///
+ /// Each factor contains information about the verification strategy that can be used. See the `SignInFirstFactor` type reference for more information.
+ public let supportedFirstFactors: [Factor]?
+
+ /// Array of the second factors that are supported in the current sign-in.
+ ///
+ /// Each factor contains information about the verification strategy that can be used. This property is populated only when the first factor is verified. See the `SignInSecondFactor` type reference for more information.
+ public let supportedSecondFactors: [Factor]?
+
+ /// The state of the verification process for the selected first factor.
+ ///
+ /// Initially, this property contains an empty verification object, since there is no first factor selected. You need to call the `prepareFirstFactor` method in order to start the verification process.
+ public let firstFactorVerification: Verification?
+
+ /// The state of the verification process for the selected second factor.
+ ///
+ /// Initially, this property contains an empty verification object, since there is no second factor selected. For the `phone_code` strategy, you need to call the `prepareSecondFactor` method in order to start the verification process. For the `totp` strategy, you can directly attempt.
+ public let secondFactorVerification: Verification?
+
+ /// An object containing information about the user of the current sign-in.
+ ///
+ /// This property is populated only once an identifier is given to the SignIn object.
+ public let userData: UserData?
+
+ /// The identifier of the session that was created upon completion of the current sign-in.
+ ///
+ /// The value of this property is `nil` if the sign-in status is not `complete`.
+ public let createdSessionId: String?
+
+ public init(
+ id: String,
+ status: SignIn.Status,
+ supportedIdentifiers: [SignIn.Identifier]? = nil,
+ identifier: String? = nil,
+ supportedFirstFactors: [Factor]? = nil,
+ supportedSecondFactors: [Factor]? = nil,
+ firstFactorVerification: Verification? = nil,
+ secondFactorVerification: Verification? = nil,
+ userData: SignIn.UserData? = nil,
+ createdSessionId: String? = nil
+ ) {
+ self.id = id
+ self.status = status
+ self.supportedIdentifiers = supportedIdentifiers
+ self.identifier = identifier
+ self.supportedFirstFactors = supportedFirstFactors
+ self.supportedSecondFactors = supportedSecondFactors
+ self.firstFactorVerification = firstFactorVerification
+ self.secondFactorVerification = secondFactorVerification
+ self.userData = userData
+ self.createdSessionId = createdSessionId
+ }
}
extension SignIn {
- /// Returns a new `SignIn` object based on the parameters you pass to it, and stores the sign-in lifecycle state in the status property. Use this method to initiate the sign-in process.
- ///
- /// - Parameters:
- /// - strategy: The strategy used to create the sign-in. See ``SignIn/CreateStrategy`` for the available strategies.
- ///
- /// What you must pass to `strategy` depends on which sign-in options you have enabled in your Clerk application instance.
- ///
- /// - Returns: A new `SignIn` object.
- /// - Throws: An error if the sign-in request fails.
- ///
- ///### Example Usage:
- /// ```swift
- /// let signIn = try await SignIn.create(
- /// strategy: .identifier("user@email.com", password: "••••••••"))
- /// )
- /// ```
- @discardableResult @MainActor
- public static func create(strategy: SignIn.CreateStrategy) async throws -> SignIn {
- try await Container.shared.signInService().create(strategy)
- }
-
- /// Returns a new `SignIn` object based on the parameters you pass to it, and stores the sign-in lifecycle state in the status property. Use this method to initiate the sign-in process.
- ///
- /// - Parameters:
- /// - params: A dictionary of parameters used to create the sign-in.
- ///
- /// What you must pass to `params` depends on which sign-in options you have enabled in your Clerk application instance.
- ///
- /// - Returns: A new `SignIn` object.
- /// - Throws: An error if the sign-in request fails.
- ///
- ///### Example Usage:
- /// ```swift
- /// let signIn = try await SignIn.create(
- /// ["identifier": "user@email.com", "password": "••••••••"]
- /// )
- /// ```
- @discardableResult @MainActor
- public static func create(_ params: T) async throws -> SignIn {
- try await Container.shared.signInService().createRaw(AnyEncodable(params))
- }
-
- /// Resets a user's password.
- ///
- /// This function allows users to reset their password by providing their current password and optionally logging them out of all other active sessions. Once the password is reset, the `SignIn` object is returned, reflecting the updated user session state.
- ///
- /// - Parameters:
- /// - params: See ``SignIn/ResetPasswordParams`` for the available parameters.
- /// - Returns: A `SignIn` object reflecting the updated user session after the password reset.
- /// - Throws: An error if the password reset attempt fails.
- @discardableResult @MainActor
- public func resetPassword(_ params: ResetPasswordParams) async throws -> SignIn {
- try await Container.shared.signInService().resetPassword(self, params)
- }
-
- /// Begins the first factor verification process.
- ///
- /// This is a required step to complete a sign-in, as users must be verified by at least one factor of authentication. The verification method is determined by the provided `PrepareFirstFactorStrategy`.
- ///
- /// Common scenarios include one-time code (OTP) or social account (SSO) verification. Each authentication identifier supports different strategies. The status of the first factor verification process can be checked using the `firstFactorVerification` attribute of the returned `SignIn` object.
- ///
- /// - Parameters:
- /// - prepareFirstFactorStrategy: The strategy to use for the first factor verification. See ``SignIn/PrepareFirstFactorStrategy`` for available strategies.
- /// - Returns: A `SignIn` object reflecting the current state of the sign-in process, including the status of the first factor verification.
- /// - Throws: An error if the first factor preparation fails.
- @discardableResult @MainActor
- public func prepareFirstFactor(strategy: PrepareFirstFactorStrategy) async throws -> SignIn {
- try await Container.shared.signInService().prepareFirstFactor(self, strategy)
- }
-
- /// Attempts to complete the first factor verification process.
- ///
- /// This is a required step in order to complete a sign-in, as users must be verified at least by one factor of authentication. The verification method is determined by the provided `AttemptFirstFactorStrategy`. Depending on the selected strategy, the parameters may vary.
- ///
- ///
- /// - Parameters:
- /// - attemptFirstFactorStrategy: The strategy to use for the first factor verification. See ``SignIn/AttemptFirstFactorStrategy`` for available strategies and their respective parameters.
- /// - Returns: A `SignIn` object reflecting the current state of the sign-in process, including the status of the first factor verification.
- /// - Throws: An error if the first factor attempt fails.
- /// - Important: Call this method after preparing the verification process using one of the available strategies.
- /// - Important: Ensure that a `SignIn` object already exists before calling this method, by first calling `SignIn.create` and then `SignIn.prepareFirstFactor`. The only strategy that does not require a prior verification is the `password` strategy.
- @discardableResult @MainActor
- public func attemptFirstFactor(strategy: AttemptFirstFactorStrategy) async throws -> SignIn {
- try await Container.shared.signInService().attemptFirstFactor(self, strategy)
- }
-
- /// Begins the second factor verification process.
- ///
- /// This step is optional in order to complete a sign in.
- ///
- /// A common scenario for the second step verification (2FA) is to require a one-time code (OTP) as proof of identity. This is determined by the accepted strategy parameter values. Each authentication identifier supports different strategies.
- ///
- /// - Parameters:
- /// - prepareSecondFactorStrategy: An enum that defines the strategy for the second factor verification. See ``SignIn/PrepareSecondFactorStrategy`` for available strategies.
- ///
- /// - Returns: A `SignIn` object. Check the secondFactorVerification attribute for the status of the second factor verification process.
- ///
- /// - Throws: An error if the second factor verification fails.
- @discardableResult @MainActor
- public func prepareSecondFactor(strategy: PrepareSecondFactorStrategy) async throws -> SignIn {
- try await Container.shared.signInService().prepareSecondFactor(self, strategy)
- }
-
- /// Attempts to complete the second factor verification process (2FA).
- ///
- /// This step is optional in order to complete a sign in.
- ///
- /// For the `phone_code` strategy, make sure that a verification has already been prepared before you call this method, by first calling `SignIn.prepareSecondFactor`. Depending on the strategy that was selected when the verification was prepared, the method parameters should be different.
- ///
- /// The `totp` strategy can directly be attempted, without the need for preparation.
- ///
- /// - Parameters:
- /// - strategy: An enum that defines the strategy for second factor verification. See ``SignIn/AttemptSecondFactorStrategy`` for available strategies.
- ///
- /// - Returns: A `SignIn` object. Check the `secondFactorVerification` attribute for the status of the second factor verification process.
- ///
- /// - Throws: An error if the second factor verification fails.
- @discardableResult @MainActor
- public func attemptSecondFactor(strategy: AttemptSecondFactorStrategy) async throws -> SignIn {
- try await Container.shared.signInService().attemptSecondFactor(self, strategy)
- }
-
- #if !os(tvOS) && !os(watchOS)
+ /// Returns a new `SignIn` object based on the parameters you pass to it, and stores the sign-in lifecycle state in the status property. Use this method to initiate the sign-in process.
+ ///
+ /// - Parameters:
+ /// - strategy: The strategy used to create the sign-in. See ``SignIn/CreateStrategy`` for the available strategies.
+ ///
+ /// What you must pass to `strategy` depends on which sign-in options you have enabled in your Clerk application instance.
+ ///
+ /// - Returns: A new `SignIn` object.
+ /// - Throws: An error if the sign-in request fails.
+ ///
+ ///### Example Usage:
+ /// ```swift
+ /// let signIn = try await SignIn.create(
+ /// strategy: .identifier("user@email.com", password: "••••••••"))
+ /// )
+ /// ```
+ @discardableResult @MainActor
+ public static func create(strategy: SignIn.CreateStrategy) async throws -> SignIn {
+ try await Container.shared.signInService().create(strategy)
+ }
+
+ /// Returns a new `SignIn` object based on the parameters you pass to it, and stores the sign-in lifecycle state in the status property. Use this method to initiate the sign-in process.
+ ///
+ /// - Parameters:
+ /// - params: A dictionary of parameters used to create the sign-in.
+ ///
+ /// What you must pass to `params` depends on which sign-in options you have enabled in your Clerk application instance.
+ ///
+ /// - Returns: A new `SignIn` object.
+ /// - Throws: An error if the sign-in request fails.
+ ///
+ ///### Example Usage:
+ /// ```swift
+ /// let signIn = try await SignIn.create(
+ /// ["identifier": "user@email.com", "password": "••••••••"]
+ /// )
+ /// ```
+ @discardableResult @MainActor
+ public static func create(_ params: T) async throws -> SignIn {
+ try await Container.shared.signInService().createWithParams(params)
+ }
+
+ /// Resets a user's password.
+ ///
+ /// This function allows users to reset their password by providing their current password and optionally logging them out of all other active sessions. Once the password is reset, the `SignIn` object is returned, reflecting the updated user session state.
+ ///
+ /// - Parameters:
+ /// - params: See ``SignIn/ResetPasswordParams`` for the available parameters.
+ /// - Returns: A `SignIn` object reflecting the updated user session after the password reset.
+ /// - Throws: An error if the password reset attempt fails.
+ @discardableResult @MainActor
+ public func resetPassword(_ params: ResetPasswordParams) async throws -> SignIn {
+ try await Container.shared.signInService().resetPassword(id, params)
+ }
+
+ /// Begins the first factor verification process.
+ ///
+ /// This is a required step to complete a sign-in, as users must be verified by at least one factor of authentication. The verification method is determined by the provided `PrepareFirstFactorStrategy`.
+ ///
+ /// Common scenarios include one-time code (OTP) or social account (SSO) verification. Each authentication identifier supports different strategies. The status of the first factor verification process can be checked using the `firstFactorVerification` attribute of the returned `SignIn` object.
+ ///
+ /// - Parameters:
+ /// - prepareFirstFactorStrategy: The strategy to use for the first factor verification. See ``SignIn/PrepareFirstFactorStrategy`` for available strategies.
+ /// - Returns: A `SignIn` object reflecting the current state of the sign-in process, including the status of the first factor verification.
+ /// - Throws: An error if the first factor preparation fails.
+ @discardableResult @MainActor
+ public func prepareFirstFactor(strategy: PrepareFirstFactorStrategy) async throws -> SignIn {
+ try await Container.shared.signInService().prepareFirstFactor(id, strategy, self)
+ }
+
+ /// Attempts to complete the first factor verification process.
+ ///
+ /// This is a required step in order to complete a sign-in, as users must be verified at least by one factor of authentication. The verification method is determined by the provided `AttemptFirstFactorStrategy`. Depending on the selected strategy, the parameters may vary.
+ ///
+ ///
+ /// - Parameters:
+ /// - attemptFirstFactorStrategy: The strategy to use for the first factor verification. See ``SignIn/AttemptFirstFactorStrategy`` for available strategies and their respective parameters.
+ /// - Returns: A `SignIn` object reflecting the current state of the sign-in process, including the status of the first factor verification.
+ /// - Throws: An error if the first factor attempt fails.
+ /// - Important: Call this method after preparing the verification process using one of the available strategies.
+ /// - Important: Ensure that a `SignIn` object already exists before calling this method, by first calling `SignIn.create` and then `SignIn.prepareFirstFactor`. The only strategy that does not require a prior verification is the `password` strategy.
+ @discardableResult @MainActor
+ public func attemptFirstFactor(strategy: AttemptFirstFactorStrategy) async throws -> SignIn {
+ try await Container.shared.signInService().attemptFirstFactor(id, strategy)
+ }
+
+ /// Begins the second factor verification process.
+ ///
+ /// This step is optional in order to complete a sign in.
+ ///
+ /// A common scenario for the second step verification (2FA) is to require a one-time code (OTP) as proof of identity. This is determined by the accepted strategy parameter values. Each authentication identifier supports different strategies.
+ ///
+ /// - Parameters:
+ /// - prepareSecondFactorStrategy: An enum that defines the strategy for the second factor verification. See ``SignIn/PrepareSecondFactorStrategy`` for available strategies.
+ ///
+ /// - Returns: A `SignIn` object. Check the secondFactorVerification attribute for the status of the second factor verification process.
+ ///
+ /// - Throws: An error if the second factor verification fails.
+ @discardableResult @MainActor
+ public func prepareSecondFactor(strategy: PrepareSecondFactorStrategy) async throws -> SignIn {
+ try await Container.shared.signInService().prepareSecondFactor(id, strategy)
+ }
+
+ /// Attempts to complete the second factor verification process (2FA).
+ ///
+ /// This step is optional in order to complete a sign in.
+ ///
+ /// For the `phone_code` strategy, make sure that a verification has already been prepared before you call this method, by first calling `SignIn.prepareSecondFactor`. Depending on the strategy that was selected when the verification was prepared, the method parameters should be different.
+ ///
+ /// The `totp` strategy can directly be attempted, without the need for preparation.
+ ///
+ /// - Parameters:
+ /// - strategy: An enum that defines the strategy for second factor verification. See ``SignIn/AttemptSecondFactorStrategy`` for available strategies.
+ ///
+ /// - Returns: A `SignIn` object. Check the `secondFactorVerification` attribute for the status of the second factor verification process.
+ ///
+ /// - Throws: An error if the second factor verification fails.
+ @discardableResult @MainActor
+ public func attemptSecondFactor(strategy: AttemptSecondFactorStrategy) async throws -> SignIn {
+ try await Container.shared.signInService().attemptSecondFactor(id, strategy)
+ }
+
+ #if !os(tvOS) && !os(watchOS)
/// Creates a new ``SignIn`` and initiates an external authentication flow using a redirect-based strategy.
///
/// This function handles the process of creating a ``SignIn`` instance,
@@ -261,11 +262,11 @@ extension SignIn {
/// ```
@discardableResult @MainActor
public static func authenticateWithRedirect(strategy: SignIn.AuthenticateWithRedirectStrategy, prefersEphemeralWebBrowserSession: Bool = false) async throws -> TransferFlowResult {
- try await Container.shared.signInService().authenticateWithRedirectCombined(strategy, prefersEphemeralWebBrowserSession)
+ try await Container.shared.signInService().authenticateWithRedirectStatic(strategy, prefersEphemeralWebBrowserSession)
}
- #endif
+ #endif
- #if !os(tvOS) && !os(watchOS)
+ #if !os(tvOS) && !os(watchOS)
/// Initiates an external authentication flow using a redirect-based strategy for the current ``SignIn`` instance.
///
/// This function starts an external web authentication session,
@@ -290,12 +291,12 @@ extension SignIn {
/// ```
@discardableResult @MainActor
public func authenticateWithRedirect(prefersEphemeralWebBrowserSession: Bool = false) async throws -> TransferFlowResult {
- try await Container.shared.signInService().authenticateWithRedirectTwoStep(self, prefersEphemeralWebBrowserSession)
+ try await Container.shared.signInService().authenticateWithRedirect(self, prefersEphemeralWebBrowserSession)
}
- #endif
+ #endif
- #if canImport(AuthenticationServices) && !os(watchOS) && !os(tvOS)
+ #if canImport(AuthenticationServices) && !os(watchOS) && !os(tvOS)
/// Presents the system sheet to allow the user to sign in using their passkey.
///
/// This method handles the process of requesting a credential for passkey-based authentication by interacting with the
@@ -321,118 +322,125 @@ extension SignIn {
/// and formats them according to the WebAuthn standard.
@MainActor
public func getCredentialForPasskey(autofill: Bool = false, preferImmediatelyAvailableCredentials: Bool = true) async throws -> String {
- try await Container.shared.signInService().getCredentialForPasskey(self, autofill, preferImmediatelyAvailableCredentials)
+ try await Container.shared.signInService().getCredentialForPasskey(self, autofill, preferImmediatelyAvailableCredentials)
+ }
+ #endif
+
+ /// Authenticates the user using an ID Token and a specified provider.
+ ///
+ /// This method facilitates authentication using an ID token provided by a specific authentication provider.
+ /// It determines whether the user needs to be transferred to a sign-up flow.
+ ///
+ /// - Parameters:
+ /// - provider: The identity provider associated with the ID token. See ``IDTokenProvider`` for supported values.
+ /// - idToken: The ID token to use for authentication, obtained from the provider during the sign-in process.
+ ///
+ /// - Throws:``ClerkClientError``
+ ///
+ /// - Returns: An ``TransferFlowResult`` containing either a sign-in or a newly created sign-up instance.
+ ///
+ /// ### Example
+ /// ```swift
+ /// let result = try await SignIn.authenticateWithIdToken(
+ /// provider: .apple,
+ /// idToken: idToken
+ /// )
+ /// ```
+ @discardableResult @MainActor
+ public static func authenticateWithIdToken(provider: IDTokenProvider, idToken: String) async throws -> TransferFlowResult {
+ try await Container.shared.signInService().authenticateWithIdTokenStatic(provider, idToken)
+ }
+
+ /// Authenticates the user using an ID Token and a specified provider.
+ ///
+ /// This method completes authentication using an ID token provided by a specific authentication provider.
+ /// It determines whether the user needs to be transferred to a sign-up flow.
+ ///
+ /// - Throws:``ClerkClientError``
+ ///
+ /// - Returns: ``TransferFlowResult`` containing either a sign-in or a newly created sign-up instance.
+ ///
+ /// ### Example
+ /// ```swift
+ /// let signIn = try await SignIn.create(strategy: .idToken(provider: .apple, idToken: "idToken"))
+ /// let result = try await signIn.authenticateWithIdToken()
+ /// ```
+ @discardableResult @MainActor
+ public func authenticateWithIdToken() async throws -> TransferFlowResult {
+ try await Container.shared.signInService().authenticateWithIdToken(self)
+ }
+
+ /// Returns the current sign-in.
+ @discardableResult @MainActor
+ public func get(rotatingTokenNonce: String? = nil) async throws -> SignIn {
+ try await Container.shared.signInService().get(id, rotatingTokenNonce)
}
- #endif
-
- /// Authenticates the user using an ID Token and a specified provider.
- ///
- /// This method facilitates authentication using an ID token provided by a specific authentication provider.
- /// It determines whether the user needs to be transferred to a sign-up flow.
- ///
- /// - Parameters:
- /// - provider: The identity provider associated with the ID token. See ``IDTokenProvider`` for supported values.
- /// - idToken: The ID token to use for authentication, obtained from the provider during the sign-in process.
- ///
- /// - Throws:``ClerkClientError``
- ///
- /// - Returns: An ``TransferFlowResult`` containing either a sign-in or a newly created sign-up instance.
- ///
- /// ### Example
- /// ```swift
- /// let result = try await SignIn.authenticateWithIdToken(
- /// provider: .apple,
- /// idToken: idToken
- /// )
- /// ```
- @discardableResult @MainActor
- public static func authenticateWithIdToken(provider: IDTokenProvider, idToken: String) async throws -> TransferFlowResult {
- try await Container.shared.signInService().authenticateWithIdTokenCombined(provider, idToken)
- }
-
- /// Authenticates the user using an ID Token and a specified provider.
- ///
- /// This method completes authentication using an ID token provided by a specific authentication provider.
- /// It determines whether the user needs to be transferred to a sign-up flow.
- ///
- /// - Throws:``ClerkClientError``
- ///
- /// - Returns: ``TransferFlowResult`` containing either a sign-in or a newly created sign-up instance.
- ///
- /// ### Example
- /// ```swift
- /// let signIn = try await SignIn.create(strategy: .idToken(provider: .apple, idToken: "idToken"))
- /// let result = try await signIn.authenticateWithIdToken()
- /// ```
- @discardableResult @MainActor
- public func authenticateWithIdToken() async throws -> TransferFlowResult {
- try await Container.shared.signInService().authenticateWithIdTokenTwoStep(self)
- }
-
- /// Returns the current sign-in.
- @discardableResult @MainActor
- public func get(rotatingTokenNonce: String? = nil) async throws -> SignIn {
- try await Container.shared.signInService().get(self, rotatingTokenNonce)
- }
}
extension SignIn {
- // MARK: - Internal Helpers
-
- /// Handles the callback url from external authentication. Determines whether to return a sign in or sign up.
- @discardableResult @MainActor
- func handleOAuthCallbackUrl(_ url: URL) async throws -> TransferFlowResult {
- if let nonce = ExternalAuthUtils.nonceFromCallbackUrl(url: url) {
- let updatedSignIn = try await get(rotatingTokenNonce: nonce)
- return .signIn(updatedSignIn)
- } else {
- // transfer flow
- let signIn = try await get()
- let result = try await signIn.handleTransferFlow()
- return result
+ // MARK: - Internal Helpers
+
+ /// Handles the callback url from external authentication. Determines whether to return a sign in or sign up.
+ @discardableResult @MainActor
+ func handleOAuthCallbackUrl(_ url: URL) async throws -> TransferFlowResult {
+ if let nonce = ExternalAuthUtils.nonceFromCallbackUrl(url: url) {
+ let updatedSignIn = try await get(rotatingTokenNonce: nonce)
+ return .signIn(updatedSignIn)
+ } else {
+ // transfer flow
+ let signIn = try await get()
+ let result = try await signIn.handleTransferFlow()
+ return result
+ }
}
- }
-
- /// Determines whether or not to return a sign in or sign up object as part of the transfer flow.
- func handleTransferFlow() async throws -> TransferFlowResult {
- if needsTransferToSignUp == true {
- let signUp = try await SignUp.create(strategy: .transfer)
- return .signUp(signUp)
- } else {
- return .signIn(self)
+
+ /// Determines whether or not to return a sign in or sign up object as part of the transfer flow.
+ func handleTransferFlow() async throws -> TransferFlowResult {
+ if needsTransferToSignUp == true {
+ let signUp = try await SignUp.create(strategy: .transfer)
+ return .signUp(signUp)
+ } else {
+ return .signIn(self)
+ }
}
- }
- /// Helper to determine if the SignIn needs to be transferred to a SignUp
- var needsTransferToSignUp: Bool {
- firstFactorVerification?.status == .transferable || secondFactorVerification?.status == .transferable
- }
+ /// Helper to determine if the SignIn needs to be transferred to a SignUp
+ var needsTransferToSignUp: Bool {
+ firstFactorVerification?.status == .transferable || secondFactorVerification?.status == .transferable
+ }
- /// The first factor for the identifier that was used to initiate the SignIn
- func identifyingFirstFactor(strategy: PrepareFirstFactorStrategy) -> Factor? {
- supportedFirstFactors?.first(where: { factor in
- factor.strategy == strategy.strategy && factor.safeIdentifier == identifier
- })
- }
+ /// The first factor for the identifier that was used to initiate the SignIn
+ func identifyingFirstFactor(strategy: PrepareFirstFactorStrategy) -> Factor? {
+ supportedFirstFactors?.first(where: { factor in
+ factor.strategy == strategy.strategy && factor.safeIdentifier == identifier
+ })
+ }
}
extension SignIn {
- static var mock: SignIn {
- SignIn(
- id: "1",
- status: .needsIdentifier,
- supportedIdentifiers: [.emailAddress, .phoneNumber],
- identifier: User.mock.emailAddresses.first?.emailAddress,
- supportedFirstFactors: [.mock],
- supportedSecondFactors: nil,
- firstFactorVerification: .mockEmailCodeUnverifiedVerification,
- secondFactorVerification: nil,
- userData: nil,
- createdSessionId: nil
- )
- }
+ static var mock: SignIn {
+ SignIn(
+ id: "1",
+ status: .needsIdentifier,
+ supportedIdentifiers: [.emailAddress, .phoneNumber],
+ identifier: User.mock.emailAddresses.first?.emailAddress,
+ supportedFirstFactors: [
+ .mockEmailCode,
+ .mockPhoneCode,
+ .mockGoogle,
+ .mockApple,
+ .mockPasskey,
+ .mockPassword
+ ],
+ supportedSecondFactors: nil,
+ firstFactorVerification: .mockEmailCodeUnverifiedVerification,
+ secondFactorVerification: nil,
+ userData: nil,
+ createdSessionId: nil
+ )
+ }
}
diff --git a/Sources/Clerk/Models/SignIn/SignInAttemptFirstFactorParams.swift b/Sources/Clerk/Models/SignIn/SignInAttemptFirstFactorParams.swift
index 8f9d08aef..1f2143d41 100644
--- a/Sources/Clerk/Models/SignIn/SignInAttemptFirstFactorParams.swift
+++ b/Sources/Clerk/Models/SignIn/SignInAttemptFirstFactorParams.swift
@@ -7,67 +7,67 @@
extension SignIn {
- /// A parameter object for attempting the first factor verification process.
- public struct AttemptFirstFactorParams: Encodable {
+ /// A parameter object for attempting the first factor verification process.
+ public struct AttemptFirstFactorParams: Encodable {
- /// The verification strategy being used.
- public let strategy: String
+ /// The verification strategy being used.
+ public let strategy: String
- /// The one-time code sent to the user (if applicable).
- public var code: String?
+ /// The one-time code sent to the user (if applicable).
+ public var code: String?
- /// The user's password (if applicable).
- public var password: String?
+ /// The user's password (if applicable).
+ public var password: String?
- /// The user's passkey public key credential (if applicable).
- public var publicKeyCredential: String?
- }
+ /// The user's passkey public key credential (if applicable).
+ public var publicKeyCredential: String?
+ }
- /// Defines the available strategies for completing the first factor verification process.
- ///
- /// Each strategy specifies a method of verifying the user during the sign-in process. The selected strategy determines how the verification will be carried out and which parameters are required.
- public enum AttemptFirstFactorStrategy: Sendable {
+ /// Defines the available strategies for completing the first factor verification process.
+ ///
+ /// Each strategy specifies a method of verifying the user during the sign-in process. The selected strategy determines how the verification will be carried out and which parameters are required.
+ public enum AttemptFirstFactorStrategy: Sendable {
- /// Verification using the user's password.
- /// - Parameter password: The user's password string to be verified.
- case password(password: String)
+ /// Verification using the user's password.
+ /// - Parameter password: The user's password string to be verified.
+ case password(password: String)
- /// Verification using a one-time code sent to the user's email.
- /// - Parameter code: The one-time code that was sent to the user's email.
- case emailCode(code: String)
+ /// Verification using a one-time code sent to the user's email.
+ /// - Parameter code: The one-time code that was sent to the user's email.
+ case emailCode(code: String)
- /// Verification using a one-time code sent to the user's phone.
- /// - Parameter code: The one-time code that was sent to the user's phone.
- case phoneCode(code: String)
+ /// Verification using a one-time code sent to the user's phone.
+ /// - Parameter code: The one-time code that was sent to the user's phone.
+ case phoneCode(code: String)
- /// Verification using the user's passkey.
- /// - Parameter publicKeyCredential: The user's passkey public key credential.
- case passkey(publicKeyCredential: String)
+ /// Verification using the user's passkey.
+ /// - Parameter publicKeyCredential: The user's passkey public key credential.
+ case passkey(publicKeyCredential: String)
- /// Verification during password reset using a one-time code sent to the user's email.
- /// - Parameter code: The one-time code that was sent to the user's email for password reset.
- case resetPasswordEmailCode(code: String)
+ /// Verification during password reset using a one-time code sent to the user's email.
+ /// - Parameter code: The one-time code that was sent to the user's email for password reset.
+ case resetPasswordEmailCode(code: String)
- /// Verification during password reset using a one-time code sent to the user's phone.
- /// - Parameter code: The one-time code that was sent to the user's phone for password reset.
- case resetPasswordPhoneCode(code: String)
+ /// Verification during password reset using a one-time code sent to the user's phone.
+ /// - Parameter code: The one-time code that was sent to the user's phone for password reset.
+ case resetPasswordPhoneCode(code: String)
- /// The parameters for the selected strategy.
- var params: AttemptFirstFactorParams {
- switch self {
- case .password(let password):
- return .init(strategy: "password", password: password)
- case .emailCode(let code):
- return .init(strategy: "email_code", code: code)
- case .phoneCode(let code):
- return .init(strategy: "phone_code", code: code)
- case .passkey(let publicKeyCredential):
- return .init(strategy: "passkey", publicKeyCredential: publicKeyCredential)
- case .resetPasswordEmailCode(let code):
- return .init(strategy: "reset_password_email_code", code: code)
- case .resetPasswordPhoneCode(let code):
- return .init(strategy: "reset_password_phone_code", code: code)
- }
+ /// The parameters for the selected strategy.
+ var params: AttemptFirstFactorParams {
+ switch self {
+ case .password(let password):
+ return .init(strategy: "password", password: password)
+ case .emailCode(let code):
+ return .init(strategy: "email_code", code: code)
+ case .phoneCode(let code):
+ return .init(strategy: "phone_code", code: code)
+ case .passkey(let publicKeyCredential):
+ return .init(strategy: "passkey", publicKeyCredential: publicKeyCredential)
+ case .resetPasswordEmailCode(let code):
+ return .init(strategy: "reset_password_email_code", code: code)
+ case .resetPasswordPhoneCode(let code):
+ return .init(strategy: "reset_password_phone_code", code: code)
+ }
+ }
}
- }
}
diff --git a/Sources/Clerk/Models/SignIn/SignInAttemptSecondFactorParams.swift b/Sources/Clerk/Models/SignIn/SignInAttemptSecondFactorParams.swift
index 2fb146d53..33c7ebb92 100644
--- a/Sources/Clerk/Models/SignIn/SignInAttemptSecondFactorParams.swift
+++ b/Sources/Clerk/Models/SignIn/SignInAttemptSecondFactorParams.swift
@@ -9,38 +9,38 @@ import Foundation
extension SignIn {
- /// A parameter object for attempting the second factor verification process.
- public struct AttemptSecondFactorParams: Encodable {
- /// The strategy used for second factor verification.
- public let strategy: String
-
- /// The verification code sent to the user or generated by the authenticator app.
- public let code: String
- }
-
- /// A strategy for attempting the second factor verification process.
- public enum AttemptSecondFactorStrategy: Sendable {
- /// User will receive a one-time authentication code via SMS. The code should be provided as part of the verification process.
- case phoneCode(code: String)
-
- /// User must provide a 6-digit TOTP code generated by their authenticator app.
- case totp(code: String)
-
- /// User can provide a backup code for verification.
- case backupCode(code: String)
-
- /// Converts the strategy to parameters for the second factor verification process.
- ///
- /// - Returns: An `AttemptSecondFactorParams` object containing the strategy and the verification code.
- var params: AttemptSecondFactorParams {
- switch self {
- case .phoneCode(let code):
- return .init(strategy: "phone_code", code: code)
- case .totp(let code):
- return .init(strategy: "totp", code: code)
- case .backupCode(let code):
- return .init(strategy: "backup_code", code: code)
- }
+ /// A parameter object for attempting the second factor verification process.
+ public struct AttemptSecondFactorParams: Encodable {
+ /// The strategy used for second factor verification.
+ public let strategy: String
+
+ /// The verification code sent to the user or generated by the authenticator app.
+ public let code: String
+ }
+
+ /// A strategy for attempting the second factor verification process.
+ public enum AttemptSecondFactorStrategy: Sendable {
+ /// User will receive a one-time authentication code via SMS. The code should be provided as part of the verification process.
+ case phoneCode(code: String)
+
+ /// User must provide a 6-digit TOTP code generated by their authenticator app.
+ case totp(code: String)
+
+ /// User can provide a backup code for verification.
+ case backupCode(code: String)
+
+ /// Converts the strategy to parameters for the second factor verification process.
+ ///
+ /// - Returns: An `AttemptSecondFactorParams` object containing the strategy and the verification code.
+ var params: AttemptSecondFactorParams {
+ switch self {
+ case .phoneCode(let code):
+ return .init(strategy: "phone_code", code: code)
+ case .totp(let code):
+ return .init(strategy: "totp", code: code)
+ case .backupCode(let code):
+ return .init(strategy: "backup_code", code: code)
+ }
+ }
}
- }
}
diff --git a/Sources/Clerk/Models/SignIn/SignInAuthenticateWithRedirectParams.swift b/Sources/Clerk/Models/SignIn/SignInAuthenticateWithRedirectParams.swift
index d1062eaa1..c986b853d 100644
--- a/Sources/Clerk/Models/SignIn/SignInAuthenticateWithRedirectParams.swift
+++ b/Sources/Clerk/Models/SignIn/SignInAuthenticateWithRedirectParams.swift
@@ -9,61 +9,52 @@ import Foundation
extension SignIn {
- /// Represents the parameters used for authenticating with a redirect.
- ///
- /// This structure is used to authenticate a user with various strategies, including OAuth, SAML, and enterprise SSO. It defines the necessary parameters for the authentication process, such as the redirect URLs and legal acceptance.
- ///
- /// - SeeAlso: `AuthenticateWithRedirect` function for initiating authentication with these parameters.
- struct AuthenticateWithRedirectParams: Encodable {
+ /// Represents the parameters used for authenticating with a redirect.
+ ///
+ /// This structure is used to authenticate a user with various strategies, including OAuth, SAML, and enterprise SSO. It defines the necessary parameters for the authentication process, such as the redirect URLs and legal acceptance.
+ ///
+ /// - SeeAlso: `AuthenticateWithRedirect` function for initiating authentication with these parameters.
+ struct AuthenticateWithRedirectParams: Encodable {
- /// The strategy to use for authentication.
- let strategy: String
-
- /// The full URL or path that the OAuth provider should redirect to, on successful authorization on their part.
- let redirectUrl: String
-
- /// The email address used to target an enterprise connection during sign-in. This is optional and only used when the strategy involves enterprise authentication.
- var emailAddress: String?
+ /// The strategy to use for authentication.
+ let strategy: String
- /// A boolean indicating whether the user has agreed to the legal compliance documents. This is an optional field.
- var legalAccepted: Bool?
+ /// The full URL or path that the OAuth provider should redirect to, on successful authorization on their part.
+ let redirectUrl: String
- /// The identifier associated with the user or the authentication session. This is an optional field.
- var identifier: String?
- }
+ /// The email address used to target an enterprise connection during sign-in. This is optional and only used when the strategy involves enterprise authentication.
+ var emailAddress: String?
- /// The strategy to use for authentication.
- public enum AuthenticateWithRedirectStrategy: Codable, Sendable {
- /// The user will be authenticated with their social connection account.
- case oauth(provider: OAuthProvider, redirectUrl: String? = nil)
+ /// A boolean indicating whether the user has agreed to the legal compliance documents. This is an optional field.
+ var legalAccepted: Bool?
- /// The user will be authenticated with their enterprise SSO account.
- case enterpriseSSO(identifier: String, redirectUrl: String? = nil)
-
- var signInStrategy: SignIn.CreateStrategy {
- switch self {
- case .oauth(let provider, let redirectUrl):
- return .oauth(provider: provider, redirectUrl: redirectUrl ?? RedirectConfigDefaults.redirectUrl)
- case .enterpriseSSO(let identifier, let redirectUrl):
- return .enterpriseSSO(identifier: identifier, redirectUrl: redirectUrl ?? RedirectConfigDefaults.redirectUrl)
- }
+ /// The identifier associated with the user or the authentication session. This is an optional field.
+ var identifier: String?
}
- var params: AuthenticateWithRedirectParams {
- switch self {
- case .oauth(let provider, let redirectUrl):
- .init(
- strategy: provider.strategy,
- redirectUrl: redirectUrl ?? RedirectConfigDefaults.redirectUrl
- )
- case .enterpriseSSO(let identifier, let redirectUrl):
- .init(
- strategy: "enterprise_sso",
- redirectUrl: redirectUrl ?? RedirectConfigDefaults.redirectUrl,
- identifier: identifier
- )
- }
+ /// The strategy to use for authentication.
+ public enum AuthenticateWithRedirectStrategy: Codable, Sendable {
+ /// The user will be authenticated with their social connection account.
+ case oauth(provider: OAuthProvider, redirectUrl: String? = nil)
+
+ /// The user will be authenticated with their enterprise SSO account.
+ case enterpriseSSO(identifier: String, redirectUrl: String? = nil)
+
+ @MainActor
+ var signInStrategy: SignIn.CreateStrategy {
+ switch self {
+ case .oauth(let provider, let redirectUrl):
+ return .oauth(
+ provider: provider,
+ redirectUrl: redirectUrl ?? Clerk.shared.settings.redirectConfig.redirectUrl
+ )
+ case .enterpriseSSO(let identifier, let redirectUrl):
+ return .enterpriseSSO(
+ identifier: identifier,
+ redirectUrl: redirectUrl ?? Clerk.shared.settings.redirectConfig.redirectUrl
+ )
+ }
+ }
}
- }
}
diff --git a/Sources/Clerk/Models/SignIn/SignInCreateParams.swift b/Sources/Clerk/Models/SignIn/SignInCreateParams.swift
index ef2e55bcd..eb3add32d 100644
--- a/Sources/Clerk/Models/SignIn/SignInCreateParams.swift
+++ b/Sources/Clerk/Models/SignIn/SignInCreateParams.swift
@@ -5,156 +5,157 @@
// Created by Mike Pitre on 1/21/25.
//
+import FactoryKit
import Foundation
extension SignIn {
- /// Represents the parameters required to initiate a sign-in flow.
- ///
- /// This structure encapsulates the various options for initiating a sign-in, including the authentication strategy, user identifier, optional passwords, and additional settings for redirect URLs or OAuth-specific parameters.
- public struct SignInCreateParams: Encodable {
-
- /// The first factor verification strategy to use in the sign-in flow.
- ///
- /// Depends on the `identifier` value, and each authentication identifier supports different verification strategies.
- var strategy: String?
-
- /// The authentication identifier for the sign-in.
- ///
- /// This can be the value of the user's email address, phone number, username, or Web3 wallet address.
- var identifier: String?
-
- /// The user's password.
- ///
- /// Only supported if the `strategy` is set to `password` and password authentication is enabled.
- var password: String?
-
- /// A ticket or token generated from the Backend API.
- ///
- /// Required if the `strategy` is set to `ticket`.
- var ticket: String?
-
- /// The ID token from a provider used for authentication (e.g., SignInWithApple).
- ///
- /// Required is strategy is set to `oauth_token_`
- var token: String?
-
- /// The URL to redirect to after successful authorization from the OAuth provider or during certain email-based sign-in flows.
+ /// Represents the parameters required to initiate a sign-in flow.
///
- /// - If `strategy` is `oauth_` or `enterprise_sso`, this specifies the full URL or path the OAuth provider should redirect to after successful authorization.
- /// - If `strategy` is `'email_link'`, this specifies the URL that the user will be redirected to when they visit the email link.
- var redirectUrl: String?
-
- /// Indicates whether the sign-in will attempt to retrieve information from the active sign-up instance to complete the sign-in process.
- ///
- /// Useful when transitioning seamlessly from a sign-up attempt to a sign-in attempt.
- var transfer: Bool?
-
- /// The value to pass to the OIDC `prompt` parameter in the generated OAuth redirect URL.
- ///
- /// Optional if `strategy` is `'oauth_'` or `'enterprise_sso'`.
- var oidcPrompt: String?
-
- /// The value to pass to the OIDC `login_hint` parameter in the generated OAuth redirect URL.
- ///
- /// Optional if `strategy` is `'oauth_'` or `'enterprise_sso'`.
- var oidcLoginHint: String?
- }
-
- /// Represents the various strategies for creating a `SignIn` request.
- public enum CreateStrategy: Sendable {
-
- /// The user will be authenticated either through SAML or OIDC depending on the configuration of their enterprise SSO account.
- ///
- /// - Parameters:
- /// - emailAddress: The email address associated with the user's enterprise SSO account.
- case enterpriseSSO(identifier: String, redirectUrl: String? = nil)
-
- /// The user will be authenicated using an ID Token, typically obtained from third-party identity providers like Apple.
- ///
- /// - Parameters:
- /// - provider: The ID token provider used for authentication (e.g., SignInWithApple).
- /// - idToken: The ID token issued by the provider for authentication.
- case idToken(provider: IDTokenProvider, idToken: String)
-
- /// The user will be authenticated with the provided identifier.
- ///
- /// - Parameters:
- /// - identifier: The authentication identifier for the sign-in. This can be the user's email address, phone number, username, or Web3 wallet address.
- /// - password: The user's password.
- /// - strategy: The ``SignIn/PrepareFirstFactorStrategy`` to use when creating the ``SignIn``. This parameter can be used to create the sign in and prepare the first factor in one step.
- case identifier(
- _ identifier: String,
- password: String? = nil,
- strategy: SignIn.PrepareFirstFactorStrategy? = nil
- )
-
- /// The user will be authenticated with their social connection account.
- ///
- /// - Parameters:
- /// - provider: The OAuth provider used for authentication, such as Google or Facebook.
- case oauth(provider: OAuthProvider, redirectUrl: String? = nil)
-
- /// The user will be authenticated with their passkey.
- case passkey
-
- /// The user will be authenticated via the ticket or token generated from the Backend API.
- case ticket(String)
-
- /// The `SignIn` will attempt to retrieve information from the active `SignUp` instance and use it to complete the sign-in process.
- ///
- /// This is useful for seamlessly transitioning a user from a sign-up attempt to a sign-in attempt.
- case transfer
-
- /// The `SignIn` will be created without any parameters.
- ///
- /// This is useful for inspecting a newly created `SignIn` object before deciding on a strategy.
- case none
-
- var params: SignInCreateParams {
- switch self {
- case .identifier(let identifier, let password, let strategy):
- .init(
- strategy: strategy?.strategy,
- identifier: identifier,
- password: password
- )
-
- case .oauth(let oauthProvider, let redirectUrl):
- .init(
- strategy: oauthProvider.strategy,
- redirectUrl: redirectUrl ?? RedirectConfigDefaults.redirectUrl
- )
-
- case .enterpriseSSO(let emailAddress, let redirectUrl):
- .init(
- strategy: "enterprise_sso",
- identifier: emailAddress,
- redirectUrl: redirectUrl ?? RedirectConfigDefaults.redirectUrl
- )
-
- case .idToken(let provider, let idToken):
- .init(
- strategy: provider.strategy,
- token: idToken
- )
-
- case .passkey:
- .init(strategy: "passkey")
+ /// This structure encapsulates the various options for initiating a sign-in, including the authentication strategy, user identifier, optional passwords, and additional settings for redirect URLs or OAuth-specific parameters.
+ public struct SignInCreateParams: Encodable {
+
+ /// The first factor verification strategy to use in the sign-in flow.
+ ///
+ /// Depends on the `identifier` value, and each authentication identifier supports different verification strategies.
+ var strategy: String?
+
+ /// The authentication identifier for the sign-in.
+ ///
+ /// This can be the value of the user's email address, phone number, username, or Web3 wallet address.
+ var identifier: String?
+
+ /// The user's password.
+ ///
+ /// Only supported if the `strategy` is set to `password` and password authentication is enabled.
+ var password: String?
+
+ /// A ticket or token generated from the Backend API.
+ ///
+ /// Required if the `strategy` is set to `ticket`.
+ var ticket: String?
+
+ /// The ID token from a provider used for authentication (e.g., SignInWithApple).
+ ///
+ /// Required is strategy is set to `oauth_token_`
+ var token: String?
+
+ /// The URL to redirect to after successful authorization from the OAuth provider or during certain email-based sign-in flows.
+ ///
+ /// - If `strategy` is `oauth_` or `enterprise_sso`, this specifies the full URL or path the OAuth provider should redirect to after successful authorization.
+ /// - If `strategy` is `'email_link'`, this specifies the URL that the user will be redirected to when they visit the email link.
+ var redirectUrl: String?
+
+ /// Indicates whether the sign-in will attempt to retrieve information from the active sign-up instance to complete the sign-in process.
+ ///
+ /// Useful when transitioning seamlessly from a sign-up attempt to a sign-in attempt.
+ var transfer: Bool?
+
+ /// The value to pass to the OIDC `prompt` parameter in the generated OAuth redirect URL.
+ ///
+ /// Optional if `strategy` is `'oauth_'` or `'enterprise_sso'`.
+ var oidcPrompt: String?
+
+ /// The value to pass to the OIDC `login_hint` parameter in the generated OAuth redirect URL.
+ ///
+ /// Optional if `strategy` is `'oauth_'` or `'enterprise_sso'`.
+ var oidcLoginHint: String?
+ }
- case .ticket(let ticket):
- .init(
- strategy: "ticket",
- ticket: ticket
+ /// Represents the various strategies for creating a `SignIn` request.
+ public enum CreateStrategy: Sendable {
+
+ /// The user will be authenticated either through SAML or OIDC depending on the configuration of their enterprise SSO account.
+ ///
+ /// - Parameters:
+ /// - emailAddress: The email address associated with the user's enterprise SSO account.
+ case enterpriseSSO(identifier: String, redirectUrl: String? = nil)
+
+ /// The user will be authenicated using an ID Token, typically obtained from third-party identity providers like Apple.
+ ///
+ /// - Parameters:
+ /// - provider: The ID token provider used for authentication (e.g., SignInWithApple).
+ /// - idToken: The ID token issued by the provider for authentication.
+ case idToken(provider: IDTokenProvider, idToken: String)
+
+ /// The user will be authenticated with the provided identifier.
+ ///
+ /// - Parameters:
+ /// - identifier: The authentication identifier for the sign-in. This can be the user's email address, phone number, username, or Web3 wallet address.
+ /// - password: The user's password.
+ /// - strategy: The ``SignIn/PrepareFirstFactorStrategy`` to use when creating the ``SignIn``. This parameter can be used to create the sign in and prepare the first factor in one step.
+ case identifier(
+ _ identifier: String,
+ password: String? = nil,
+ strategy: SignIn.PrepareFirstFactorStrategy? = nil
)
- case .transfer:
- .init(transfer: true)
-
- case .none:
- .init()
- }
+ /// The user will be authenticated with their social connection account.
+ ///
+ /// - Parameters:
+ /// - provider: The OAuth provider used for authentication, such as Google or Facebook.
+ case oauth(provider: OAuthProvider, redirectUrl: String? = nil)
+
+ /// The user will be authenticated with their passkey.
+ case passkey
+
+ /// The user will be authenticated via the ticket or token generated from the Backend API.
+ case ticket(String)
+
+ /// The `SignIn` will attempt to retrieve information from the active `SignUp` instance and use it to complete the sign-in process.
+ ///
+ /// This is useful for seamlessly transitioning a user from a sign-up attempt to a sign-in attempt.
+ case transfer
+
+ /// The `SignIn` will be created without any parameters.
+ ///
+ /// This is useful for inspecting a newly created `SignIn` object before deciding on a strategy.
+ case none
+
+ var params: SignInCreateParams {
+ switch self {
+ case .identifier(let identifier, let password, let strategy):
+ .init(
+ strategy: strategy?.strategy,
+ identifier: identifier,
+ password: password
+ )
+
+ case .oauth(let oauthProvider, let redirectUrl):
+ .init(
+ strategy: oauthProvider.strategy,
+ redirectUrl: redirectUrl ?? Container.shared.clerkSettings().redirectConfig.redirectUrl
+ )
+
+ case .enterpriseSSO(let emailAddress, let redirectUrl):
+ .init(
+ strategy: "enterprise_sso",
+ identifier: emailAddress,
+ redirectUrl: redirectUrl ?? Container.shared.clerkSettings().redirectConfig.redirectUrl
+ )
+
+ case .idToken(let provider, let idToken):
+ .init(
+ strategy: provider.strategy,
+ token: idToken
+ )
+
+ case .passkey:
+ .init(strategy: "passkey")
+
+ case .ticket(let ticket):
+ .init(
+ strategy: "ticket",
+ ticket: ticket
+ )
+
+ case .transfer:
+ .init(transfer: true)
+
+ case .none:
+ .init()
+ }
+ }
}
- }
}
diff --git a/Sources/Clerk/Models/SignIn/SignInIdentifier.swift b/Sources/Clerk/Models/SignIn/SignInIdentifier.swift
index f82a95970..4700c5b38 100644
--- a/Sources/Clerk/Models/SignIn/SignInIdentifier.swift
+++ b/Sources/Clerk/Models/SignIn/SignInIdentifier.swift
@@ -7,35 +7,38 @@
extension SignIn {
- /// Represents the authentication identifiers supported for signing in.
- ///
- /// The `Identifier` enum defines the types of identifiers that can be used during the sign-in process. Each identifier corresponds to a specific authentication method.
- public enum Identifier: String, Codable, Sendable, Equatable, Hashable {
+ /// Represents the authentication identifiers supported for signing in.
+ ///
+ /// The `Identifier` enum defines the types of identifiers that can be used during the sign-in process. Each identifier corresponds to a specific authentication method.
+ public enum Identifier: String, Codable, Sendable, Equatable, Hashable {
- /// Represents an email address identifier.
- case emailAddress = "email_address"
+ /// Represents an email address identifier.
+ case emailAddress = "email_address"
- /// Represents a phone number identifier.
- case phoneNumber = "phone_number"
+ /// Represents a phone number identifier.
+ case phoneNumber = "phone_number"
- /// Represents a Web3 wallet address identifier).
- case web3Wallet = "web3_wallet"
+ /// Represents a Web3 wallet address identifier).
+ case web3Wallet = "web3_wallet"
- /// Represents a username identifier.
- case username
+ /// Represents a username identifier.
+ case username
- /// Represents an unsupported or unknown identifier.
- case unknown
+ /// Represents a passkey identifier.
+ case passkey
- /// Initializes a `SignInIdentifier` from a decoder.
- ///
- /// This initializer attempts to decode a `SignInIdentifier` from the raw value. If the value is not recognized, it defaults to `.unknown`.
- ///
- /// - Parameter decoder: The decoder to use for decoding the raw value.
- /// - Throws: An error if the decoding process fails.
- public init(from decoder: Decoder) throws {
- self = try .init(rawValue: decoder.singleValueContainer().decode(RawValue.self)) ?? .unknown
+ /// Represents an unsupported or unknown identifier.
+ case unknown
+
+ /// Initializes a `SignInIdentifier` from a decoder.
+ ///
+ /// This initializer attempts to decode a `SignInIdentifier` from the raw value. If the value is not recognized, it defaults to `.unknown`.
+ ///
+ /// - Parameter decoder: The decoder to use for decoding the raw value.
+ /// - Throws: An error if the decoding process fails.
+ public init(from decoder: Decoder) throws {
+ self = try .init(rawValue: decoder.singleValueContainer().decode(RawValue.self)) ?? .unknown
+ }
}
- }
}
diff --git a/Sources/Clerk/Models/SignIn/SignInPrepareFirstFactorParams.swift b/Sources/Clerk/Models/SignIn/SignInPrepareFirstFactorParams.swift
index 5e05e49eb..c7d7a1183 100644
--- a/Sources/Clerk/Models/SignIn/SignInPrepareFirstFactorParams.swift
+++ b/Sources/Clerk/Models/SignIn/SignInPrepareFirstFactorParams.swift
@@ -9,116 +9,117 @@ import Foundation
extension SignIn {
- /// A parameter object for preparing the first factor verification.
- struct PrepareFirstFactorParams: Encodable {
- /// The strategy value depends on the object's identifier value. Each authentication identifier supports different verification strategies.
- let strategy: String
-
- /// Unique identifier for the user's email address that will receive an email message with the one-time authentication code. This parameter will work only when the `email_code` strategy is specified.
- var emailAddressId: String?
-
- /// Unique identifier for the user's phone number that will receive an SMS message with the one-time authentication code. This parameter will work only when the `phone_code` strategy is specified.
- var phoneNumberId: String?
-
- /// The URL that the OAuth provider should redirect to, on successful authorization on their part. This parameter is required only if you set the strategy param to an OAuth strategy like `oauth_`.
- var redirectUrl: String?
- }
-
- /// Represents the strategies for beginning the first factor verification process.
- ///
- /// The `PrepareFirstFactorStrategy` enum defines the different methods available for verifying the first factor in the sign-in process. Each strategy corresponds to a specific type of authentication.
- public enum PrepareFirstFactorStrategy: Sendable {
-
- /// The user will receive a one-time authentication code via email.
- /// - Parameters:
- /// - emailAddressId: ID to specify a particular email address.
- case emailCode(emailAddressId: String? = nil)
-
- /// The user will receive a one-time authentication code via SMS.
- /// - Parameters:
- /// - phoneNumberId: ID to specify a particular phone number.
- case phoneCode(phoneNumberId: String? = nil)
-
- /// The user will be authenticated with their social connection account.
- case oauth(provider: OAuthProvider, redirectUrl: String? = nil)
-
- /// The user will be authenticated either through SAML or OIDC, depending on the configuration of their enterprise SSO account.
- case enterpriseSSO(redirectUrl: String? = nil)
-
- /// The verification will attempt to be completed using the user's passkey.
- case passkey
-
- /// Used during a password reset flow. The user will receive a one-time code via email.
- /// - Parameters:
- /// - emailAddressId: ID to specify a particular email address.
- case resetPasswordEmailCode(emailAddressId: String? = nil)
-
- /// Used during a password reset flow. The user will receive a one-time code via SMS.
- /// - Parameters:
- /// - phoneNumberId: ID to specify a particular phone number.
- case resetPasswordPhoneCode(phoneNumberId: String? = nil)
-
- var strategy: String {
- switch self {
- case .emailCode:
- "email_code"
- case .phoneCode:
- "phone_code"
- case .oauth(let provider, _):
- provider.strategy
- case .enterpriseSSO:
- "enterprise_sso"
- case .passkey:
- "passkey"
- case .resetPasswordEmailCode:
- "reset_password_email_code"
- case .resetPasswordPhoneCode:
- "reset_password_phone_code"
- }
+ /// A parameter object for preparing the first factor verification.
+ struct PrepareFirstFactorParams: Encodable {
+ /// The strategy value depends on the object's identifier value. Each authentication identifier supports different verification strategies.
+ let strategy: String
+
+ /// Unique identifier for the user's email address that will receive an email message with the one-time authentication code. This parameter will work only when the `email_code` strategy is specified.
+ var emailAddressId: String?
+
+ /// Unique identifier for the user's phone number that will receive an SMS message with the one-time authentication code. This parameter will work only when the `phone_code` strategy is specified.
+ var phoneNumberId: String?
+
+ /// The URL that the OAuth provider should redirect to, on successful authorization on their part. This parameter is required only if you set the strategy param to an OAuth strategy like `oauth_`.
+ var redirectUrl: String?
}
- func params(signIn: SignIn) -> PrepareFirstFactorParams {
- switch self {
-
- case .emailCode(let emailAddressId):
- return .init(
- strategy: strategy,
- emailAddressId: emailAddressId ?? signIn.identifyingFirstFactor(strategy: self)?.emailAddressId
- )
-
- case .phoneCode(let phoneNumberId):
- return .init(
- strategy: strategy,
- phoneNumberId: phoneNumberId ?? signIn.identifyingFirstFactor(strategy: self)?.phoneNumberId
- )
-
- case .oauth(let provider, let redirectUrl):
- return .init(
- strategy: provider.strategy,
- redirectUrl: redirectUrl ?? RedirectConfigDefaults.redirectUrl
- )
-
- case .passkey:
- return .init(strategy: strategy)
-
- case .enterpriseSSO(let redirectUrl):
- return .init(
- strategy: strategy,
- redirectUrl: redirectUrl ?? RedirectConfigDefaults.redirectUrl
- )
-
- case .resetPasswordEmailCode(let emailAddressId):
- return .init(
- strategy: strategy,
- emailAddressId: emailAddressId ?? signIn.identifyingFirstFactor(strategy: self)?.emailAddressId
- )
-
- case .resetPasswordPhoneCode(let phoneNumberId):
- return .init(
- strategy: strategy,
- phoneNumberId: phoneNumberId ?? signIn.identifyingFirstFactor(strategy: self)?.phoneNumberId
- )
- }
+ /// Represents the strategies for beginning the first factor verification process.
+ ///
+ /// The `PrepareFirstFactorStrategy` enum defines the different methods available for verifying the first factor in the sign-in process. Each strategy corresponds to a specific type of authentication.
+ public enum PrepareFirstFactorStrategy: Sendable {
+
+ /// The user will receive a one-time authentication code via email.
+ /// - Parameters:
+ /// - emailAddressId: ID to specify a particular email address.
+ case emailCode(emailAddressId: String? = nil)
+
+ /// The user will receive a one-time authentication code via SMS.
+ /// - Parameters:
+ /// - phoneNumberId: ID to specify a particular phone number.
+ case phoneCode(phoneNumberId: String? = nil)
+
+ /// The user will be authenticated with their social connection account.
+ case oauth(provider: OAuthProvider, redirectUrl: String? = nil)
+
+ /// The user will be authenticated either through SAML or OIDC, depending on the configuration of their enterprise SSO account.
+ case enterpriseSSO(redirectUrl: String? = nil)
+
+ /// The verification will attempt to be completed using the user's passkey.
+ case passkey
+
+ /// Used during a password reset flow. The user will receive a one-time code via email.
+ /// - Parameters:
+ /// - emailAddressId: ID to specify a particular email address.
+ case resetPasswordEmailCode(emailAddressId: String? = nil)
+
+ /// Used during a password reset flow. The user will receive a one-time code via SMS.
+ /// - Parameters:
+ /// - phoneNumberId: ID to specify a particular phone number.
+ case resetPasswordPhoneCode(phoneNumberId: String? = nil)
+
+ var strategy: String {
+ switch self {
+ case .emailCode:
+ "email_code"
+ case .phoneCode:
+ "phone_code"
+ case .oauth(let provider, _):
+ provider.strategy
+ case .enterpriseSSO:
+ "enterprise_sso"
+ case .passkey:
+ "passkey"
+ case .resetPasswordEmailCode:
+ "reset_password_email_code"
+ case .resetPasswordPhoneCode:
+ "reset_password_phone_code"
+ }
+ }
+
+ @MainActor
+ func params(signIn: SignIn) -> PrepareFirstFactorParams {
+ switch self {
+
+ case .emailCode(let emailAddressId):
+ return .init(
+ strategy: strategy,
+ emailAddressId: emailAddressId ?? signIn.identifyingFirstFactor(strategy: self)?.emailAddressId
+ )
+
+ case .phoneCode(let phoneNumberId):
+ return .init(
+ strategy: strategy,
+ phoneNumberId: phoneNumberId ?? signIn.identifyingFirstFactor(strategy: self)?.phoneNumberId
+ )
+
+ case .oauth(let provider, let redirectUrl):
+ return .init(
+ strategy: provider.strategy,
+ redirectUrl: redirectUrl ?? Clerk.shared.settings.redirectConfig.redirectUrl
+ )
+
+ case .passkey:
+ return .init(strategy: strategy)
+
+ case .enterpriseSSO(let redirectUrl):
+ return .init(
+ strategy: strategy,
+ redirectUrl: redirectUrl ?? Clerk.shared.settings.redirectConfig.redirectUrl
+ )
+
+ case .resetPasswordEmailCode(let emailAddressId):
+ return .init(
+ strategy: strategy,
+ emailAddressId: emailAddressId ?? signIn.identifyingFirstFactor(strategy: self)?.emailAddressId
+ )
+
+ case .resetPasswordPhoneCode(let phoneNumberId):
+ return .init(
+ strategy: strategy,
+ phoneNumberId: phoneNumberId ?? signIn.identifyingFirstFactor(strategy: self)?.phoneNumberId
+ )
+ }
+ }
}
- }
}
diff --git a/Sources/Clerk/Models/SignIn/SignInPrepareSecondFactorParams.swift b/Sources/Clerk/Models/SignIn/SignInPrepareSecondFactorParams.swift
index adf6bbfbd..19185cf9d 100644
--- a/Sources/Clerk/Models/SignIn/SignInPrepareSecondFactorParams.swift
+++ b/Sources/Clerk/Models/SignIn/SignInPrepareSecondFactorParams.swift
@@ -9,23 +9,23 @@ import Foundation
extension SignIn {
- /// A parameter object for preparing the second factor verification.
- struct PrepareSecondFactorParams: Encodable {
- /// The strategy used for second factor verification..
- let strategy: String
- }
+ /// A parameter object for preparing the second factor verification.
+ struct PrepareSecondFactorParams: Encodable {
+ /// The strategy used for second factor verification..
+ let strategy: String
+ }
- /// A strategy for preparing the second factor verification process.
- public enum PrepareSecondFactorStrategy: Sendable {
+ /// A strategy for preparing the second factor verification process.
+ public enum PrepareSecondFactorStrategy: Sendable {
- /// phoneCode: The user will receive a one-time authentication code via SMS. At least one phone number should be on file for the user.
- case phoneCode
+ /// phoneCode: The user will receive a one-time authentication code via SMS. At least one phone number should be on file for the user.
+ case phoneCode
- var params: PrepareSecondFactorParams {
- switch self {
- case .phoneCode:
- return .init(strategy: "phone_code")
- }
+ var params: PrepareSecondFactorParams {
+ switch self {
+ case .phoneCode:
+ return .init(strategy: "phone_code")
+ }
+ }
}
- }
}
diff --git a/Sources/Clerk/Models/SignIn/SignInResetPasswordParams.swift b/Sources/Clerk/Models/SignIn/SignInResetPasswordParams.swift
index bb2b62e81..9267ad1dc 100644
--- a/Sources/Clerk/Models/SignIn/SignInResetPasswordParams.swift
+++ b/Sources/Clerk/Models/SignIn/SignInResetPasswordParams.swift
@@ -9,28 +9,28 @@ import Foundation
extension SignIn {
- /// A parameter object for resetting a user's password.
- ///
- /// - Parameters:
- /// - password: The user's current password.
- /// - signOutOfOtherSessions: If true, log the user out of all other authenticated sessions.
- public struct ResetPasswordParams: Encodable, Sendable {
-
- /// Creates a new `ResetPasswordParams` object.
+ /// A parameter object for resetting a user's password.
///
/// - Parameters:
/// - password: The user's current password.
/// - signOutOfOtherSessions: If true, log the user out of all other authenticated sessions.
- public init(password: String, signOutOfOtherSessions: Bool? = nil) {
- self.password = password
- self.signOutOfOtherSessions = signOutOfOtherSessions
- }
+ public struct ResetPasswordParams: Encodable, Sendable {
- /// The user's current password.
- public let password: String
+ /// Creates a new `ResetPasswordParams` object.
+ ///
+ /// - Parameters:
+ /// - password: The user's current password.
+ /// - signOutOfOtherSessions: If true, log the user out of all other authenticated sessions.
+ public init(password: String, signOutOfOtherSessions: Bool? = nil) {
+ self.password = password
+ self.signOutOfOtherSessions = signOutOfOtherSessions
+ }
- /// If true, log the user out of all other authenticated sessions.
- public let signOutOfOtherSessions: Bool?
- }
+ /// The user's current password.
+ public let password: String
+
+ /// If true, log the user out of all other authenticated sessions.
+ public let signOutOfOtherSessions: Bool?
+ }
}
diff --git a/Sources/Clerk/Models/SignIn/SignInService.swift b/Sources/Clerk/Models/SignIn/SignInService.swift
index 3455108ed..ca7becada 100644
--- a/Sources/Clerk/Models/SignIn/SignInService.swift
+++ b/Sources/Clerk/Models/SignIn/SignInService.swift
@@ -2,161 +2,201 @@
// SignInService.swift
// Clerk
//
-// Created by Mike Pitre on 2/26/25.
+// Created by Mike Pitre on 7/28/25.
//
import AuthenticationServices
-import Factory
+import FactoryKit
import Foundation
-struct SignInService {
- var create: (_ strategy: SignIn.CreateStrategy) async throws -> SignIn
- var createRaw: (_ params: AnyEncodable) async throws -> SignIn
- var resetPassword: (_ signIn: SignIn, _ params: SignIn.ResetPasswordParams) async throws -> SignIn
- var prepareFirstFactor: (_ signIn: SignIn, _ prepareFirstFactorStrategy: SignIn.PrepareFirstFactorStrategy) async throws -> SignIn
- var attemptFirstFactor: (_ signIn: SignIn, _ attemptFirstFactorStrategy: SignIn.AttemptFirstFactorStrategy) async throws -> SignIn
- var prepareSecondFactor: (_ signIn: SignIn, _ prepareSecondFactorStrategy: SignIn.PrepareSecondFactorStrategy) async throws -> SignIn
- var attemptSecondFactor: (_ signIn: SignIn, _ strategy: SignIn.AttemptSecondFactorStrategy) async throws -> SignIn
- var authenticateWithRedirectCombined: (_ strategy: SignIn.AuthenticateWithRedirectStrategy, _ prefersEphemeralWebBrowserSession: Bool) async throws -> TransferFlowResult
- var authenticateWithRedirectTwoStep: (_ signIn: SignIn, _ prefersEphemeralWebBrowserSession: Bool) async throws -> TransferFlowResult
- var getCredentialForPasskey: (_ signIn: SignIn, _ autofill: Bool, _ preferImmediatelyAvailableCredentials: Bool) async throws -> String
- var authenticateWithIdTokenCombined: (_ provider: IDTokenProvider, _ idToken: String) async throws -> TransferFlowResult
- var authenticateWithIdTokenTwoStep: (_ signIn: SignIn) async throws -> TransferFlowResult
- var get: (_ signIn: SignIn, _ rotatingTokenNonce: String?) async throws -> SignIn
+extension Container {
+
+ var signInService: Factory {
+ self { @MainActor in SignInService() }
+ }
+
}
-extension SignInService {
-
- static var liveValue: Self {
- .init(
- create: { strategy in
- let request = ClerkFAPI.v1.client.signIns.post(body: strategy.params)
- return try await Container.shared.apiClient().send(request).value.response
- },
- createRaw: { params in
- let request = ClerkFAPI.v1.client.signIns.post(body: params)
- return try await Container.shared.apiClient().send(request).value.response
- },
- resetPassword: { signIn, params in
- let request = ClerkFAPI.v1.client.signIns.id(signIn.id).resetPassword.post(params)
- return try await Container.shared.apiClient().send(request).value.response
- },
- prepareFirstFactor: { signIn, strategy in
- let request = ClerkFAPI.v1.client.signIns.id(signIn.id).prepareFirstFactor.post(strategy.params(signIn: signIn))
- return try await Container.shared.apiClient().send(request).value.response
- },
- attemptFirstFactor: { signIn, strategy in
- let request = ClerkFAPI.v1.client.signIns.id(signIn.id).attemptFirstFactor.post(body: strategy.params)
- return try await Container.shared.apiClient().send(request).value.response
- },
- prepareSecondFactor: { signIn, strategy in
- let request = ClerkFAPI.v1.client.signIns.id(signIn.id).prepareSecondFactor.post(strategy.params)
- return try await Container.shared.apiClient().send(request).value.response
- },
- attemptSecondFactor: { signIn, strategy in
- let request = ClerkFAPI.v1.client.signIns.id(signIn.id).attemptSecondFactor.post(strategy.params)
- return try await Container.shared.apiClient().send(request).value.response
- },
- authenticateWithRedirectCombined: { strategy, prefersEphemeralWebBrowserSession in
+@MainActor
+struct SignInService {
+
+ var create: (_ strategy: SignIn.CreateStrategy) async throws -> SignIn = { strategy in
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/client/sign_ins")
+ .method(.post)
+ .body(formEncode: strategy.params)
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
+
+ var createWithParams: (_ params: any Encodable & Sendable) async throws -> SignIn = { params in
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/client/sign_ins")
+ .method(.post)
+ .body(formEncode: params)
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
+
+ var resetPassword: (_ signInId: String, _ params: SignIn.ResetPasswordParams) async throws -> SignIn = { signInId, params in
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/client/sign_ins/\(signInId)/reset_password")
+ .method(.post)
+ .body(formEncode: params)
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
+
+ var prepareFirstFactor: (_ signInId: String, _ strategy: SignIn.PrepareFirstFactorStrategy, _ signIn: SignIn) async throws -> SignIn = { signInId, strategy, signIn in
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/client/sign_ins/\(signInId)/prepare_first_factor")
+ .method(.post)
+ .body(formEncode: strategy.params(signIn: signIn))
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
+
+ var attemptFirstFactor: (_ signInId: String, _ strategy: SignIn.AttemptFirstFactorStrategy) async throws -> SignIn = { signInId, strategy in
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/client/sign_ins/\(signInId)/attempt_first_factor")
+ .method(.post)
+ .body(formEncode: strategy.params)
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
+
+ var prepareSecondFactor: (_ signInId: String, _ strategy: SignIn.PrepareSecondFactorStrategy) async throws -> SignIn = { signInId, strategy in
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/client/sign_ins/\(signInId)/prepare_second_factor")
+ .method(.post)
+ .body(formEncode: strategy.params)
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
+
+ var attemptSecondFactor: (_ signInId: String, _ strategy: SignIn.AttemptSecondFactorStrategy) async throws -> SignIn = { signInId, strategy in
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/client/sign_ins/\(signInId)/attempt_second_factor")
+ .method(.post)
+ .body(formEncode: strategy.params)
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
+
+ var get: (_ signInId: String, _ rotatingTokenNonce: String?) async throws -> SignIn = { signInId, rotatingTokenNonce in
+ var queryItems: [URLQueryItem] = []
+ if let rotatingTokenNonce {
+ queryItems.append(
+ .init(
+ name: "rotating_token_nonce",
+ value: rotatingTokenNonce.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed))
+ )
+ }
+
+ return try await Container.shared.apiClient().request()
+ .add(path: "/v1/client/sign_ins/\(signInId)")
+ .add(queryItems: queryItems)
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
+
+ #if !os(tvOS) && !os(watchOS)
+ var authenticateWithRedirectStatic: (_ strategy: SignIn.AuthenticateWithRedirectStrategy, _ prefersEphemeralWebBrowserSession: Bool) async throws -> TransferFlowResult = { strategy, prefersEphemeralWebBrowserSession in
let signIn = try await SignIn.create(strategy: strategy.signInStrategy)
guard let externalVerificationRedirectUrl = signIn.firstFactorVerification?.externalVerificationRedirectUrl, let url = URL(string: externalVerificationRedirectUrl) else {
- throw ClerkClientError(message: "Redirect URL is missing or invalid. Unable to start external authentication flow.")
+ throw ClerkClientError(message: "Redirect URL is missing or invalid. Unable to start external authentication flow.")
}
- let authSession = await WebAuthentication(url: url, prefersEphemeralWebBrowserSession: prefersEphemeralWebBrowserSession)
+ let authSession = WebAuthentication(url: url, prefersEphemeralWebBrowserSession: prefersEphemeralWebBrowserSession)
let callbackUrl = try await authSession.start()
let transferFlowResult = try await signIn.handleOAuthCallbackUrl(callbackUrl)
return transferFlowResult
- },
- authenticateWithRedirectTwoStep: { signIn, prefersEphemeralWebBrowserSession in
+ }
+
+ var authenticateWithRedirect: (_ signIn: SignIn, _ prefersEphemeralWebBrowserSession: Bool) async throws -> TransferFlowResult = { signIn, prefersEphemeralWebBrowserSession in
guard let externalVerificationRedirectUrl = signIn.firstFactorVerification?.externalVerificationRedirectUrl,
- let url = URL(string: externalVerificationRedirectUrl)
+ let url = URL(string: externalVerificationRedirectUrl)
else {
- throw ClerkClientError(message: "Redirect URL is missing or invalid. Unable to start external authentication flow.")
+ throw ClerkClientError(message: "Redirect URL is missing or invalid. Unable to start external authentication flow.")
}
- let authSession = await WebAuthentication(url: url, prefersEphemeralWebBrowserSession: prefersEphemeralWebBrowserSession)
+ let authSession = WebAuthentication(url: url, prefersEphemeralWebBrowserSession: prefersEphemeralWebBrowserSession)
let callbackUrl = try await authSession.start()
let transferFlowResult = try await signIn.handleOAuthCallbackUrl(callbackUrl)
return transferFlowResult
- },
- getCredentialForPasskey: { signIn, autofill, preferImmediatelyAvailableCredentials in
- #if canImport(AuthenticationServices) && !os(watchOS)
- guard
+ }
+ #endif
+
+ #if canImport(AuthenticationServices) && !os(watchOS) && !os(tvOS)
+ var getCredentialForPasskey: (_ signIn: SignIn, _ autofill: Bool, _ preferImmediatelyAvailableCredentials: Bool) async throws -> String = { signIn, autofill, preferImmediatelyAvailableCredentials in
+ guard
let nonceJSON = signIn.firstFactorVerification?.nonce?.toJSON(),
let challengeString = nonceJSON["challenge"]?.stringValue,
let challenge = challengeString.dataFromBase64URL()
- else {
- throw ClerkClientError(message: "Unable to locate the challenge for the passkey.")
- }
+ else {
+ throw ClerkClientError(message: "Unable to get the challenge for the passkey.")
+ }
- let manager = PasskeyHelper()
- var authorization: ASAuthorization
+ let manager = PasskeyHelper()
+ var authorization: ASAuthorization
- #if os(iOS) && !targetEnvironment(macCatalyst)
- if autofill {
- authorization = try await manager.beginAutoFillAssistedPasskeySignIn(
+ #if os(iOS) && !targetEnvironment(macCatalyst)
+ if autofill {
+ authorization = try await manager.beginAutoFillAssistedPasskeySignIn(
challenge: challenge
- )
- } else {
- authorization = try await manager.signIn(
+ )
+ } else {
+ authorization = try await manager.signIn(
challenge: challenge,
preferImmediatelyAvailableCredentials: preferImmediatelyAvailableCredentials
- )
- }
- #else
- authorization = try await manager.signIn(
- challenge: challenge,
- preferImmediatelyAvailableCredentials: preferImmediatelyAvailableCredentials
)
- #endif
+ }
+ #else
+ authorization = try await manager.signIn(
+ challenge: challenge,
+ preferImmediatelyAvailableCredentials: preferImmediatelyAvailableCredentials
+ )
+ #endif
- guard
+ guard
let credentialAssertion = authorization.credential as? ASAuthorizationPlatformPublicKeyCredentialAssertion,
let authenticatorData = credentialAssertion.rawAuthenticatorData
- else {
+ else {
throw ClerkClientError(message: "Invalid credential type.")
- }
+ }
- let publicKeyCredential: [String: any Encodable] = [
+ let publicKeyCredential: [String: any Encodable] = [
"id": credentialAssertion.credentialID.base64EncodedString().base64URLFromBase64String(),
"rawId": credentialAssertion.credentialID.base64EncodedString().base64URLFromBase64String(),
"type": "public-key",
"response": [
- "authenticatorData": authenticatorData.base64EncodedString().base64URLFromBase64String(),
- "clientDataJSON": credentialAssertion.rawClientDataJSON.base64EncodedString().base64URLFromBase64String(),
- "signature": credentialAssertion.signature.base64EncodedString().base64URLFromBase64String(),
- "userHandle": credentialAssertion.userID.base64EncodedString().base64URLFromBase64String(),
- ],
- ]
-
- return try JSON(publicKeyCredential).debugDescription
- #else
- throw ClerkClientError(message: "Passkeys authentication is not supported on this platform.")
- #endif
- },
- authenticateWithIdTokenCombined: { provider, idToken in
+ "authenticatorData": authenticatorData.base64EncodedString().base64URLFromBase64String(),
+ "clientDataJSON": credentialAssertion.rawClientDataJSON.base64EncodedString().base64URLFromBase64String(),
+ "signature": credentialAssertion.signature.base64EncodedString().base64URLFromBase64String(),
+ "userHandle": credentialAssertion.userID.base64EncodedString().base64URLFromBase64String()
+ ]
+ ]
+
+ return try JSON(publicKeyCredential).debugDescription
+ }
+ #endif
+
+ var authenticateWithIdTokenStatic: (_ provider: IDTokenProvider, _ idToken: String) async throws -> TransferFlowResult = { provider, idToken in
let signIn = try await SignIn.create(strategy: .idToken(provider: provider, idToken: idToken))
return try await signIn.handleTransferFlow()
- },
- authenticateWithIdTokenTwoStep: { signIn in
- try await signIn.handleTransferFlow()
- },
- get: { signIn, rotatingTokenNonce in
- let request = ClerkFAPI.v1.client.signIns.id(signIn.id).get(rotatingTokenNonce: rotatingTokenNonce)
- let response = try await Container.shared.apiClient().send(request)
- return response.value.response
- }
- )
- }
-
-}
+ }
-extension Container {
-
- var signInService: Factory {
- self { .liveValue }
- }
+ var authenticateWithIdToken: (_ signIn: SignIn) async throws -> TransferFlowResult = { signIn in
+ try await signIn.handleTransferFlow()
+ }
}
diff --git a/Sources/Clerk/Models/SignIn/SignInStatus.swift b/Sources/Clerk/Models/SignIn/SignInStatus.swift
index f33465788..f252771fe 100644
--- a/Sources/Clerk/Models/SignIn/SignInStatus.swift
+++ b/Sources/Clerk/Models/SignIn/SignInStatus.swift
@@ -7,38 +7,38 @@
extension SignIn {
- /// Represents the current status of the sign-in process.
- ///
- /// The `Status` enum defines the possible states of a sign-in flow. Each state indicates a specific requirement or completion level in the sign-in process.
- public enum Status: String, Codable, Sendable, Equatable {
+ /// Represents the current status of the sign-in process.
+ ///
+ /// The `Status` enum defines the possible states of a sign-in flow. Each state indicates a specific requirement or completion level in the sign-in process.
+ public enum Status: String, Codable, Sendable, Equatable {
- /// The user is signed in.
- case complete
+ /// The user is signed in.
+ case complete
- /// The user's identifier (e.g., email address, phone number, username) hasn't been provided.
- case needsIdentifier = "needs_identifier"
+ /// The user's identifier (e.g., email address, phone number, username) hasn't been provided.
+ case needsIdentifier = "needs_identifier"
- /// A first-factor verification strategy is missing.
- case needsFirstFactor = "needs_first_factor"
+ /// A first-factor verification strategy is missing.
+ case needsFirstFactor = "needs_first_factor"
- /// A second-factor verification strategy is missing.
- case needsSecondFactor = "needs_second_factor"
+ /// A second-factor verification strategy is missing.
+ case needsSecondFactor = "needs_second_factor"
- /// The user needs to set a new password.
- case needsNewPassword = "needs_new_password"
+ /// The user needs to set a new password.
+ case needsNewPassword = "needs_new_password"
- /// The sign-in returned an unknown status value.
- case unknown
+ /// The sign-in returned an unknown status value.
+ case unknown
- /// Initializes a `SignInStatus` from a decoder.
- ///
- /// This initializer attempts to decode a `SignInStatus` from the raw value. If the value is not recognized, it defaults to `.unknown`.
- ///
- /// - Parameter decoder: The decoder to use for decoding the raw value.
- /// - Throws: An error if the decoding process fails.
- public init(from decoder: Decoder) throws {
- self = try .init(rawValue: decoder.singleValueContainer().decode(RawValue.self)) ?? .unknown
+ /// Initializes a `SignInStatus` from a decoder.
+ ///
+ /// This initializer attempts to decode a `SignInStatus` from the raw value. If the value is not recognized, it defaults to `.unknown`.
+ ///
+ /// - Parameter decoder: The decoder to use for decoding the raw value.
+ /// - Throws: An error if the decoding process fails.
+ public init(from decoder: Decoder) throws {
+ self = try .init(rawValue: decoder.singleValueContainer().decode(RawValue.self)) ?? .unknown
+ }
}
- }
}
diff --git a/Sources/Clerk/Models/SignIn/SignInUserData.swift b/Sources/Clerk/Models/SignIn/SignInUserData.swift
index 97041b8e4..2c03d7a04 100644
--- a/Sources/Clerk/Models/SignIn/SignInUserData.swift
+++ b/Sources/Clerk/Models/SignIn/SignInUserData.swift
@@ -7,32 +7,32 @@
extension SignIn {
- /// An object containing information about the user of the current sign-in. This property is populated only once an identifier is given to the SignIn object.
- public struct UserData: Codable, Sendable, Equatable, Hashable {
-
- /// The user's first name.
- public let firstName: String?
-
- /// The user's last name.
- public let lastName: String?
-
- /// Holds the default avatar or user's uploaded profile image.
- public let imageUrl: String?
-
- /// A getter boolean to check if the user has uploaded an image or one was copied from OAuth. Returns false if Clerk is displaying an avatar for the user.
- public let hasImage: Bool?
-
- public init(
- firstName: String? = nil,
- lastName: String? = nil,
- imageUrl: String? = nil,
- hasImage: Bool? = nil
- ) {
- self.firstName = firstName
- self.lastName = lastName
- self.imageUrl = imageUrl
- self.hasImage = hasImage
+ /// An object containing information about the user of the current sign-in. This property is populated only once an identifier is given to the SignIn object.
+ public struct UserData: Codable, Sendable, Equatable, Hashable {
+
+ /// The user's first name.
+ public let firstName: String?
+
+ /// The user's last name.
+ public let lastName: String?
+
+ /// Holds the default avatar or user's uploaded profile image.
+ public let imageUrl: String?
+
+ /// A getter boolean to check if the user has uploaded an image or one was copied from OAuth. Returns false if Clerk is displaying an avatar for the user.
+ public let hasImage: Bool?
+
+ public init(
+ firstName: String? = nil,
+ lastName: String? = nil,
+ imageUrl: String? = nil,
+ hasImage: Bool? = nil
+ ) {
+ self.firstName = firstName
+ self.lastName = lastName
+ self.imageUrl = imageUrl
+ self.hasImage = hasImage
+ }
}
- }
}
diff --git a/Sources/Clerk/Models/SignUp/SignUp.swift b/Sources/Clerk/Models/SignUp/SignUp.swift
index 2db564290..049a40e2a 100644
--- a/Sources/Clerk/Models/SignUp/SignUp.swift
+++ b/Sources/Clerk/Models/SignUp/SignUp.swift
@@ -6,7 +6,7 @@
//
import AuthenticationServices
-import Factory
+import FactoryKit
import Foundation
/// The `SignUp` object holds the state of the current sign-up and provides helper methods to navigate and complete the sign-up process.
@@ -26,202 +26,202 @@ import Foundation
/// If the verification is successful, the newly created session is set as the active session.
public struct SignUp: Codable, Sendable, Equatable, Hashable {
- /// The unique identifier of the current sign-up.
- public let id: String
-
- /// The status of the current sign-up.
- ///
- /// See ``SignUp/Status-swift.enum`` for supported values.
- public let status: Status
-
- /// An array of all the required fields that need to be supplied and verified in order for this sign-up to be marked as complete and converted into a user.
- public let requiredFields: [String]
-
- /// An array of all the fields that can be supplied to the sign-up, but their absence does not prevent the sign-up from being marked as complete.
- public let optionalFields: [String]
-
- /// An array of all the fields whose values are not supplied yet but they are mandatory in order for a sign-up to be marked as complete.
- public let missingFields: [String]
-
- /// An array of all the fields whose values have been supplied, but they need additional verification in order for them to be accepted.
- ///
- /// Examples of such fields are `email_address` and `phone_number`.
- public let unverifiedFields: [String]
-
- /// An object that contains information about all the verifications that are in-flight.
- public let verifications: [String: Verification?]
-
- /// The username supplied to the current sign-up. Only supported if username is enabled in the instance settings.
- public let username: String?
-
- /// The email address supplied to the current sign-up. Only supported if email address is enabled in the instance settings.
- public let emailAddress: String?
-
- /// The user's phone number in E.164 format. Only supported if phone number is enabled in the instance settings.
- public let phoneNumber: String?
-
- /// The Web3 wallet address, made up of 0x + 40 hexadecimal characters. Only supported if Web3 authentication is enabled in the instance settings.
- public let web3Wallet: String?
-
- /// The value of this attribute is true if a password was supplied to the current sign-up. Only supported if password is enabled in the instance settings.
- public let passwordEnabled: Bool
-
- /// The first name supplied to the current sign-up. Only supported if name is enabled in the instance settings.
- public let firstName: String?
-
- /// The last name supplied to the current sign-up. Only supported if name is enabled in the instance settings.
- public let lastName: String?
-
- /// Metadata that can be read and set from the frontend. Once the sign-up is complete, the value of this field will be automatically copied to the newly created user's unsafe metadata. One common use case for this attribute is to use it to implement custom fields that can be collected during sign-up and will automatically be attached to the created User object.
- public let unsafeMetadata: JSON?
-
- /// The identifier of the newly-created session. This attribute is populated only when the sign-up is complete.
- public let createdSessionId: String?
-
- /// The identifier of the newly-created user. This attribute is populated only when the sign-up is complete.
- public let createdUserId: String?
-
- /// The date when the sign-up was abandoned by the user.
- public let abandonAt: Date
-
- public init(
- id: String,
- status: SignUp.Status,
- requiredFields: [String],
- optionalFields: [String],
- missingFields: [String],
- unverifiedFields: [String],
- verifications: [String: Verification?],
- username: String? = nil,
- emailAddress: String? = nil,
- phoneNumber: String? = nil,
- web3Wallet: String? = nil,
- passwordEnabled: Bool,
- firstName: String? = nil,
- lastName: String? = nil,
- unsafeMetadata: JSON? = nil,
- createdSessionId: String? = nil,
- createdUserId: String? = nil,
- abandonAt: Date
- ) {
- self.id = id
- self.status = status
- self.requiredFields = requiredFields
- self.optionalFields = optionalFields
- self.missingFields = missingFields
- self.unverifiedFields = unverifiedFields
- self.verifications = verifications
- self.username = username
- self.emailAddress = emailAddress
- self.phoneNumber = phoneNumber
- self.web3Wallet = web3Wallet
- self.passwordEnabled = passwordEnabled
- self.firstName = firstName
- self.lastName = lastName
- self.unsafeMetadata = unsafeMetadata
- self.createdSessionId = createdSessionId
- self.createdUserId = createdUserId
- self.abandonAt = abandonAt
- }
+ /// The unique identifier of the current sign-up.
+ public let id: String
+
+ /// The status of the current sign-up.
+ ///
+ /// See ``SignUp/Status-swift.enum`` for supported values.
+ public let status: Status
+
+ /// An array of all the required fields that need to be supplied and verified in order for this sign-up to be marked as complete and converted into a user.
+ public let requiredFields: [String]
+
+ /// An array of all the fields that can be supplied to the sign-up, but their absence does not prevent the sign-up from being marked as complete.
+ public let optionalFields: [String]
+
+ /// An array of all the fields whose values are not supplied yet but they are mandatory in order for a sign-up to be marked as complete.
+ public let missingFields: [String]
+
+ /// An array of all the fields whose values have been supplied, but they need additional verification in order for them to be accepted.
+ ///
+ /// Examples of such fields are `email_address` and `phone_number`.
+ public let unverifiedFields: [String]
+
+ /// An object that contains information about all the verifications that are in-flight.
+ public let verifications: [String: Verification?]
+
+ /// The username supplied to the current sign-up. Only supported if username is enabled in the instance settings.
+ public let username: String?
+
+ /// The email address supplied to the current sign-up. Only supported if email address is enabled in the instance settings.
+ public let emailAddress: String?
+
+ /// The user's phone number in E.164 format. Only supported if phone number is enabled in the instance settings.
+ public let phoneNumber: String?
+
+ /// The Web3 wallet address, made up of 0x + 40 hexadecimal characters. Only supported if Web3 authentication is enabled in the instance settings.
+ public let web3Wallet: String?
+
+ /// The value of this attribute is true if a password was supplied to the current sign-up. Only supported if password is enabled in the instance settings.
+ public let passwordEnabled: Bool
+
+ /// The first name supplied to the current sign-up. Only supported if name is enabled in the instance settings.
+ public let firstName: String?
+
+ /// The last name supplied to the current sign-up. Only supported if name is enabled in the instance settings.
+ public let lastName: String?
+
+ /// Metadata that can be read and set from the frontend. Once the sign-up is complete, the value of this field will be automatically copied to the newly created user's unsafe metadata. One common use case for this attribute is to use it to implement custom fields that can be collected during sign-up and will automatically be attached to the created User object.
+ public let unsafeMetadata: JSON?
+
+ /// The identifier of the newly-created session. This attribute is populated only when the sign-up is complete.
+ public let createdSessionId: String?
+
+ /// The identifier of the newly-created user. This attribute is populated only when the sign-up is complete.
+ public let createdUserId: String?
+
+ /// The date when the sign-up was abandoned by the user.
+ public let abandonAt: Date
+
+ public init(
+ id: String,
+ status: SignUp.Status,
+ requiredFields: [String],
+ optionalFields: [String],
+ missingFields: [String],
+ unverifiedFields: [String],
+ verifications: [String: Verification?],
+ username: String? = nil,
+ emailAddress: String? = nil,
+ phoneNumber: String? = nil,
+ web3Wallet: String? = nil,
+ passwordEnabled: Bool,
+ firstName: String? = nil,
+ lastName: String? = nil,
+ unsafeMetadata: JSON? = nil,
+ createdSessionId: String? = nil,
+ createdUserId: String? = nil,
+ abandonAt: Date
+ ) {
+ self.id = id
+ self.status = status
+ self.requiredFields = requiredFields
+ self.optionalFields = optionalFields
+ self.missingFields = missingFields
+ self.unverifiedFields = unverifiedFields
+ self.verifications = verifications
+ self.username = username
+ self.emailAddress = emailAddress
+ self.phoneNumber = phoneNumber
+ self.web3Wallet = web3Wallet
+ self.passwordEnabled = passwordEnabled
+ self.firstName = firstName
+ self.lastName = lastName
+ self.unsafeMetadata = unsafeMetadata
+ self.createdSessionId = createdSessionId
+ self.createdUserId = createdUserId
+ self.abandonAt = abandonAt
+ }
}
extension SignUp {
- /// Initiates a new sign-up process and returns a `SignUp` object based on the provided strategy and optional parameters.
- ///
- /// This method initiates a new sign-up process by sending the appropriate parameters to Clerk's API.
- /// It deactivates any existing sign-up process and stores the sign-up lifecycle state in the `status` property of the new `SignUp` object.
- /// If required fields are provided, the sign-up process can be completed in one step. If not, Clerk's flexible sign-up process allows multi-step flows.
- ///
- /// What you must pass to params depends on which sign-up options you have enabled in your Clerk application instance.
- ///
- /// - Parameters:
- /// - strategy: The strategy to use for creating the sign-up. This defines the parameters used for the sign-up process. See ``SignUp/CreateStrategy`` for available strategies.
- /// - legalAccepted: A Boolean value indicating whether the user has accepted the legal terms.
- ///
- /// - Returns: A `SignUp` object containing the current status and details of the sign-up process. The `status` property reflects the current state of the sign-up.
- ///
- /// ### Example Usage:
- /// ```swift
- /// let signUp = try await SignUp.create(strategy: .standard(emailAddress: "user@email.com", password: "••••••••"))
- /// ```
- @discardableResult @MainActor
- public static func create(strategy: SignUp.CreateStrategy, legalAccepted: Bool? = nil) async throws -> SignUp {
- try await Container.shared.signUpService().create(strategy, legalAccepted)
- }
-
- /// Initiates a new sign-up process and returns a `SignUp` object based on the provided strategy and optional parameters.
- ///
- /// This method initiates a new sign-up process by sending the appropriate parameters to Clerk's API.
- /// It deactivates any existing sign-up process and stores the sign-up lifecycle state in the `status` property of the new `SignUp` object.
- /// If required fields are provided, the sign-up process can be completed in one step. If not, Clerk's flexible sign-up process allows multi-step flows.
- ///
- /// What you must pass to params depends on which sign-up options you have enabled in your Clerk application instance.
- ///
- /// - Parameters:
- /// - params: A dictionary of parameters used to create the sign-up.
- ///
- /// - Returns: A `SignUp` object containing the current status and details of the sign-up process. The `status` property reflects the current state of the sign-up.
- ///
- /// ### Example Usage:
- /// ```swift
- /// let signUp = try await SignUp.create(["email_address": "user@email.com", "password": "••••••••"])
- /// ```
- @discardableResult @MainActor
- public static func create(_ params: T) async throws -> SignUp {
- try await Container.shared.signUpService().createRaw(AnyEncodable(params))
- }
-
- /// This method is used to update the current sign-up.
- ///
- /// This method is used to modify the details of an ongoing sign-up process.
- /// It allows you to update any fields previously specified during the sign-up flow,
- /// such as personal information, email, phone number, or other attributes.
- ///
- /// - Parameter params: An instance of ``SignUp/UpdateParams`` (alias of ``SignUp/CreateParams``) containing the fields to update.
- /// Fields provided in `params` will overwrite the corresponding fields in the current sign-up.
- ///
- /// - Throws: An error if the update operation fails, such as due to invalid parameters or network issues.
- ///
- /// - Returns: The updated `SignUp` object reflecting the changes.
- @discardableResult @MainActor
- public func update(params: UpdateParams) async throws -> SignUp {
- try await Container.shared.signUpService().update(self, params)
- }
-
- /// The `prepareVerification` method is used to initiate the verification process for a field that requires it.
- ///
- /// As mentioned, there are two fields that need to be verified:
- ///
- /// - `emailAddress`: The email address can be verified via an email code. This is a one-time code that is sent
- /// to the email already provided to the `SignUp` object. The `prepareVerification` sends this email.
- /// - `phoneNumber`: The phone number can be verified via a phone code. This is a one-time code that is sent
- /// via an SMS to the phone already provided to the `SignUp` object. The `prepareVerification` sends this SMS.
- ///
- /// - Parameter strategy: A `PrepareStrategy` specifying which field requires verification.
- /// - Throws: An error if the request to prepare verification fails.
- /// - Returns: The updated `SignUp` object reflecting the verification initiation.
- @discardableResult @MainActor
- public func prepareVerification(strategy: PrepareStrategy) async throws -> SignUp {
- try await Container.shared.signUpService().prepareVerification(self, strategy)
- }
-
- /// Attempts to complete the in-flight verification process that corresponds to the given strategy. In order to use this method, you should first initiate a verification process by calling SignUp.prepareVerification.
- ///
- /// Depending on the strategy, the method parameters could differ.
- ///
- /// - Parameter strategy: The strategy to use for the verification attempt. See ``SignUp/AttemptStrategy``
- /// for supported strategies.
- ///
- /// - Throws: An error if the verification attempt fails.
- ///
- /// - Returns: The updated `SignUp` object reflecting the verification attempt's result.
- @discardableResult @MainActor
- public func attemptVerification(strategy: AttemptStrategy) async throws -> SignUp {
- try await Container.shared.signUpService().attemptVerification(self, strategy)
- }
-
- #if !os(tvOS) && !os(watchOS)
+ /// Initiates a new sign-up process and returns a `SignUp` object based on the provided strategy and optional parameters.
+ ///
+ /// This method initiates a new sign-up process by sending the appropriate parameters to Clerk's API.
+ /// It deactivates any existing sign-up process and stores the sign-up lifecycle state in the `status` property of the new `SignUp` object.
+ /// If required fields are provided, the sign-up process can be completed in one step. If not, Clerk's flexible sign-up process allows multi-step flows.
+ ///
+ /// What you must pass to params depends on which sign-up options you have enabled in your Clerk application instance.
+ ///
+ /// - Parameters:
+ /// - strategy: The strategy to use for creating the sign-up. This defines the parameters used for the sign-up process. See ``SignUp/CreateStrategy`` for available strategies.
+ /// - legalAccepted: A Boolean value indicating whether the user has accepted the legal terms.
+ ///
+ /// - Returns: A `SignUp` object containing the current status and details of the sign-up process. The `status` property reflects the current state of the sign-up.
+ ///
+ /// ### Example Usage:
+ /// ```swift
+ /// let signUp = try await SignUp.create(strategy: .standard(emailAddress: "user@email.com", password: "••••••••"))
+ /// ```
+ @discardableResult @MainActor
+ public static func create(strategy: SignUp.CreateStrategy, legalAccepted: Bool? = nil) async throws -> SignUp {
+ try await Container.shared.signUpService().create(strategy, legalAccepted)
+ }
+
+ /// Initiates a new sign-up process and returns a `SignUp` object based on the provided strategy and optional parameters.
+ ///
+ /// This method initiates a new sign-up process by sending the appropriate parameters to Clerk's API.
+ /// It deactivates any existing sign-up process and stores the sign-up lifecycle state in the `status` property of the new `SignUp` object.
+ /// If required fields are provided, the sign-up process can be completed in one step. If not, Clerk's flexible sign-up process allows multi-step flows.
+ ///
+ /// What you must pass to params depends on which sign-up options you have enabled in your Clerk application instance.
+ ///
+ /// - Parameters:
+ /// - params: A dictionary of parameters used to create the sign-up.
+ ///
+ /// - Returns: A `SignUp` object containing the current status and details of the sign-up process. The `status` property reflects the current state of the sign-up.
+ ///
+ /// ### Example Usage:
+ /// ```swift
+ /// let signUp = try await SignUp.create(["email_address": "user@email.com", "password": "••••••••"])
+ /// ```
+ @discardableResult @MainActor
+ public static func create(_ params: T) async throws -> SignUp {
+ try await Container.shared.signUpService().createWithParams(params)
+ }
+
+ /// This method is used to update the current sign-up.
+ ///
+ /// This method is used to modify the details of an ongoing sign-up process.
+ /// It allows you to update any fields previously specified during the sign-up flow,
+ /// such as personal information, email, phone number, or other attributes.
+ ///
+ /// - Parameter params: An instance of ``SignUp/UpdateParams`` (alias of ``SignUp/CreateParams``) containing the fields to update.
+ /// Fields provided in `params` will overwrite the corresponding fields in the current sign-up.
+ ///
+ /// - Throws: An error if the update operation fails, such as due to invalid parameters or network issues.
+ ///
+ /// - Returns: The updated `SignUp` object reflecting the changes.
+ @discardableResult @MainActor
+ public func update(params: UpdateParams) async throws -> SignUp {
+ try await Container.shared.signUpService().update(id, params)
+ }
+
+ /// The `prepareVerification` method is used to initiate the verification process for a field that requires it.
+ ///
+ /// As mentioned, there are two fields that need to be verified:
+ ///
+ /// - `emailAddress`: The email address can be verified via an email code. This is a one-time code that is sent
+ /// to the email already provided to the `SignUp` object. The `prepareVerification` sends this email.
+ /// - `phoneNumber`: The phone number can be verified via a phone code. This is a one-time code that is sent
+ /// via an SMS to the phone already provided to the `SignUp` object. The `prepareVerification` sends this SMS.
+ ///
+ /// - Parameter strategy: A `PrepareStrategy` specifying which field requires verification.
+ /// - Throws: An error if the request to prepare verification fails.
+ /// - Returns: The updated `SignUp` object reflecting the verification initiation.
+ @discardableResult @MainActor
+ public func prepareVerification(strategy: PrepareStrategy) async throws -> SignUp {
+ try await Container.shared.signUpService().prepareVerification(id, strategy)
+ }
+
+ /// Attempts to complete the in-flight verification process that corresponds to the given strategy. In order to use this method, you should first initiate a verification process by calling SignUp.prepareVerification.
+ ///
+ /// Depending on the strategy, the method parameters could differ.
+ ///
+ /// - Parameter strategy: The strategy to use for the verification attempt. See ``SignUp/AttemptStrategy``
+ /// for supported strategies.
+ ///
+ /// - Throws: An error if the verification attempt fails.
+ ///
+ /// - Returns: The updated `SignUp` object reflecting the verification attempt's result.
+ @discardableResult @MainActor
+ public func attemptVerification(strategy: AttemptStrategy) async throws -> SignUp {
+ try await Container.shared.signUpService().attemptVerification(id, strategy)
+ }
+
+ #if !os(tvOS) && !os(watchOS)
/// Creates a new ``SignUp`` and initiates an external authentication flow using a redirect-based strategy.
///
/// This function handles the process of creating a ``SignUp`` instance,
@@ -247,11 +247,11 @@ extension SignUp {
/// ```
@discardableResult @MainActor
public static func authenticateWithRedirect(strategy: SignUp.AuthenticateWithRedirectStrategy, prefersEphemeralWebBrowserSession: Bool = false) async throws -> TransferFlowResult {
- try await Container.shared.signUpService().authenticateWithRedirectCombined(strategy, prefersEphemeralWebBrowserSession)
+ try await Container.shared.signUpService().authenticateWithRedirectStatic(strategy, prefersEphemeralWebBrowserSession)
}
- #endif
+ #endif
- #if !os(tvOS) && !os(watchOS)
+ #if !os(tvOS) && !os(watchOS)
/// Initiates an external authentication flow using a redirect-based strategy for the current ``SignUp`` instance.
///
/// This function starts an external web authentication session,
@@ -276,117 +276,117 @@ extension SignUp {
/// ```
@discardableResult @MainActor
public func authenticateWithRedirect(prefersEphemeralWebBrowserSession: Bool = false) async throws -> TransferFlowResult {
- try await Container.shared.signUpService().authenticateWithRedirectTwoStep(self, prefersEphemeralWebBrowserSession)
+ try await Container.shared.signUpService().authenticateWithRedirect(self, prefersEphemeralWebBrowserSession)
+ }
+ #endif
+
+ /// Authenticates the user using an ID Token and a specified provider.
+ ///
+ /// This method facilitates authentication using an ID token provided by a specific authentication provider.
+ /// It determines whether the user needs to be transferred to a sign-in flow.
+ ///
+ /// - Parameters:
+ /// - provider: The identity provider associated with the ID token. See ``IDTokenProvider`` for supported values.
+ /// - idToken: The ID token to use for authentication, obtained from the provider during the sign-in process.
+ ///
+ /// - Throws:``ClerkClientError``
+ ///
+ /// - Returns: An ``TransferFlowResult`` containing either a sign-in or a newly created sign-up instance.
+ ///
+ /// ### Example
+ /// ```swift
+ /// let result = try await SignUp.authenticateWithIdToken(
+ /// provider: .apple,
+ /// idToken: idToken
+ /// )
+ /// ```
+ @discardableResult @MainActor
+ public static func authenticateWithIdToken(provider: IDTokenProvider, idToken: String) async throws -> TransferFlowResult {
+ try await Container.shared.signUpService().authenticateWithIdTokenStatic(provider, idToken)
+ }
+
+ /// Authenticates the user using an ID Token and a specified provider.
+ ///
+ /// This method completes authentication using an ID token provided by a specific authentication provider.
+ /// It determines whether the user needs to be transferred to a sign-in flow.
+ ///
+ /// - Throws:``ClerkClientError``
+ ///
+ /// - Returns: ``TransferFlowResult`` containing either a sign-in or a newly created sign-up instance.
+ ///
+ /// ### Example
+ /// ```swift
+ /// let signUp = try await SignUp.create(strategy: .idToken(provider: .apple, idToken: "idToken"))
+ /// let result = try await signUp.authenticateWithIdToken()
+ /// ```
+ @discardableResult @MainActor
+ public func authenticateWithIdToken() async throws -> TransferFlowResult {
+ try await Container.shared.signUpService().authenticateWithIdToken(self)
}
- #endif
-
- /// Authenticates the user using an ID Token and a specified provider.
- ///
- /// This method facilitates authentication using an ID token provided by a specific authentication provider.
- /// It determines whether the user needs to be transferred to a sign-in flow.
- ///
- /// - Parameters:
- /// - provider: The identity provider associated with the ID token. See ``IDTokenProvider`` for supported values.
- /// - idToken: The ID token to use for authentication, obtained from the provider during the sign-in process.
- ///
- /// - Throws:``ClerkClientError``
- ///
- /// - Returns: An ``TransferFlowResult`` containing either a sign-in or a newly created sign-up instance.
- ///
- /// ### Example
- /// ```swift
- /// let result = try await SignUp.authenticateWithIdToken(
- /// provider: .apple,
- /// idToken: idToken
- /// )
- /// ```
- @discardableResult @MainActor
- public static func authenticateWithIdToken(provider: IDTokenProvider, idToken: String) async throws -> TransferFlowResult {
- try await Container.shared.signUpService().authenticateWithIdTokenCombined(provider, idToken)
- }
-
- /// Authenticates the user using an ID Token and a specified provider.
- ///
- /// This method completes authentication using an ID token provided by a specific authentication provider.
- /// It determines whether the user needs to be transferred to a sign-in flow.
- ///
- /// - Throws:``ClerkClientError``
- ///
- /// - Returns: ``TransferFlowResult`` containing either a sign-in or a newly created sign-up instance.
- ///
- /// ### Example
- /// ```swift
- /// let signUp = try await SignUp.create(strategy: .idToken(provider: .apple, idToken: "idToken"))
- /// let result = try await signUp.authenticateWithIdToken()
- /// ```
- @discardableResult @MainActor
- public func authenticateWithIdToken() async throws -> TransferFlowResult {
- try await Container.shared.signUpService().authenticateWithIdTokenTwoStep(self)
- }
}
extension SignUp {
- // MARK: - Internal Helpers
+ // MARK: - Internal Helpers
- private var needsTransferToSignIn: Bool {
- verifications.contains(where: { $0.key == "external_account" && $0.value?.status == .transferable })
- }
+ private var needsTransferToSignIn: Bool {
+ verifications.contains(where: { $0.key == "external_account" && $0.value?.status == .transferable })
+ }
- /// Determines whether or not to return a sign in or sign up object as part of the transfer flow.
- func handleTransferFlow() async throws -> TransferFlowResult {
- if needsTransferToSignIn == true {
- let signIn = try await SignIn.create(strategy: .transfer)
- return .signIn(signIn)
- } else {
- return .signUp(self)
+ /// Determines whether or not to return a sign in or sign up object as part of the transfer flow.
+ func handleTransferFlow() async throws -> TransferFlowResult {
+ if needsTransferToSignIn == true {
+ let signIn = try await SignIn.create(strategy: .transfer)
+ return .signIn(signIn)
+ } else {
+ return .signUp(self)
+ }
}
- }
-
- @discardableResult @MainActor
- func handleOAuthCallbackUrl(_ url: URL) async throws -> TransferFlowResult {
- if let nonce = ExternalAuthUtils.nonceFromCallbackUrl(url: url) {
- let updatedSignUp = try await get(rotatingTokenNonce: nonce)
- return .signUp(updatedSignUp)
- } else {
- // transfer flow
- let signUp = try await get()
- let result = try await signUp.handleTransferFlow()
- return result
+
+ @discardableResult @MainActor
+ func handleOAuthCallbackUrl(_ url: URL) async throws -> TransferFlowResult {
+ if let nonce = ExternalAuthUtils.nonceFromCallbackUrl(url: url) {
+ let updatedSignUp = try await get(rotatingTokenNonce: nonce)
+ return .signUp(updatedSignUp)
+ } else {
+ // transfer flow
+ let signUp = try await get()
+ let result = try await signUp.handleTransferFlow()
+ return result
+ }
}
- }
- /// Returns the current sign up.
- @discardableResult @MainActor
- func get(rotatingTokenNonce: String? = nil) async throws -> SignUp {
- try await Container.shared.signUpService().get(self, rotatingTokenNonce)
- }
+ /// Returns the current sign up.
+ @discardableResult @MainActor
+ func get(rotatingTokenNonce: String? = nil) async throws -> SignUp {
+ try await Container.shared.signUpService().get(id, rotatingTokenNonce)
+ }
}
extension SignUp {
- static var mock: SignUp {
- SignUp(
- id: "1",
- status: .missingRequirements,
- requiredFields: [],
- optionalFields: [],
- missingFields: [],
- unverifiedFields: [],
- verifications: ["email_address": .mockPhoneCodeVerifiedVerification],
- username: User.mock.username,
- emailAddress: User.mock.emailAddresses.first?.emailAddress,
- phoneNumber: User.mock.phoneNumbers.first?.phoneNumber,
- web3Wallet: nil,
- passwordEnabled: User.mock.passwordEnabled,
- firstName: User.mock.firstName,
- lastName: User.mock.lastName,
- unsafeMetadata: nil,
- createdSessionId: nil,
- createdUserId: nil,
- abandonAt: Date(timeIntervalSinceReferenceDate: 1234567890)
- )
- }
+ static var mock: SignUp {
+ SignUp(
+ id: "1",
+ status: .missingRequirements,
+ requiredFields: [],
+ optionalFields: [],
+ missingFields: [],
+ unverifiedFields: [],
+ verifications: ["email_address": .mockPhoneCodeVerifiedVerification],
+ username: User.mock.username,
+ emailAddress: User.mock.emailAddresses.first?.emailAddress,
+ phoneNumber: User.mock.phoneNumbers.first?.phoneNumber,
+ web3Wallet: nil,
+ passwordEnabled: User.mock.passwordEnabled,
+ firstName: User.mock.firstName,
+ lastName: User.mock.lastName,
+ unsafeMetadata: nil,
+ createdSessionId: nil,
+ createdUserId: nil,
+ abandonAt: Date(timeIntervalSinceReferenceDate: 1234567890)
+ )
+ }
}
diff --git a/Sources/Clerk/Models/SignUp/SignUpAttemptVerificationParams.swift b/Sources/Clerk/Models/SignUp/SignUpAttemptVerificationParams.swift
index dae988893..a07a0b20a 100644
--- a/Sources/Clerk/Models/SignUp/SignUpAttemptVerificationParams.swift
+++ b/Sources/Clerk/Models/SignUp/SignUpAttemptVerificationParams.swift
@@ -9,34 +9,34 @@ import Foundation
extension SignUp {
- /// Defines the strategies for attempting verification during the sign-up process.
- public enum AttemptStrategy: Sendable {
- /// Attempts verification using a code sent to the user's email address.
- /// - Parameter code: The one-time code sent to the user's email address.
- case emailCode(code: String)
-
- /// Attempts verification using a code sent to the user's phone number.
- /// - Parameter code: The one-time code sent to the user's phone number.
- case phoneCode(code: String)
-
- /// Converts the selected strategy into `AttemptVerificationParams` for the API request.
- var params: AttemptVerificationParams {
- switch self {
- case .emailCode(let code):
- return .init(strategy: "email_code", code: code)
- case .phoneCode(let code):
- return .init(strategy: "phone_code", code: code)
- }
+ /// Defines the strategies for attempting verification during the sign-up process.
+ public enum AttemptStrategy: Sendable {
+ /// Attempts verification using a code sent to the user's email address.
+ /// - Parameter code: The one-time code sent to the user's email address.
+ case emailCode(code: String)
+
+ /// Attempts verification using a code sent to the user's phone number.
+ /// - Parameter code: The one-time code sent to the user's phone number.
+ case phoneCode(code: String)
+
+ /// Converts the selected strategy into `AttemptVerificationParams` for the API request.
+ var params: AttemptVerificationParams {
+ switch self {
+ case .emailCode(let code):
+ return .init(strategy: "email_code", code: code)
+ case .phoneCode(let code):
+ return .init(strategy: "phone_code", code: code)
+ }
+ }
}
- }
- /// Parameters used for the verification attempt during the sign-up process.
- public struct AttemptVerificationParams: Encodable {
- /// The strategy used for verification (e.g., `email_code` or `phone_code`).
- public let strategy: String
+ /// Parameters used for the verification attempt during the sign-up process.
+ public struct AttemptVerificationParams: Encodable {
+ /// The strategy used for verification (e.g., `email_code` or `phone_code`).
+ public let strategy: String
- /// The verification code provided by the user.
- public let code: String
- }
+ /// The verification code provided by the user.
+ public let code: String
+ }
}
diff --git a/Sources/Clerk/Models/SignUp/SignUpAuthenticateWithRedirectParams.swift b/Sources/Clerk/Models/SignUp/SignUpAuthenticateWithRedirectParams.swift
index 2ca912282..36be16781 100644
--- a/Sources/Clerk/Models/SignUp/SignUpAuthenticateWithRedirectParams.swift
+++ b/Sources/Clerk/Models/SignUp/SignUpAuthenticateWithRedirectParams.swift
@@ -9,67 +9,69 @@ import Foundation
extension SignUp {
- /// Represents the parameters used for authenticating with a redirect.
- ///
- /// This structure is used to authenticate a user with various strategies, including OAuth, SAML, and enterprise SSO. It defines the necessary parameters for the authentication process, such as the redirect URLs and legal acceptance.
- ///
- /// - SeeAlso: `AuthenticateWithRedirect` function for initiating authentication with these parameters.
- struct AuthenticateWithRedirectParams: Encodable {
+ /// Represents the parameters used for authenticating with a redirect.
+ ///
+ /// This structure is used to authenticate a user with various strategies, including OAuth, SAML, and enterprise SSO. It defines the necessary parameters for the authentication process, such as the redirect URLs and legal acceptance.
+ ///
+ /// - SeeAlso: `AuthenticateWithRedirect` function for initiating authentication with these parameters.
+ struct AuthenticateWithRedirectParams: Encodable {
- /// The strategy to use for authentication.
- let strategy: String
+ /// The strategy to use for authentication.
+ let strategy: String
- /// The full URL or path that the OAuth provider should redirect to, on successful authorization on their part.
- let redirectUrl: String
+ /// The full URL or path that the OAuth provider should redirect to, on successful authorization on their part.
+ let redirectUrl: String
- /// The email address used to target an enterprise connection during sign-in. This is optional and only used when the strategy involves enterprise authentication.
- var emailAddress: String?
+ /// The email address used to target an enterprise connection during sign-in. This is optional and only used when the strategy involves enterprise authentication.
+ var emailAddress: String?
- /// A boolean indicating whether the user has agreed to the legal compliance documents. This is an optional field.
- var legalAccepted: Bool?
+ /// A boolean indicating whether the user has agreed to the legal compliance documents. This is an optional field.
+ var legalAccepted: Bool?
- /// The identifier associated with the user or the authentication session. This is an optional field.
- var identifier: String?
- }
+ /// The identifier associated with the user or the authentication session. This is an optional field.
+ var identifier: String?
+ }
- /// The strategy to use for authentication.
- public enum AuthenticateWithRedirectStrategy: Codable, Sendable {
- /// The user will be authenticated with their social connection account.
- case oauth(provider: OAuthProvider, redirectUrl: String? = nil)
+ /// The strategy to use for authentication.
+ public enum AuthenticateWithRedirectStrategy: Codable, Sendable {
+ /// The user will be authenticated with their social connection account.
+ case oauth(provider: OAuthProvider, redirectUrl: String? = nil)
- /// The user will be authenticated with their enterprise SSO account.
- case enterpriseSSO(identifier: String, redirectUrl: String? = nil)
+ /// The user will be authenticated with their enterprise SSO account.
+ case enterpriseSSO(identifier: String, redirectUrl: String? = nil)
- var signUpStrategy: SignUp.CreateStrategy {
- switch self {
- case .oauth(let provider, let redirectUrl):
- .oauth(
- provider: provider,
- redirectUrl: redirectUrl ?? RedirectConfigDefaults.redirectUrl
- )
- case .enterpriseSSO(let identifier, let redirectUrl):
- .enterpriseSSO(
- identifier: identifier,
- redirectUrl: redirectUrl ?? RedirectConfigDefaults.redirectUrl
- )
- }
- }
+ @MainActor
+ var signUpStrategy: SignUp.CreateStrategy {
+ switch self {
+ case .oauth(let provider, let redirectUrl):
+ .oauth(
+ provider: provider,
+ redirectUrl: redirectUrl ?? Clerk.shared.settings.redirectConfig.redirectUrl
+ )
+ case .enterpriseSSO(let identifier, let redirectUrl):
+ .enterpriseSSO(
+ identifier: identifier,
+ redirectUrl: redirectUrl ?? Clerk.shared.settings.redirectConfig.redirectUrl
+ )
+ }
+ }
- var params: AuthenticateWithRedirectParams {
- switch self {
- case .oauth(let provider, let redirectUrl):
- .init(
- strategy: provider.strategy,
- redirectUrl: redirectUrl ?? RedirectConfigDefaults.redirectUrl
- )
- case .enterpriseSSO(let identifier, let redirectUrl):
- .init(
- strategy: "enterprise_sso",
- redirectUrl: redirectUrl ?? RedirectConfigDefaults.redirectUrl,
- identifier: identifier
- )
- }
+ @MainActor
+ var params: AuthenticateWithRedirectParams {
+ switch self {
+ case .oauth(let provider, let redirectUrl):
+ .init(
+ strategy: provider.strategy,
+ redirectUrl: redirectUrl ?? Clerk.shared.settings.redirectConfig.redirectUrl
+ )
+ case .enterpriseSSO(let identifier, let redirectUrl):
+ .init(
+ strategy: "enterprise_sso",
+ redirectUrl: redirectUrl ?? Clerk.shared.settings.redirectConfig.redirectUrl,
+ identifier: identifier
+ )
+ }
+ }
}
- }
}
diff --git a/Sources/Clerk/Models/SignUp/SignUpCreateParams.swift b/Sources/Clerk/Models/SignUp/SignUpCreateParams.swift
index c685d99e3..1ac1e18b7 100644
--- a/Sources/Clerk/Models/SignUp/SignUpCreateParams.swift
+++ b/Sources/Clerk/Models/SignUp/SignUpCreateParams.swift
@@ -9,208 +9,209 @@ import Foundation
extension SignUp {
- /// Parameters used to create and configure a new sign-up process.
- ///
- /// The `CreateParams` struct defines all the parameters that can be passed when initiating a sign-up process.
- /// These parameters provide flexibility to support various authentication strategies, user details, and custom configurations.
- public struct CreateParams: Encodable, Sendable {
-
- /// The strategy to use for the sign-up flow.
- public var strategy: String?
-
- /// The user's first name. Only supported if name is enabled.
- public var firstName: String?
-
- /// The user's last name. Only supported if name is enabled.
- public var lastName: String?
-
- /// The user's password. Only supported if password is enabled.
- public var password: String?
-
- /// The user's email address. Only supported if email address is enabled. Keep in mind that the email address requires an extra verification process.
- public var emailAddress: String?
-
- /// The user's phone number in E.164 format. Only supported if phone number is enabled. Keep in mind that the phone number requires an extra verification process.
- public var phoneNumber: String?
-
- /// Required if Web3 authentication is enabled. The Web3 wallet address, made up of 0x + 40 hexadecimal characters.
- public var web3Wallet: String?
-
- /// The user's username. Only supported if usernames are enabled.
- public var username: String?
-
- /// Metadata that can be read and set from the frontend.
- ///
- /// Once the sign-up is complete, the value of this field will be automatically copied to the newly created user's unsafe metadata.
- /// One common use case for this attribute is to use it to implement custom fields that can be collected during sign-up and will automatically be attached to the created User object.
- public var unsafeMetadata: JSON?
-
- /// If strategy is set to 'oauth_{provider}' or 'enterprise_sso', this specifies full URL or path that the OAuth provider should redirect to after successful authorization on their part.
- ///
- /// If strategy is set to 'email_link', this specifies The full URL that the user will be redirected to when they visit the email link. See the custom flow for implementation details.
- public var redirectUrl: String?
-
- /// Required if strategy is set to 'ticket'. The ticket or token generated from the Backend API.
- public var ticket: String?
-
- /// When set to true, the SignUp will attempt to retrieve information from the active SignIn instance and use it to complete the sign-up process.
+ /// Parameters used to create and configure a new sign-up process.
///
- /// This is useful when you want to seamlessly transition a user from a sign-in attempt to a sign-up attempt.
- public var transfer: Bool?
-
- /// A boolean indicating whether the user has agreed to the legal compliance documents.
- public var legalAccepted: Bool?
-
- /// Optional if strategy is set to 'oauth_{provider}' or 'enterprise_sso'. The value to pass to the OIDC prompt parameter in the generated OAuth redirect URL.
- public var oidcPrompt: String?
-
- /// Optional if strategy is set to 'oauth_' or 'enterprise_sso'. The value to pass to the OIDC login_hint parameter in the generated OAuth redirect URL.
- public var oidcLoginHint: String?
-
- /// The ID token from a provider used for authentication (e.g., SignInWithApple).
- public var token: String?
-
- public init(
- strategy: String? = nil,
- firstName: String? = nil,
- lastName: String? = nil,
- password: String? = nil,
- emailAddress: String? = nil,
- phoneNumber: String? = nil,
- web3Wallet: String? = nil,
- username: String? = nil,
- unsafeMetadata: JSON? = nil,
- redirectUrl: String? = nil,
- ticket: String? = nil,
- transfer: Bool? = nil,
- legalAccepted: Bool? = nil,
- oidcPrompt: String? = nil,
- oidcLoginHint: String? = nil,
- token: String? = nil
- ) {
- self.strategy = strategy
- self.firstName = firstName
- self.lastName = lastName
- self.password = password
- self.emailAddress = emailAddress
- self.phoneNumber = phoneNumber
- self.web3Wallet = web3Wallet
- self.username = username
- self.unsafeMetadata = unsafeMetadata
- self.redirectUrl = redirectUrl
- self.ticket = ticket
- self.transfer = transfer
- self.legalAccepted = legalAccepted
- self.oidcPrompt = oidcPrompt
- self.oidcLoginHint = oidcLoginHint
- self.token = token
+ /// The `CreateParams` struct defines all the parameters that can be passed when initiating a sign-up process.
+ /// These parameters provide flexibility to support various authentication strategies, user details, and custom configurations.
+ public struct CreateParams: Encodable, Sendable {
+
+ /// The strategy to use for the sign-up flow.
+ public var strategy: String?
+
+ /// The user's first name. Only supported if name is enabled.
+ public var firstName: String?
+
+ /// The user's last name. Only supported if name is enabled.
+ public var lastName: String?
+
+ /// The user's password. Only supported if password is enabled.
+ public var password: String?
+
+ /// The user's email address. Only supported if email address is enabled. Keep in mind that the email address requires an extra verification process.
+ public var emailAddress: String?
+
+ /// The user's phone number in E.164 format. Only supported if phone number is enabled. Keep in mind that the phone number requires an extra verification process.
+ public var phoneNumber: String?
+
+ /// Required if Web3 authentication is enabled. The Web3 wallet address, made up of 0x + 40 hexadecimal characters.
+ public var web3Wallet: String?
+
+ /// The user's username. Only supported if usernames are enabled.
+ public var username: String?
+
+ /// Metadata that can be read and set from the frontend.
+ ///
+ /// Once the sign-up is complete, the value of this field will be automatically copied to the newly created user's unsafe metadata.
+ /// One common use case for this attribute is to use it to implement custom fields that can be collected during sign-up and will automatically be attached to the created User object.
+ public var unsafeMetadata: JSON?
+
+ /// If strategy is set to 'oauth_{provider}' or 'enterprise_sso', this specifies full URL or path that the OAuth provider should redirect to after successful authorization on their part.
+ ///
+ /// If strategy is set to 'email_link', this specifies The full URL that the user will be redirected to when they visit the email link. See the custom flow for implementation details.
+ public var redirectUrl: String?
+
+ /// Required if strategy is set to 'ticket'. The ticket or token generated from the Backend API.
+ public var ticket: String?
+
+ /// When set to true, the SignUp will attempt to retrieve information from the active SignIn instance and use it to complete the sign-up process.
+ ///
+ /// This is useful when you want to seamlessly transition a user from a sign-in attempt to a sign-up attempt.
+ public var transfer: Bool?
+
+ /// A boolean indicating whether the user has agreed to the legal compliance documents.
+ public var legalAccepted: Bool?
+
+ /// Optional if strategy is set to 'oauth_{provider}' or 'enterprise_sso'. The value to pass to the OIDC prompt parameter in the generated OAuth redirect URL.
+ public var oidcPrompt: String?
+
+ /// Optional if strategy is set to 'oauth_' or 'enterprise_sso'. The value to pass to the OIDC login_hint parameter in the generated OAuth redirect URL.
+ public var oidcLoginHint: String?
+
+ /// The ID token from a provider used for authentication (e.g., SignInWithApple).
+ public var token: String?
+
+ public init(
+ strategy: String? = nil,
+ firstName: String? = nil,
+ lastName: String? = nil,
+ password: String? = nil,
+ emailAddress: String? = nil,
+ phoneNumber: String? = nil,
+ web3Wallet: String? = nil,
+ username: String? = nil,
+ unsafeMetadata: JSON? = nil,
+ redirectUrl: String? = nil,
+ ticket: String? = nil,
+ transfer: Bool? = nil,
+ legalAccepted: Bool? = nil,
+ oidcPrompt: String? = nil,
+ oidcLoginHint: String? = nil,
+ token: String? = nil
+ ) {
+ self.strategy = strategy
+ self.firstName = firstName
+ self.lastName = lastName
+ self.password = password
+ self.emailAddress = emailAddress
+ self.phoneNumber = phoneNumber
+ self.web3Wallet = web3Wallet
+ self.username = username
+ self.unsafeMetadata = unsafeMetadata
+ self.redirectUrl = redirectUrl
+ self.ticket = ticket
+ self.transfer = transfer
+ self.legalAccepted = legalAccepted
+ self.oidcPrompt = oidcPrompt
+ self.oidcLoginHint = oidcLoginHint
+ self.token = token
+ }
}
- }
- /// Represents the various strategies for initiating a `SignUp` request.
- public enum CreateStrategy: Sendable {
-
- /// Standard sign-up strategy, allowing the user to provide common details such as email, password, and personal information.
- ///
- /// - Parameters:
- /// - emailAddress: The user's email address (optional).
- /// - password: The user's password (optional).
- /// - firstName: The user's first name (optional).
- /// - lastName: The user's last name (optional).
- /// - username: The user's username (optional).
- /// - phoneNumber: The user's phone number (optional).
- case standard(
- emailAddress: String? = nil,
- password: String? = nil,
- firstName: String? = nil,
- lastName: String? = nil,
- username: String? = nil,
- phoneNumber: String? = nil
- )
-
- /// OAuth-based sign-up strategy, using an external provider for authentication.
- ///
- /// - Parameter provider: The OAuth provider used for authentication.
- case oauth(provider: OAuthProvider, redirectUrl: String? = nil)
-
- /// Enterprise single sign-on (SSO) sign-up strategy, allowing authentication through an enterprise identity provider.
- ///
- /// - Parameter identifier: The unique identifier for the enterprise SSO user.
- case enterpriseSSO(identifier: String, redirectUrl: String? = nil)
-
- /// The user will be authenticated via the ticket or token generated from the Backend API.
- case ticket(String)
-
- /// Sign-up strategy using an ID Token, typically obtained from third-party identity providers like Apple.
- ///
- /// - Parameters:
- /// - provider: The provider of the ID token.
- /// - idToken: The ID token to be used for authentication.
- /// - firstName: The user's first name (optional).
- /// - lastName: The user's last name (optional).
- case idToken(
- provider: IDTokenProvider,
- idToken: String,
- firstName: String? = nil,
- lastName: String? = nil
- )
-
- /// Transfers an active sign-in instance to a new sign-up process.
- case transfer
-
- /// The `SignUp` will be created without any parameters.
- ///
- /// This is useful for inspecting a newly created `SignUp` object before deciding on a strategy.
- case none
-
- /// Converts the strategy into the appropriate `CreateParams` object for a `SignUp` request.
- ///
- /// This computed property maps each strategy case to its corresponding `CreateParams` object.
- ///
- /// - Returns: A `CreateParams` object containing all the necessary data for the `SignUp` request.
- var params: CreateParams {
- switch self {
- case .standard(let emailAddress, let password, let firstName, let lastName, let username, let phoneNumber):
- .init(
- firstName: firstName,
- lastName: lastName,
- password: password,
- emailAddress: emailAddress,
- phoneNumber: phoneNumber,
- username: username
- )
- case .oauth(let provider, let redirectUrl):
- .init(
- strategy: provider.strategy,
- redirectUrl: redirectUrl ?? RedirectConfigDefaults.redirectUrl
- )
- case .enterpriseSSO(let identifier, let redirectUrl):
- .init(
- strategy: "enterprise_sso",
- emailAddress: identifier,
- redirectUrl: redirectUrl ?? RedirectConfigDefaults.redirectUrl
+ /// Represents the various strategies for initiating a `SignUp` request.
+ public enum CreateStrategy: Sendable {
+
+ /// Standard sign-up strategy, allowing the user to provide common details such as email, password, and personal information.
+ ///
+ /// - Parameters:
+ /// - emailAddress: The user's email address (optional).
+ /// - password: The user's password (optional).
+ /// - firstName: The user's first name (optional).
+ /// - lastName: The user's last name (optional).
+ /// - username: The user's username (optional).
+ /// - phoneNumber: The user's phone number (optional).
+ case standard(
+ emailAddress: String? = nil,
+ password: String? = nil,
+ firstName: String? = nil,
+ lastName: String? = nil,
+ username: String? = nil,
+ phoneNumber: String? = nil
)
- case .ticket(let ticket):
- .init(
- strategy: "ticket",
- ticket: ticket
- )
- case .idToken(let provider, let idToken, let firstName, let lastName):
- .init(
- strategy: provider.strategy,
- firstName: firstName,
- lastName: lastName,
- token: idToken
+
+ /// OAuth-based sign-up strategy, using an external provider for authentication.
+ ///
+ /// - Parameter provider: The OAuth provider used for authentication.
+ case oauth(provider: OAuthProvider, redirectUrl: String? = nil)
+
+ /// Enterprise single sign-on (SSO) sign-up strategy, allowing authentication through an enterprise identity provider.
+ ///
+ /// - Parameter identifier: The unique identifier for the enterprise SSO user.
+ case enterpriseSSO(identifier: String, redirectUrl: String? = nil)
+
+ /// The user will be authenticated via the ticket or token generated from the Backend API.
+ case ticket(String)
+
+ /// Sign-up strategy using an ID Token, typically obtained from third-party identity providers like Apple.
+ ///
+ /// - Parameters:
+ /// - provider: The provider of the ID token.
+ /// - idToken: The ID token to be used for authentication.
+ /// - firstName: The user's first name (optional).
+ /// - lastName: The user's last name (optional).
+ case idToken(
+ provider: IDTokenProvider,
+ idToken: String,
+ firstName: String? = nil,
+ lastName: String? = nil
)
- case .transfer:
- .init(transfer: true)
- case .none:
- .init()
- }
+
+ /// Transfers an active sign-in instance to a new sign-up process.
+ case transfer
+
+ /// The `SignUp` will be created without any parameters.
+ ///
+ /// This is useful for inspecting a newly created `SignUp` object before deciding on a strategy.
+ case none
+
+ /// Converts the strategy into the appropriate `CreateParams` object for a `SignUp` request.
+ ///
+ /// This computed property maps each strategy case to its corresponding `CreateParams` object.
+ ///
+ /// - Returns: A `CreateParams` object containing all the necessary data for the `SignUp` request.
+ @MainActor
+ var params: CreateParams {
+ switch self {
+ case .standard(let emailAddress, let password, let firstName, let lastName, let username, let phoneNumber):
+ .init(
+ firstName: firstName,
+ lastName: lastName,
+ password: password,
+ emailAddress: emailAddress,
+ phoneNumber: phoneNumber,
+ username: username
+ )
+ case .oauth(let provider, let redirectUrl):
+ .init(
+ strategy: provider.strategy,
+ redirectUrl: redirectUrl ?? Clerk.shared.settings.redirectConfig.redirectUrl
+ )
+ case .enterpriseSSO(let identifier, let redirectUrl):
+ .init(
+ strategy: "enterprise_sso",
+ emailAddress: identifier,
+ redirectUrl: redirectUrl ?? Clerk.shared.settings.redirectConfig.redirectUrl
+ )
+ case .ticket(let ticket):
+ .init(
+ strategy: "ticket",
+ ticket: ticket
+ )
+ case .idToken(let provider, let idToken, let firstName, let lastName):
+ .init(
+ strategy: provider.strategy,
+ firstName: firstName,
+ lastName: lastName,
+ token: idToken
+ )
+ case .transfer:
+ .init(transfer: true)
+ case .none:
+ .init()
+ }
+ }
}
- }
- /// UpdateParams is a mirror of CreateParams with the same fields and types.
- public typealias UpdateParams = CreateParams
+ /// UpdateParams is a mirror of CreateParams with the same fields and types.
+ public typealias UpdateParams = CreateParams
}
diff --git a/Sources/Clerk/Models/SignUp/SignUpPrepareVerificationParams.swift b/Sources/Clerk/Models/SignUp/SignUpPrepareVerificationParams.swift
index 11ea2a378..a1fbcfec3 100644
--- a/Sources/Clerk/Models/SignUp/SignUpPrepareVerificationParams.swift
+++ b/Sources/Clerk/Models/SignUp/SignUpPrepareVerificationParams.swift
@@ -9,30 +9,30 @@ import Foundation
extension SignUp {
- /// Defines the strategies for preparing a verification step during the sign-up process.
- public enum PrepareStrategy: Sendable {
- /// Send an email with a unique token to input.
- case emailCode
-
- /// User will receive a one-time authentication code via SMS.
- case phoneCode
-
- /// Returns the parameters for the verification process based on the chosen strategy.
- var params: PrepareVerificationParams {
- switch self {
- case .emailCode:
- .init(strategy: "email_code")
- case .phoneCode:
- .init(strategy: "phone_code")
- }
- }
+ /// Defines the strategies for preparing a verification step during the sign-up process.
+ public enum PrepareStrategy: Sendable {
+ /// Send an email with a unique token to input.
+ case emailCode
+
+ /// User will receive a one-time authentication code via SMS.
+ case phoneCode
+
+ /// Returns the parameters for the verification process based on the chosen strategy.
+ var params: PrepareVerificationParams {
+ switch self {
+ case .emailCode:
+ .init(strategy: "email_code")
+ case .phoneCode:
+ .init(strategy: "phone_code")
+ }
+ }
- }
+ }
- /// Parameters used to prepare the verification process for the sign-up flow.
- public struct PrepareVerificationParams: Encodable {
- /// The verification strategy to use.
- public let strategy: String
- }
+ /// Parameters used to prepare the verification process for the sign-up flow.
+ public struct PrepareVerificationParams: Encodable {
+ /// The verification strategy to use.
+ public let strategy: String
+ }
}
diff --git a/Sources/Clerk/Models/SignUp/SignUpService.swift b/Sources/Clerk/Models/SignUp/SignUpService.swift
index 540218198..a2a1f0f56 100644
--- a/Sources/Clerk/Models/SignUp/SignUpService.swift
+++ b/Sources/Clerk/Models/SignUp/SignUpService.swift
@@ -1,110 +1,146 @@
//
-// SwiftUIView.swift
+// SignUpService.swift
// Clerk
//
-// Created by Mike Pitre on 2/27/25.
+// Created by Mike Pitre on 7/28/25.
//
-import Factory
+import AuthenticationServices
+import FactoryKit
import Foundation
-struct SignUpService {
- var create: (_ strategy: SignUp.CreateStrategy, _ legalAccepted: Bool?) async throws -> SignUp
- var createRaw: (_ params: AnyEncodable) async throws -> SignUp
- var update: (_ signUp: SignUp, _ params: SignUp.UpdateParams) async throws -> SignUp
- var prepareVerification: (_ signUp: SignUp, _ strategy: SignUp.PrepareStrategy) async throws -> SignUp
- var attemptVerification: (_ signUp: SignUp, _ strategy: SignUp.AttemptStrategy) async throws -> SignUp
- var authenticateWithRedirectCombined: (_ strategy: SignUp.AuthenticateWithRedirectStrategy, _ prefersEphemeralWebBrowserSession: Bool) async throws -> TransferFlowResult
- var authenticateWithRedirectTwoStep: (_ signUp: SignUp, _ prefersEphemeralWebBrowserSession: Bool) async throws -> TransferFlowResult
- var authenticateWithIdTokenCombined: (_ provider: IDTokenProvider, _ idToken: String) async throws -> TransferFlowResult
- var authenticateWithIdTokenTwoStep: (_ signUp: SignUp) async throws -> TransferFlowResult
- var get: (_ signUp: SignUp, _ rotatingTokenNonce: String?) async throws -> SignUp
+extension Container {
+
+ var signUpService: Factory {
+ self { @MainActor in SignUpService() }
+ }
+
}
-extension SignUpService {
+@MainActor
+struct SignUpService {
- static var liveValue: Self {
- .init(
- create: { strategy, legalAccepted in
+ var create: (_ strategy: SignUp.CreateStrategy, _ legalAccepted: Bool?) async throws -> SignUp = { @MainActor strategy, legalAccepted in
var params = strategy.params
params.legalAccepted = legalAccepted
- let request = ClerkFAPI.v1.client.signUps.post(params)
- return try await Container.shared.apiClient().send(request).value.response
- },
- createRaw: { params in
- let request = ClerkFAPI.v1.client.signUps.post(params)
- return try await Container.shared.apiClient().send(request).value.response
- },
- update: { signUp, params in
- let request = ClerkFAPI.v1.client.signUps.id(signUp.id).patch(params)
- return try await Container.shared.apiClient().send(request).value.response
- },
- prepareVerification: { signUp, strategy in
- let request = ClerkFAPI.v1.client.signUps.id(signUp.id).prepareVerification.post(strategy.params)
- return try await Container.shared.apiClient().send(request).value.response
- },
- attemptVerification: { signUp, strategy in
- let request = ClerkFAPI.v1.client.signUps.id(signUp.id).attemptVerification.post(strategy.params)
- return try await Container.shared.apiClient().send(request).value.response
- },
- authenticateWithRedirectCombined: { strategy, prefersEphemeralWebBrowserSession in
+
+ return try await Container.shared.apiClient().request()
+ .add(path: "/v1/client/sign_ups")
+ .method(.post)
+ .body(formEncode: params)
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
+
+ var createWithParams: (_ params: any Encodable & Sendable) async throws -> SignUp = { params in
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/client/sign_ups")
+ .method(.post)
+ .body(formEncode: params)
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
+
+ var update: (_ signUpId: String, _ params: SignUp.UpdateParams) async throws -> SignUp = { signUpId, params in
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/client/sign_ups/\(signUpId)")
+ .method(.patch)
+ .body(formEncode: params)
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
+
+ var prepareVerification: (_ signUpId: String, _ strategy: SignUp.PrepareStrategy) async throws -> SignUp = { signUpId, strategy in
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/client/sign_ups/\(signUpId)/prepare_verification")
+ .method(.post)
+ .body(formEncode: strategy.params)
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
+
+ var attemptVerification: (_ signUpId: String, _ strategy: SignUp.AttemptStrategy) async throws -> SignUp = { signUpId, strategy in
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/client/sign_ups/\(signUpId)/attempt_verification")
+ .method(.post)
+ .body(formEncode: strategy.params)
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
+
+ var get: (_ signUpId: String, _ rotatingTokenNonce: String?) async throws -> SignUp = { signUpId, rotatingTokenNonce in
+ var queryItems: [URLQueryItem] = []
+ if let rotatingTokenNonce {
+ queryItems.append(
+ .init(
+ name: "rotating_token_nonce",
+ value: rotatingTokenNonce.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)
+ )
+ )
+ }
+
+ return try await Container.shared.apiClient().request()
+ .add(path: "/v1/client/sign_ups/\(signUpId)")
+ .add(queryItems: queryItems)
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
+
+ #if !os(tvOS) && !os(watchOS)
+ var authenticateWithRedirectStatic: (_ strategy: SignUp.AuthenticateWithRedirectStrategy, _ prefersEphemeralWebBrowserSession: Bool) async throws -> TransferFlowResult = { strategy, prefersEphemeralWebBrowserSession in
let signUp = try await SignUp.create(strategy: strategy.signUpStrategy)
guard
- let verification = signUp.verifications.first(where: { $0.key == "external_account" })?.value,
- let redirectUrl = verification.externalVerificationRedirectUrl,
- let url = URL(string: redirectUrl)
+ let verification = signUp.verifications.first(where: { $0.key == "external_account" })?.value,
+ let redirectUrl = verification.externalVerificationRedirectUrl,
+ let url = URL(string: redirectUrl)
else {
- throw ClerkClientError(message: "Redirect URL is missing or invalid. Unable to start external authentication flow.")
+ throw ClerkClientError(message: "Redirect URL is missing or invalid. Unable to start external authentication flow.")
}
- let authSession = await WebAuthentication(
- url: url,
- prefersEphemeralWebBrowserSession: prefersEphemeralWebBrowserSession
+ let authSession = WebAuthentication(
+ url: url,
+ prefersEphemeralWebBrowserSession: prefersEphemeralWebBrowserSession
)
let callbackUrl = try await authSession.start()
let transferFlowResult = try await signUp.handleOAuthCallbackUrl(callbackUrl)
return transferFlowResult
- },
- authenticateWithRedirectTwoStep: { signUp, prefersEphemeralWebBrowserSession in
+ }
+
+ var authenticateWithRedirect: (_ signUp: SignUp, _ prefersEphemeralWebBrowserSession: Bool) async throws -> TransferFlowResult = { signUp, prefersEphemeralWebBrowserSession in
guard
- let verification = signUp.verifications.first(where: { $0.key == "external_account" })?.value,
- let redirectUrl = verification.externalVerificationRedirectUrl,
- let url = URL(string: redirectUrl)
+ let verification = signUp.verifications.first(where: { $0.key == "external_account" })?.value,
+ let redirectUrl = verification.externalVerificationRedirectUrl,
+ let url = URL(string: redirectUrl)
else {
- throw ClerkClientError(message: "Redirect URL is missing or invalid. Unable to start external authentication flow.")
+ throw ClerkClientError(message: "Redirect URL is missing or invalid. Unable to start external authentication flow.")
}
- let authSession = await WebAuthentication(
- url: url,
- prefersEphemeralWebBrowserSession: prefersEphemeralWebBrowserSession
+ let authSession = WebAuthentication(
+ url: url,
+ prefersEphemeralWebBrowserSession: prefersEphemeralWebBrowserSession
)
let callbackUrl = try await authSession.start()
let transferFlowResult = try await signUp.handleOAuthCallbackUrl(callbackUrl)
return transferFlowResult
- },
- authenticateWithIdTokenCombined: { provider, idToken in
+ }
+ #endif
+
+ var authenticateWithIdTokenStatic: (_ provider: IDTokenProvider, _ idToken: String) async throws -> TransferFlowResult = { provider, idToken in
let signUp = try await SignUp.create(strategy: .idToken(provider: provider, idToken: idToken))
return try await signUp.handleTransferFlow()
- },
- authenticateWithIdTokenTwoStep: { signUp in
- try await signUp.handleTransferFlow()
- },
- get: { signUp, rotatingTokenNonce in
- let request = ClerkFAPI.v1.client.signUps.id(signUp.id).get(rotatingTokenNonce: rotatingTokenNonce)
- return try await Container.shared.apiClient().send(request).value.response
- }
- )
- }
+ }
-}
-
-extension Container {
-
- var signUpService: Factory {
- self { .liveValue }
- }
+ var authenticateWithIdToken: (_ signUp: SignUp) async throws -> TransferFlowResult = { signUp in
+ try await signUp.handleTransferFlow()
+ }
}
diff --git a/Sources/Clerk/Models/SignUp/SignUpStatus.swift b/Sources/Clerk/Models/SignUp/SignUpStatus.swift
index 3c0f7b333..b9c76293f 100644
--- a/Sources/Clerk/Models/SignUp/SignUpStatus.swift
+++ b/Sources/Clerk/Models/SignUp/SignUpStatus.swift
@@ -7,25 +7,25 @@
extension SignUp {
- /// Represents the current status of the sign-up process.
- ///
- /// The `Status` enum defines the possible states of a sign-up flow. Each state indicates a specific requirement or completion level in the sign-up process.
- public enum Status: String, Codable, Sendable, Equatable {
- /// The sign-up has been inactive for over 24 hours.
- case abandoned
+ /// Represents the current status of the sign-up process.
+ ///
+ /// The `Status` enum defines the possible states of a sign-up flow. Each state indicates a specific requirement or completion level in the sign-up process.
+ public enum Status: String, Codable, Sendable, Equatable {
+ /// The sign-up has been inactive for over 24 hours.
+ case abandoned
- /// A requirement is unverified or missing from the Email, Phone, Username settings. For example, in the Clerk Dashboard, the Password setting is required but a password wasn't provided in the custom flow.
- case missingRequirements = "missing_requirements"
+ /// A requirement is unverified or missing from the Email, Phone, Username settings. For example, in the Clerk Dashboard, the Password setting is required but a password wasn't provided in the custom flow.
+ case missingRequirements = "missing_requirements"
- /// All the required fields have been supplied and verified, so the sign-up is complete and a new user and a session have been created.
- case complete
+ /// All the required fields have been supplied and verified, so the sign-up is complete and a new user and a session have been created.
+ case complete
- /// The status is unknown.
- case unknown
+ /// The status is unknown.
+ case unknown
- public init(from decoder: Decoder) throws {
- self = try .init(rawValue: decoder.singleValueContainer().decode(RawValue.self)) ?? .unknown
+ public init(from decoder: Decoder) throws {
+ self = try .init(rawValue: decoder.singleValueContainer().decode(RawValue.self)) ?? .unknown
+ }
}
- }
}
diff --git a/Sources/Clerk/Models/TOTPResource.swift b/Sources/Clerk/Models/TOTPResource.swift
index 7e3a20992..145219816 100644
--- a/Sources/Clerk/Models/TOTPResource.swift
+++ b/Sources/Clerk/Models/TOTPResource.swift
@@ -10,58 +10,58 @@ import Foundation
/// Represents information about a TOTP configuration.
public struct TOTPResource: Codable, Hashable, Equatable, Sendable {
- /// A unique identifier for this TOTP secret.
- public let id: String
+ /// A unique identifier for this TOTP secret.
+ public let id: String
- /// The generated TOTP secret. Note: this is only returned to the client upon creation and cannot be retrieved afterwards.
- public let secret: String?
+ /// The generated TOTP secret. Note: this is only returned to the client upon creation and cannot be retrieved afterwards.
+ public let secret: String?
- /// A complete TOTP configuration URI including the Issuer, Account, etc that can be pasted to an authenticator app or encoded to a QR code and scanned for convenience. Just like the secret, the URI is exposed to the client only upon creation and cannot be retrieved afterwards.
- public let uri: String?
+ /// A complete TOTP configuration URI including the Issuer, Account, etc that can be pasted to an authenticator app or encoded to a QR code and scanned for convenience. Just like the secret, the URI is exposed to the client only upon creation and cannot be retrieved afterwards.
+ public let uri: String?
- /// Whether this TOTP secret has been verified by the user by providing one code generated with it. TOTP is not enabled on the user unless they have a verified secret.
- public let verified: Bool
+ /// Whether this TOTP secret has been verified by the user by providing one code generated with it. TOTP is not enabled on the user unless they have a verified secret.
+ public let verified: Bool
- /// A set of fresh generated Backup codes. Note that this will be populated if the feature is enabled in your instance and the user doesn't already have backup codes generated.
- public let backupCodes: [String]?
+ /// A set of fresh generated Backup codes. Note that this will be populated if the feature is enabled in your instance and the user doesn't already have backup codes generated.
+ public let backupCodes: [String]?
- /// Creation date of the TOTP secret.
- public let createdAt: Date
+ /// Creation date of the TOTP secret.
+ public let createdAt: Date
- /// Update timestamp of the TOTP secret.
- public let updatedAt: Date
+ /// Update timestamp of the TOTP secret.
+ public let updatedAt: Date
- public init(
- id: String,
- secret: String? = nil,
- uri: String? = nil,
- verified: Bool,
- backupCodes: [String]? = nil,
- createdAt: Date,
- updatedAt: Date
- ) {
- self.id = id
- self.secret = secret
- self.uri = uri
- self.verified = verified
- self.backupCodes = backupCodes
- self.createdAt = createdAt
- self.updatedAt = updatedAt
- }
+ public init(
+ id: String,
+ secret: String? = nil,
+ uri: String? = nil,
+ verified: Bool,
+ backupCodes: [String]? = nil,
+ createdAt: Date,
+ updatedAt: Date
+ ) {
+ self.id = id
+ self.secret = secret
+ self.uri = uri
+ self.verified = verified
+ self.backupCodes = backupCodes
+ self.createdAt = createdAt
+ self.updatedAt = updatedAt
+ }
}
extension TOTPResource {
- static var mock: TOTPResource {
- .init(
- id: "1",
- secret: "1234567890",
- uri: "https://mock.com/totp",
- verified: true,
- backupCodes: ["123", "456"],
- createdAt: .distantPast,
- updatedAt: .distantPast
- )
- }
+ static var mock: TOTPResource {
+ .init(
+ id: "1",
+ secret: "1234567890",
+ uri: "https://mock.com/totp",
+ verified: true,
+ backupCodes: ["123", "456"],
+ createdAt: .distantPast,
+ updatedAt: .distantPast
+ )
+ }
}
diff --git a/Sources/Clerk/Models/TokenResource.swift b/Sources/Clerk/Models/TokenResource.swift
index 999afbeb5..26c136838 100644
--- a/Sources/Clerk/Models/TokenResource.swift
+++ b/Sources/Clerk/Models/TokenResource.swift
@@ -12,32 +12,32 @@ import Foundation
/// The `TokenResource` structure encapsulates a token, such as a JWT.
public struct TokenResource: Codable, Equatable, Sendable {
- /// The jwt represented as a `String`.
- public let jwt: String
+ /// The jwt represented as a `String`.
+ public let jwt: String
- public init(jwt: String) {
- self.jwt = jwt
- }
+ public init(jwt: String) {
+ self.jwt = jwt
+ }
}
extension TokenResource {
- /// Attempts to decode the JWT into a `DecodedJWT` object.
- ///
- /// - Returns: A `DecodedJWT` object if decoding is successful; otherwise `nil`.
- internal var decodedJWT: DecodedJWT? {
- do {
- return try DecodedJWT(jwt: jwt)
- } catch {
- dump(error)
- return nil
+ /// Attempts to decode the JWT into a `DecodedJWT` object.
+ ///
+ /// - Returns: A `DecodedJWT` object if decoding is successful; otherwise `nil`.
+ internal var decodedJWT: DecodedJWT? {
+ do {
+ return try DecodedJWT(jwt: jwt)
+ } catch {
+ ClerkLogger.error("Failed to decode JWT", error: error)
+ return nil
+ }
}
- }
}
extension TokenResource {
- static var mock: TokenResource {
- .init(jwt: "jwt")
- }
+ static var mock: TokenResource {
+ .init(jwt: "jwt")
+ }
}
diff --git a/Sources/Clerk/Models/TransferFlowResult.swift b/Sources/Clerk/Models/TransferFlowResult.swift
index d74b35f69..352ce8e3d 100644
--- a/Sources/Clerk/Models/TransferFlowResult.swift
+++ b/Sources/Clerk/Models/TransferFlowResult.swift
@@ -26,11 +26,11 @@ import Foundation
/// }
/// ```
public enum TransferFlowResult: Sendable {
- /// The authentication flow resulted in a sign-in instance. This case indicates that the user is
- /// being signed in, although further steps may be required to complete the process.
- case signIn(SignIn)
+ /// The authentication flow resulted in a sign-in instance. This case indicates that the user is
+ /// being signed in, although further steps may be required to complete the process.
+ case signIn(SignIn)
- /// The authentication flow resulted in a sign-up instance. This case indicates that the user is
- /// being signed up, although further steps may be required to complete the process.
- case signUp(SignUp)
+ /// The authentication flow resulted in a sign-up instance. This case indicates that the user is
+ /// being signed up, although further steps may be required to complete the process.
+ case signUp(SignUp)
}
diff --git a/Sources/Clerk/Models/User/User.swift b/Sources/Clerk/Models/User/User.swift
index 99a351979..f8be0b42a 100644
--- a/Sources/Clerk/Models/User/User.swift
+++ b/Sources/Clerk/Models/User/User.swift
@@ -5,7 +5,7 @@
// Created by Mike Pitre on 10/16/23.
//
-import Factory
+import FactoryKit
import Foundation
/// The `User` object holds all of the information for a single user of your application and provides a set of methods to manage their account.
@@ -22,390 +22,422 @@ import Foundation
/// Backend API, but public metadata can also be accessed from the Frontend API.
///
/// The Clerk iOS SDK provides some helper methods on the User object to help retrieve and update user information and authentication status.
-public struct User: Codable, Equatable, Sendable, Hashable {
+public struct User: Codable, Equatable, Sendable, Hashable, Identifiable {
- /// A boolean indicating whether the user has enabled Backup codes.
- public let backupCodeEnabled: Bool
+ /// A boolean indicating whether the user has enabled Backup codes.
+ public let backupCodeEnabled: Bool
- /// Date when the user was first created.
- public let createdAt: Date
+ /// Date when the user was first created.
+ public let createdAt: Date
- /// A boolean indicating whether the organization creation is enabled for the user or not.
- public let createOrganizationEnabled: Bool
+ /// A boolean indicating whether the organization creation is enabled for the user or not.
+ public let createOrganizationEnabled: Bool
- /// An integer indicating the number of organizations that can be created by the user. If the value is 0, then the user can create unlimited organizations. Default is null.
- public let createOrganizationsLimit: Int?
+ /// An integer indicating the number of organizations that can be created by the user. If the value is 0, then the user can create unlimited organizations. Default is null.
+ public let createOrganizationsLimit: Int?
- /// A boolean indicating whether the user is able to delete their own account or not.
- public let deleteSelfEnabled: Bool
+ /// A boolean indicating whether the user is able to delete their own account or not.
+ public let deleteSelfEnabled: Bool
- /// An array of all the EmailAddress objects associated with the user. Includes the primary.
- public let emailAddresses: [EmailAddress]
+ /// An array of all the EmailAddress objects associated with the user. Includes the primary.
+ public let emailAddresses: [EmailAddress]
- /// A list of enterprise accounts associated with the user.
- public let enterpriseAccounts: [EnterpriseAccount]?
+ /// A list of enterprise accounts associated with the user.
+ public let enterpriseAccounts: [EnterpriseAccount]?
- /// An array of all the ExternalAccount objects associated with the user via OAuth. Note: This includes both verified & unverified external accounts.
- public let externalAccounts: [ExternalAccount]
+ /// An array of all the ExternalAccount objects associated with the user via OAuth. Note: This includes both verified & unverified external accounts.
+ public let externalAccounts: [ExternalAccount]
- /// The user's first name.
- public let firstName: String?
+ /// The user's first name.
+ public let firstName: String?
- /// A getter boolean to check if the user has uploaded an image or one was copied from OAuth. Returns false if Clerk is displaying an avatar for the user.
- public let hasImage: Bool
+ /// A getter boolean to check if the user has uploaded an image or one was copied from OAuth. Returns false if Clerk is displaying an avatar for the user.
+ public let hasImage: Bool
- /// A getter boolean to check if the user has verified an email address.
- public var hasVerifiedEmailAddress: Bool {
- emailAddresses.contains { emailAddress in
- emailAddress.verification?.status == .verified
+ /// A getter boolean to check if the user has verified an email address.
+ public var hasVerifiedEmailAddress: Bool {
+ emailAddresses.contains { emailAddress in
+ emailAddress.verification?.status == .verified
+ }
}
- }
- /// A getter boolean to check if the user has verified a phone number.
- public var hasVerifiedPhoneNumber: Bool {
- phoneNumbers.contains { phoneNumber in
- phoneNumber.verification?.status == .verified
+ /// A getter boolean to check if the user has verified a phone number.
+ public var hasVerifiedPhoneNumber: Bool {
+ phoneNumbers.contains { phoneNumber in
+ phoneNumber.verification?.status == .verified
+ }
}
- }
- /// The unique identifier for the user.
- public let id: String
+ /// The unique identifier for the user.
+ public let id: String
- /// Holds the default avatar or user's uploaded profile image
- public let imageUrl: String
+ /// Holds the default avatar or user's uploaded profile image
+ public let imageUrl: String
- /// Date when the user last signed in. May be empty if the user has never signed in.
- public let lastSignInAt: Date?
+ /// Date when the user last signed in. May be empty if the user has never signed in.
+ public let lastSignInAt: Date?
- /// The user's last name.
- public let lastName: String?
+ /// The user's last name.
+ public let lastName: String?
- /// The date on which the user accepted the legal requirements if required.
- public let legalAcceptedAt: Date?
+ /// The date on which the user accepted the legal requirements if required.
+ public let legalAcceptedAt: Date?
- /// A list of OrganizationMemberships representing the list of organizations the user is member with.
- public let organizationMemberships: [OrganizationMembership]?
+ /// A list of OrganizationMemberships representing the list of organizations the user is member with.
+ public let organizationMemberships: [OrganizationMembership]?
- /// An array of all the Passkey objects associated with the user.
- public let passkeys: [Passkey]
+ /// An array of all the Passkey objects associated with the user.
+ public let passkeys: [Passkey]
- /// A boolean indicating whether the user has a password on their account.
- public let passwordEnabled: Bool
+ /// A boolean indicating whether the user has a password on their account.
+ public let passwordEnabled: Bool
- /// An array of all the PhoneNumber objects associated with the user. Includes the primary.
- public let phoneNumbers: [PhoneNumber]
+ /// An array of all the PhoneNumber objects associated with the user. Includes the primary.
+ public let phoneNumbers: [PhoneNumber]
- /// Information about the user's primary email address.
- public var primaryEmailAddress: EmailAddress? {
- emailAddresses.first(where: { $0.id == primaryEmailAddressId })
- }
+ /// Information about the user's primary email address.
+ public var primaryEmailAddress: EmailAddress? {
+ emailAddresses.first(where: { $0.id == primaryEmailAddressId })
+ }
- /// The unique identifier for the EmailAddress that the user has set as primary.
- public let primaryEmailAddressId: String?
+ /// The unique identifier for the EmailAddress that the user has set as primary.
+ public let primaryEmailAddressId: String?
- /// Information about the user's primary phone number.
- public var primaryPhoneNumber: PhoneNumber? {
- phoneNumbers.first(where: { $0.id == primaryPhoneNumberId })
- }
+ /// Information about the user's primary phone number.
+ public var primaryPhoneNumber: PhoneNumber? {
+ phoneNumbers.first(where: { $0.id == primaryPhoneNumberId })
+ }
- /// The unique identifier for the PhoneNumber that the user has set as primary.
- public let primaryPhoneNumberId: String?
+ /// The unique identifier for the PhoneNumber that the user has set as primary.
+ public let primaryPhoneNumberId: String?
- /// Metadata that can be read from the Frontend API and Backend API and can be set only from the Backend API .
- public let publicMetadata: JSON?
+ /// Metadata that can be read from the Frontend API and Backend API and can be set only from the Backend API .
+ public let publicMetadata: JSON?
- /// A boolean indicating whether the user has enabled TOTP by generating a TOTP secret and verifying it via an authenticator app.
- public let totpEnabled: Bool
+ /// A boolean indicating whether the user has enabled TOTP by generating a TOTP secret and verifying it via an authenticator app.
+ public let totpEnabled: Bool
- /// A boolean indicating whether the user has enabled two-factor authentication.
- public let twoFactorEnabled: Bool
+ /// A boolean indicating whether the user has enabled two-factor authentication.
+ public let twoFactorEnabled: Bool
- /// Date of the last time the user was updated.
- public let updatedAt: Date
+ /// Date of the last time the user was updated.
+ public let updatedAt: Date
- /**
- Metadata that can be read and set from the Frontend API. One common use case for this attribute is to implement custom fields that will be attached to the User object.
- Please note that there is also an unsafeMetadata attribute in the SignUp object. The value of that field will be automatically copied to the user's unsafe metadata once the sign up is complete.
- */
- public let unsafeMetadata: JSON?
+ /**
+ Metadata that can be read and set from the Frontend API. One common use case for this attribute is to implement custom fields that will be attached to the User object.
+ Please note that there is also an unsafeMetadata attribute in the SignUp object. The value of that field will be automatically copied to the user's unsafe metadata once the sign up is complete.
+ */
+ public let unsafeMetadata: JSON?
- /// A getter for the user's list of unverified external accounts.
- public var unverifiedExternalAccounts: [ExternalAccount] {
- externalAccounts.filter { externalAccount in
- externalAccount.verification?.status == .unverified
+ /// A getter for the user's list of unverified external accounts.
+ public var unverifiedExternalAccounts: [ExternalAccount] {
+ externalAccounts.filter { externalAccount in
+ externalAccount.verification?.status == .unverified
+ }
}
- }
- /// The user's username.
- public let username: String?
+ /// The user's username.
+ public let username: String?
- /// A getter for the user's list of verified external accounts.
- public var verifiedExternalAccounts: [ExternalAccount] {
- externalAccounts.filter { externalAccount in
- externalAccount.verification?.status == .verified
+ /// A getter for the user's list of verified external accounts.
+ public var verifiedExternalAccounts: [ExternalAccount] {
+ externalAccounts.filter { externalAccount in
+ externalAccount.verification?.status == .verified
+ }
+ }
+
+ public init(
+ backupCodeEnabled: Bool,
+ createdAt: Date,
+ createOrganizationEnabled: Bool,
+ createOrganizationsLimit: Int? = nil,
+ deleteSelfEnabled: Bool,
+ emailAddresses: [EmailAddress],
+ enterpriseAccounts: [EnterpriseAccount]? = nil,
+ externalAccounts: [ExternalAccount],
+ firstName: String? = nil,
+ hasImage: Bool,
+ id: String,
+ imageUrl: String,
+ lastSignInAt: Date? = nil,
+ lastName: String? = nil,
+ legalAcceptedAt: Date? = nil,
+ organizationMemberships: [OrganizationMembership]?,
+ passkeys: [Passkey],
+ passwordEnabled: Bool,
+ phoneNumbers: [PhoneNumber],
+ primaryEmailAddressId: String? = nil,
+ primaryPhoneNumberId: String? = nil,
+ publicMetadata: JSON? = nil,
+ totpEnabled: Bool,
+ twoFactorEnabled: Bool,
+ updatedAt: Date,
+ unsafeMetadata: JSON? = nil,
+ username: String? = nil
+ ) {
+ self.backupCodeEnabled = backupCodeEnabled
+ self.createdAt = createdAt
+ self.createOrganizationEnabled = createOrganizationEnabled
+ self.createOrganizationsLimit = createOrganizationsLimit
+ self.deleteSelfEnabled = deleteSelfEnabled
+ self.emailAddresses = emailAddresses
+ self.enterpriseAccounts = enterpriseAccounts
+ self.externalAccounts = externalAccounts
+ self.firstName = firstName
+ self.hasImage = hasImage
+ self.id = id
+ self.imageUrl = imageUrl
+ self.lastSignInAt = lastSignInAt
+ self.lastName = lastName
+ self.legalAcceptedAt = legalAcceptedAt
+ self.organizationMemberships = organizationMemberships
+ self.passkeys = passkeys
+ self.passwordEnabled = passwordEnabled
+ self.phoneNumbers = phoneNumbers
+ self.primaryEmailAddressId = primaryEmailAddressId
+ self.primaryPhoneNumberId = primaryPhoneNumberId
+ self.publicMetadata = publicMetadata
+ self.totpEnabled = totpEnabled
+ self.twoFactorEnabled = twoFactorEnabled
+ self.updatedAt = updatedAt
+ self.unsafeMetadata = unsafeMetadata
+ self.username = username
}
- }
-
- public init(
- backupCodeEnabled: Bool,
- createdAt: Date,
- createOrganizationEnabled: Bool,
- createOrganizationsLimit: Int? = nil,
- deleteSelfEnabled: Bool,
- emailAddresses: [EmailAddress],
- enterpriseAccounts: [EnterpriseAccount]? = nil,
- externalAccounts: [ExternalAccount],
- firstName: String? = nil,
- hasImage: Bool,
- id: String,
- imageUrl: String,
- lastSignInAt: Date? = nil,
- lastName: String? = nil,
- legalAcceptedAt: Date? = nil,
- organizationMemberships: [OrganizationMembership]?,
- passkeys: [Passkey],
- passwordEnabled: Bool,
- phoneNumbers: [PhoneNumber],
- primaryEmailAddressId: String? = nil,
- primaryPhoneNumberId: String? = nil,
- publicMetadata: JSON? = nil,
- totpEnabled: Bool,
- twoFactorEnabled: Bool,
- updatedAt: Date,
- unsafeMetadata: JSON? = nil,
- username: String? = nil
- ) {
- self.backupCodeEnabled = backupCodeEnabled
- self.createdAt = createdAt
- self.createOrganizationEnabled = createOrganizationEnabled
- self.createOrganizationsLimit = createOrganizationsLimit
- self.deleteSelfEnabled = deleteSelfEnabled
- self.emailAddresses = emailAddresses
- self.enterpriseAccounts = enterpriseAccounts
- self.externalAccounts = externalAccounts
- self.firstName = firstName
- self.hasImage = hasImage
- self.id = id
- self.imageUrl = imageUrl
- self.lastSignInAt = lastSignInAt
- self.lastName = lastName
- self.legalAcceptedAt = legalAcceptedAt
- self.organizationMemberships = organizationMemberships
- self.passkeys = passkeys
- self.passwordEnabled = passwordEnabled
- self.phoneNumbers = phoneNumbers
- self.primaryEmailAddressId = primaryEmailAddressId
- self.primaryPhoneNumberId = primaryPhoneNumberId
- self.publicMetadata = publicMetadata
- self.totpEnabled = totpEnabled
- self.twoFactorEnabled = twoFactorEnabled
- self.updatedAt = updatedAt
- self.unsafeMetadata = unsafeMetadata
- self.username = username
- }
}
extension User {
- /// Updates the user's attributes. Use this method to save information you collected about the user.
- ///
- /// The appropriate settings must be enabled in the Clerk Dashboard for the user to be able to update their attributes.
- ///
- /// For example, if you want to use the `update(.init(firstName:))` method, you must enable the Name setting.
- /// It can be found in the Email, phone, username > Personal information section in the Clerk Dashboard.
- @discardableResult @MainActor
- public func update(_ params: User.UpdateParams) async throws -> User {
- try await Container.shared.userService().update(params)
- }
-
- /// Generates a fresh new set of backup codes for the user. Every time the method is called, it will replace the previously generated backup codes.
- ///
- /// - Returns: ``BackupCodeResource``
- @discardableResult @MainActor
- public func createBackupCodes() async throws -> BackupCodeResource {
- try await Container.shared.userService().createBackupCodes()
- }
-
- /// Adds an email address for the user. A new EmailAddress will be created and associated with the user.
- /// - Parameter email: The value of the email address.
- @discardableResult @MainActor
- public func createEmailAddress(_ email: String) async throws -> EmailAddress {
- try await Container.shared.userService().createEmailAddress(email)
- }
-
- /// Adds a phone number for the user. A new PhoneNumber will be created and associated with the user.
- /// - Parameter phoneNumber: The value of the phone number, in E.164 format.
- @discardableResult @MainActor
- public func createPhoneNumber(_ phoneNumber: String) async throws -> PhoneNumber {
- try await Container.shared.userService().createPhoneNumber(phoneNumber)
- }
-
- /// Adds an external account for the user. A new ExternalAccount will be created and associated with the user.
- ///
- /// This method is useful if you want to allow an already signed-in user to connect their account with an external OAuth provider, such as Facebook, GitHub, etc., so that they can sign in with that provider in the future.
- /// - Parameters:
- /// - provider: The OAuth provider. For example: `.facebook`, `.github`, etc.
- /// - redirectUrl: The full URL or path that the OAuth provider should redirect to, on successful authorization on their part.
- /// - additionalScopes: Additional scopes for your user to be prompted to approve.
- @discardableResult @MainActor
- public func createExternalAccount(provider: OAuthProvider, redirectUrl: String? = nil, additionalScopes: [String]? = nil) async throws -> ExternalAccount {
- try await Container.shared.userService().createExternalAccountOAuth(provider, redirectUrl, additionalScopes)
- }
-
- /// Adds an external account for the user. A new ExternalAccount will be created and associated with the user.
- ///
- /// This method is useful if you want to allow an already signed-in user to connect their account with an external provider using an ID token provider, such as Apple, etc., so that they can sign in with that provider in the future.
- /// - Parameters:
- /// - provider: The IDTokenProvider. For example: `.apple`.
- /// - idToken: The ID token from the provider.
- @discardableResult @MainActor
- public func createExternalAccount(provider: IDTokenProvider, idToken: String) async throws -> ExternalAccount {
- try await Container.shared.userService().createExternalAccountIDToken(provider, idToken)
- }
-
- #if canImport(AuthenticationServices) && !os(watchOS)
+ /// Updates the user's attributes. Use this method to save information you collected about the user.
+ ///
+ /// The appropriate settings must be enabled in the Clerk Dashboard for the user to be able to update their attributes.
+ ///
+ /// For example, if you want to use the `update(.init(firstName:))` method, you must enable the Name setting.
+ /// It can be found in the Email, phone, username > Personal information section in the Clerk Dashboard.
+ @discardableResult @MainActor
+ public func update(_ params: User.UpdateParams) async throws -> User {
+ try await Container.shared.userService().update(params)
+ }
+
+ /// Generates a fresh new set of backup codes for the user. Every time the method is called, it will replace the previously generated backup codes.
+ ///
+ /// - Returns: ``BackupCodeResource``
+ @discardableResult @MainActor
+ public func createBackupCodes() async throws -> BackupCodeResource {
+ try await Container.shared.userService().createBackupCodes()
+ }
+
+ /// Adds an email address for the user. A new EmailAddress will be created and associated with the user.
+ /// - Parameter email: The value of the email address.
+ @discardableResult @MainActor
+ public func createEmailAddress(_ emailAddress: String) async throws -> EmailAddress {
+ try await Container.shared.userService().createEmailAddress(emailAddress)
+ }
+
+ /// Adds a phone number for the user. A new PhoneNumber will be created and associated with the user.
+ /// - Parameter phoneNumber: The value of the phone number, in E.164 format.
+ @discardableResult @MainActor
+ public func createPhoneNumber(_ phoneNumber: String) async throws -> PhoneNumber {
+ try await Container.shared.userService().createPhoneNumber(phoneNumber)
+ }
+
+ /// Adds an external account for the user. A new ExternalAccount will be created and associated with the user.
+ ///
+ /// This method is useful if you want to allow an already signed-in user to connect their account with an external OAuth provider, such as Facebook, GitHub, etc., so that they can sign in with that provider in the future.
+ /// - Parameters:
+ /// - provider: The OAuth provider. For example: `.facebook`, `.github`, etc.
+ /// - redirectUrl: The full URL or path that the OAuth provider should redirect to, on successful authorization on their part.
+ /// - additionalScopes: Additional scopes for your user to be prompted to approve.
+ @discardableResult @MainActor
+ public func createExternalAccount(provider: OAuthProvider, redirectUrl: String? = nil, additionalScopes: [String]? = nil) async throws -> ExternalAccount {
+ try await Container.shared.userService().createExternalAccount(provider, redirectUrl, additionalScopes)
+ }
+
+ /// Adds an external account for the user. A new ExternalAccount will be created and associated with the user.
+ ///
+ /// This method is useful if you want to allow an already signed-in user to connect their account with an external provider using an ID token provider, such as Apple, etc., so that they can sign in with that provider in the future.
+ /// - Parameters:
+ /// - provider: The IDTokenProvider. For example: `.apple`.
+ /// - idToken: The ID token from the provider.
+ @discardableResult @MainActor
+ public func createExternalAccount(provider: IDTokenProvider, idToken: String) async throws -> ExternalAccount {
+ try await Container.shared.userService().createExternalAccountToken(provider, idToken)
+ }
+
+ #if canImport(AuthenticationServices) && !os(watchOS)
/// Creates a passkey for the signed-in user.
///
/// - Returns: ``Passkey``
@discardableResult @MainActor
public func createPasskey() async throws -> Passkey {
- try await Container.shared.userService().createPasskey()
+ try await Container.shared.userService().createPasskey()
+ }
+ #endif
+
+ /// Generates a TOTP secret for a user that can be used to register the application on the user's authenticator app of choice.
+ ///
+ /// Note that if this method is called again (while still unverified), it replaces the previously generated secret.
+ @discardableResult @MainActor
+ public func createTOTP() async throws -> TOTPResource {
+ try await Container.shared.userService().createTotp()
+ }
+
+ /// Verifies a TOTP secret after a user has created it.
+ ///
+ /// The user must provide a code from their authenticator app, that has been generated using the previously created secret.
+ /// This way, correct set up and ownership of the authenticator app can be validated.
+ /// - Parameter code: A 6 digit TOTP generated from the user's authenticator app.
+ @discardableResult @MainActor
+ public func verifyTOTP(code: String) async throws -> TOTPResource {
+ try await Container.shared.userService().verifyTotp(code)
+ }
+
+ /// Disables TOTP by deleting the user's TOTP secret.
+ @discardableResult @MainActor
+ public func disableTOTP() async throws -> DeletedObject {
+ try await Container.shared.userService().disableTotp()
+ }
+
+ /// Retrieves a list of organization invitations for the user.
+ /// - Parameters:
+ /// - initialPage: A number that can be used to skip the first n-1 pages. For example, if initialPage is set to 10, it is will skip the first 9 pages and will fetch the 10th page.
+ /// - pageSize: A number that indicates the maximum number of results that should be returned for a specific page.
+ /// - Returns: A ``ClerkPaginatedResponse`` of ``UserOrganizationInvitation`` objects.
+ @discardableResult @MainActor
+ public func getOrganizationInvitations(
+ initialPage: Int = 0,
+ pageSize: Int = 20
+ ) async throws -> ClerkPaginatedResponse {
+ return try await Container.shared.userService().getOrganizationInvitations(initialPage, pageSize)
+ }
+
+ /// Retrieves a list of organization memberships for the user.
+ /// - Parameters:
+ /// - initialPage: A number that can be used to skip the first n-1 pages. For example, if initialPage is set to 10, it is will skip the first 9 pages and will fetch the 10th page.
+ /// - pageSize: A number that indicates the maximum number of results that should be returned for a specific page.
+ /// - Returns: A ``ClerkPaginatedResponse`` of ``OrganizationMembership`` objects.
+ @discardableResult @MainActor
+ public func getOrganizationMemberships(
+ initialPage: Int = 0,
+ pageSize: Int = 20
+ ) async throws -> ClerkPaginatedResponse {
+ try await Container.shared.userService().getOrganizationMemberships(initialPage, pageSize)
+ }
+
+ /// Retrieves a list of organization suggestions for the user.
+ /// - Parameters:
+ /// - initialPage: A number that can be used to skip the first n-1 pages. For example, if initialPage is set to 10, it is will skip the first 9 pages and will fetch the 10th page.
+ /// - pageSize: A number that indicates the maximum number of results that should be returned for a specific page.
+ /// - status: The status an invitation can have.
+ /// - Returns: A ``ClerkPaginatedResponse`` of ``OrganizationSuggestion`` objects.
+ @discardableResult @MainActor
+ public func getOrganizationSuggestions(
+ initialPage: Int = 0,
+ pageSize: Int = 20,
+ status: String? = nil
+ ) async throws -> ClerkPaginatedResponse {
+ try await Container.shared.userService().getOrganizationSuggestions(initialPage, pageSize, status)
+ }
+
+ /// Retrieves all active sessions for this user.
+ ///
+ /// This method uses a cache so a network request will only be triggered only once. Returns an array of SessionWithActivities objects.
+ @discardableResult @MainActor
+ public func getSessions() async throws -> [Session] {
+ try await Container.shared.userService().getSessions(self)
+ }
+
+ /// Updates the user's password. Passwords must be at least 8 characters long.
+ @discardableResult @MainActor
+ public func updatePassword(_ params: UpdatePasswordParams) async throws -> User {
+ try await Container.shared.userService().updatePassword(params)
+ }
+
+ /// Adds the user's profile image or replaces it if one already exists. This method will upload an image and associate it with the user.
+ /// - Parameters:
+ /// - imageData: The image, in data format, to set as the user's profile image.
+ @discardableResult @MainActor
+ public func setProfileImage(imageData: Data) async throws -> ImageResource {
+ try await Container.shared.userService().setProfileImage(imageData)
+ }
+
+ /// Deletes the user's profile image.
+ @discardableResult @MainActor
+ public func deleteProfileImage() async throws -> DeletedObject {
+ try await Container.shared.userService().deleteProfileImage()
+ }
+
+ /// Deletes the current user.
+ @discardableResult @MainActor
+ public func delete() async throws -> DeletedObject {
+ try await Container.shared.userService().delete()
}
- #endif
-
- /// Generates a TOTP secret for a user that can be used to register the application on the user's authenticator app of choice.
- ///
- /// Note that if this method is called again (while still unverified), it replaces the previously generated secret.
- @discardableResult @MainActor
- public func createTOTP() async throws -> TOTPResource {
- try await Container.shared.userService().createTOTP()
- }
-
- /// Verifies a TOTP secret after a user has created it.
- ///
- /// The user must provide a code from their authenticator app, that has been generated using the previously created secret.
- /// This way, correct set up and ownership of the authenticator app can be validated.
- /// - Parameter code: A 6 digit TOTP generated from the user's authenticator app.
- @discardableResult @MainActor
- public func verifyTOTP(code: String) async throws -> TOTPResource {
- try await Container.shared.userService().verifyTOTP(code)
- }
-
- /// Disables TOTP by deleting the user's TOTP secret.
- @discardableResult @MainActor
- public func disableTOTP() async throws -> DeletedObject {
- try await Container.shared.userService().disableTOTP()
- }
-
- /// Retrieves a list of organization invitations for the user.
- /// - Parameters:
- /// - initialPage: A number that can be used to skip the first n-1 pages. For example, if initialPage is set to 10, it is will skip the first 9 pages and will fetch the 10th page.
- /// - pageSize: A number that indicates the maximum number of results that should be returned for a specific page.
- /// - Returns: A ``ClerkPaginatedResponse`` of ``UserOrganizationInvitation`` objects.
- @discardableResult @MainActor
- public func getOrganizationInvitations(
- initialPage: Int = 0,
- pageSize: Int = 20
- ) async throws -> ClerkPaginatedResponse {
- try await Container.shared.userService().getOrganizationInvitations(initialPage, pageSize)
- }
-
- /// Retrieves a list of organization memberships for the user.
- /// - Parameters:
- /// - initialPage: A number that can be used to skip the first n-1 pages. For example, if initialPage is set to 10, it is will skip the first 9 pages and will fetch the 10th page.
- /// - pageSize: A number that indicates the maximum number of results that should be returned for a specific page.
- /// - Returns: A ``ClerkPaginatedResponse`` of ``OrganizationMembership`` objects.
- @discardableResult @MainActor
- public func getOrganizationMemberships(
- initialPage: Int = 0,
- pageSize: Int = 20
- ) async throws -> ClerkPaginatedResponse {
- try await Container.shared.userService().getOrganizationMemberships(initialPage, pageSize)
- }
-
- /// Retrieves a list of organization suggestions for the user.
- /// - Parameters:
- /// - initialPage: A number that can be used to skip the first n-1 pages. For example, if initialPage is set to 10, it is will skip the first 9 pages and will fetch the 10th page.
- /// - pageSize: A number that indicates the maximum number of results that should be returned for a specific page.
- /// - status: The status an invitation can have.
- /// - Returns: A ``ClerkPaginatedResponse`` of ``OrganizationSuggestion`` objects.
- @discardableResult @MainActor
- public func getOrganizationSuggestions(
- initialPage: Int = 0,
- pageSize: Int = 20,
- status: String? = nil
- ) async throws -> ClerkPaginatedResponse {
- try await Container.shared.userService().getOrganizationSuggestions(initialPage, pageSize, status)
- }
-
- /// Retrieves all active sessions for this user.
- ///
- /// This method uses a cache so a network request will only be triggered only once. Returns an array of SessionWithActivities objects.
- @discardableResult @MainActor
- public func getSessions() async throws -> [Session] {
- try await Container.shared.userService().getSessions(self)
- }
-
- /// Updates the user's password. Passwords must be at least 8 characters long.
- @discardableResult @MainActor
- public func updatePassword(_ params: UpdatePasswordParams) async throws -> User {
- try await Container.shared.userService().updatePassword(params)
- }
-
- /// Adds the user's profile image or replaces it if one already exists. This method will upload an image and associate it with the user.
- /// - Parameters:
- /// - imageData: The image, in data format, to set as the user's profile image.
- @discardableResult @MainActor
- public func setProfileImage(imageData: Data) async throws -> ImageResource {
- try await Container.shared.userService().setProfileImage(imageData)
- }
-
- /// Deletes the user's profile image.
- @discardableResult @MainActor
- public func deleteProfileImage() async throws -> DeletedObject {
- try await Container.shared.userService().deleteProfileImage()
- }
-
- /// Deletes the current user.
- @discardableResult @MainActor
- public func delete() async throws -> DeletedObject {
- try await Container.shared.userService().delete()
- }
}
extension User {
- static var mock: Self {
- .init(
- backupCodeEnabled: true,
- createdAt: .distantPast,
- createOrganizationEnabled: true,
- createOrganizationsLimit: 0,
- deleteSelfEnabled: true,
- emailAddresses: [.mock],
- enterpriseAccounts: [],
- externalAccounts: [.mockVerified, .mockVerified, .mockUnverified],
- firstName: "First",
- hasImage: false,
- id: "1",
- imageUrl: "",
- lastSignInAt: .now,
- lastName: "Last",
- legalAcceptedAt: .now,
- organizationMemberships: [.mockWithUserData],
- passkeys: [.mock],
- passwordEnabled: true,
- phoneNumbers: [.mock],
- primaryEmailAddressId: "1",
- primaryPhoneNumberId: "1",
- publicMetadata: nil,
- totpEnabled: true,
- twoFactorEnabled: true,
- updatedAt: .now,
- unsafeMetadata: nil,
- username: "username"
- )
- }
+ static var mock: Self {
+ .init(
+ backupCodeEnabled: true,
+ createdAt: .distantPast,
+ createOrganizationEnabled: true,
+ createOrganizationsLimit: 0,
+ deleteSelfEnabled: true,
+ emailAddresses: [.mock, .mock2],
+ enterpriseAccounts: [],
+ externalAccounts: [.mockVerified, .mockVerified, .mockUnverified],
+ firstName: "First",
+ hasImage: false,
+ id: "1",
+ imageUrl: "",
+ lastSignInAt: .now,
+ lastName: "Last",
+ legalAcceptedAt: .now,
+ organizationMemberships: [.mockWithUserData],
+ passkeys: [.mock],
+ passwordEnabled: true,
+ phoneNumbers: [.mock, .mock2, .mockMfa],
+ primaryEmailAddressId: "1",
+ primaryPhoneNumberId: "1",
+ publicMetadata: nil,
+ totpEnabled: false,
+ twoFactorEnabled: true,
+ updatedAt: .now,
+ unsafeMetadata: nil,
+ username: "username"
+ )
+ }
+
+ static var mock2: Self {
+ .init(
+ backupCodeEnabled: true,
+ createdAt: .distantPast,
+ createOrganizationEnabled: true,
+ createOrganizationsLimit: 0,
+ deleteSelfEnabled: true,
+ emailAddresses: [.mock],
+ enterpriseAccounts: [],
+ externalAccounts: [.mockVerified, .mockVerified, .mockUnverified],
+ firstName: nil,
+ hasImage: false,
+ id: "2",
+ imageUrl: "",
+ lastSignInAt: .now,
+ lastName: nil,
+ legalAcceptedAt: .now,
+ organizationMemberships: [.mockWithUserData],
+ passkeys: [.mock],
+ passwordEnabled: true,
+ phoneNumbers: [.mock],
+ primaryEmailAddressId: "1",
+ primaryPhoneNumberId: "1",
+ publicMetadata: nil,
+ totpEnabled: true,
+ twoFactorEnabled: true,
+ updatedAt: .now,
+ unsafeMetadata: nil,
+ username: "username2"
+ )
+ }
}
diff --git a/Sources/Clerk/Models/User/UserService.swift b/Sources/Clerk/Models/User/UserService.swift
index 2552c19ca..d15788308 100644
--- a/Sources/Clerk/Models/User/UserService.swift
+++ b/Sources/Clerk/Models/User/UserService.swift
@@ -2,207 +2,227 @@
// UserService.swift
// Clerk
//
-// Created by Mike Pitre on 2/25/25.
+// Created by Mike Pitre on 7/28/25.
//
import AuthenticationServices
-import Factory
+import FactoryKit
import Foundation
-import Get
-struct UserService {
- var update: @MainActor (_ params: User.UpdateParams) async throws -> User
- var createBackupCodes: @MainActor () async throws -> BackupCodeResource
- var createEmailAddress: @MainActor (_ email: String) async throws -> EmailAddress
- var createPhoneNumber: @MainActor (_ phoneNumber: String) async throws -> PhoneNumber
- var createExternalAccountOAuth: @MainActor (_ provider: OAuthProvider, _ redirectUrl: String?, _ additionalScopes: [String]?) async throws -> ExternalAccount
- var createExternalAccountIDToken: @MainActor (_ provider: IDTokenProvider, _ idToken: String) async throws -> ExternalAccount
- var createPasskey: @MainActor () async throws -> Passkey
- var createTOTP: @MainActor () async throws -> TOTPResource
- var verifyTOTP: @MainActor (_ code: String) async throws -> TOTPResource
- var disableTOTP: @MainActor () async throws -> DeletedObject
- var getOrganizationInvitations: @MainActor (_ initialPage: Int, _ pageSize: Int) async throws -> ClerkPaginatedResponse
- var getOrganizationMemberships: @MainActor (_ initialPage: Int, _ pageSize: Int) async throws -> ClerkPaginatedResponse
- var getOrganizationSuggestions: @MainActor (_ initialPage: Int, _ pageSize: Int, _ status: String?) async throws -> ClerkPaginatedResponse
- var getSessions: @MainActor (_ user: User) async throws -> [Session]
- var updatePassword: @MainActor (_ params: User.UpdatePasswordParams) async throws -> User
- var setProfileImage: @MainActor (_ imageData: Data) async throws -> ImageResource
- var deleteProfileImage: @MainActor () async throws -> DeletedObject
- var delete: @MainActor () async throws -> DeletedObject
+extension Container {
+
+ var userService: Factory {
+ self { @MainActor in UserService() }
+ }
+
}
-extension UserService {
+@MainActor
+struct UserService {
- static var liveValue: UserService {
- .init(
- update: { params in
- let request = ClerkFAPI.v1.me.update(
- queryItems: [.init(name: "_clerk_session_id", value: Clerk.shared.session?.id)],
- body: params
- )
- return try await Container.shared.apiClient().send(request).value.response
- },
- createBackupCodes: {
- let request = Request>(
- path: "/v1/me/backup_codes",
- method: .post,
- query: [("_clerk_session_id", Clerk.shared.session?.id)]
- )
- return try await Container.shared.apiClient().send(request).value.response
- },
- createEmailAddress: { email in
- let request = ClerkFAPI.v1.me.emailAddresses.post(
- queryItems: [.init(name: "_clerk_session_id", value: Clerk.shared.session?.id)],
- body: ["email_address": email]
- )
- return try await Container.shared.apiClient().send(request).value.response
- },
- createPhoneNumber: { phoneNumber in
- let request = ClerkFAPI.v1.me.phoneNumbers.post(
- queryItems: [.init(name: "_clerk_session_id", value: Clerk.shared.session?.id)],
- body: ["phone_number": phoneNumber]
- )
- return try await Container.shared.apiClient().send(request).value.response
- },
- createExternalAccountOAuth: { provider, redirectUrl, additionalScopes in
- let request = ClerkFAPI.v1.me.externalAccounts.create(
- queryItems: [.init(name: "_clerk_session_id", value: Clerk.shared.session?.id)],
- body: [
- "strategy": provider.strategy,
- "redirect_url": redirectUrl ?? RedirectConfigDefaults.redirectUrl,
- "additional_scopes": additionalScopes?.joined(separator: ","),
- ]
- )
- return try await Container.shared.apiClient().send(request).value.response
- },
- createExternalAccountIDToken: { provider, idToken in
- let request = ClerkFAPI.v1.me.externalAccounts.create(
- queryItems: [.init(name: "_clerk_session_id", value: Clerk.shared.session?.id)],
- body: [
- "strategy": provider.strategy,
- "token": idToken,
- ]
- )
- return try await Container.shared.apiClient().send(request).value.response
- },
- createPasskey: {
- #if canImport(AuthenticationServices) && !os(watchOS)
- let passkey = try await Passkey.create()
+ var update: (_ params: User.UpdateParams) async throws -> User = { params in
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/me")
+ .method(.patch)
+ .body(formEncode: params)
+ .addClerkSessionId()
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
- guard let challenge = passkey.challenge else {
+ var createBackupCodes: () async throws -> BackupCodeResource = {
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/me/backup_codes")
+ .method(.post)
+ .addClerkSessionId()
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
+
+ var createEmailAddress: (_ emailAddress: String) async throws -> EmailAddress = { emailAddress in
+ try await EmailAddress.create(emailAddress)
+ }
+
+ var createPhoneNumber: (_ phoneNumber: String) async throws -> PhoneNumber = { phoneNumber in
+ try await PhoneNumber.create(phoneNumber)
+ }
+
+ var createExternalAccount: (_ provider: OAuthProvider, _ redirectUrl: String?, _ additionalScopes: [String]?) async throws -> ExternalAccount = { provider, redirectUrl, additionalScopes in
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/me/external_accounts")
+ .method(.post)
+ .addClerkSessionId()
+ .body(
+ formEncode: [
+ "strategy": provider.strategy,
+ "redirect_url": redirectUrl ?? Clerk.shared.settings.redirectConfig.redirectUrl,
+ "additional_scopes": additionalScopes?.joined(separator: ",")
+ ].filter({ $0.value != nil })
+ )
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
+
+ var createExternalAccountToken: (_ provider: IDTokenProvider, _ idToken: String) async throws -> ExternalAccount = { provider, idToken in
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/me/external_accounts")
+ .method(.post)
+ .addClerkSessionId()
+ .body(formEncode: [
+ "strategy": provider.strategy,
+ "token": idToken
+ ])
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
+
+ #if canImport(AuthenticationServices) && !os(watchOS)
+ var createPasskey: () async throws -> Passkey = {
+ let passkey = try await Passkey.create()
+
+ guard let challenge = passkey.challenge else {
throw ClerkClientError(message: "Unable to get the challenge for the passkey.")
- }
+ }
- guard let name = passkey.username else {
+ guard let name = passkey.username else {
throw ClerkClientError(message: "Unable to get the username for the passkey.")
- }
+ }
- guard let userId = passkey.userId else {
+ guard let userId = passkey.userId else {
throw ClerkClientError(message: "Unable to get the user ID for the passkey.")
- }
+ }
- let manager = PasskeyHelper()
- let authorization = try await manager.createPasskey(
+ let manager = PasskeyHelper()
+ let authorization = try await manager.createPasskey(
challenge: challenge,
name: name,
userId: userId
- )
+ )
- guard
+ guard
let credentialRegistration = authorization.credential as? ASAuthorizationPlatformPublicKeyCredentialRegistration,
let rawAttestationObject = credentialRegistration.rawAttestationObject
- else {
+ else {
throw ClerkClientError(message: "Invalid credential type.")
- }
+ }
- let publicKeyCredential: [String: any Encodable] = [
+ let publicKeyCredential: [String: any Encodable] = [
"id": credentialRegistration.credentialID.base64EncodedString().base64URLFromBase64String(),
"rawId": credentialRegistration.credentialID.base64EncodedString().base64URLFromBase64String(),
"type": "public-key",
"response": [
- "attestationObject": rawAttestationObject.base64EncodedString().base64URLFromBase64String(),
- "clientDataJSON": credentialRegistration.rawClientDataJSON.base64EncodedString().base64URLFromBase64String(),
- ],
- ]
-
- let publicKeyCredentialJSON = try JSON(publicKeyCredential)
-
- return try await passkey.attemptVerification(credential: publicKeyCredentialJSON.debugDescription)
- #else
- throw ClerkClientError(message: "Passkey authentication is not supported on this platform.")
- #endif
- },
- createTOTP: {
- let request = ClerkFAPI.v1.me.totp.post(
- queryItems: [.init(name: "_clerk_session_id", value: Clerk.shared.session?.id)]
- )
- return try await Container.shared.apiClient().send(request).value.response
- },
- verifyTOTP: { code in
- let request = ClerkFAPI.v1.me.totp.attemptVerification.post(
- queryItems: [.init(name: "_clerk_session_id", value: Clerk.shared.session?.id)],
- body: ["code": code]
- )
- return try await Container.shared.apiClient().send(request).value.response
- },
- disableTOTP: {
- let request = ClerkFAPI.v1.me.totp.delete(
- queryItems: [.init(name: "_clerk_session_id", value: Clerk.shared.session?.id)]
- )
- return try await Container.shared.apiClient().send(request).value.response
- },
- getOrganizationInvitations: { initialPage, pageSize in
- let request = Request>>(
- path: "/v1/me/organization_invitations",
- query: [
- ("offset", String(initialPage)),
- ("limit", String(pageSize)),
- ("_clerk_session_id", Clerk.shared.session?.id),
- ]
- )
- return try await Container.shared.apiClient().send(request).value.response
- },
- getOrganizationMemberships: { initialPage, pageSize in
- let request = Request>>(
- path: "/v1/me/organization_memberships",
- query: [
- ("offset", String(initialPage)),
- ("limit", String(pageSize)),
- ("paginated", "true"),
- ("_clerk_session_id", Clerk.shared.session?.id),
- ]
- )
- return try await Container.shared.apiClient().send(request).value.response
- },
- getOrganizationSuggestions: { initialPage, pageSize, status in
- let request = Request>>(
- path: "/v1/me/organization_suggestions",
- query: [
- ("offset", String(initialPage)),
- ("limit", String(pageSize)),
- ("status", status),
- ("_clerk_session_id", Clerk.shared.session?.id),
- ].filter({ $1 != nil })
- )
- return try await Container.shared.apiClient().send(request).value.response
- },
- getSessions: { user in
- let request = ClerkFAPI.v1.me.sessions.active.get(
- queryItems: [.init(name: "_clerk_session_id", value: Clerk.shared.session?.id)]
- )
+ "attestationObject": rawAttestationObject.base64EncodedString().base64URLFromBase64String(),
+ "clientDataJSON": credentialRegistration.rawClientDataJSON.base64EncodedString().base64URLFromBase64String()
+ ]
+ ]
+
+ let publicKeyCredentialJSON = try JSON(publicKeyCredential)
+
+ return try await passkey.attemptVerification(credential: publicKeyCredentialJSON.debugDescription)
+ }
+ #endif
+
+ var createTotp: () async throws -> TOTPResource = {
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/me/totp")
+ .method(.post)
+ .addClerkSessionId()
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
+
+ var verifyTotp: (_ code: String) async throws -> TOTPResource = { code in
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/me/totp/attempt_verification")
+ .method(.post)
+ .addClerkSessionId()
+ .body(formEncode: ["code": code])
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
+
+ var disableTotp: () async throws -> DeletedObject = {
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/me/totp")
+ .method(.delete)
+ .addClerkSessionId()
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
+
+ var getOrganizationInvitations: (_ initialPage: Int, _ pageSize: Int) async throws -> ClerkPaginatedResponse = { initialPage, pageSize in
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/me/organization_invitations")
+ .addClerkSessionId()
+ .add(queryItems: [
+ .init(name: "offset", value: String(initialPage)),
+ .init(name: "limit", value: String(pageSize))
+ ])
+ .data(type: ClientResponse>.self)
+ .async()
+ .response
+ }
+
+ var getOrganizationMemberships: (_ initialPage: Int, _ pageSize: Int) async throws -> ClerkPaginatedResponse = { initialPage, pageSize in
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/me/organization_memberships")
+ .addClerkSessionId()
+ .add(queryItems: [
+ .init(name: "offset", value: String(initialPage)),
+ .init(name: "limit", value: String(pageSize)),
+ .init(name: "paginated", value: "true")
+ ])
+ .data(type: ClientResponse>.self)
+ .async()
+ .response
+ }
+
+ var getOrganizationSuggestions: (_ initialPage: Int, _ pageSize: Int, _ status: String?) async throws -> ClerkPaginatedResponse = { initialPage, pageSize, status in
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/me/organization_suggestions")
+ .addClerkSessionId()
+ .add(
+ queryItems: [
+ .init(name: "offset", value: String(initialPage)),
+ .init(name: "limit", value: String(pageSize)),
+ .init(name: "status", value: status)
+ ].filter({ $0.value != nil })
+ )
+ .data(type: ClientResponse>.self)
+ .async()
+ .response
+ }
+
+ var getSessions: (_ user: User) async throws -> [Session] = { user in
+ let sessions = try await Container.shared.apiClient().request()
+ .add(path: "/v1/me/sessions/active")
+ .addClerkSessionId()
+ .data(type: [Session].self)
+ .async()
- let sessions = try await Container.shared.apiClient().send(request).value
Clerk.shared.sessionsByUserId[user.id] = sessions
return sessions
- },
- updatePassword: { params in
- let request = ClerkFAPI.v1.me.changePassword.post(
- queryItems: [.init(name: "_clerk_session_id", value: Clerk.shared.session?.id)],
- body: params
- )
- return try await Container.shared.apiClient().send(request).value.response
- },
- setProfileImage: { imageData in
+ }
+
+ var updatePassword: (_ params: User.UpdatePasswordParams) async throws -> User = { params in
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/me/change_password")
+ .method(.post)
+ .body(formEncode: params)
+ .addClerkSessionId()
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
+
+ var setProfileImage: (_ imageData: Data) async throws -> ImageResource = { imageData in
let boundary = UUID().uuidString
var data = Data()
data.append("\r\n--\(boundary)\r\n".data(using: .utf8)!)
@@ -211,32 +231,37 @@ extension UserService {
data.append(imageData)
data.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
- let request = ClerkFAPI.v1.me.profileImage.post(
- queryItems: [.init(name: "_clerk_session_id", value: Clerk.shared.session?.id)],
- headers: ["Content-Type": "multipart/form-data; boundary=\(boundary)"]
- )
- return try await Container.shared.apiClient().upload(for: request, from: data).value.response
- },
- deleteProfileImage: {
- let request = ClerkFAPI.v1.me.profileImage.delete(
- queryItems: [.init(name: "_clerk_session_id", value: Clerk.shared.session?.id)]
- )
- return try await Container.shared.apiClient().send(request).value.response
- },
- delete: {
- let request = ClerkFAPI.v1.me.delete(
- queryItems: [.init(name: "_clerk_session_id", value: Clerk.shared.session?.id)]
- )
- return try await Container.shared.apiClient().send(request).value.response
- }
- )
- }
-}
+ return try await Container.shared.apiClient().request()
+ .add(path: "/v1/me/profile_image")
+ .method(.post)
+ .body(data: data)
+ .addClerkSessionId()
+ .with {
+ $0.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
+ }
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
-extension Container {
+ var deleteProfileImage: () async throws -> DeletedObject = {
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/me/profile_image")
+ .method(.delete)
+ .addClerkSessionId()
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
- var userService: Factory {
- self { .liveValue }
- }
+ var delete: () async throws -> DeletedObject = {
+ try await Container.shared.apiClient().request()
+ .add(path: "/v1/me")
+ .method(.delete)
+ .addClerkSessionId()
+ .data(type: ClientResponse.self)
+ .async()
+ .response
+ }
}
diff --git a/Sources/Clerk/Models/User/UserUpdateParams.swift b/Sources/Clerk/Models/User/UserUpdateParams.swift
index d52ef676e..c8728fde6 100644
--- a/Sources/Clerk/Models/User/UserUpdateParams.swift
+++ b/Sources/Clerk/Models/User/UserUpdateParams.swift
@@ -7,44 +7,44 @@
extension User {
- public struct UpdateParams: Encodable, Sendable {
-
- public init(
- username: String? = nil,
- firstName: String? = nil,
- lastName: String? = nil,
- primaryEmailAddressId: String? = nil,
- primaryPhoneNumberId: String? = nil,
- unsafeMetadata: JSON? = nil
- ) {
- self.username = username
- self.firstName = firstName
- self.lastName = lastName
- self.primaryEmailAddressId = primaryEmailAddressId
- self.primaryPhoneNumberId = primaryPhoneNumberId
- self.unsafeMetadata = unsafeMetadata
+ public struct UpdateParams: Encodable, Sendable {
+
+ public init(
+ username: String? = nil,
+ firstName: String? = nil,
+ lastName: String? = nil,
+ primaryEmailAddressId: String? = nil,
+ primaryPhoneNumberId: String? = nil,
+ unsafeMetadata: JSON? = nil
+ ) {
+ self.username = username
+ self.firstName = firstName
+ self.lastName = lastName
+ self.primaryEmailAddressId = primaryEmailAddressId
+ self.primaryPhoneNumberId = primaryPhoneNumberId
+ self.unsafeMetadata = unsafeMetadata
+ }
+
+ /// The user's username.
+ public var username: String?
+
+ /// The user's first name.
+ public var firstName: String?
+
+ /// The user's last name.
+ public var lastName: String?
+
+ /// The unique identifier for the EmailAddress that the user has set as primary.
+ public var primaryEmailAddressId: String?
+
+ /// The unique identifier for the PhoneNumber that the user has set as primary.
+ public var primaryPhoneNumberId: String?
+
+ /**
+ Metadata that can be read and set from the Frontend API. One common use case for this attribute is to implement custom fields that will be attached to the User object.
+ Please note that there is also an unsafeMetadata attribute in the SignUp object. The value of that field will be automatically copied to the user's unsafe metadata once the sign up is complete.
+ */
+ public var unsafeMetadata: JSON?
}
- /// The user's username.
- public var username: String?
-
- /// The user's first name.
- public var firstName: String?
-
- /// The user's last name.
- public var lastName: String?
-
- /// The unique identifier for the EmailAddress that the user has set as primary.
- public var primaryEmailAddressId: String?
-
- /// The unique identifier for the PhoneNumber that the user has set as primary.
- public var primaryPhoneNumberId: String?
-
- /**
- Metadata that can be read and set from the Frontend API. One common use case for this attribute is to implement custom fields that will be attached to the User object.
- Please note that there is also an unsafeMetadata attribute in the SignUp object. The value of that field will be automatically copied to the user's unsafe metadata once the sign up is complete.
- */
- public var unsafeMetadata: JSON?
- }
-
}
diff --git a/Sources/Clerk/Models/User/UserUpdatePasswordParams.swift b/Sources/Clerk/Models/User/UserUpdatePasswordParams.swift
index 6ffdc797d..1d0e317a4 100644
--- a/Sources/Clerk/Models/User/UserUpdatePasswordParams.swift
+++ b/Sources/Clerk/Models/User/UserUpdatePasswordParams.swift
@@ -7,24 +7,24 @@
extension User {
- public struct UpdatePasswordParams: Encodable, Sendable {
+ public struct UpdatePasswordParams: Encodable, Sendable {
- public init(
- newPassword: String,
- currentPassword: String,
- signOutOfOtherSessions: Bool
- ) {
- self.newPassword = newPassword
- self.currentPassword = currentPassword
- self.signOutOfOtherSessions = signOutOfOtherSessions
- }
+ public init(
+ currentPassword: String? = nil,
+ newPassword: String,
+ signOutOfOtherSessions: Bool = true
+ ) {
+ self.currentPassword = currentPassword
+ self.newPassword = newPassword
+ self.signOutOfOtherSessions = signOutOfOtherSessions
+ }
- /// The user's new password.
- public let newPassword: String
- /// The user's current password.
- public let currentPassword: String
- /// If set to true, all sessions will be signed out.
- public let signOutOfOtherSessions: Bool
- }
+ /// The user's current password.
+ public let currentPassword: String?
+ /// The user's new password.
+ public let newPassword: String
+ /// If set to true, all sessions will be signed out.
+ public let signOutOfOtherSessions: Bool
+ }
}
diff --git a/Sources/Clerk/Models/Verification.swift b/Sources/Clerk/Models/Verification.swift
index f6a4fa9f1..5a955085f 100644
--- a/Sources/Clerk/Models/Verification.swift
+++ b/Sources/Clerk/Models/Verification.swift
@@ -10,157 +10,157 @@ import Foundation
/// The state of the verification process of a sign-in or sign-up attempt.
public struct Verification: Codable, Equatable, Hashable, Sendable {
- /// The state of the verification.
- public let status: Status?
-
- /// The strategy pertaining to the parent sign-up or sign-in attempt.
- public let strategy: String?
-
- /// The number of attempts related to the verification.
- public let attempts: Int?
-
- /// The time the verification will expire at.
- public let expireAt: Date?
-
- /// The last error the verification attempt ran into.
- public let error: ClerkAPIError?
-
- /// The redirect URL for an external verification.
- public let externalVerificationRedirectUrl: String?
-
- /// The nonce pertaining to the verification.
- public let nonce: String?
-
- public init(
- status: Verification.Status? = nil,
- strategy: String? = nil,
- attempts: Int? = nil,
- expireAt: Date? = nil,
- error: ClerkAPIError? = nil,
- externalVerificationRedirectUrl: String? = nil,
- nonce: String? = nil
- ) {
- self.status = status
- self.strategy = strategy
- self.attempts = attempts
- self.expireAt = expireAt
- self.error = error
- self.externalVerificationRedirectUrl = externalVerificationRedirectUrl
- self.nonce = nonce
- }
-
- /// The state of the verification.
- public enum Status: String, Codable, Sendable {
- case unverified
- case verified
- case transferable
- case failed
- case expired
-
- case unknown
-
- public init(from decoder: Decoder) throws {
- self = try .init(rawValue: decoder.singleValueContainer().decode(RawValue.self)) ?? .unknown
+ /// The state of the verification.
+ public let status: Status?
+
+ /// The strategy pertaining to the parent sign-up or sign-in attempt.
+ public let strategy: String?
+
+ /// The number of attempts related to the verification.
+ public let attempts: Int?
+
+ /// The time the verification will expire at.
+ public let expireAt: Date?
+
+ /// The last error the verification attempt ran into.
+ public let error: ClerkAPIError?
+
+ /// The redirect URL for an external verification.
+ public let externalVerificationRedirectUrl: String?
+
+ /// The nonce pertaining to the verification.
+ public let nonce: String?
+
+ public init(
+ status: Verification.Status? = nil,
+ strategy: String? = nil,
+ attempts: Int? = nil,
+ expireAt: Date? = nil,
+ error: ClerkAPIError? = nil,
+ externalVerificationRedirectUrl: String? = nil,
+ nonce: String? = nil
+ ) {
+ self.status = status
+ self.strategy = strategy
+ self.attempts = attempts
+ self.expireAt = expireAt
+ self.error = error
+ self.externalVerificationRedirectUrl = externalVerificationRedirectUrl
+ self.nonce = nonce
+ }
+
+ /// The state of the verification.
+ public enum Status: String, Codable, Sendable {
+ case unverified
+ case verified
+ case transferable
+ case failed
+ case expired
+
+ case unknown
+
+ public init(from decoder: Decoder) throws {
+ self = try .init(rawValue: decoder.singleValueContainer().decode(RawValue.self)) ?? .unknown
+ }
}
- }
}
extension Verification {
- static var mockEmailCodeVerifiedVerification: Verification {
- Verification(
- status: .verified,
- strategy: "email_code",
- attempts: nil,
- expireAt: Date(timeIntervalSinceReferenceDate: 1234567890),
- error: nil,
- externalVerificationRedirectUrl: nil,
- nonce: nil
- )
- }
-
- static var mockEmailCodeUnverifiedVerification: Verification {
- Verification(
- status: .unverified,
- strategy: "email_code",
- attempts: nil,
- expireAt: Date(timeIntervalSinceReferenceDate: 1234567890),
- error: nil,
- externalVerificationRedirectUrl: nil,
- nonce: nil
- )
- }
-
- static var mockPhoneCodeVerifiedVerification: Verification {
- Verification(
- status: .verified,
- strategy: "phone_code",
- attempts: 0,
- expireAt: Date(timeIntervalSinceReferenceDate: 1234567890),
- error: nil,
- externalVerificationRedirectUrl: nil,
- nonce: nil
- )
- }
-
- static var mockPhoneCodeUnverifiedVerification: Verification {
- Verification(
- status: .unverified,
- strategy: "phone_code",
- attempts: 0,
- expireAt: Date(timeIntervalSinceReferenceDate: 1234567890),
- error: nil,
- externalVerificationRedirectUrl: nil,
- nonce: nil
- )
- }
-
- static var mockPasskeyVerifiedVerification: Verification {
- Verification(
- status: .verified,
- strategy: "passkey",
- attempts: 0,
- expireAt: Date(timeIntervalSinceReferenceDate: 1234567890),
- error: nil,
- externalVerificationRedirectUrl: nil,
- nonce: "12345"
- )
- }
-
- static var mockPasskeyUnverifiedVerification: Verification {
- Verification(
- status: .unverified,
- strategy: "passkey",
- attempts: 0,
- expireAt: Date(timeIntervalSinceReferenceDate: 1234567890),
- error: nil,
- externalVerificationRedirectUrl: nil,
- nonce: "12345"
- )
- }
-
- static var mockExternalAccountVerifiedVerification: Verification {
- Verification(
- status: .verified,
- strategy: "oauth_google",
- attempts: 0,
- expireAt: Date(timeIntervalSinceReferenceDate: 1234567890),
- error: nil,
- externalVerificationRedirectUrl: nil,
- nonce: nil
- )
- }
-
- static var mockExternalAccountUnverifiedVerification: Verification {
- Verification(
- status: .unverified,
- strategy: "oauth_google",
- attempts: 0,
- expireAt: Date(timeIntervalSinceReferenceDate: 1234567890),
- error: nil,
- externalVerificationRedirectUrl: "https://accounts.google.com",
- nonce: nil
- )
- }
+ static var mockEmailCodeVerifiedVerification: Verification {
+ Verification(
+ status: .verified,
+ strategy: "email_code",
+ attempts: nil,
+ expireAt: Date(timeIntervalSinceReferenceDate: 1234567890),
+ error: nil,
+ externalVerificationRedirectUrl: nil,
+ nonce: nil
+ )
+ }
+
+ static var mockEmailCodeUnverifiedVerification: Verification {
+ Verification(
+ status: .unverified,
+ strategy: "email_code",
+ attempts: nil,
+ expireAt: Date(timeIntervalSinceReferenceDate: 1234567890),
+ error: nil,
+ externalVerificationRedirectUrl: nil,
+ nonce: nil
+ )
+ }
+
+ static var mockPhoneCodeVerifiedVerification: Verification {
+ Verification(
+ status: .verified,
+ strategy: "phone_code",
+ attempts: 0,
+ expireAt: Date(timeIntervalSinceReferenceDate: 1234567890),
+ error: nil,
+ externalVerificationRedirectUrl: nil,
+ nonce: nil
+ )
+ }
+
+ static var mockPhoneCodeUnverifiedVerification: Verification {
+ Verification(
+ status: .unverified,
+ strategy: "phone_code",
+ attempts: 0,
+ expireAt: Date(timeIntervalSinceReferenceDate: 1234567890),
+ error: nil,
+ externalVerificationRedirectUrl: nil,
+ nonce: nil
+ )
+ }
+
+ static var mockPasskeyVerifiedVerification: Verification {
+ Verification(
+ status: .verified,
+ strategy: "passkey",
+ attempts: 0,
+ expireAt: Date(timeIntervalSinceReferenceDate: 1234567890),
+ error: nil,
+ externalVerificationRedirectUrl: nil,
+ nonce: "12345"
+ )
+ }
+
+ static var mockPasskeyUnverifiedVerification: Verification {
+ Verification(
+ status: .unverified,
+ strategy: "passkey",
+ attempts: 0,
+ expireAt: Date(timeIntervalSinceReferenceDate: 1234567890),
+ error: nil,
+ externalVerificationRedirectUrl: nil,
+ nonce: "12345"
+ )
+ }
+
+ static var mockExternalAccountVerifiedVerification: Verification {
+ Verification(
+ status: .verified,
+ strategy: "oauth_google",
+ attempts: 0,
+ expireAt: Date(timeIntervalSinceReferenceDate: 1234567890),
+ error: nil,
+ externalVerificationRedirectUrl: nil,
+ nonce: nil
+ )
+ }
+
+ static var mockExternalAccountUnverifiedVerification: Verification {
+ Verification(
+ status: .unverified,
+ strategy: "oauth_google",
+ attempts: 0,
+ expireAt: Date(timeIntervalSinceReferenceDate: 1234567890),
+ error: nil,
+ externalVerificationRedirectUrl: "https://accounts.google.com",
+ nonce: nil
+ )
+ }
}
diff --git a/Sources/Clerk/Paths/ClerkFAPI.swift b/Sources/Clerk/Paths/ClerkFAPI.swift
deleted file mode 100644
index de29eb045..000000000
--- a/Sources/Clerk/Paths/ClerkFAPI.swift
+++ /dev/null
@@ -1,10 +0,0 @@
-//
-// ClerkFAPI.swift
-//
-//
-// Created by Mike Pitre on 10/2/23.
-//
-
-import Foundation
-
-enum ClerkFAPI {}
diff --git a/Sources/Clerk/Paths/PathsV1.swift b/Sources/Clerk/Paths/PathsV1.swift
deleted file mode 100644
index c6f906ca0..000000000
--- a/Sources/Clerk/Paths/PathsV1.swift
+++ /dev/null
@@ -1,19 +0,0 @@
-//
-// PathsV1.swift
-//
-//
-// Created by Mike Pitre on 2/10/24.
-//
-
-import Foundation
-
-extension ClerkFAPI {
- static var v1: V1Endpoint {
- V1Endpoint(path: "/v1")
- }
-
- struct V1Endpoint {
- /// Path: `/v1`
- let path: String
- }
-}
diff --git a/Sources/Clerk/Paths/PathsV1Client.swift b/Sources/Clerk/Paths/PathsV1Client.swift
deleted file mode 100644
index ad62eea98..000000000
--- a/Sources/Clerk/Paths/PathsV1Client.swift
+++ /dev/null
@@ -1,25 +0,0 @@
-//
-// PathsV1Client.swift
-//
-//
-// Created by Mike Pitre on 2/10/24.
-//
-
-import Foundation
-import Get
-
-extension ClerkFAPI.V1Endpoint {
-
- var client: ClientEndpoint {
- ClientEndpoint(path: path + "/client")
- }
-
- struct ClientEndpoint {
- /// Path: `/v1/client`
- let path: String
-
- var get: Request> {
- .init(path: path)
- }
- }
-}
diff --git a/Sources/Clerk/Paths/PathsV1ClientDeviceAttestation.swift b/Sources/Clerk/Paths/PathsV1ClientDeviceAttestation.swift
deleted file mode 100644
index 57c97c126..000000000
--- a/Sources/Clerk/Paths/PathsV1ClientDeviceAttestation.swift
+++ /dev/null
@@ -1,22 +0,0 @@
-//
-// PathsV1ClientDeviceAttestation.swift
-// Clerk
-//
-// Created by Mike Pitre on 1/29/25.
-//
-
-import Foundation
-import Get
-
-extension ClerkFAPI.V1Endpoint.ClientEndpoint {
-
- var deviceAttestation: DeviceAttestationEndpoint {
- DeviceAttestationEndpoint(path: path + "/device_attestation")
- }
-
- struct DeviceAttestationEndpoint {
- /// Path: `v1/client/device_attestation`
- let path: String
- }
-
-}
diff --git a/Sources/Clerk/Paths/PathsV1ClientDeviceAttestationChallenges.swift b/Sources/Clerk/Paths/PathsV1ClientDeviceAttestationChallenges.swift
deleted file mode 100644
index ce7e19799..000000000
--- a/Sources/Clerk/Paths/PathsV1ClientDeviceAttestationChallenges.swift
+++ /dev/null
@@ -1,26 +0,0 @@
-//
-// PathsV1ClientDeviceAttestationChallenges.swift
-// Clerk
-//
-// Created by Mike Pitre on 1/29/25.
-//
-
-import Foundation
-import Get
-
-extension ClerkFAPI.V1Endpoint.ClientEndpoint.DeviceAttestationEndpoint {
-
- var challenges: ChallengesEndpoint {
- ChallengesEndpoint(path: path + "/challenges")
- }
-
- struct ChallengesEndpoint {
- /// Path: `v1/client/device_attestation/challenges`
- let path: String
-
- var post: Request<[String: String]> {
- .init(path: path, method: .post)
- }
- }
-
-}
diff --git a/Sources/Clerk/Paths/PathsV1ClientDeviceAttestationVerify.swift b/Sources/Clerk/Paths/PathsV1ClientDeviceAttestationVerify.swift
deleted file mode 100644
index db1481352..000000000
--- a/Sources/Clerk/Paths/PathsV1ClientDeviceAttestationVerify.swift
+++ /dev/null
@@ -1,26 +0,0 @@
-//
-// PathsV1ClientDeviceAttestationVerify.swift
-// Clerk
-//
-// Created by Mike Pitre on 1/29/25.
-//
-
-import Foundation
-import Get
-
-extension ClerkFAPI.V1Endpoint.ClientEndpoint.DeviceAttestationEndpoint {
-
- var verify: VerifyEndpoint {
- VerifyEndpoint(path: path + "/verify")
- }
-
- struct VerifyEndpoint {
- /// Path: `v1/client/device_attestation/verify`
- let path: String
-
- func post(_ body: any Encodable) -> Request {
- .init(path: path, method: .post, body: body)
- }
- }
-
-}
diff --git a/Sources/Clerk/Paths/PathsV1ClientSessions.swift b/Sources/Clerk/Paths/PathsV1ClientSessions.swift
deleted file mode 100644
index 6af803e0c..000000000
--- a/Sources/Clerk/Paths/PathsV1ClientSessions.swift
+++ /dev/null
@@ -1,26 +0,0 @@
-//
-// PathsV1ClientSessions.swift
-//
-//
-// Created by Mike Pitre on 2/10/24.
-//
-
-import Foundation
-import Get
-
-extension ClerkFAPI.V1Endpoint.ClientEndpoint {
-
- var sessions: SessionsEndpoint {
- SessionsEndpoint(path: path + "/sessions")
- }
-
- struct SessionsEndpoint {
- /// Path: `v1/client/sessions`
- let path: String
-
- var delete: Request> {
- .init(path: path, method: .delete)
- }
- }
-
-}
diff --git a/Sources/Clerk/Paths/PathsV1ClientSessionsWithID.swift b/Sources/Clerk/Paths/PathsV1ClientSessionsWithID.swift
deleted file mode 100644
index b95fe3cac..000000000
--- a/Sources/Clerk/Paths/PathsV1ClientSessionsWithID.swift
+++ /dev/null
@@ -1,21 +0,0 @@
-//
-// PathsV1ClientSessionsWithID.swift
-//
-//
-// Created by Mike Pitre on 2/10/24.
-//
-
-import Foundation
-
-extension ClerkFAPI.V1Endpoint.ClientEndpoint.SessionsEndpoint {
-
- func id(_ id: String) -> WithIdEndpoint {
- WithIdEndpoint(path: path + "/\(id)")
- }
-
- struct WithIdEndpoint {
- /// Path: `v1/client/sessions/{id}`
- let path: String
- }
-
-}
diff --git a/Sources/Clerk/Paths/PathsV1ClientSessionsWithIDRemove.swift b/Sources/Clerk/Paths/PathsV1ClientSessionsWithIDRemove.swift
deleted file mode 100644
index 39384aedc..000000000
--- a/Sources/Clerk/Paths/PathsV1ClientSessionsWithIDRemove.swift
+++ /dev/null
@@ -1,26 +0,0 @@
-//
-// PathsV1ClientSessionsWithIDRemove.swift
-//
-//
-// Created by Mike Pitre on 2/10/24.
-//
-
-import Foundation
-import Get
-
-extension ClerkFAPI.V1Endpoint.ClientEndpoint.SessionsEndpoint.WithIdEndpoint {
-
- var remove: RemoveEndpoint {
- RemoveEndpoint(path: path + "/remove")
- }
-
- struct RemoveEndpoint {
- /// Path: `v1/client/sessions/{id}/remove`
- let path: String
-
- var post: Request> {
- .init(path: path, method: .post)
- }
- }
-
-}
diff --git a/Sources/Clerk/Paths/PathsV1ClientSessionsWithIDTokens.swift b/Sources/Clerk/Paths/PathsV1ClientSessionsWithIDTokens.swift
deleted file mode 100644
index 5de8f867c..000000000
--- a/Sources/Clerk/Paths/PathsV1ClientSessionsWithIDTokens.swift
+++ /dev/null
@@ -1,25 +0,0 @@
-//
-// PathsV1ClientSessionsWithIDTokens.swift
-//
-//
-// Created by Mike Pitre on 2/10/24.
-//
-
-import Foundation
-import Get
-
-extension ClerkFAPI.V1Endpoint.ClientEndpoint.SessionsEndpoint.WithIdEndpoint {
-
- var tokens: TokensEndpoint {
- TokensEndpoint(path: path + "/tokens")
- }
-
- struct TokensEndpoint {
- /// Path: `v1/client/sessions/{id}/tokens`
- let path: String
-
- func post() -> Request {
- .init(path: path, method: .post)
- }
- }
-}
diff --git a/Sources/Clerk/Paths/PathsV1ClientSessionsWithIDTokensTemplate.swift b/Sources/Clerk/Paths/PathsV1ClientSessionsWithIDTokensTemplate.swift
deleted file mode 100644
index 48595cca7..000000000
--- a/Sources/Clerk/Paths/PathsV1ClientSessionsWithIDTokensTemplate.swift
+++ /dev/null
@@ -1,25 +0,0 @@
-//
-// PathsV1ClientSessionsWithIDTokensTemplate.swift
-//
-//
-// Created by Mike Pitre on 2/10/24.
-//
-
-import Foundation
-import Get
-
-extension ClerkFAPI.V1Endpoint.ClientEndpoint.SessionsEndpoint.WithIdEndpoint.TokensEndpoint {
-
- func template(_ template: String) -> TemplateEndpoint {
- TemplateEndpoint(path: path + "/\(template)")
- }
-
- struct TemplateEndpoint {
- /// Path: `v1/client/sessions/{id}/tokens/{template}`
- let path: String
-
- func post() -> Request {
- .init(path: path, method: .post)
- }
- }
-}
diff --git a/Sources/Clerk/Paths/PathsV1ClientSignIns.swift b/Sources/Clerk/Paths/PathsV1ClientSignIns.swift
deleted file mode 100644
index 4b561999d..000000000
--- a/Sources/Clerk/Paths/PathsV1ClientSignIns.swift
+++ /dev/null
@@ -1,26 +0,0 @@
-//
-// PathsV1ClientSignIns.swift
-//
-//
-// Created by Mike Pitre on 2/10/24.
-//
-
-import Foundation
-import Get
-
-extension ClerkFAPI.V1Endpoint.ClientEndpoint {
-
- var signIns: SignInsEndpoint {
- SignInsEndpoint(path: path + "/sign_ins")
- }
-
- struct SignInsEndpoint {
- /// Path: `v1/client/sign_ins`
- let path: String
-
- func post(body: any Encodable) -> Request> {
- .init(path: path, method: .post, body: body)
- }
- }
-
-}
diff --git a/Sources/Clerk/Paths/PathsV1ClientSignInsWithID.swift b/Sources/Clerk/Paths/PathsV1ClientSignInsWithID.swift
deleted file mode 100644
index 0fc226657..000000000
--- a/Sources/Clerk/Paths/PathsV1ClientSignInsWithID.swift
+++ /dev/null
@@ -1,31 +0,0 @@
-//
-// PathsV1ClientSignInsWithID.swift
-//
-//
-// Created by Mike Pitre on 2/10/24.
-//
-
-import Foundation
-import Get
-
-extension ClerkFAPI.V1Endpoint.ClientEndpoint.SignInsEndpoint {
-
- func id(_ id: String) -> WithIdEndpoint {
- WithIdEndpoint(path: path + "/\(id)")
- }
-
- struct WithIdEndpoint {
- /// Path: `/v1/client/sign_ins/{id}`
- let path: String
-
- func get(rotatingTokenNonce: String? = nil) -> Request> {
- if let rotatingTokenNonce {
- let queryEncodedNonce = rotatingTokenNonce.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)
- return .init(path: path, query: [("rotating_token_nonce", queryEncodedNonce)])
- } else {
- return .init(path: path)
- }
- }
- }
-
-}
diff --git a/Sources/Clerk/Paths/PathsV1ClientSignInsWithIDAttemptFirstFactor.swift b/Sources/Clerk/Paths/PathsV1ClientSignInsWithIDAttemptFirstFactor.swift
deleted file mode 100644
index 489583465..000000000
--- a/Sources/Clerk/Paths/PathsV1ClientSignInsWithIDAttemptFirstFactor.swift
+++ /dev/null
@@ -1,26 +0,0 @@
-//
-// PathsV1ClientSignInsWithIDAttemptFirstFactor.swift
-//
-//
-// Created by Mike Pitre on 2/10/24.
-//
-
-import Foundation
-import Get
-
-extension ClerkFAPI.V1Endpoint.ClientEndpoint.SignInsEndpoint.WithIdEndpoint {
-
- var attemptFirstFactor: AttemptFirstFactorEndpoint {
- AttemptFirstFactorEndpoint(path: path + "/attempt_first_factor")
- }
-
- struct AttemptFirstFactorEndpoint {
- /// Path: `v1/client/sign_ins/{id}/attempt_first_factor`
- let path: String
-
- func post(body: any Encodable) -> Request> {
- .init(path: path, method: .post, body: body)
- }
- }
-
-}
diff --git a/Sources/Clerk/Paths/PathsV1ClientSignInsWithIDAttemptSecondFactor.swift b/Sources/Clerk/Paths/PathsV1ClientSignInsWithIDAttemptSecondFactor.swift
deleted file mode 100644
index cd65eaad2..000000000
--- a/Sources/Clerk/Paths/PathsV1ClientSignInsWithIDAttemptSecondFactor.swift
+++ /dev/null
@@ -1,26 +0,0 @@
-//
-// PathsV1ClientSignInsWithIDAttemptSecondFactor.swift
-//
-//
-// Created by Mike Pitre on 2/10/24.
-//
-
-import Foundation
-import Get
-
-extension ClerkFAPI.V1Endpoint.ClientEndpoint.SignInsEndpoint.WithIdEndpoint {
-
- var attemptSecondFactor: AttemptSecondFactorEndpoint {
- AttemptSecondFactorEndpoint(path: path + "/attempt_second_factor")
- }
-
- struct AttemptSecondFactorEndpoint {
- /// Path: `v1/client/sign_ins/{id}/attempt_second_factor`
- let path: String
-
- func post(_ params: SignIn.AttemptSecondFactorParams) -> Request> {
- .init(path: path, method: .post, body: params)
- }
- }
-
-}
diff --git a/Sources/Clerk/Paths/PathsV1ClientSignInsWithIDPrepareFirstFactor.swift b/Sources/Clerk/Paths/PathsV1ClientSignInsWithIDPrepareFirstFactor.swift
deleted file mode 100644
index a8472a8d9..000000000
--- a/Sources/Clerk/Paths/PathsV1ClientSignInsWithIDPrepareFirstFactor.swift
+++ /dev/null
@@ -1,26 +0,0 @@
-//
-// PathsV1ClientSignInsWithIDPrepareFirstFactor.swift
-//
-//
-// Created by Mike Pitre on 2/10/24.
-//
-
-import Foundation
-import Get
-
-extension ClerkFAPI.V1Endpoint.ClientEndpoint.SignInsEndpoint.WithIdEndpoint {
-
- var prepareFirstFactor: PrepareFirstFactorEndpoint {
- PrepareFirstFactorEndpoint(path: path + "/prepare_first_factor")
- }
-
- struct PrepareFirstFactorEndpoint {
- /// Path: `v1/client/sign_ins/{id}/prepare_first_factor`
- let path: String
-
- func post(_ params: SignIn.PrepareFirstFactorParams) -> Request> {
- .init(path: path, method: .post, body: params)
- }
- }
-
-}
diff --git a/Sources/Clerk/Paths/PathsV1ClientSignInsWithIDPrepareSecondFactor.swift b/Sources/Clerk/Paths/PathsV1ClientSignInsWithIDPrepareSecondFactor.swift
deleted file mode 100644
index 57d792932..000000000
--- a/Sources/Clerk/Paths/PathsV1ClientSignInsWithIDPrepareSecondFactor.swift
+++ /dev/null
@@ -1,26 +0,0 @@
-//
-// PathsV1ClientSignInsWithIDPrepareSecondFactor.swift
-//
-//
-// Created by Mike Pitre on 2/10/24.
-//
-
-import Foundation
-import Get
-
-extension ClerkFAPI.V1Endpoint.ClientEndpoint.SignInsEndpoint.WithIdEndpoint {
-
- var prepareSecondFactor: PrepareSecondFactorEndpoint {
- PrepareSecondFactorEndpoint(path: path + "/prepare_second_factor")
- }
-
- struct PrepareSecondFactorEndpoint {
- /// Path: `v1/client/sign_ins/{id}/prepare_second_factor`
- let path: String
-
- func post(_ params: SignIn.PrepareSecondFactorParams) -> Request> {
- .init(path: path, method: .post, body: params)
- }
- }
-
-}
diff --git a/Sources/Clerk/Paths/PathsV1ClientSignInsWithIDResetPassword.swift b/Sources/Clerk/Paths/PathsV1ClientSignInsWithIDResetPassword.swift
deleted file mode 100644
index 732c84622..000000000
--- a/Sources/Clerk/Paths/PathsV1ClientSignInsWithIDResetPassword.swift
+++ /dev/null
@@ -1,26 +0,0 @@
-//
-// PathsV1ClientSignInsWithIDResetPassword.swift
-//
-//
-// Created by Mike Pitre on 2/10/24.
-//
-
-import Foundation
-import Get
-
-extension ClerkFAPI.V1Endpoint.ClientEndpoint.SignInsEndpoint.WithIdEndpoint {
-
- var resetPassword: ResetPasswordEndpoint {
- ResetPasswordEndpoint(path: path + "/reset_password")
- }
-
- struct ResetPasswordEndpoint {
- /// Path: `v1/client/sign_ins/{id}/reset_password`
- let path: String
-
- func post(_ params: any Encodable) -> Request> {
- .init(path: path, method: .post, body: params)
- }
- }
-
-}
diff --git a/Sources/Clerk/Paths/PathsV1ClientSignUps.swift b/Sources/Clerk/Paths/PathsV1ClientSignUps.swift
deleted file mode 100644
index 7ab4c06c2..000000000
--- a/Sources/Clerk/Paths/PathsV1ClientSignUps.swift
+++ /dev/null
@@ -1,29 +0,0 @@
-//
-// PathsV1ClientSignUps.swift
-//
-//
-// Created by Mike Pitre on 2/10/24.
-//
-
-import Foundation
-import Get
-
-extension ClerkFAPI.V1Endpoint.ClientEndpoint {
-
- var signUps: SignUpsEndpoint {
- SignUpsEndpoint(path: path + "/sign_ups")
- }
-
- struct SignUpsEndpoint {
- /// Path: `v1/client/sign_ups`
- let path: String
-
- var get: Request> {
- .init(path: path)
- }
-
- func post(_ body: any Encodable) -> Request> {
- .init(path: path, method: .post, body: body)
- }
- }
-}
diff --git a/Sources/Clerk/Paths/PathsV1ClientSignUpsWithID.swift b/Sources/Clerk/Paths/PathsV1ClientSignUpsWithID.swift
deleted file mode 100644
index 35630381c..000000000
--- a/Sources/Clerk/Paths/PathsV1ClientSignUpsWithID.swift
+++ /dev/null
@@ -1,34 +0,0 @@
-//
-// PathsV1ClientSignUpsWithID.swift
-//
-//
-// Created by Mike Pitre on 2/10/24.
-//
-
-import Foundation
-import Get
-
-extension ClerkFAPI.V1Endpoint.ClientEndpoint.SignUpsEndpoint {
-
- func id(_ id: String) -> WithIdEndpoint {
- WithIdEndpoint(path: path + "/\(id)")
- }
-
- struct WithIdEndpoint {
- /// Path: `/v1/client/sign_ups/{id}`
- let path: String
-
- func get(rotatingTokenNonce: String? = nil) -> Request> {
- if let rotatingTokenNonce {
- let queryEncodedNonce = rotatingTokenNonce.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)
- return .init(path: path, query: [("rotating_token_nonce", queryEncodedNonce)])
- } else {
- return .init(path: path)
- }
- }
-
- func patch(_ params: SignUp.UpdateParams) -> Request> {
- .init(path: path, method: .patch, body: params)
- }
- }
-}
diff --git a/Sources/Clerk/Paths/PathsV1ClientSignUpsWithIDAttemptVerification.swift b/Sources/Clerk/Paths/PathsV1ClientSignUpsWithIDAttemptVerification.swift
deleted file mode 100644
index b59997c8c..000000000
--- a/Sources/Clerk/Paths/PathsV1ClientSignUpsWithIDAttemptVerification.swift
+++ /dev/null
@@ -1,25 +0,0 @@
-//
-// PathsV1ClientSignUpsWithIDAttemptVerification.swift
-//
-//
-// Created by Mike Pitre on 2/10/24.
-//
-
-import Foundation
-import Get
-
-extension ClerkFAPI.V1Endpoint.ClientEndpoint.SignUpsEndpoint.WithIdEndpoint {
-
- var attemptVerification: AttemptVerificationEndpoint {
- AttemptVerificationEndpoint(path: path + "/attempt_verification")
- }
-
- struct AttemptVerificationEndpoint {
- /// Path: `v1/client/sign_ups/{id}/attempt_verification`
- let path: String
-
- func post(_ params: SignUp.AttemptVerificationParams) -> Request> {
- .init(path: path, method: .post, body: params)
- }
- }
-}
diff --git a/Sources/Clerk/Paths/PathsV1ClientSignUpsWithIDPrepareVerification.swift b/Sources/Clerk/Paths/PathsV1ClientSignUpsWithIDPrepareVerification.swift
deleted file mode 100644
index 7b4c4d5fd..000000000
--- a/Sources/Clerk/Paths/PathsV1ClientSignUpsWithIDPrepareVerification.swift
+++ /dev/null
@@ -1,25 +0,0 @@
-//
-// PathsV1ClientSignUpsWithIDPrepareVerification.swift
-//
-//
-// Created by Mike Pitre on 2/10/24.
-//
-
-import Foundation
-import Get
-
-extension ClerkFAPI.V1Endpoint.ClientEndpoint.SignUpsEndpoint.WithIdEndpoint {
-
- var prepareVerification: PrepareVerificationEndpoint {
- PrepareVerificationEndpoint(path: path + "/prepare_verification")
- }
-
- struct PrepareVerificationEndpoint {
- /// Path: `v1/client/sign_ups/{id}/prepare_verification`
- let path: String
-
- func post(_ params: SignUp.PrepareVerificationParams) -> Request> {
- .init(path: path, method: .post, body: params)
- }
- }
-}
diff --git a/Sources/Clerk/Paths/PathsV1ClientVerify.swift b/Sources/Clerk/Paths/PathsV1ClientVerify.swift
deleted file mode 100644
index fb05e164b..000000000
--- a/Sources/Clerk/Paths/PathsV1ClientVerify.swift
+++ /dev/null
@@ -1,26 +0,0 @@
-//
-// PathsV1ClientVerify.swift
-// Clerk
-//
-// Created by Mike Pitre on 1/30/25.
-//
-
-import Foundation
-import Get
-
-extension ClerkFAPI.V1Endpoint.ClientEndpoint {
-
- var verify: VerifyEndpoint {
- VerifyEndpoint(path: path + "/verify")
- }
-
- struct VerifyEndpoint {
- /// Path: `v1/client/verify`
- let path: String
-
- func post(_ body: any Encodable) -> Request {
- .init(path: path, method: .post, body: body)
- }
- }
-
-}
diff --git a/Sources/Clerk/Paths/PathsV1Environment.swift b/Sources/Clerk/Paths/PathsV1Environment.swift
deleted file mode 100644
index 82979e20b..000000000
--- a/Sources/Clerk/Paths/PathsV1Environment.swift
+++ /dev/null
@@ -1,26 +0,0 @@
-//
-// PathsV1Environment.swift
-//
-//
-// Created by Mike Pitre on 2/10/24.
-//
-
-import Foundation
-import Get
-
-extension ClerkFAPI.V1Endpoint {
-
- var environment: EnvironmentEndpoint {
- EnvironmentEndpoint(path: path + "/environment")
- }
-
- struct EnvironmentEndpoint {
- /// Path: `v1/environment`
- let path: String
-
- var get: Request {
- .init(path: path)
- }
- }
-
-}
diff --git a/Sources/Clerk/Paths/PathsV1Me.swift b/Sources/Clerk/Paths/PathsV1Me.swift
deleted file mode 100644
index 9b7c08c9a..000000000
--- a/Sources/Clerk/Paths/PathsV1Me.swift
+++ /dev/null
@@ -1,34 +0,0 @@
-//
-// PathsV1Me.swift
-//
-//
-// Created by Mike Pitre on 2/10/24.
-//
-
-import Foundation
-import Get
-
-extension ClerkFAPI.V1Endpoint {
-
- var me: MeEndpoint {
- MeEndpoint(path: path + "/me")
- }
-
- struct MeEndpoint {
- /// Path: `v1/me`
- let path: String
-
- func get() -> Request> {
- .init(path: path)
- }
-
- func update(queryItems: [URLQueryItem] = [], body: any Encodable) -> Request> {
- .init(path: path, method: .patch, query: queryItems.asTuples, body: body)
- }
-
- func delete(queryItems: [URLQueryItem] = []) -> Request> {
- .init(path: path, method: .delete, query: queryItems.asTuples)
- }
- }
-
-}
diff --git a/Sources/Clerk/Paths/PathsV1MeChangePassword.swift b/Sources/Clerk/Paths/PathsV1MeChangePassword.swift
deleted file mode 100644
index 3a4c69828..000000000
--- a/Sources/Clerk/Paths/PathsV1MeChangePassword.swift
+++ /dev/null
@@ -1,26 +0,0 @@
-//
-// PathsV1MeChangePassword.swift
-//
-//
-// Created by Mike Pitre on 2/10/24.
-//
-
-import Foundation
-import Get
-
-extension ClerkFAPI.V1Endpoint.MeEndpoint {
-
- var changePassword: ChangePasswordEndpoint {
- ChangePasswordEndpoint(path: path + "/change_password")
- }
-
- struct ChangePasswordEndpoint {
- /// Path: `v1/me/change_password`
- let path: String
-
- func post(queryItems: [URLQueryItem] = [], body: any Encodable) -> Request> {
- .init(path: path, method: .post, query: queryItems.asTuples, body: body)
- }
-
- }
-}
diff --git a/Sources/Clerk/Paths/PathsV1MeEmailAddresses.swift b/Sources/Clerk/Paths/PathsV1MeEmailAddresses.swift
deleted file mode 100644
index 328dcfcd6..000000000
--- a/Sources/Clerk/Paths/PathsV1MeEmailAddresses.swift
+++ /dev/null
@@ -1,26 +0,0 @@
-//
-// PathsV1MeEmailAddresses.swift
-//
-//
-// Created by Mike Pitre on 2/10/24.
-//
-
-import Foundation
-import Get
-
-extension ClerkFAPI.V1Endpoint.MeEndpoint {
-
- var emailAddresses: EmailAddressesEndpoint {
- EmailAddressesEndpoint(path: path + "/email_addresses")
- }
-
- struct EmailAddressesEndpoint {
- /// Path: `v1/me/email_addresses`
- let path: String
-
- func post(queryItems: [URLQueryItem] = [], body: any Encodable) -> Request> {
- .init(path: path, method: .post, query: queryItems.asTuples, body: body)
- }
- }
-
-}
diff --git a/Sources/Clerk/Paths/PathsV1MeEmailAddressesWithID.swift b/Sources/Clerk/Paths/PathsV1MeEmailAddressesWithID.swift
deleted file mode 100644
index 836168974..000000000
--- a/Sources/Clerk/Paths/PathsV1MeEmailAddressesWithID.swift
+++ /dev/null
@@ -1,34 +0,0 @@
-//
-// PathsV1MeEmailAddressesWithID.swift
-//
-//
-// Created by Mike Pitre on 2/10/24.
-//
-
-import Foundation
-import Get
-
-extension ClerkFAPI.V1Endpoint.MeEndpoint.EmailAddressesEndpoint {
-
- func id(_ id: String) -> WithIdEndpoint {
- WithIdEndpoint(path: path + "/\(id)")
- }
-
- struct WithIdEndpoint {
- /// Path: `/v1/client/email_addresses/{id}`
- let path: String
-
- var get: Request> {
- .init(path: path)
- }
-
- func delete(queryItems: [URLQueryItem] = []) -> Request> {
- .init(
- path: path,
- method: .delete,
- query: queryItems.asTuples
- )
- }
- }
-
-}
diff --git a/Sources/Clerk/Paths/PathsV1MeEmailAddressesWithIDAttemptVerification.swift b/Sources/Clerk/Paths/PathsV1MeEmailAddressesWithIDAttemptVerification.swift
deleted file mode 100644
index 2ebe60784..000000000
--- a/Sources/Clerk/Paths/PathsV1MeEmailAddressesWithIDAttemptVerification.swift
+++ /dev/null
@@ -1,25 +0,0 @@
-//
-// PathsV1MeEmailAddressesWithIDAttemptVerification.swift
-//
-//
-// Created by Mike Pitre on 2/10/24.
-//
-
-import Foundation
-import Get
-
-extension ClerkFAPI.V1Endpoint.MeEndpoint.EmailAddressesEndpoint.WithIdEndpoint {
-
- var attemptVerification: AttemptVerificationEndpoint {
- AttemptVerificationEndpoint(path: path + "/attempt_verification")
- }
-
- struct AttemptVerificationEndpoint {
- /// Path: `v1/me/email_addresses/{id}/attempt_verification`
- let path: String
-
- func post(queryItems: [URLQueryItem] = [], body: Encodable) -> Request> {
- .init(path: path, method: .post, query: queryItems.asTuples, body: body)
- }
- }
-}
diff --git a/Sources/Clerk/Paths/PathsV1MeEmailAddressesWithIDPrepareVerification.swift b/Sources/Clerk/Paths/PathsV1MeEmailAddressesWithIDPrepareVerification.swift
deleted file mode 100644
index 02b93c838..000000000
--- a/Sources/Clerk/Paths/PathsV1MeEmailAddressesWithIDPrepareVerification.swift
+++ /dev/null
@@ -1,25 +0,0 @@
-//
-// PathsV1MeEmailAddressesWithIDPrepareVerification.swift
-//
-//
-// Created by Mike Pitre on 2/10/24.
-//
-
-import Foundation
-import Get
-
-extension ClerkFAPI.V1Endpoint.MeEndpoint.EmailAddressesEndpoint.WithIdEndpoint {
-
- var prepareVerification: PrepareVerificationEndpoint {
- PrepareVerificationEndpoint(path: path + "/prepare_verification")
- }
-
- struct PrepareVerificationEndpoint {
- /// Path: `v1/me/email_addresses/{id}/prepare_verification`
- let path: String
-
- func post(queryItems: [URLQueryItem] = [], body: any Encodable) -> Request> {
- .init(path: path, method: .post, query: queryItems.asTuples, body: body)
- }
- }
-}
diff --git a/Sources/Clerk/Paths/PathsV1MeExternalAccounts.swift b/Sources/Clerk/Paths/PathsV1MeExternalAccounts.swift
deleted file mode 100644
index 51d25c648..000000000
--- a/Sources/Clerk/Paths/PathsV1MeExternalAccounts.swift
+++ /dev/null
@@ -1,26 +0,0 @@
-//
-// PathsV1MeExternalAccounts.swift
-//
-//
-// Created by Mike Pitre on 2/10/24.
-//
-
-import Foundation
-import Get
-
-extension ClerkFAPI.V1Endpoint.MeEndpoint {
-
- var externalAccounts: ExternalAccountsEndpoint {
- ExternalAccountsEndpoint(path: path + "/external_accounts")
- }
-
- struct ExternalAccountsEndpoint {
- /// Path: `v1/me/external_accounts`
- let path: String
-
- func create(queryItems: [URLQueryItem] = [], body: any Encodable) -> Request> {
- .init(path: path, method: .post, query: queryItems.asTuples, body: body)
- }
- }
-
-}
diff --git a/Sources/Clerk/Paths/PathsV1MeExternalAccountsWithID.swift b/Sources/Clerk/Paths/PathsV1MeExternalAccountsWithID.swift
deleted file mode 100644
index bf78be16b..000000000
--- a/Sources/Clerk/Paths/PathsV1MeExternalAccountsWithID.swift
+++ /dev/null
@@ -1,30 +0,0 @@
-//
-// PathsV1MeExternalAccountsWithID.swift
-//
-//
-// Created by Mike Pitre on 2/10/24.
-//
-
-import Foundation
-import Get
-
-extension ClerkFAPI.V1Endpoint.MeEndpoint.ExternalAccountsEndpoint {
-
- func id(_ id: String) -> WithIdEndpoint {
- WithIdEndpoint(path: path + "/\(id)")
- }
-
- struct WithIdEndpoint {
- /// Path: `/v1/me/external_accounts/{id}`
- let path: String
-
- var get: Request> {
- return .init(path: path)
- }
-
- func delete(queryItems: [URLQueryItem] = []) -> Request> {
- .init(path: path, method: .delete, query: queryItems.asTuples)
- }
- }
-
-}
diff --git a/Sources/Clerk/Paths/PathsV1MePasskeys.swift b/Sources/Clerk/Paths/PathsV1MePasskeys.swift
deleted file mode 100644
index 8a234048a..000000000
--- a/Sources/Clerk/Paths/PathsV1MePasskeys.swift
+++ /dev/null
@@ -1,26 +0,0 @@
-//
-// PathsV1MePasskeys.swift
-// Clerk
-//
-// Created by Mike Pitre on 9/6/24.
-//
-
-import Foundation
-import Get
-
-extension ClerkFAPI.V1Endpoint.MeEndpoint {
-
- var passkeys: PasskeysEndpoint {
- PasskeysEndpoint(path: path + "/passkeys")
- }
-
- struct PasskeysEndpoint {
- /// Path: `/v1/me/passkeys`
- let path: String
-
- func post(queryItems: [URLQueryItem] = []) -> Request> {
- .init(path: path, method: .post, query: queryItems.asTuples)
- }
- }
-
-}
diff --git a/Sources/Clerk/Paths/PathsV1MePasskeysWithID.swift b/Sources/Clerk/Paths/PathsV1MePasskeysWithID.swift
deleted file mode 100644
index 27d7ed515..000000000
--- a/Sources/Clerk/Paths/PathsV1MePasskeysWithID.swift
+++ /dev/null
@@ -1,39 +0,0 @@
-//
-// PathsV1MePasskeysWithID.swift
-// Clerk
-//
-// Created by Mike Pitre on 9/6/24.
-//
-
-import Foundation
-import Get
-
-extension ClerkFAPI.V1Endpoint.MeEndpoint.PasskeysEndpoint {
-
- func withId(_ id: String) -> WithIdEndpoint {
- WithIdEndpoint(path: path + "/\(id)")
- }
-
- struct WithIdEndpoint {
- /// Path: `/v1/me/passkeys/{id}`
- let path: String
-
- func patch(queryItems: [URLQueryItem] = [], body: any Encodable) -> Request> {
- .init(
- path: path,
- method: .patch,
- query: queryItems.asTuples,
- body: body
- )
- }
-
- func delete(queryItems: [URLQueryItem] = []) -> Request> {
- .init(
- path: path,
- method: .delete,
- query: queryItems.asTuples
- )
- }
- }
-
-}
diff --git a/Sources/Clerk/Paths/PathsV1MePasskeysWithIDAttemptVerification.swift b/Sources/Clerk/Paths/PathsV1MePasskeysWithIDAttemptVerification.swift
deleted file mode 100644
index 2f1ca902d..000000000
--- a/Sources/Clerk/Paths/PathsV1MePasskeysWithIDAttemptVerification.swift
+++ /dev/null
@@ -1,26 +0,0 @@
-//
-// PathsV1MePasskeysWithIDAttemptVerification.swift
-// Clerk
-//
-// Created by Mike Pitre on 9/6/24.
-//
-
-import Foundation
-import Get
-
-extension ClerkFAPI.V1Endpoint.MeEndpoint.PasskeysEndpoint.WithIdEndpoint {
-
- var attemptVerification: AttemptVerificationEndpoint {
- AttemptVerificationEndpoint(path: path + "/attempt_verification")
- }
-
- struct AttemptVerificationEndpoint {
- /// Path: `/v1/me/passkeys/{id}`
- let path: String
-
- func post(queryItems: [URLQueryItem] = [], body: any Encodable) -> Request> {
- .init(path: path, method: .post, query: queryItems.asTuples, body: body)
- }
- }
-
-}
diff --git a/Sources/Clerk/Paths/PathsV1MePhoneNumbers.swift b/Sources/Clerk/Paths/PathsV1MePhoneNumbers.swift
deleted file mode 100644
index 9a28f6031..000000000
--- a/Sources/Clerk/Paths/PathsV1MePhoneNumbers.swift
+++ /dev/null
@@ -1,26 +0,0 @@
-//
-// PathsV1MePhoneNumbers.swift
-//
-//
-// Created by Mike Pitre on 2/10/24.
-//
-
-import Foundation
-import Get
-
-extension ClerkFAPI.V1Endpoint.MeEndpoint {
-
- var phoneNumbers: PhoneNumbersEndpoint {
- PhoneNumbersEndpoint(path: path + "/phone_numbers")
- }
-
- struct PhoneNumbersEndpoint {
- /// Path: `v1/me/phone_numbers`
- let path: String
-
- func post(queryItems: [URLQueryItem] = [], body: any Encodable) -> Request> {
- .init(path: path, method: .post, query: queryItems.asTuples, body: body)
- }
- }
-
-}
diff --git a/Sources/Clerk/Paths/PathsV1MePhoneNumbersWithID.swift b/Sources/Clerk/Paths/PathsV1MePhoneNumbersWithID.swift
deleted file mode 100644
index b86920883..000000000
--- a/Sources/Clerk/Paths/PathsV1MePhoneNumbersWithID.swift
+++ /dev/null
@@ -1,42 +0,0 @@
-//
-// PathsV1MePhoneNumbersWithID.swift
-//
-//
-// Created by Mike Pitre on 2/10/24.
-//
-
-import Foundation
-import Get
-
-extension ClerkFAPI.V1Endpoint.MeEndpoint.PhoneNumbersEndpoint {
-
- func id(_ id: String) -> WithID {
- WithID(path: path + "/\(id)")
- }
-
- struct WithID {
- /// Path: `/v1/me/phone_numbers/{id}`
- let path: String
-
- var get: Request