Build a custom flow for managing multi-factor authentication
Multi-factor verification (MFA) is an added layer of security that requires users to provide a second verification factor to access an account.
Clerk supports MFA through SMS verification code, Authenticator application, and Backup codes.
This guide will walk you through how to build a custom flow that allows users to manage their MFA settings:
SMS verification code
Enable multi-factor authentication
For your users to be able to enable MFA for their account, you need to enable MFA for your application.
- In the Clerk Dashboard, navigate to the Multi-factor page.
- For the purpose of this guide, toggle on both the SMS verification code and Backup codes strategies.
- Select Save.
Build the custom flow
This example consists of two pages:
- The main page where users can manage their SMS MFA settings
- The page where users can add a phone number to their account
Use the following tabs to view the code necessary for each page.
'use client'
import * as React from 'react'
import { useUser, useReverification, useClerk, useSession } from '@clerk/nextjs'
import { BackupCodeResource, PhoneNumberResource } from '@clerk/shared/types'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
// Display phone numbers reserved for MFA
const ManageMfaPhoneNumbers = () => {
const { isSignedIn, user } = useUser()
const clerk = useClerk()
// Handle signed out state
// If the user is trying to set up MFA, they should be able to access this page
if (!isSignedIn && clerk.session?.currentTask?.key !== 'setup-mfa')
return <p>You must be signed in to access this page</p>
// Check if any phone numbers are reserved for MFA
const mfaPhones = user?.phoneNumbers
.filter((ph) => ph.verification.status === 'verified')
.filter((ph) => ph.reservedForSecondFactor)
.sort((ph: PhoneNumberResource) => (ph.defaultSecondFactor ? -1 : 1))
if (!mfaPhones || mfaPhones.length === 0) {
return <p>There are currently no phone numbers reserved for MFA.</p>
}
return (
<>
<h2>Phone numbers reserved for MFA</h2>
<ul>
{mfaPhones.map((phone) => {
return (
<li key={phone.id} style={{ display: 'flex', gap: '10px' }}>
<p>
{phone.phoneNumber} {phone.defaultSecondFactor && '(Default)'}
</p>
<div>
<button onClick={() => phone.setReservedForSecondFactor({ reserved: false })}>
Disable for MFA
</button>
</div>
{!phone.defaultSecondFactor && (
<div>
<button onClick={() => phone.makeDefaultSecondFactor()}>Make default</button>
</div>
)}
<div>
<button onClick={() => phone.destroy()}>Remove from account</button>
</div>
</li>
)
})}
</ul>
</>
)
}
// Display phone numbers that are not reserved for MFA
const ManageAvailablePhoneNumbers = () => {
const { isSignedIn, user } = useUser()
const clerk = useClerk()
const router = useRouter()
const setReservedForSecondFactor = useReverification((phone: PhoneNumberResource) =>
phone.setReservedForSecondFactor({ reserved: true }),
)
const destroyPhone = useReverification((phone: PhoneNumberResource) => phone.destroy())
// Complete the session task when phone number is picked for MFA
const completeTask = async () => {
try {
await clerk.setActive({
session: clerk.session?.id,
navigate: async ({ session, decorateUrl }) => {
const url = decorateUrl('/')
if (url.startsWith('http')) {
window.location.href = url
} else {
router.push(url)
}
},
})
} catch (err) {
// See https://clerk.com/docs/guides/development/custom-flows/error-handling
// for more info on error handling
console.error(JSON.stringify(err, null, 2))
}
}
// Handle signed out state
// If the user is trying to set up MFA, they should be able to access this page
if (!isSignedIn && clerk.session?.currentTask?.key !== 'setup-mfa')
return <p>You must be signed in to access this page</p>
// Get verified phone numbers that aren't reserved for MFA
const availableForMfaPhones = user?.phoneNumbers
.filter((ph) => ph.verification.status === 'verified')
.filter((ph) => !ph.reservedForSecondFactor)
// Enable a phone number for MFA
const reservePhoneForMfa = async (phone: PhoneNumberResource) => {
try {
// Set the phone number as reserved for MFA
await setReservedForSecondFactor(phone)
if (clerk.session?.currentTask?.key === 'setup-mfa') completeTask()
// Refresh the user information to reflect changes
await user?.reload()
} catch (err) {
// See https://clerk.com/docs/guides/development/custom-flows/error-handling
// for more info on error handling
console.error(JSON.stringify(err, null, 2))
}
}
if (!availableForMfaPhones || availableForMfaPhones.length === 0) {
return <p>There are currently no verified phone numbers available to be reserved for MFA.</p>
}
return (
<>
<h2>Phone numbers that are not reserved for MFA</h2>
<ul>
{availableForMfaPhones.map((phone) => {
return (
<li key={phone.id} style={{ display: 'flex', gap: '10px' }}>
<p>{phone.phoneNumber}</p>
<div>
<button onClick={() => reservePhoneForMfa(phone)}>Use for MFA</button>
</div>
<div>
<button onClick={() => destroyPhone(phone)}>Remove from account</button>
</div>
</li>
)
})}
</ul>
</>
)
}
// Generate and display backup codes
function GenerateBackupCodes() {
const { user } = useUser()
const [backupCodes, setBackupCodes] = React.useState<BackupCodeResource | undefined>(undefined)
const createBackupCode = useReverification(() => user?.createBackupCode())
const [loading, setLoading] = React.useState(false)
React.useEffect(() => {
if (backupCodes) return
setLoading(true)
void createBackupCode()
.then((backupCode: BackupCodeResource | undefined) => {
if (backupCode) setBackupCodes(backupCode)
setLoading(false)
})
.catch((err) => {
// See https://clerk.com/docs/guides/development/custom-flows/error-handling
// for more info on error handling
console.error(JSON.stringify(err, null, 2))
setLoading(false)
})
}, [backupCodes, createBackupCode])
if (loading) return <p>Loading...</p>
if (!backupCodes) return <p>There was a problem generating backup codes</p>
return (
<ol>
{backupCodes.codes.map((code, index) => (
<li key={index}>{code}</li>
))}
</ol>
)
}
export default function ManageMFA() {
const [showBackupCodes, setShowBackupCodes] = React.useState(false)
const { isLoaded, isSignedIn, user } = useUser()
const { session } = useSession()
const clerk = useClerk()
const router = useRouter()
// Complete the session task when phone number is picked for MFA
const completeTask = async () => {
try {
await clerk.setActive({
session: clerk.session?.id,
navigate: async ({ session, decorateUrl }) => {
const url = decorateUrl('/')
if (url.startsWith('http')) {
window.location.href = url
} else {
router.push(url)
}
},
})
} catch (err) {
// See https://clerk.com/docs/guides/development/custom-flows/error-handling
// for more info on error handling
console.error(JSON.stringify(err, null, 2))
}
}
// Handle loading state
if (!isLoaded) return <p>Loading...</p>
// Handle signed out state
// If the user is trying to set up MFA, they should be able to access this page
if (!isSignedIn && session?.currentTask?.key !== 'setup-mfa')
return <p>You must be signed in to access this page</p>
return (
<>
<h1>User MFA Settings</h1>
{/* Manage SMS MFA */}
<ManageMfaPhoneNumbers />
<ManageAvailablePhoneNumbers />
<Link href="/account/add-phone">Add a new phone number</Link>
{/* Manage backup codes */}
{user?.twoFactorEnabled && (
<div>
<p>
Generate new backup codes? -{' '}
<button onClick={() => setShowBackupCodes(true)}>Generate</button>
</p>
</div>
)}
{showBackupCodes && (
<>
<GenerateBackupCodes />
<button
onClick={() => {
setShowBackupCodes(false)
completeTask()
}}
>
Done
</button>
</>
)}
</>
)
}'use client'
import * as React from 'react'
import { useSession, useUser, useReverification } from '@clerk/nextjs'
import { PhoneNumberResource } from '@clerk/shared/types'
export default function Page() {
const { isLoaded, isSignedIn, user } = useUser()
const { session } = useSession()
const createPhoneNumber = useReverification((phone: string) =>
user?.createPhoneNumber({ phoneNumber: phone }),
)
const [phone, setPhone] = React.useState('')
const [code, setCode] = React.useState('')
const [isVerifying, setIsVerifying] = React.useState(false)
const [successful, setSuccessful] = React.useState(false)
const [phoneObj, setPhoneObj] = React.useState<PhoneNumberResource | undefined>()
// Handle loading state
if (!isLoaded) return <p>Loading...</p>
// Handle signed-out state
// If the user is trying to set up MFA, they should be able to access this page
if (!isSignedIn && session?.currentTask?.key !== 'setup-mfa')
return <p>You must be signed in to access this page</p>
// Handle addition of the phone number
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
try {
// Add unverified phone number to user
const res = await createPhoneNumber(phone)
// Reload user to get updated User object
await user.reload()
// Create a reference to the new phone number to use related methods
const phoneNumber = user.phoneNumbers.find((a) => a.id === res?.id)
setPhoneObj(phoneNumber)
// Send the user an SMS with the verification code
phoneNumber?.prepareVerification()
// Set to true to display second form
// and capture the code
setIsVerifying(true)
} catch (err) {
// See https://clerk.com/docs/guides/development/custom-flows/error-handling
// for more info on error handling
console.error(JSON.stringify(err, null, 2))
}
}
// Handle the submission of the verification form
const verifyCode = async (e: React.FormEvent) => {
e.preventDefault()
try {
// Verify that the provided code matches the code sent to the user
const phoneVerifyAttempt = await phoneObj?.attemptVerification({ code })
if (phoneVerifyAttempt?.verification.status === 'verified') {
setSuccessful(true)
} else {
// If the status is not complete, check why. User may need to
// complete further steps.
console.error(JSON.stringify(phoneVerifyAttempt, null, 2))
}
} catch (err) {
console.error(JSON.stringify(err, null, 2))
}
}
// Display a success message if the phone number was added successfully
if (successful) return <h1>Phone added</h1>
// Display the verification form to capture the code
if (isVerifying) {
return (
<>
<h1>Verify phone</h1>
<div>
<form onSubmit={(e) => verifyCode(e)}>
<div>
<label htmlFor="code">Enter code</label>
<input
onChange={(e) => setCode(e.target.value)}
id="code"
name="code"
type="text"
value={code}
/>
</div>
<div>
<button type="submit">Verify</button>
</div>
</form>
</div>
</>
)
}
// Display the initial form to capture the phone number
return (
<>
<h1>Add phone</h1>
<div>
<form onSubmit={(e) => handleSubmit(e)}>
<div>
<label htmlFor="phone">Enter phone number</label>
<input
onChange={(e) => setPhone(e.target.value)}
id="phone"
name="phone"
type="phone"
value={phone}
/>
</div>
<div>
<button type="submit">Continue</button>
</div>
</form>
</div>
</>
)
}import { ThemedText } from '@/components/themed-text'
import { ThemedView } from '@/components/themed-view'
import { useUser } from '@clerk/expo'
import { PhoneNumberResource } from '@clerk/types'
import { Href, useRouter } from 'expo-router'
import * as React from 'react'
import { Pressable, StyleSheet, TextInput, TouchableOpacity } from 'react-native'
export default function Page() {
const { isLoaded, user } = useUser()
const router = useRouter()
const [phone, setPhone] = React.useState('')
const [code, setCode] = React.useState('')
const [isVerifying, setIsVerifying] = React.useState(false)
const [successful, setSuccessful] = React.useState(false)
const [phoneObj, setPhoneObj] = React.useState<PhoneNumberResource | undefined>()
// Handle loading state
if (!isLoaded) {
return (
<ThemedView style={styles.container}>
<ThemedText>Loading...</ThemedText>
</ThemedView>
)
}
// Step 1: Add the phone number to the user's User object
const handleSubmit = async () => {
try {
// Add unverified phone number to user
const res = await user?.createPhoneNumber({ phoneNumber: phone })
// Reload user to get updated User object
await user?.reload()
// Create a reference to the new phone number to use related methods
const phoneNumber = user?.phoneNumbers.find((a) => a.id === res?.id)
setPhoneObj(phoneNumber)
// Send the user an SMS with the verification code
await phoneNumber?.prepareVerification()
// Set to true to display second form
// and capture the code
setIsVerifying(true)
} catch (err) {
// See https://clerk.com/docs/guides/development/custom-flows/error-handling
// for more info on error handling
console.error(JSON.stringify(err, null, 2))
}
}
// Step 2: Verify the OTP the user supplied
const verifyCode = async () => {
try {
// Verify that the provided code matches the code sent to the user
const phoneVerifyAttempt = await phoneObj?.attemptVerification({ code })
if (phoneVerifyAttempt?.verification.status === 'verified') {
setSuccessful(true)
} else {
// If the status is not complete, check why. User may need to
// complete further steps.
console.error(JSON.stringify(phoneVerifyAttempt, null, 2))
}
} catch (err) {
console.error(JSON.stringify(err, null, 2))
}
}
// Step 3 UI: Display a success message if the phone number was added successfully
if (successful) {
return (
<ThemedView style={styles.container}>
<ThemedText type="title" style={styles.title}>
Phone added
</ThemedText>
<TouchableOpacity
style={[styles.button]}
onPress={() => router.replace('/(account)/manage-mfa' as Href)}
activeOpacity={0.85}
>
<ThemedText style={styles.buttonText}>Manage MFA</ThemedText>
</TouchableOpacity>
</ThemedView>
)
}
// Step 2 UI: Display the verification form to capture the code
if (isVerifying) {
return (
<ThemedView style={styles.container}>
<ThemedText type="title" style={styles.title}>
Verify phone
</ThemedText>
<ThemedText style={styles.label}>Enter code</ThemedText>
<TextInput
style={styles.input}
value={code}
placeholder="Enter code"
placeholderTextColor="#666666"
onChangeText={setCode}
keyboardType="numeric"
/>
<Pressable
style={({ pressed }) => [
styles.button,
!code && styles.buttonDisabled,
pressed && styles.buttonPressed,
]}
onPress={verifyCode}
disabled={!code}
>
<ThemedText style={styles.buttonText}>Verify</ThemedText>
</Pressable>
</ThemedView>
)
}
// Step 1 UI: Display the initial form to capture the phone number
return (
<ThemedView style={styles.container}>
<ThemedText type="title" style={styles.title}>
Add phone
</ThemedText>
<ThemedText style={styles.label}>Enter phone number</ThemedText>
<TextInput
style={styles.input}
value={phone}
placeholder="e.g +1234567890"
placeholderTextColor="#666666"
onChangeText={setPhone}
keyboardType="phone-pad"
/>
<Pressable
style={({ pressed }) => [
styles.button,
!phone && styles.buttonDisabled,
pressed && styles.buttonPressed,
]}
onPress={handleSubmit}
disabled={!phone}
>
<ThemedText style={styles.buttonText}>Continue</ThemedText>
</Pressable>
</ThemedView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 20,
gap: 12,
},
title: {
marginBottom: 8,
},
label: {
fontWeight: '600',
fontSize: 14,
},
input: {
borderWidth: 1,
borderColor: '#ccc',
borderRadius: 8,
padding: 12,
fontSize: 16,
backgroundColor: '#fff',
},
button: {
backgroundColor: '#0a7ea4',
paddingVertical: 12,
paddingHorizontal: 24,
borderRadius: 8,
alignItems: 'center',
marginTop: 8,
},
buttonPressed: {
opacity: 0.7,
},
buttonDisabled: {
opacity: 0.5,
},
buttonText: {
color: '#fff',
fontWeight: '600',
},
}) import SwiftUI
import ClerkKit
struct AddPhoneView: View {
@Environment(Clerk.self) private var clerk
@State private var phone = ""
@State private var code = ""
@State private var newPhoneNumber: PhoneNumber?
@State private var isVerified = false
var body: some View {
VStack(alignment: .leading, spacing: 16) {
if isVerified {
Text("Phone added!")
} else if let newPhoneNumber {
TextField("Enter code", text: $code)
Button("Verify") {
Task { await verifyCode(code, for: newPhoneNumber) }
}
} else {
TextField("Enter phone number", text: $phone)
Button("Continue") {
Task { await createPhone() }
}
.disabled(phone.isEmpty)
}
}
}
}
extension AddPhoneView {
private func createPhone() async {
do {
guard let user = clerk.user else { return }
// Create the phone number
let phoneNumber = try await user.createPhoneNumber(phone)
// Send the user an SMS with the verification code
newPhoneNumber = try await phoneNumber.sendCode()
} catch {
// See https://clerk.com/docs/guides/development/custom-flows/error-handling
// for more info on error handling
dump(error)
}
}
private func verifyCode(_ code: String, for phoneNumber: PhoneNumber) async {
do {
// Verify that the provided code matches the code sent to the user
newPhoneNumber = try await phoneNumber.verifyCode(code)
isVerified = true
} catch {
// See https://clerk.com/docs/guides/development/custom-flows/error-handling
// for more info on error handling
dump(error)
}
}
}package com.clerk.customflows.addphone
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.clerk.api.Clerk
import com.clerk.api.network.serialization.errorMessage
import com.clerk.api.network.serialization.flatMap
import com.clerk.api.network.serialization.onFailure
import com.clerk.api.network.serialization.onSuccess
import com.clerk.api.phonenumber.PhoneNumber
import com.clerk.api.phonenumber.attemptVerification
import com.clerk.api.phonenumber.prepareVerification
import com.clerk.api.user.createPhoneNumber
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.launch
class AddPhoneViewModel : ViewModel() {
private val _uiState = MutableStateFlow<UiState>(UiState.NeedsVerification)
val uiState = _uiState.asStateFlow()
init {
combine(Clerk.isInitialized, Clerk.userFlow) { isInitialized, user ->
_uiState.value =
when {
!isInitialized -> UiState.Loading
user == null -> UiState.SignedOut
else -> UiState.NeedsVerification
}
}
.launchIn(viewModelScope)
}
fun createPhoneNumber(phoneNumber: String) {
val user = requireNotNull(Clerk.userFlow.value)
// Add an unverified phone number to the user,
// then send the user an SMS with the verification code
viewModelScope.launch {
user
.createPhoneNumber(phoneNumber)
.flatMap { it.prepareVerification() }
.onSuccess {
// Update the state to show that the phone number has been created
// and that the user needs to verify the phone number
_uiState.value = UiState.Verifying(it)
}
.onFailure {
Log.e(
"AddPhoneViewModel",
"Failed to create phone number and prepare verification: ${it.errorMessage}",
)
}
}
}
fun verifyCode(code: String, newPhoneNumber: PhoneNumber) {
viewModelScope.launch {
newPhoneNumber
.attemptVerification(code)
.onSuccess {
// Update the state to show that the phone number has been verified
_uiState.value = UiState.Verified
}
.onFailure {
Log.e("AddPhoneViewModel", "Failed to verify phone number: ${it.errorMessage}")
}
}
}
sealed interface UiState {
data object Loading : UiState
data object NeedsVerification : UiState
data class Verifying(val phoneNumber: PhoneNumber) : UiState
data object Verified : UiState
data object SignedOut : UiState
}
}package com.clerk.customflows.addphone
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.clerk.api.phonenumber.PhoneNumber
class AddPhoneActivity : ComponentActivity() {
val viewModel: AddPhoneViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val state by viewModel.uiState.collectAsStateWithLifecycle()
AddPhoneView(
state = state,
onCreatePhoneNumber = viewModel::createPhoneNumber,
onVerifyCode = viewModel::verifyCode,
)
}
}
}
@Composable
fun AddPhoneView(
state: AddPhoneViewModel.UiState,
onCreatePhoneNumber: (String) -> Unit,
onVerifyCode: (String, PhoneNumber) -> Unit,
) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
when (state) {
AddPhoneViewModel.UiState.NeedsVerification -> {
InputContentView(buttonText = "Continue", placeholder = "Enter phone number") {
onCreatePhoneNumber(it)
}
}
AddPhoneViewModel.UiState.Verified -> Text("Verified!")
is AddPhoneViewModel.UiState.Verifying -> {
InputContentView(buttonText = "Verify", placeholder = "Enter code") {
onVerifyCode(it, state.phoneNumber)
}
}
AddPhoneViewModel.UiState.Loading -> CircularProgressIndicator()
AddPhoneViewModel.UiState.SignedOut -> Text("You must be signed in to add a phone number.")
}
}
}
@Composable
fun InputContentView(
buttonText: String,
placeholder: String,
modifier: Modifier = Modifier,
onClick: (String) -> Unit,
) {
var input by remember { mutableStateOf("") }
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier) {
TextField(
modifier = Modifier.padding(bottom = 16.dp),
value = input,
onValueChange = { input = it },
placeholder = { Text(placeholder) },
)
Button(onClick = { onClick(input) }) { Text(buttonText) }
}
}To allow users to configure their MFA settings, you'll create a basic dashboard.
This example consists of three pages:
- The layout page that checks if the user is signed in
- The page where users can manage their account, including their MFA settings
- The page where users can add a phone number to their account
Use the following tabs to view the code necessary for each page.
- Create the
(account)route group. This groups your/manage-mfapage and the "/add-phone" page. - Create a
_layout.tsxfile with the following code. The useAuth() hook is used to check if the user is signed in. If the user isn't signed in, they'll be redirected to the sign-in page. You check if the user has a pendingsetup-mfatask because if they're trying to access their account settings to set up MFA, they should be able to access these routes, so we don't want to redirect them to the sign-in page.
import { Redirect, Stack } from 'expo-router'
import { useAuth, useSession } from '@clerk/expo'
export default function AuthenticatedLayout() {
const { isSignedIn } = useAuth()
const { session } = useSession()
// If the user isn't signed in and they're not trying to set up MFA, redirect them to the sign-in page
if (!isSignedIn && session?.currentTask?.key !== 'setup-mfa') {
return <Redirect href={'/sign-in'} />
}
return <Stack />
}import * as React from 'react'
import {
View,
Text,
ScrollView,
TouchableOpacity,
StyleSheet,
ActivityIndicator,
} from 'react-native'
import { useUser, useReverification, useClerk } from '@clerk/expo'
import { BackupCodeResource, PhoneNumberResource } from '@clerk/types'
import { Href, useRouter } from 'expo-router'
// Display phone numbers reserved for MFA
const ManageMfaPhoneNumbers = () => {
const { user } = useUser()
// Check if any phone numbers are reserved for MFA
const mfaPhones = user?.phoneNumbers
.filter((ph) => ph.verification.status === 'verified')
.filter((ph) => ph.reservedForSecondFactor)
.sort((ph: PhoneNumberResource) => (ph.defaultSecondFactor ? -1 : 1))
if (!mfaPhones || mfaPhones.length === 0) {
return (
<Text style={styles.infoText}>There are currently no phone numbers reserved for MFA.</Text>
)
}
return (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Phone numbers reserved for MFA</Text>
{mfaPhones.map((phone) => {
return (
<View key={phone.id} style={styles.phoneItem}>
<View style={styles.phoneInfo}>
<Text style={styles.phoneNumber}>
{phone.phoneNumber}{' '}
{phone.defaultSecondFactor && <Text style={styles.badge}>(Default)</Text>}
</Text>
</View>
<View style={styles.buttonGroup}>
<TouchableOpacity
style={[styles.button, styles.secondaryButton]}
onPress={() => phone.setReservedForSecondFactor({ reserved: false })}
>
<Text style={styles.secondaryButtonText}>Disable for MFA</Text>
</TouchableOpacity>
{!phone.defaultSecondFactor && (
<TouchableOpacity
style={[styles.button, styles.primaryButton]}
onPress={() => phone.makeDefaultSecondFactor()}
>
<Text style={styles.primaryButtonText}>Make default</Text>
</TouchableOpacity>
)}
<TouchableOpacity
style={[styles.button, styles.dangerButton]}
onPress={() => phone.destroy()}
>
<Text style={styles.dangerButtonText}>Remove</Text>
</TouchableOpacity>
</View>
</View>
)
})}
</View>
)
}
// Display phone numbers that are not reserved for MFA
const ManageAvailablePhoneNumbers = () => {
const { user } = useUser()
const clerk = useClerk()
const setReservedForSecondFactor = useReverification((phone: PhoneNumberResource) =>
phone.setReservedForSecondFactor({ reserved: true }),
)
const destroyPhone = useReverification((phone: PhoneNumberResource) => phone.destroy())
// Complete the session task when phone number is picked for MFA
const completeTask = async () => {
try {
await clerk.setActive({ session: clerk.session?.id })
} catch (err) {
// See https://clerk.com/docs/guides/development/custom-flows/error-handling
// for more info on error handling
console.error(JSON.stringify(err, null, 2))
}
}
// Get verified phone numbers that aren't reserved for MFA
const availableForMfaPhones = user?.phoneNumbers
.filter((ph) => ph.verification.status === 'verified')
.filter((ph) => !ph.reservedForSecondFactor)
// Enable a phone number for MFA
const reservePhoneForMfa = async (phone: PhoneNumberResource) => {
try {
// Set the phone number as reserved for MFA
await setReservedForSecondFactor(phone)
if (clerk.session?.currentTask?.key === 'setup-mfa') completeTask()
// Refresh the user information to reflect changes
await user?.reload()
} catch (err) {
// See https://clerk.com/docs/guides/development/custom-flows/error-handling
// for more info on error handling
console.error(JSON.stringify(err, null, 2))
}
}
if (!availableForMfaPhones || availableForMfaPhones.length === 0) {
return (
<Text style={styles.infoText}>
There are currently no verified phone numbers available to be reserved for MFA.
</Text>
)
}
return (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Phone numbers not reserved for MFA</Text>
{availableForMfaPhones.map((phone) => {
return (
<View key={phone.id} style={styles.phoneItem}>
<View style={styles.phoneInfo}>
<Text style={styles.phoneNumber}>{phone.phoneNumber}</Text>
</View>
<View style={styles.buttonGroup}>
<TouchableOpacity
style={[styles.button, styles.primaryButton]}
onPress={() => reservePhoneForMfa(phone)}
>
<Text style={styles.primaryButtonText}>Use for MFA</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, styles.dangerButton]}
onPress={() => destroyPhone(phone)}
>
<Text style={styles.dangerButtonText}>Remove</Text>
</TouchableOpacity>
</View>
</View>
)
})}
</View>
)
}
// Generate and display backup codes
function GenerateBackupCodes() {
const { user } = useUser()
const [backupCodes, setBackupCodes] = React.useState<BackupCodeResource | undefined>(undefined)
const createBackupCode = useReverification(() => user?.createBackupCode())
const [loading, setLoading] = React.useState(false)
React.useEffect(() => {
if (backupCodes) return
setLoading(true)
void createBackupCode()
.then((backupCode: BackupCodeResource | undefined) => {
if (backupCode) setBackupCodes(backupCode)
setLoading(false)
})
.catch((err) => {
// See https://clerk.com/docs/guides/development/custom-flows/error-handling
// for more info on error handling
console.error(JSON.stringify(err, null, 2))
setLoading(false)
})
}, [backupCodes, createBackupCode])
if (loading)
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#6366f1" />
<Text style={styles.loadingText}>Generating backup codes...</Text>
</View>
)
if (!backupCodes)
return <Text style={styles.warningText}>There was a problem generating backup codes</Text>
return (
<View style={styles.backupCodesContainer}>
<Text style={styles.backupCodesTitle}>Save these backup codes:</Text>
{backupCodes.codes.map((code, index) => (
<View key={index} style={styles.backupCodeItem}>
<Text style={styles.backupCodeNumber}>{index + 1}.</Text>
<Text style={styles.backupCode}>{code}</Text>
</View>
))}
</View>
)
}
export default function ManageMFA() {
const [showBackupCodes, setShowBackupCodes] = React.useState(false)
const { isLoaded, user } = useUser()
const router = useRouter()
// Handle loading state
if (!isLoaded)
return (
<View style={styles.container}>
<ActivityIndicator size="large" color="#6366f1" />
<Text style={styles.loadingText}>Loading...</Text>
</View>
)
return (
<ScrollView style={styles.container} contentContainerStyle={styles.contentContainer}>
<Text style={styles.title}>User MFA Settings</Text>
{/* Manage SMS MFA */}
<ManageMfaPhoneNumbers />
<ManageAvailablePhoneNumbers />
<TouchableOpacity
style={[styles.button, styles.primaryButton, styles.linkButton]}
onPress={() => router.push('/(account)/add-phone' as Href)}
activeOpacity={0.85}
>
<Text style={styles.primaryButtonText}>Add a new phone number</Text>
</TouchableOpacity>
{/* Manage backup codes */}
{user?.twoFactorEnabled && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Backup Codes</Text>
{!showBackupCodes ? (
<View style={styles.backupPrompt}>
<Text style={styles.infoText}>Generate new backup codes for account recovery</Text>
<TouchableOpacity
style={[styles.button, styles.primaryButton]}
onPress={() => setShowBackupCodes(true)}
>
<Text style={styles.primaryButtonText}>Generate Backup Codes</Text>
</TouchableOpacity>
</View>
) : (
<View>
<GenerateBackupCodes />
<TouchableOpacity
style={[styles.button, styles.successButton, styles.doneButton]}
onPress={() => setShowBackupCodes(false)}
>
<Text style={styles.successButtonText}>Done</Text>
</TouchableOpacity>
</View>
)}
</View>
)}
</ScrollView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f9fafb',
},
contentContainer: {
padding: 20,
},
title: {
fontSize: 28,
fontWeight: 'bold',
color: '#111827',
marginBottom: 24,
},
section: {
marginBottom: 24,
},
sectionTitle: {
fontSize: 20,
fontWeight: '600',
color: '#374151',
marginBottom: 12,
},
phoneItem: {
backgroundColor: '#ffffff',
borderRadius: 12,
padding: 16,
marginBottom: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 3,
elevation: 2,
},
phoneInfo: {
marginBottom: 12,
},
phoneNumber: {
fontSize: 16,
color: '#111827',
fontWeight: '500',
},
badge: {
color: '#6366f1',
fontWeight: '600',
},
buttonGroup: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
},
button: {
paddingVertical: 10,
paddingHorizontal: 16,
borderRadius: 8,
alignItems: 'center',
justifyContent: 'center',
},
primaryButton: {
backgroundColor: '#6366f1',
},
primaryButtonText: {
color: '#ffffff',
fontSize: 14,
fontWeight: '600',
},
secondaryButton: {
backgroundColor: '#f3f4f6',
borderWidth: 1,
borderColor: '#d1d5db',
},
secondaryButtonText: {
color: '#374151',
fontSize: 14,
fontWeight: '600',
},
dangerButton: {
backgroundColor: '#fef2f2',
borderWidth: 1,
borderColor: '#fecaca',
},
dangerButtonText: {
color: '#dc2626',
fontSize: 14,
fontWeight: '600',
},
successButton: {
backgroundColor: '#10b981',
},
successButtonText: {
color: '#ffffff',
fontSize: 14,
fontWeight: '600',
},
linkButton: {
marginTop: 8,
alignSelf: 'stretch',
},
infoText: {
fontSize: 14,
color: '#6b7280',
marginBottom: 12,
},
warningText: {
fontSize: 14,
color: '#dc2626',
textAlign: 'center',
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
fontSize: 14,
color: '#6b7280',
marginTop: 12,
},
backupCodesContainer: {
backgroundColor: '#ffffff',
borderRadius: 12,
padding: 16,
marginBottom: 16,
},
backupCodesTitle: {
fontSize: 16,
fontWeight: '600',
color: '#111827',
marginBottom: 12,
},
backupCodeItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 8,
paddingHorizontal: 12,
backgroundColor: '#f9fafb',
borderRadius: 6,
marginBottom: 6,
},
backupCodeNumber: {
fontSize: 14,
fontWeight: '600',
color: '#6b7280',
width: 24,
},
backupCode: {
fontSize: 16,
fontFamily: 'monospace',
color: '#111827',
fontWeight: '500',
},
backupPrompt: {
backgroundColor: '#ffffff',
borderRadius: 12,
padding: 16,
},
doneButton: {
marginTop: 16,
},
})'use client'
import * as React from 'react'
import { useSession, useUser, useReverification } from '@clerk/nextjs'
import { PhoneNumberResource } from '@clerk/shared/types'
export default function Page() {
const { isLoaded, isSignedIn, user } = useUser()
const { session } = useSession()
const createPhoneNumber = useReverification((phone: string) =>
user?.createPhoneNumber({ phoneNumber: phone }),
)
const [phone, setPhone] = React.useState('')
const [code, setCode] = React.useState('')
const [isVerifying, setIsVerifying] = React.useState(false)
const [successful, setSuccessful] = React.useState(false)
const [phoneObj, setPhoneObj] = React.useState<PhoneNumberResource | undefined>()
// Handle loading state
if (!isLoaded) return <p>Loading...</p>
// Handle signed-out state
// If the user is trying to set up MFA, they should be able to access this page
if (!isSignedIn && session?.currentTask?.key !== 'setup-mfa')
return <p>You must be signed in to access this page</p>
// Handle addition of the phone number
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
try {
// Add unverified phone number to user
const res = await createPhoneNumber(phone)
// Reload user to get updated User object
await user.reload()
// Create a reference to the new phone number to use related methods
const phoneNumber = user.phoneNumbers.find((a) => a.id === res?.id)
setPhoneObj(phoneNumber)
// Send the user an SMS with the verification code
phoneNumber?.prepareVerification()
// Set to true to display second form
// and capture the code
setIsVerifying(true)
} catch (err) {
// See https://clerk.com/docs/guides/development/custom-flows/error-handling
// for more info on error handling
console.error(JSON.stringify(err, null, 2))
}
}
// Handle the submission of the verification form
const verifyCode = async (e: React.FormEvent) => {
e.preventDefault()
try {
// Verify that the provided code matches the code sent to the user
const phoneVerifyAttempt = await phoneObj?.attemptVerification({ code })
if (phoneVerifyAttempt?.verification.status === 'verified') {
setSuccessful(true)
} else {
// If the status is not complete, check why. User may need to
// complete further steps.
console.error(JSON.stringify(phoneVerifyAttempt, null, 2))
}
} catch (err) {
console.error(JSON.stringify(err, null, 2))
}
}
// Display a success message if the phone number was added successfully
if (successful) return <h1>Phone added</h1>
// Display the verification form to capture the code
if (isVerifying) {
return (
<>
<h1>Verify phone</h1>
<div>
<form onSubmit={(e) => verifyCode(e)}>
<div>
<label htmlFor="code">Enter code</label>
<input
onChange={(e) => setCode(e.target.value)}
id="code"
name="code"
type="text"
value={code}
/>
</div>
<div>
<button type="submit">Verify</button>
</div>
</form>
</div>
</>
)
}
// Display the initial form to capture the phone number
return (
<>
<h1>Add phone</h1>
<div>
<form onSubmit={(e) => handleSubmit(e)}>
<div>
<label htmlFor="phone">Enter phone number</label>
<input
onChange={(e) => setPhone(e.target.value)}
id="phone"
name="phone"
type="phone"
value={phone}
/>
</div>
<div>
<button type="submit">Continue</button>
</div>
</form>
</div>
</>
)
}import { ThemedText } from '@/components/themed-text'
import { ThemedView } from '@/components/themed-view'
import { useUser } from '@clerk/expo'
import { PhoneNumberResource } from '@clerk/types'
import { Href, useRouter } from 'expo-router'
import * as React from 'react'
import { Pressable, StyleSheet, TextInput, TouchableOpacity } from 'react-native'
export default function Page() {
const { isLoaded, user } = useUser()
const router = useRouter()
const [phone, setPhone] = React.useState('')
const [code, setCode] = React.useState('')
const [isVerifying, setIsVerifying] = React.useState(false)
const [successful, setSuccessful] = React.useState(false)
const [phoneObj, setPhoneObj] = React.useState<PhoneNumberResource | undefined>()
// Handle loading state
if (!isLoaded) {
return (
<ThemedView style={styles.container}>
<ThemedText>Loading...</ThemedText>
</ThemedView>
)
}
// Step 1: Add the phone number to the user's User object
const handleSubmit = async () => {
try {
// Add unverified phone number to user
const res = await user?.createPhoneNumber({ phoneNumber: phone })
// Reload user to get updated User object
await user?.reload()
// Create a reference to the new phone number to use related methods
const phoneNumber = user?.phoneNumbers.find((a) => a.id === res?.id)
setPhoneObj(phoneNumber)
// Send the user an SMS with the verification code
await phoneNumber?.prepareVerification()
// Set to true to display second form
// and capture the code
setIsVerifying(true)
} catch (err) {
// See https://clerk.com/docs/guides/development/custom-flows/error-handling
// for more info on error handling
console.error(JSON.stringify(err, null, 2))
}
}
// Step 2: Verify the OTP the user supplied
const verifyCode = async () => {
try {
// Verify that the provided code matches the code sent to the user
const phoneVerifyAttempt = await phoneObj?.attemptVerification({ code })
if (phoneVerifyAttempt?.verification.status === 'verified') {
setSuccessful(true)
} else {
// If the status is not complete, check why. User may need to
// complete further steps.
console.error(JSON.stringify(phoneVerifyAttempt, null, 2))
}
} catch (err) {
console.error(JSON.stringify(err, null, 2))
}
}
// Step 3 UI: Display a success message if the phone number was added successfully
if (successful) {
return (
<ThemedView style={styles.container}>
<ThemedText type="title" style={styles.title}>
Phone added
</ThemedText>
<TouchableOpacity
style={[styles.button]}
onPress={() => router.replace('/(account)/manage-mfa' as Href)}
activeOpacity={0.85}
>
<ThemedText style={styles.buttonText}>Manage MFA</ThemedText>
</TouchableOpacity>
</ThemedView>
)
}
// Step 2 UI: Display the verification form to capture the code
if (isVerifying) {
return (
<ThemedView style={styles.container}>
<ThemedText type="title" style={styles.title}>
Verify phone
</ThemedText>
<ThemedText style={styles.label}>Enter code</ThemedText>
<TextInput
style={styles.input}
value={code}
placeholder="Enter code"
placeholderTextColor="#666666"
onChangeText={setCode}
keyboardType="numeric"
/>
<Pressable
style={({ pressed }) => [
styles.button,
!code && styles.buttonDisabled,
pressed && styles.buttonPressed,
]}
onPress={verifyCode}
disabled={!code}
>
<ThemedText style={styles.buttonText}>Verify</ThemedText>
</Pressable>
</ThemedView>
)
}
// Step 1 UI: Display the initial form to capture the phone number
return (
<ThemedView style={styles.container}>
<ThemedText type="title" style={styles.title}>
Add phone
</ThemedText>
<ThemedText style={styles.label}>Enter phone number</ThemedText>
<TextInput
style={styles.input}
value={phone}
placeholder="e.g +1234567890"
placeholderTextColor="#666666"
onChangeText={setPhone}
keyboardType="phone-pad"
/>
<Pressable
style={({ pressed }) => [
styles.button,
!phone && styles.buttonDisabled,
pressed && styles.buttonPressed,
]}
onPress={handleSubmit}
disabled={!phone}
>
<ThemedText style={styles.buttonText}>Continue</ThemedText>
</Pressable>
</ThemedView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 20,
gap: 12,
},
title: {
marginBottom: 8,
},
label: {
fontWeight: '600',
fontSize: 14,
},
input: {
borderWidth: 1,
borderColor: '#ccc',
borderRadius: 8,
padding: 12,
fontSize: 16,
backgroundColor: '#fff',
},
button: {
backgroundColor: '#0a7ea4',
paddingVertical: 12,
paddingHorizontal: 24,
borderRadius: 8,
alignItems: 'center',
marginTop: 8,
},
buttonPressed: {
opacity: 0.7,
},
buttonDisabled: {
opacity: 0.5,
},
buttonText: {
color: '#fff',
fontWeight: '600',
},
}) import SwiftUI
import ClerkKit
struct AddPhoneView: View {
@Environment(Clerk.self) private var clerk
@State private var phone = ""
@State private var code = ""
@State private var newPhoneNumber: PhoneNumber?
@State private var isVerified = false
var body: some View {
VStack(alignment: .leading, spacing: 16) {
if isVerified {
Text("Phone added!")
} else if let newPhoneNumber {
TextField("Enter code", text: $code)
Button("Verify") {
Task { await verifyCode(code, for: newPhoneNumber) }
}
} else {
TextField("Enter phone number", text: $phone)
Button("Continue") {
Task { await createPhone() }
}
.disabled(phone.isEmpty)
}
}
}
}
extension AddPhoneView {
private func createPhone() async {
do {
guard let user = clerk.user else { return }
// Create the phone number
let phoneNumber = try await user.createPhoneNumber(phone)
// Send the user an SMS with the verification code
newPhoneNumber = try await phoneNumber.sendCode()
} catch {
// See https://clerk.com/docs/guides/development/custom-flows/error-handling
// for more info on error handling
dump(error)
}
}
private func verifyCode(_ code: String, for phoneNumber: PhoneNumber) async {
do {
// Verify that the provided code matches the code sent to the user
newPhoneNumber = try await phoneNumber.verifyCode(code)
isVerified = true
} catch {
// See https://clerk.com/docs/guides/development/custom-flows/error-handling
// for more info on error handling
dump(error)
}
}
}package com.clerk.customflows.addphone
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.clerk.api.Clerk
import com.clerk.api.network.serialization.errorMessage
import com.clerk.api.network.serialization.flatMap
import com.clerk.api.network.serialization.onFailure
import com.clerk.api.network.serialization.onSuccess
import com.clerk.api.phonenumber.PhoneNumber
import com.clerk.api.phonenumber.attemptVerification
import com.clerk.api.phonenumber.prepareVerification
import com.clerk.api.user.createPhoneNumber
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.launch
class AddPhoneViewModel : ViewModel() {
private val _uiState = MutableStateFlow<UiState>(UiState.NeedsVerification)
val uiState = _uiState.asStateFlow()
init {
combine(Clerk.isInitialized, Clerk.userFlow) { isInitialized, user ->
_uiState.value =
when {
!isInitialized -> UiState.Loading
user == null -> UiState.SignedOut
else -> UiState.NeedsVerification
}
}
.launchIn(viewModelScope)
}
fun createPhoneNumber(phoneNumber: String) {
val user = requireNotNull(Clerk.userFlow.value)
// Add an unverified phone number to the user,
// then send the user an SMS with the verification code
viewModelScope.launch {
user
.createPhoneNumber(phoneNumber)
.flatMap { it.prepareVerification() }
.onSuccess {
// Update the state to show that the phone number has been created
// and that the user needs to verify the phone number
_uiState.value = UiState.Verifying(it)
}
.onFailure {
Log.e(
"AddPhoneViewModel",
"Failed to create phone number and prepare verification: ${it.errorMessage}",
)
}
}
}
fun verifyCode(code: String, newPhoneNumber: PhoneNumber) {
viewModelScope.launch {
newPhoneNumber
.attemptVerification(code)
.onSuccess {
// Update the state to show that the phone number has been verified
_uiState.value = UiState.Verified
}
.onFailure {
Log.e("AddPhoneViewModel", "Failed to verify phone number: ${it.errorMessage}")
}
}
}
sealed interface UiState {
data object Loading : UiState
data object NeedsVerification : UiState
data class Verifying(val phoneNumber: PhoneNumber) : UiState
data object Verified : UiState
data object SignedOut : UiState
}
}package com.clerk.customflows.addphone
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.clerk.api.phonenumber.PhoneNumber
class AddPhoneActivity : ComponentActivity() {
val viewModel: AddPhoneViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val state by viewModel.uiState.collectAsStateWithLifecycle()
AddPhoneView(
state = state,
onCreatePhoneNumber = viewModel::createPhoneNumber,
onVerifyCode = viewModel::verifyCode,
)
}
}
}
@Composable
fun AddPhoneView(
state: AddPhoneViewModel.UiState,
onCreatePhoneNumber: (String) -> Unit,
onVerifyCode: (String, PhoneNumber) -> Unit,
) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
when (state) {
AddPhoneViewModel.UiState.NeedsVerification -> {
InputContentView(buttonText = "Continue", placeholder = "Enter phone number") {
onCreatePhoneNumber(it)
}
}
AddPhoneViewModel.UiState.Verified -> Text("Verified!")
is AddPhoneViewModel.UiState.Verifying -> {
InputContentView(buttonText = "Verify", placeholder = "Enter code") {
onVerifyCode(it, state.phoneNumber)
}
}
AddPhoneViewModel.UiState.Loading -> CircularProgressIndicator()
AddPhoneViewModel.UiState.SignedOut -> Text("You must be signed in to add a phone number.")
}
}
}
@Composable
fun InputContentView(
buttonText: String,
placeholder: String,
modifier: Modifier = Modifier,
onClick: (String) -> Unit,
) {
var input by remember { mutableStateOf("") }
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier) {
TextField(
modifier = Modifier.padding(bottom = 16.dp),
value = input,
onValueChange = { input = it },
placeholder = { Text(placeholder) },
)
Button(onClick = { onClick(input) }) { Text(buttonText) }
}
}This example consists of two SwiftUI views:
- A view where users can manage SMS-based MFA and regenerate backup codes
- A view where users can add and verify a phone number
Use the following tabs to view the code necessary for each view.
import SwiftUI
import ClerkKit
struct ManageSMSMFAView: View {
@Environment(Clerk.self) private var clerk
@State private var generatedBackupCodes = [String]()
private var mfaPhoneNumbers: [PhoneNumber] {
(clerk.user?.phoneNumbers ?? [])
.filter { $0.verification?.status == .verified && $0.reservedForSecondFactor }
.sorted { lhs, rhs in
if lhs.defaultSecondFactor == rhs.defaultSecondFactor {
return lhs.createdAt < rhs.createdAt
}
return lhs.defaultSecondFactor && !rhs.defaultSecondFactor
}
}
private var availablePhoneNumbers: [PhoneNumber] {
(clerk.user?.phoneNumbers ?? [])
.filter { $0.verification?.status == .verified && !$0.reservedForSecondFactor }
}
var body: some View {
List {
Section("Phone numbers reserved for MFA") {
if mfaPhoneNumbers.isEmpty {
Text("There are currently no phone numbers reserved for MFA.")
} else {
ForEach(mfaPhoneNumbers) { phoneNumber in
VStack(alignment: .leading, spacing: 8) {
Text(phoneNumber.phoneNumber)
if phoneNumber.defaultSecondFactor {
Text("Default")
}
HStack {
Button("Disable for MFA") {
Task { await disableMfa(for: phoneNumber) }
}
if clerk.user?.totpEnabled != true && !phoneNumber.defaultSecondFactor {
Button("Make default") {
Task { await makeDefaultSecondFactor(phoneNumber) }
}
}
Button("Remove from account", role: .destructive) {
Task { await removePhoneNumber(phoneNumber) }
}
}
.buttonStyle(.borderless)
}
}
}
}
Section("Verified phone numbers available for MFA") {
if availablePhoneNumbers.isEmpty {
Text("There are currently no verified phone numbers available to reserve for MFA.")
} else {
ForEach(availablePhoneNumbers) { phoneNumber in
VStack(alignment: .leading, spacing: 8) {
Text(phoneNumber.phoneNumber)
HStack {
Button("Use for MFA") {
Task { await reserveForSecondFactor(phoneNumber) }
}
Button("Remove from account", role: .destructive) {
Task { await removePhoneNumber(phoneNumber) }
}
}
.buttonStyle(.borderless)
}
}
}
NavigationLink("Add a phone number") {
AddPhoneView()
}
}
if clerk.user?.twoFactorEnabled == true {
Section("Backup codes") {
if generatedBackupCodes.isEmpty {
Button("Generate new backup codes") {
Task { await regenerateBackupCodes() }
}
} else {
ForEach(generatedBackupCodes, id: \.self) { code in
Text(code)
}
Button("Done") {
generatedBackupCodes = []
}
}
}
}
}
.navigationTitle("Manage SMS MFA")
}
private func reserveForSecondFactor(_ phoneNumber: PhoneNumber) async {
do {
let reservedPhoneNumber = try await phoneNumber.setReservedForSecondFactor()
generatedBackupCodes = reservedPhoneNumber.backupCodes ?? []
} catch {
// See https://clerk.com/docs/guides/development/custom-flows/error-handling
// for more info on error handling
dump(error)
}
}
private func disableMfa(for phoneNumber: PhoneNumber) async {
do {
try await phoneNumber.setReservedForSecondFactor(reserved: false)
} catch {
// See https://clerk.com/docs/guides/development/custom-flows/error-handling
// for more info on error handling
dump(error)
}
}
private func makeDefaultSecondFactor(_ phoneNumber: PhoneNumber) async {
do {
try await phoneNumber.makeDefaultSecondFactor()
} catch {
// See https://clerk.com/docs/guides/development/custom-flows/error-handling
// for more info on error handling
dump(error)
}
}
private func removePhoneNumber(_ phoneNumber: PhoneNumber) async {
do {
try await phoneNumber.delete()
} catch {
// See https://clerk.com/docs/guides/development/custom-flows/error-handling
// for more info on error handling
dump(error)
}
}
private func regenerateBackupCodes() async {
do {
guard let user = clerk.user else { return }
generatedBackupCodes = try await user.createBackupCodes().codes
} catch {
// See https://clerk.com/docs/guides/development/custom-flows/error-handling
// for more info on error handling
dump(error)
}
}
}'use client'
import * as React from 'react'
import { useSession, useUser, useReverification } from '@clerk/nextjs'
import { PhoneNumberResource } from '@clerk/shared/types'
export default function Page() {
const { isLoaded, isSignedIn, user } = useUser()
const { session } = useSession()
const createPhoneNumber = useReverification((phone: string) =>
user?.createPhoneNumber({ phoneNumber: phone }),
)
const [phone, setPhone] = React.useState('')
const [code, setCode] = React.useState('')
const [isVerifying, setIsVerifying] = React.useState(false)
const [successful, setSuccessful] = React.useState(false)
const [phoneObj, setPhoneObj] = React.useState<PhoneNumberResource | undefined>()
// Handle loading state
if (!isLoaded) return <p>Loading...</p>
// Handle signed-out state
// If the user is trying to set up MFA, they should be able to access this page
if (!isSignedIn && session?.currentTask?.key !== 'setup-mfa')
return <p>You must be signed in to access this page</p>
// Handle addition of the phone number
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
try {
// Add unverified phone number to user
const res = await createPhoneNumber(phone)
// Reload user to get updated User object
await user.reload()
// Create a reference to the new phone number to use related methods
const phoneNumber = user.phoneNumbers.find((a) => a.id === res?.id)
setPhoneObj(phoneNumber)
// Send the user an SMS with the verification code
phoneNumber?.prepareVerification()
// Set to true to display second form
// and capture the code
setIsVerifying(true)
} catch (err) {
// See https://clerk.com/docs/guides/development/custom-flows/error-handling
// for more info on error handling
console.error(JSON.stringify(err, null, 2))
}
}
// Handle the submission of the verification form
const verifyCode = async (e: React.FormEvent) => {
e.preventDefault()
try {
// Verify that the provided code matches the code sent to the user
const phoneVerifyAttempt = await phoneObj?.attemptVerification({ code })
if (phoneVerifyAttempt?.verification.status === 'verified') {
setSuccessful(true)
} else {
// If the status is not complete, check why. User may need to
// complete further steps.
console.error(JSON.stringify(phoneVerifyAttempt, null, 2))
}
} catch (err) {
console.error(JSON.stringify(err, null, 2))
}
}
// Display a success message if the phone number was added successfully
if (successful) return <h1>Phone added</h1>
// Display the verification form to capture the code
if (isVerifying) {
return (
<>
<h1>Verify phone</h1>
<div>
<form onSubmit={(e) => verifyCode(e)}>
<div>
<label htmlFor="code">Enter code</label>
<input
onChange={(e) => setCode(e.target.value)}
id="code"
name="code"
type="text"
value={code}
/>
</div>
<div>
<button type="submit">Verify</button>
</div>
</form>
</div>
</>
)
}
// Display the initial form to capture the phone number
return (
<>
<h1>Add phone</h1>
<div>
<form onSubmit={(e) => handleSubmit(e)}>
<div>
<label htmlFor="phone">Enter phone number</label>
<input
onChange={(e) => setPhone(e.target.value)}
id="phone"
name="phone"
type="phone"
value={phone}
/>
</div>
<div>
<button type="submit">Continue</button>
</div>
</form>
</div>
</>
)
}import { ThemedText } from '@/components/themed-text'
import { ThemedView } from '@/components/themed-view'
import { useUser } from '@clerk/expo'
import { PhoneNumberResource } from '@clerk/types'
import { Href, useRouter } from 'expo-router'
import * as React from 'react'
import { Pressable, StyleSheet, TextInput, TouchableOpacity } from 'react-native'
export default function Page() {
const { isLoaded, user } = useUser()
const router = useRouter()
const [phone, setPhone] = React.useState('')
const [code, setCode] = React.useState('')
const [isVerifying, setIsVerifying] = React.useState(false)
const [successful, setSuccessful] = React.useState(false)
const [phoneObj, setPhoneObj] = React.useState<PhoneNumberResource | undefined>()
// Handle loading state
if (!isLoaded) {
return (
<ThemedView style={styles.container}>
<ThemedText>Loading...</ThemedText>
</ThemedView>
)
}
// Step 1: Add the phone number to the user's User object
const handleSubmit = async () => {
try {
// Add unverified phone number to user
const res = await user?.createPhoneNumber({ phoneNumber: phone })
// Reload user to get updated User object
await user?.reload()
// Create a reference to the new phone number to use related methods
const phoneNumber = user?.phoneNumbers.find((a) => a.id === res?.id)
setPhoneObj(phoneNumber)
// Send the user an SMS with the verification code
await phoneNumber?.prepareVerification()
// Set to true to display second form
// and capture the code
setIsVerifying(true)
} catch (err) {
// See https://clerk.com/docs/guides/development/custom-flows/error-handling
// for more info on error handling
console.error(JSON.stringify(err, null, 2))
}
}
// Step 2: Verify the OTP the user supplied
const verifyCode = async () => {
try {
// Verify that the provided code matches the code sent to the user
const phoneVerifyAttempt = await phoneObj?.attemptVerification({ code })
if (phoneVerifyAttempt?.verification.status === 'verified') {
setSuccessful(true)
} else {
// If the status is not complete, check why. User may need to
// complete further steps.
console.error(JSON.stringify(phoneVerifyAttempt, null, 2))
}
} catch (err) {
console.error(JSON.stringify(err, null, 2))
}
}
// Step 3 UI: Display a success message if the phone number was added successfully
if (successful) {
return (
<ThemedView style={styles.container}>
<ThemedText type="title" style={styles.title}>
Phone added
</ThemedText>
<TouchableOpacity
style={[styles.button]}
onPress={() => router.replace('/(account)/manage-mfa' as Href)}
activeOpacity={0.85}
>
<ThemedText style={styles.buttonText}>Manage MFA</ThemedText>
</TouchableOpacity>
</ThemedView>
)
}
// Step 2 UI: Display the verification form to capture the code
if (isVerifying) {
return (
<ThemedView style={styles.container}>
<ThemedText type="title" style={styles.title}>
Verify phone
</ThemedText>
<ThemedText style={styles.label}>Enter code</ThemedText>
<TextInput
style={styles.input}
value={code}
placeholder="Enter code"
placeholderTextColor="#666666"
onChangeText={setCode}
keyboardType="numeric"
/>
<Pressable
style={({ pressed }) => [
styles.button,
!code && styles.buttonDisabled,
pressed && styles.buttonPressed,
]}
onPress={verifyCode}
disabled={!code}
>
<ThemedText style={styles.buttonText}>Verify</ThemedText>
</Pressable>
</ThemedView>
)
}
// Step 1 UI: Display the initial form to capture the phone number
return (
<ThemedView style={styles.container}>
<ThemedText type="title" style={styles.title}>
Add phone
</ThemedText>
<ThemedText style={styles.label}>Enter phone number</ThemedText>
<TextInput
style={styles.input}
value={phone}
placeholder="e.g +1234567890"
placeholderTextColor="#666666"
onChangeText={setPhone}
keyboardType="phone-pad"
/>
<Pressable
style={({ pressed }) => [
styles.button,
!phone && styles.buttonDisabled,
pressed && styles.buttonPressed,
]}
onPress={handleSubmit}
disabled={!phone}
>
<ThemedText style={styles.buttonText}>Continue</ThemedText>
</Pressable>
</ThemedView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 20,
gap: 12,
},
title: {
marginBottom: 8,
},
label: {
fontWeight: '600',
fontSize: 14,
},
input: {
borderWidth: 1,
borderColor: '#ccc',
borderRadius: 8,
padding: 12,
fontSize: 16,
backgroundColor: '#fff',
},
button: {
backgroundColor: '#0a7ea4',
paddingVertical: 12,
paddingHorizontal: 24,
borderRadius: 8,
alignItems: 'center',
marginTop: 8,
},
buttonPressed: {
opacity: 0.7,
},
buttonDisabled: {
opacity: 0.5,
},
buttonText: {
color: '#fff',
fontWeight: '600',
},
}) import SwiftUI
import ClerkKit
struct AddPhoneView: View {
@Environment(Clerk.self) private var clerk
@State private var phone = ""
@State private var code = ""
@State private var newPhoneNumber: PhoneNumber?
@State private var isVerified = false
var body: some View {
VStack(alignment: .leading, spacing: 16) {
if isVerified {
Text("Phone added!")
} else if let newPhoneNumber {
TextField("Enter code", text: $code)
Button("Verify") {
Task { await verifyCode(code, for: newPhoneNumber) }
}
} else {
TextField("Enter phone number", text: $phone)
Button("Continue") {
Task { await createPhone() }
}
.disabled(phone.isEmpty)
}
}
}
}
extension AddPhoneView {
private func createPhone() async {
do {
guard let user = clerk.user else { return }
// Create the phone number
let phoneNumber = try await user.createPhoneNumber(phone)
// Send the user an SMS with the verification code
newPhoneNumber = try await phoneNumber.sendCode()
} catch {
// See https://clerk.com/docs/guides/development/custom-flows/error-handling
// for more info on error handling
dump(error)
}
}
private func verifyCode(_ code: String, for phoneNumber: PhoneNumber) async {
do {
// Verify that the provided code matches the code sent to the user
newPhoneNumber = try await phoneNumber.verifyCode(code)
isVerified = true
} catch {
// See https://clerk.com/docs/guides/development/custom-flows/error-handling
// for more info on error handling
dump(error)
}
}
}package com.clerk.customflows.addphone
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.clerk.api.Clerk
import com.clerk.api.network.serialization.errorMessage
import com.clerk.api.network.serialization.flatMap
import com.clerk.api.network.serialization.onFailure
import com.clerk.api.network.serialization.onSuccess
import com.clerk.api.phonenumber.PhoneNumber
import com.clerk.api.phonenumber.attemptVerification
import com.clerk.api.phonenumber.prepareVerification
import com.clerk.api.user.createPhoneNumber
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.launch
class AddPhoneViewModel : ViewModel() {
private val _uiState = MutableStateFlow<UiState>(UiState.NeedsVerification)
val uiState = _uiState.asStateFlow()
init {
combine(Clerk.isInitialized, Clerk.userFlow) { isInitialized, user ->
_uiState.value =
when {
!isInitialized -> UiState.Loading
user == null -> UiState.SignedOut
else -> UiState.NeedsVerification
}
}
.launchIn(viewModelScope)
}
fun createPhoneNumber(phoneNumber: String) {
val user = requireNotNull(Clerk.userFlow.value)
// Add an unverified phone number to the user,
// then send the user an SMS with the verification code
viewModelScope.launch {
user
.createPhoneNumber(phoneNumber)
.flatMap { it.prepareVerification() }
.onSuccess {
// Update the state to show that the phone number has been created
// and that the user needs to verify the phone number
_uiState.value = UiState.Verifying(it)
}
.onFailure {
Log.e(
"AddPhoneViewModel",
"Failed to create phone number and prepare verification: ${it.errorMessage}",
)
}
}
}
fun verifyCode(code: String, newPhoneNumber: PhoneNumber) {
viewModelScope.launch {
newPhoneNumber
.attemptVerification(code)
.onSuccess {
// Update the state to show that the phone number has been verified
_uiState.value = UiState.Verified
}
.onFailure {
Log.e("AddPhoneViewModel", "Failed to verify phone number: ${it.errorMessage}")
}
}
}
sealed interface UiState {
data object Loading : UiState
data object NeedsVerification : UiState
data class Verifying(val phoneNumber: PhoneNumber) : UiState
data object Verified : UiState
data object SignedOut : UiState
}
}package com.clerk.customflows.addphone
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.clerk.api.phonenumber.PhoneNumber
class AddPhoneActivity : ComponentActivity() {
val viewModel: AddPhoneViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val state by viewModel.uiState.collectAsStateWithLifecycle()
AddPhoneView(
state = state,
onCreatePhoneNumber = viewModel::createPhoneNumber,
onVerifyCode = viewModel::verifyCode,
)
}
}
}
@Composable
fun AddPhoneView(
state: AddPhoneViewModel.UiState,
onCreatePhoneNumber: (String) -> Unit,
onVerifyCode: (String, PhoneNumber) -> Unit,
) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
when (state) {
AddPhoneViewModel.UiState.NeedsVerification -> {
InputContentView(buttonText = "Continue", placeholder = "Enter phone number") {
onCreatePhoneNumber(it)
}
}
AddPhoneViewModel.UiState.Verified -> Text("Verified!")
is AddPhoneViewModel.UiState.Verifying -> {
InputContentView(buttonText = "Verify", placeholder = "Enter code") {
onVerifyCode(it, state.phoneNumber)
}
}
AddPhoneViewModel.UiState.Loading -> CircularProgressIndicator()
AddPhoneViewModel.UiState.SignedOut -> Text("You must be signed in to add a phone number.")
}
}
}
@Composable
fun InputContentView(
buttonText: String,
placeholder: String,
modifier: Modifier = Modifier,
onClick: (String) -> Unit,
) {
var input by remember { mutableStateOf("") }
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier) {
TextField(
modifier = Modifier.padding(bottom = 16.dp),
value = input,
onValueChange = { input = it },
placeholder = { Text(placeholder) },
)
Button(onClick = { onClick(input) }) { Text(buttonText) }
}
}This example consists of two Android screens:
- A screen where users can manage SMS-based MFA and regenerate backup codes
- A screen where users can add and verify a phone number
Use the following tabs to view the code necessary for each screen.
The phoneNumbersReservedForMfa() and phoneNumbersAvailableForMfa() helpers keep the screen aligned with Clerk's MFA rules, including avoiding the user's last remaining first-factor phone number.
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.clerk.api.Clerk
import com.clerk.api.network.serialization.errorMessage
import com.clerk.api.network.serialization.onFailure
import com.clerk.api.network.serialization.onSuccess
import com.clerk.api.phonenumber.PhoneNumber
import com.clerk.api.phonenumber.delete
import com.clerk.api.phonenumber.makeDefaultSecondFactor
import com.clerk.api.phonenumber.setReservedForSecondFactor
import com.clerk.api.session.hasMfaRequiredTask
import com.clerk.api.user.createBackupCodes
import com.clerk.api.user.phoneNumbersAvailableForMfa
import com.clerk.api.user.phoneNumbersReservedForMfa
import com.clerk.api.user.reload
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
class ManageSmsMfaViewModel : ViewModel() {
private val _generatedBackupCodes = MutableStateFlow<List<String>>(emptyList())
private val _isWorking = MutableStateFlow(false)
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
val uiState = _uiState.asStateFlow()
init {
combine(
Clerk.isInitialized,
Clerk.userFlow,
Clerk.sessionFlow,
_generatedBackupCodes,
_isWorking,
) { isInitialized, user, session, generatedBackupCodes, isWorking ->
when {
!isInitialized -> UiState.Loading
user == null && session?.hasMfaRequiredTask != true -> UiState.SignedOut
else ->
UiState.Ready(
reservedPhoneNumbers =
user
?.phoneNumbersReservedForMfa()
?.sortedWith(
compareByDescending<PhoneNumber> { it.defaultSecondFactor }.thenBy {
it.createdAt
},
) ?: emptyList(),
availablePhoneNumbers = user?.phoneNumbersAvailableForMfa() ?: emptyList(),
generatedBackupCodes = generatedBackupCodes,
totpEnabled = user?.totpEnabled == true,
twoFactorEnabled = user?.twoFactorEnabled == true,
isWorking = isWorking,
)
}
}.onEach { _uiState.value = it }.launchIn(viewModelScope)
}
fun reserveForSecondFactor(phoneNumber: PhoneNumber) {
_isWorking.value = true
viewModelScope.launch {
phoneNumber
.setReservedForSecondFactor(true)
.onSuccess {
if (it.backupCodes.orEmpty().isNotEmpty()) {
_generatedBackupCodes.value = it.backupCodes.orEmpty()
}
completeSetupTaskIfNeeded()
refreshUser()
}
.onFailure {
Log.e("ManageSmsMfa", "Failed to reserve phone number for MFA: ${it.errorMessage}")
}
_isWorking.value = false
}
}
fun disableForMfa(phoneNumber: PhoneNumber) {
_isWorking.value = true
viewModelScope.launch {
phoneNumber
.setReservedForSecondFactor(false)
.onSuccess { refreshUser() }
.onFailure {
Log.e("ManageSmsMfa", "Failed to disable MFA for phone number: ${it.errorMessage}")
}
_isWorking.value = false
}
}
fun makeDefault(phoneNumber: PhoneNumber) {
_isWorking.value = true
viewModelScope.launch {
phoneNumber
.makeDefaultSecondFactor()
.onSuccess { refreshUser() }
.onFailure {
Log.e(
"ManageSmsMfa",
"Failed to make phone number the default second factor: ${it.errorMessage}",
)
}
_isWorking.value = false
}
}
fun removePhoneNumber(phoneNumber: PhoneNumber) {
_isWorking.value = true
viewModelScope.launch {
phoneNumber
.delete()
.onSuccess { refreshUser() }
.onFailure {
Log.e("ManageSmsMfa", "Failed to remove phone number: ${it.errorMessage}")
}
_isWorking.value = false
}
}
fun regenerateBackupCodes() {
_isWorking.value = true
viewModelScope.launch {
val user = Clerk.user ?: run {
_isWorking.value = false
return@launch
}
user
.createBackupCodes()
.onSuccess { _generatedBackupCodes.value = it.codes }
.onFailure {
Log.e("ManageSmsMfa", "Failed to generate backup codes: ${it.errorMessage}")
}
_isWorking.value = false
}
}
fun clearBackupCodes() {
_generatedBackupCodes.value = emptyList()
}
private suspend fun refreshUser() {
Clerk.user?.reload()?.onFailure {
Log.e("ManageSmsMfa", "Failed to reload user: ${it.errorMessage}")
}
}
private suspend fun completeSetupTaskIfNeeded() {
val session = Clerk.session ?: return
if (!session.hasMfaRequiredTask) return
Clerk.auth.setActive(sessionId = session.id).onFailure {
Log.e("ManageSmsMfa", "Failed to complete setup-mfa task: ${it.errorMessage}")
}
}
sealed interface UiState {
data object Loading : UiState
data object SignedOut : UiState
data class Ready(
val reservedPhoneNumbers: List<PhoneNumber>,
val availablePhoneNumbers: List<PhoneNumber>,
val generatedBackupCodes: List<String>,
val totpEnabled: Boolean,
val twoFactorEnabled: Boolean,
val isWorking: Boolean,
) : UiState
}
}import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.clerk.api.phonenumber.PhoneNumber
class ManageSmsMfaActivity : ComponentActivity() {
private val viewModel: ManageSmsMfaViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val state by viewModel.uiState.collectAsStateWithLifecycle()
MaterialTheme {
Surface(modifier = Modifier.fillMaxSize()) {
when (val uiState = state) {
ManageSmsMfaViewModel.UiState.Loading -> LoadingState()
ManageSmsMfaViewModel.UiState.SignedOut -> SignedOutState()
is ManageSmsMfaViewModel.UiState.Ready ->
ManageSmsMfaScreen(
state = uiState,
onReserveForSecondFactor = viewModel::reserveForSecondFactor,
onDisableForMfa = viewModel::disableForMfa,
onMakeDefault = viewModel::makeDefault,
onRemovePhoneNumber = viewModel::removePhoneNumber,
onGenerateBackupCodes = viewModel::regenerateBackupCodes,
onClearBackupCodes = viewModel::clearBackupCodes,
onAddPhoneNumber = {
startActivity(Intent(this, AddPhoneActivity::class.java))
},
)
}
}
}
}
}
}
@Composable
private fun ManageSmsMfaScreen(
state: ManageSmsMfaViewModel.UiState.Ready,
onReserveForSecondFactor: (PhoneNumber) -> Unit,
onDisableForMfa: (PhoneNumber) -> Unit,
onMakeDefault: (PhoneNumber) -> Unit,
onRemovePhoneNumber: (PhoneNumber) -> Unit,
onGenerateBackupCodes: () -> Unit,
onClearBackupCodes: () -> Unit,
onAddPhoneNumber: () -> Unit,
) {
Column(
modifier =
Modifier.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
Text("Manage SMS MFA", style = MaterialTheme.typography.headlineMedium)
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
"Phone numbers reserved for MFA",
style = MaterialTheme.typography.titleMedium,
)
if (state.reservedPhoneNumbers.isEmpty()) {
Text("There are currently no phone numbers reserved for MFA.")
} else {
state.reservedPhoneNumbers.forEach { phoneNumber ->
Surface(
modifier = Modifier.fillMaxWidth(),
tonalElevation = 2.dp,
shape = MaterialTheme.shapes.medium,
) {
Column(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
val label =
buildString {
append(phoneNumber.phoneNumber)
if (phoneNumber.defaultSecondFactor) {
append(" (Default)")
}
}
Text(label, style = MaterialTheme.typography.bodyLarge)
OutlinedButton(
modifier = Modifier.fillMaxWidth(),
onClick = { onDisableForMfa(phoneNumber) },
enabled = !state.isWorking,
) {
Text("Disable for MFA")
}
if (!state.totpEnabled && !phoneNumber.defaultSecondFactor) {
Button(
modifier = Modifier.fillMaxWidth(),
onClick = { onMakeDefault(phoneNumber) },
enabled = !state.isWorking,
) {
Text("Make default")
}
}
TextButton(
modifier = Modifier.fillMaxWidth(),
onClick = { onRemovePhoneNumber(phoneNumber) },
enabled = !state.isWorking,
) {
Text("Remove from account")
}
}
}
}
}
}
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
"Verified phone numbers available for MFA",
style = MaterialTheme.typography.titleMedium,
)
if (state.availablePhoneNumbers.isEmpty()) {
Text("There are currently no verified phone numbers available to reserve for MFA.")
} else {
state.availablePhoneNumbers.forEach { phoneNumber ->
Surface(
modifier = Modifier.fillMaxWidth(),
tonalElevation = 2.dp,
shape = MaterialTheme.shapes.medium,
) {
Column(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(phoneNumber.phoneNumber, style = MaterialTheme.typography.bodyLarge)
Button(
modifier = Modifier.fillMaxWidth(),
onClick = { onReserveForSecondFactor(phoneNumber) },
enabled = !state.isWorking,
) {
Text("Use for MFA")
}
TextButton(
modifier = Modifier.fillMaxWidth(),
onClick = { onRemovePhoneNumber(phoneNumber) },
enabled = !state.isWorking,
) {
Text("Remove from account")
}
}
}
}
}
OutlinedButton(
modifier = Modifier.fillMaxWidth(),
onClick = onAddPhoneNumber,
enabled = !state.isWorking,
) {
Text("Add a phone number")
}
}
if (state.twoFactorEnabled) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Backup codes", style = MaterialTheme.typography.titleMedium)
if (state.generatedBackupCodes.isEmpty()) {
Button(
modifier = Modifier.fillMaxWidth(),
onClick = onGenerateBackupCodes,
enabled = !state.isWorking,
) {
Text("Generate new backup codes")
}
} else {
state.generatedBackupCodes.forEach { code ->
Text(code, style = MaterialTheme.typography.bodyLarge)
}
TextButton(
modifier = Modifier.fillMaxWidth(),
onClick = onClearBackupCodes,
enabled = !state.isWorking,
) {
Text("Done")
}
}
}
}
}
}
@Composable
private fun LoadingState() {
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.Center,
) {
CircularProgressIndicator()
}
}
@Composable
private fun SignedOutState() {
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.Center,
) {
Text("You must be signed in to manage MFA settings.")
}
}import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.clerk.api.Clerk
import com.clerk.api.network.serialization.errorMessage
import com.clerk.api.network.serialization.flatMap
import com.clerk.api.network.serialization.onFailure
import com.clerk.api.network.serialization.onSuccess
import com.clerk.api.phonenumber.PhoneNumber
import com.clerk.api.phonenumber.attemptVerification
import com.clerk.api.phonenumber.prepareVerification
import com.clerk.api.user.createPhoneNumber
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
class AddPhoneViewModel : ViewModel() {
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
val uiState = _uiState.asStateFlow()
init {
combine(Clerk.isInitialized, Clerk.userFlow) { isInitialized, user ->
when {
!isInitialized -> UiState.Loading
user == null -> UiState.SignedOut
else -> UiState.NeedsVerification
}
}.onEach { state ->
if (_uiState.value !is UiState.Verifying && _uiState.value !is UiState.Verified) {
_uiState.value = state
}
}.launchIn(viewModelScope)
}
fun createPhoneNumber(phoneNumber: String) {
val user = requireNotNull(Clerk.userFlow.value)
viewModelScope.launch {
user
.createPhoneNumber(phoneNumber)
.flatMap { it.prepareVerification() }
.onSuccess {
_uiState.value = UiState.Verifying(it)
}
.onFailure {
Log.e(
"AddPhoneViewModel",
"Failed to create phone number and prepare verification: ${it.errorMessage}",
)
}
}
}
fun verifyCode(code: String, newPhoneNumber: PhoneNumber) {
viewModelScope.launch {
newPhoneNumber
.attemptVerification(code)
.onSuccess {
_uiState.value = UiState.Verified
}
.onFailure {
Log.e("AddPhoneViewModel", "Failed to verify phone number: ${it.errorMessage}")
}
}
}
sealed interface UiState {
data object Loading : UiState
data object NeedsVerification : UiState
data class Verifying(val phoneNumber: PhoneNumber) : UiState
data object Verified : UiState
data object SignedOut : UiState
}
}import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.clerk.api.phonenumber.PhoneNumber
class AddPhoneActivity : ComponentActivity() {
private val viewModel: AddPhoneViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val state by viewModel.uiState.collectAsStateWithLifecycle()
MaterialTheme {
Surface(modifier = Modifier.fillMaxSize()) {
AddPhoneView(
state = state,
onCreatePhoneNumber = viewModel::createPhoneNumber,
onVerifyCode = viewModel::verifyCode,
)
}
}
}
}
}
@Composable
fun AddPhoneView(
state: AddPhoneViewModel.UiState,
onCreatePhoneNumber: (String) -> Unit,
onVerifyCode: (String, PhoneNumber) -> Unit,
) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
when (state) {
AddPhoneViewModel.UiState.NeedsVerification -> {
InputContentView(buttonText = "Continue", placeholder = "Enter phone number") {
onCreatePhoneNumber(it)
}
}
AddPhoneViewModel.UiState.Verified -> Text("Phone added.")
is AddPhoneViewModel.UiState.Verifying -> {
InputContentView(buttonText = "Verify", placeholder = "Enter code") {
onVerifyCode(it, state.phoneNumber)
}
}
AddPhoneViewModel.UiState.Loading -> CircularProgressIndicator()
AddPhoneViewModel.UiState.SignedOut -> {
Text("You must be signed in to add a phone number.")
}
}
}
}
@Composable
private fun InputContentView(
buttonText: String,
placeholder: String,
modifier: Modifier = Modifier,
onClick: (String) -> Unit,
) {
var input by remember { mutableStateOf("") }
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier) {
TextField(
modifier = Modifier.padding(bottom = 16.dp),
value = input,
onValueChange = { input = it },
placeholder = { Text(placeholder) },
)
Button(onClick = { onClick(input) }, enabled = input.isNotBlank()) {
Text(buttonText)
}
}
}Enable multi-factor authentication
For your users to be able to enable MFA for their account, you need to enable MFA for your application.
- In the Clerk Dashboard, navigate to the Multi-factor page.
- For the purpose of this guide, toggle on both the Authenticator application and Backup codes strategies.
- Select Save.
Install dependencies
Install react-native-qr-svg for the QR code.
npm install react-native-qr-svgpnpm add react-native-qr-svgyarn add react-native-qr-svgbun add react-native-qr-svgThis example consists of two pages:
- The main page where users can manage their MFA settings
- The page where users can add TOTP MFA
Use the following tabs to view the code necessary for each page.
'use client'
import * as React from 'react'
import { useUser, useReverification, useClerk, useSession } from '@clerk/nextjs'
import Link from 'next/link'
import { BackupCodeResource } from '@clerk/shared/types'
import { useRouter } from 'next/navigation'
// If TOTP is enabled, provide the option to disable it
const TotpEnabled = () => {
const { user } = useUser()
const disableTOTP = useReverification(() => user?.disableTOTP())
return (
<div>
<p>
TOTP via authentication app enabled - <button onClick={() => disableTOTP()}>Remove</button>
</p>
</div>
)
}
// If TOTP is disabled, provide the option to enable it
const TotpDisabled = () => {
return (
<div>
<p>
Add TOTP via authentication app -{' '}
<Link href="/account/manage-mfa/add">
<button>Add</button>
</Link>
</p>
</div>
)
}
// Generate and display backup codes
export function GenerateBackupCodes() {
const { user } = useUser()
const [backupCodes, setBackupCodes] = React.useState<BackupCodeResource | undefined>(undefined)
const createBackupCode = useReverification(() => user?.createBackupCode())
const [loading, setLoading] = React.useState(false)
React.useEffect(() => {
if (backupCodes) return
setLoading(true)
void createBackupCode()
.then((backupCode: BackupCodeResource | undefined) => {
setBackupCodes(backupCode)
setLoading(false)
})
.catch((err) => {
// See https://clerk.com/docs/guides/development/custom-flows/error-handling
// for more info on error handling
console.error(JSON.stringify(err, null, 2))
setLoading(false)
})
}, [backupCodes, createBackupCode])
if (loading) return <p>Loading...</p>
if (!backupCodes) return <p>There was a problem generating backup codes</p>
return (
<ol>
{backupCodes.codes.map((code, index) => (
<li key={index}>{code}</li>
))}
</ol>
)
}
export default function ManageMFA() {
const { isLoaded, isSignedIn, user } = useUser()
const clerk = useClerk()
const router = useRouter()
// Complete the session task when phone number is picked for MFA
const completeTask = async () => {
try {
await clerk.setActive({
session: clerk.session?.id,
navigate: async ({ decorateUrl }) => {
const url = decorateUrl('/')
if (url.startsWith('http')) {
window.location.href = url
} else {
router.push(url)
}
},
})
} catch (err) {
// See https://clerk.com/docs/guides/development/custom-flows/error-handling
// for more info on error handling
console.error(JSON.stringify(err, null, 2))
}
}
const [showNewCodes, setShowNewCodes] = React.useState(false)
// Handle loading state
if (!isLoaded) return <p>Loading...</p>
// Handle signed out state
// If the user is trying to set up MFA, they should be able to access this page
if (!isSignedIn && clerk.session?.currentTask?.key !== 'setup-mfa')
return <p>You must be signed in to access this page</p>
return (
<>
<h1>User MFA Settings</h1>
{/* Manage TOTP MFA */}
{user?.totpEnabled ? <TotpEnabled /> : <TotpDisabled />}
{/* Manage backup codes */}
{user?.backupCodeEnabled && user.twoFactorEnabled && (
<div>
<p>
Generate new backup codes? -{' '}
<button
onClick={() => {
setShowNewCodes(true)
}}
>
Generate
</button>
</p>
</div>
)}
{showNewCodes && (
<>
<GenerateBackupCodes />
<button onClick={() => setShowNewCodes(false)}>Done</button>
</>
)}
</>
)
}'use client'
import { useUser, useReverification, useClerk, useSession } from '@clerk/nextjs'
import { TOTPResource } from '@clerk/shared/types'
import Link from 'next/link'
import * as React from 'react'
import { QRCodeSVG } from 'qrcode.react'
import { GenerateBackupCodes } from '../page'
import { useRouter } from 'next/navigation'
type AddTotpSteps = 'add' | 'verify' | 'backupcodes' | 'success'
type DisplayFormat = 'qr' | 'uri'
function AddTotpScreen({
setStep,
}: {
setStep: React.Dispatch<React.SetStateAction<AddTotpSteps>>
}) {
const { user } = useUser()
const [totp, setTOTP] = React.useState<TOTPResource | undefined>(undefined)
const [displayFormat, setDisplayFormat] = React.useState<DisplayFormat>('qr')
const createTOTP = useReverification(() => user?.createTOTP())
React.useEffect(() => {
void createTOTP()
.then((totp: TOTPResource | undefined) => {
if (totp) setTOTP(totp)
})
.catch((err) =>
// See https://clerk.com/docs/guides/development/custom-flows/error-handling
// for more info on error handling
console.error(JSON.stringify(err, null, 2)),
)
}, [createTOTP])
return (
<>
<h1>Add TOTP MFA</h1>
{totp && displayFormat === 'qr' && (
<>
<div>
<QRCodeSVG value={totp?.uri || ''} size={200} />
</div>
<button onClick={() => setDisplayFormat('uri')}>Use URI instead</button>
</>
)}
{totp && displayFormat === 'uri' && (
<>
<div>
<p>{totp.uri}</p>
</div>
<button onClick={() => setDisplayFormat('qr')}>Use QR Code instead</button>
</>
)}
<button onClick={() => setStep('add')}>Reset</button>
<p>Once you have set up your authentication app, verify your code</p>
<button onClick={() => setStep('verify')}>Verify</button>
</>
)
}
function VerifyTotpScreen({
setStep,
}: {
setStep: React.Dispatch<React.SetStateAction<AddTotpSteps>>
}) {
const { user } = useUser()
const [code, setCode] = React.useState('')
const verifyTotp = async (e: React.FormEvent) => {
e.preventDefault()
try {
await user?.verifyTOTP({ code })
setStep('backupcodes')
} catch (err) {
console.error(JSON.stringify(err, null, 2))
}
}
return (
<>
<h1>Verify TOTP</h1>
<form onSubmit={(e) => verifyTotp(e)}>
<label htmlFor="totp-code">Enter the code from your authentication app</label>
<input type="text" id="totp-code" onChange={(e) => setCode(e.currentTarget.value)} />
<button type="submit">Verify code</button>
<button onClick={() => setStep('add')}>Reset</button>
</form>
</>
)
}
function BackupCodeScreen({
setStep,
}: {
setStep: React.Dispatch<React.SetStateAction<AddTotpSteps>>
}) {
const clerk = useClerk()
const router = useRouter()
// Complete the session task when phone number is picked for MFA
const completeTask = async () => {
try {
await clerk.setActive({
session: clerk.session?.id,
navigate: async ({ decorateUrl }) => {
const url = decorateUrl('/')
if (url.startsWith('http')) {
window.location.href = url
} else {
router.push(url)
}
},
})
} catch (err) {
// See https://clerk.com/docs/guides/development/custom-flows/error-handling
// for more info on error handling
console.error(JSON.stringify(err, null, 2))
}
}
return (
<>
<h1>Verification was a success!</h1>
<div>
<p>
Save this list of backup codes somewhere safe in case you need to access your account in
an emergency
</p>
<GenerateBackupCodes />
<button
onClick={() => {
setStep('success')
completeTask()
}}
>
Finish
</button>
</div>
</>
)
}
function SuccessScreen() {
return (
<>
<h1>Success!</h1>
<p>You have successfully added TOTP MFA via an authentication application.</p>
</>
)
}
export default function AddMFaScreen() {
const [step, setStep] = React.useState<AddTotpSteps>('add')
const { isLoaded, isSignedIn } = useUser()
const { session } = useSession()
// Handle loading state
if (!isLoaded) return <p>Loading...</p>
// Handle signed out state
// If the user is trying to set up MFA, they should be able to access this page
if (!isSignedIn && session?.currentTask?.key !== 'setup-mfa')
return <p>You must be signed in to access this page</p>
return (
<>
{step === 'add' && <AddTotpScreen setStep={setStep} />}
{step === 'verify' && <VerifyTotpScreen setStep={setStep} />}
{step === 'backupcodes' && <BackupCodeScreen setStep={setStep} />}
{step === 'success' && <SuccessScreen />}
<Link href="/account/manage-mfa">Manage MFA</Link>
</>
)
}To allow users to configure their MFA settings, you'll create a basic dashboard.
This example consists of three pages:
- The layout page that checks if the user is signed in
- The page where users can manage their account, including their MFA settings
- The page where users can add TOTP MFA
Use the following tabs to view the code necessary for each page.
- Create the
(account)route group. This groups your/manage-mfapage and the/add-mfapage. - Create a
_layout.tsxfile with the following code. The useAuth() hook is used to check if the user is signed in. If the user isn't signed in, they'll be redirected to the sign-in page. You check if the user has a pendingsetup-mfatask because if they're trying to access their account settings to set up MFA, they should be able to access these routes, so we don't want to redirect them to the sign-in page.
import { Redirect, Stack } from 'expo-router'
import { useAuth, useSession } from '@clerk/expo'
export default function AuthenticatedLayout() {
const { isSignedIn } = useAuth()
const { session } = useSession()
// If the user isn't signed in and they're not trying to set up MFA, redirect them to the sign-in page
if (!isSignedIn && session?.currentTask?.key !== 'setup-mfa') {
return <Redirect href={'/sign-in'} />
}
return <Stack />
}In the (account) group, create an manage-mfa.tsx file with the following code. This page shows users whether or not MFA is enabled, and allows them to add MFA with an authenticator app.
import * as React from 'react'
import {
View,
Text,
ScrollView,
TouchableOpacity,
StyleSheet,
ActivityIndicator,
} from 'react-native'
import { useUser, useReverification, useClerk } from '@clerk/expo'
import { Href, useRouter } from 'expo-router'
import { BackupCodeResource } from '@clerk/types'
// If TOTP is enabled, provide the option to disable it
const TotpEnabled = () => {
const { user } = useUser()
const disableTOTP = useReverification(() => user?.disableTOTP())
return (
<View style={styles.section}>
<View style={styles.card}>
<View style={styles.cardContent}>
<Text style={styles.cardTitle}>Authenticator App (TOTP)</Text>
<Text style={styles.statusBadge}>✓ Enabled</Text>
</View>
<TouchableOpacity
style={[styles.button, styles.dangerButton]}
onPress={() => disableTOTP()}
>
<Text style={styles.dangerButtonText}>Remove</Text>
</TouchableOpacity>
</View>
</View>
)
}
// If TOTP is disabled, provide the option to enable it
const TotpDisabled = () => {
const router = useRouter()
return (
<View style={styles.section}>
<View style={styles.card}>
<View style={styles.cardContent}>
<Text style={styles.cardTitle}>Authenticator App (TOTP)</Text>
<Text style={styles.infoText}>
Add an authenticator app for two-factor authentication
</Text>
</View>
<TouchableOpacity
style={[styles.button, styles.primaryButton, styles.buttonFullWidth]}
onPress={() => router.push('/(account)/add-mfa' as Href)}
activeOpacity={0.85}
>
<Text style={styles.primaryButtonText}>Add</Text>
</TouchableOpacity>
</View>
</View>
)
}
// Generate and display backup codes
export function GenerateBackupCodes() {
const { user } = useUser()
const [backupCodes, setBackupCodes] = React.useState<BackupCodeResource | undefined>(undefined)
const createBackupCode = useReverification(() => user?.createBackupCode())
const [loading, setLoading] = React.useState(false)
React.useEffect(() => {
if (backupCodes) return
setLoading(true)
void createBackupCode()
.then((backupCode: BackupCodeResource | undefined) => {
setBackupCodes(backupCode)
setLoading(false)
})
.catch((err) => {
// See https://clerk.com/docs/guides/development/custom-flows/error-handling
// for more info on error handling
console.error(JSON.stringify(err, null, 2))
setLoading(false)
})
}, [backupCodes, createBackupCode])
if (loading)
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#6366f1" />
<Text style={styles.loadingText}>Generating backup codes...</Text>
</View>
)
if (!backupCodes)
return <Text style={styles.warningText}>There was a problem generating backup codes</Text>
return (
<View style={styles.backupCodesContainer}>
<Text style={styles.backupCodesTitle}>Save these backup codes:</Text>
<Text style={styles.backupCodesSubtitle}>
Store them in a safe place. Each code can only be used once.
</Text>
{backupCodes.codes.map((code, index) => (
<View key={index} style={styles.backupCodeItem}>
<Text style={styles.backupCodeNumber}>{index + 1}.</Text>
<Text style={styles.backupCode}>{code}</Text>
</View>
))}
</View>
)
}
export default function ManageMFA() {
const { isLoaded, user } = useUser()
const [showNewCodes, setShowNewCodes] = React.useState(false)
// Handle loading state
if (!isLoaded)
return (
<View style={styles.container}>
<ActivityIndicator size="large" color="#6366f1" />
<Text style={styles.loadingText}>Loading...</Text>
</View>
)
return (
<ScrollView style={styles.container} contentContainerStyle={styles.contentContainer}>
<Text style={styles.title}>User MFA Settings</Text>
{/* Manage TOTP MFA */}
{user?.totpEnabled ? <TotpEnabled /> : <TotpDisabled />}
{/* Manage backup codes */}
{user?.backupCodeEnabled && user.twoFactorEnabled && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Backup Codes</Text>
{!showNewCodes ? (
<View style={styles.card}>
<Text style={styles.infoText}>Generate new backup codes for account recovery</Text>
<TouchableOpacity
style={[styles.button, styles.primaryButton]}
onPress={() => {
setShowNewCodes(true)
}}
>
<Text style={styles.primaryButtonText}>Generate New Codes</Text>
</TouchableOpacity>
</View>
) : (
<View>
<GenerateBackupCodes />
<TouchableOpacity
style={[styles.button, styles.successButton, styles.doneButton]}
onPress={() => setShowNewCodes(false)}
>
<Text style={styles.successButtonText}>Done</Text>
</TouchableOpacity>
</View>
)}
</View>
)}
</ScrollView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f9fafb',
},
contentContainer: {
padding: 20,
},
title: {
fontSize: 28,
fontWeight: 'bold',
color: '#111827',
marginBottom: 24,
},
section: {
marginBottom: 24,
},
sectionTitle: {
fontSize: 20,
fontWeight: '600',
color: '#374151',
marginBottom: 12,
},
card: {
backgroundColor: '#ffffff',
borderRadius: 12,
padding: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 3,
elevation: 2,
},
cardContent: {
marginBottom: 12,
},
cardTitle: {
fontSize: 18,
fontWeight: '600',
color: '#111827',
marginBottom: 4,
},
statusBadge: {
fontSize: 14,
fontWeight: '600',
color: '#10b981',
marginTop: 4,
},
button: {
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 8,
alignItems: 'center',
justifyContent: 'center',
},
buttonFullWidth: {
alignSelf: 'stretch',
},
primaryButton: {
backgroundColor: '#6366f1',
},
primaryButtonText: {
color: '#ffffff',
fontSize: 14,
fontWeight: '600',
},
dangerButton: {
backgroundColor: '#fef2f2',
borderWidth: 1,
borderColor: '#fecaca',
},
dangerButtonText: {
color: '#dc2626',
fontSize: 14,
fontWeight: '600',
},
successButton: {
backgroundColor: '#10b981',
},
successButtonText: {
color: '#ffffff',
fontSize: 14,
fontWeight: '600',
},
infoText: {
fontSize: 14,
color: '#6b7280',
marginBottom: 12,
},
warningText: {
fontSize: 14,
color: '#dc2626',
textAlign: 'center',
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
fontSize: 14,
color: '#6b7280',
marginTop: 12,
},
backupCodesContainer: {
backgroundColor: '#ffffff',
borderRadius: 12,
padding: 16,
marginBottom: 16,
},
backupCodesTitle: {
fontSize: 18,
fontWeight: '600',
color: '#111827',
marginBottom: 4,
},
backupCodesSubtitle: {
fontSize: 14,
color: '#6b7280',
marginBottom: 16,
},
backupCodeItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 8,
paddingHorizontal: 12,
backgroundColor: '#f9fafb',
borderRadius: 6,
marginBottom: 6,
},
backupCodeNumber: {
fontSize: 14,
fontWeight: '600',
color: '#6b7280',
width: 24,
},
backupCode: {
fontSize: 16,
fontFamily: 'monospace',
color: '#111827',
fontWeight: '500',
},
doneButton: {
marginTop: 16,
},
})In the (account) group, create an add-mfa.tsx file with the following code. This page adds the functionality for generating the QR code and backup codes.
import { useUser, useReverification, useClerk, useSession } from '@clerk/expo'
import { TOTPResource } from '@clerk/types'
import * as React from 'react'
import {
View,
Text,
Pressable,
TextInput,
ScrollView,
StyleSheet,
ActivityIndicator,
Alert,
} from 'react-native'
import { Href, Link, useRouter } from 'expo-router'
import { QrCodeSvg } from 'react-native-qr-svg'
import { GenerateBackupCodes } from './manage-mfa'
type AddTotpSteps = 'add' | 'verify' | 'backupcodes' | 'success'
type DisplayFormat = 'qr' | 'uri'
function AddTotpScreen({
setStep,
}: {
setStep: React.Dispatch<React.SetStateAction<AddTotpSteps>>
}) {
const { user } = useUser()
const createTOTP = useReverification(() => user?.createTOTP())
const [totp, setTOTP] = React.useState<TOTPResource | undefined>(undefined)
const [displayFormat, setDisplayFormat] = React.useState<DisplayFormat>('uri')
const [loading, setLoading] = React.useState(true)
React.useEffect(() => {
setLoading(true)
void createTOTP()
.then((totp: TOTPResource | undefined) => {
if (totp) setTOTP(totp)
setLoading(false)
})
.catch((err) => {
console.error(JSON.stringify(err, null, 2))
Alert.alert('Error', 'Failed to create TOTP. Please try again.')
setLoading(false)
})
}, [createTOTP])
if (loading) {
return (
<View style={styles.container}>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#6366f1" />
<Text style={styles.loadingText}>Setting up authenticator...</Text>
</View>
</View>
)
}
return (
<ScrollView style={styles.container} contentContainerStyle={styles.contentContainer}>
<Text style={styles.title}>Add TOTP MFA</Text>
<Text style={styles.subtitle}>
Set up two-factor authentication using an authenticator app
</Text>
{totp && (
<View style={styles.card}>
<Text style={styles.cardTitle}>Step 1: Scan or Copy</Text>
{displayFormat === 'qr' && (
<View style={styles.qrSection}>
<View style={styles.qrContainer}>
<QrCodeSvg value={totp?.uri || ''} frameSize={240} />
</View>
<Pressable style={styles.switchButton} onPress={() => setDisplayFormat('uri')}>
<Text style={styles.switchButtonText}>Use URI instead</Text>
</Pressable>
</View>
)}
{displayFormat === 'uri' && (
<View style={styles.uriSection}>
<Text style={styles.uriLabel}>Copy this code into your authenticator app:</Text>
<View style={styles.uriContainer}>
<Text style={styles.uriText} selectable>
{totp.uri}
</Text>
</View>
<Pressable style={styles.switchButton} onPress={() => setDisplayFormat('qr')}>
<Text style={styles.switchButtonText}>Use QR Code instead</Text>
</Pressable>
</View>
)}
<View style={styles.instructionsSection}>
<Text style={styles.instructionText}>
• Open your authenticator app (Google Authenticator, Authy, etc.)
</Text>
<Text style={styles.instructionText}>
• {displayFormat === 'qr' ? 'Scan the QR code' : 'Enter the code manually'}
</Text>
<Text style={styles.instructionText}>• Your app will generate a 6-digit code</Text>
</View>
</View>
)}
<View style={styles.actionButtons}>
<Pressable style={styles.secondaryButton} onPress={() => setStep('add')}>
<Text style={styles.secondaryButtonText}>Reset</Text>
</Pressable>
<Pressable style={styles.primaryButton} onPress={() => setStep('verify')}>
<Text style={styles.primaryButtonText}>Continue to Verify</Text>
</Pressable>
</View>
</ScrollView>
)
}
function VerifyTotpScreen({
setStep,
}: {
setStep: React.Dispatch<React.SetStateAction<AddTotpSteps>>
}) {
const { user } = useUser()
const [code, setCode] = React.useState('')
const [loading, setLoading] = React.useState(false)
const [error, setError] = React.useState('')
const verifyTotp = async () => {
if (!code || code.length !== 6) {
setError('Please enter a valid 6-digit code')
return
}
setLoading(true)
setError('')
try {
await user?.verifyTOTP({ code })
setStep('backupcodes')
} catch (err: any) {
console.error(JSON.stringify(err, null, 2))
setError(err?.errors?.[0]?.message || 'Invalid code. Please try again.')
} finally {
setLoading(false)
}
}
return (
<ScrollView style={styles.container} contentContainerStyle={styles.contentContainer}>
<Text style={styles.title}>Verify TOTP</Text>
<Text style={styles.subtitle}>Enter the 6-digit code from your authenticator app</Text>
<View style={styles.card}>
<Text style={styles.cardTitle}>Step 2: Verify Code</Text>
<View style={styles.inputGroup}>
<Text style={styles.label}>Authenticator Code</Text>
<TextInput
style={[styles.input, error && styles.inputError]}
placeholder="000000"
placeholderTextColor="#9ca3af"
value={code}
onChangeText={(text) => {
setCode(text)
setError('')
}}
keyboardType="number-pad"
maxLength={6}
autoFocus
/>
{error && <Text style={styles.errorText}>{error}</Text>}
</View>
<View style={styles.hintBox}>
<Text style={styles.hintText}>
The code refreshes every 30 seconds in your authenticator app
</Text>
</View>
</View>
<View style={styles.actionButtons}>
<Pressable style={styles.secondaryButton} onPress={() => setStep('add')} disabled={loading}>
<Text style={styles.secondaryButtonText}>Back</Text>
</Pressable>
<Pressable
style={[styles.primaryButton, loading && styles.buttonDisabled]}
onPress={verifyTotp}
disabled={loading}
>
{loading ? (
<ActivityIndicator size="small" color="#ffffff" />
) : (
<Text style={styles.primaryButtonText}>Verify Code</Text>
)}
</Pressable>
</View>
</ScrollView>
)
}
function BackupCodeScreen({
setStep,
}: {
setStep: React.Dispatch<React.SetStateAction<AddTotpSteps>>
}) {
const clerk = useClerk()
const router = useRouter()
const [loading, setLoading] = React.useState(false)
const completeSetup = async () => {
setLoading(true)
setStep('success')
try {
await clerk.setActive({ session: clerk.session?.id })
} catch (err) {
console.error(JSON.stringify(err, null, 2))
} finally {
setLoading(false)
}
}
return (
<ScrollView style={styles.container} contentContainerStyle={styles.contentContainer}>
<View style={styles.successBanner}>
<Text style={styles.successIcon}>✓</Text>
<Text style={styles.successBannerText}>Verification Successful!</Text>
</View>
<Text style={styles.title}>Save Your Backup Codes</Text>
<Text style={styles.subtitle}>
Keep these codes safe. You can use them to access your account if you lose your device.
</Text>
<View style={styles.card}>
<GenerateBackupCodes />
</View>
<View style={styles.warningBox}>
<Text style={styles.warningText}>
⚠ Important: Each backup code can only be used once. Store them securely.
</Text>
</View>
<Pressable
style={[styles.successButton, styles.fullWidthButton, loading && styles.buttonDisabled]}
onPress={completeSetup}
disabled={loading}
>
{loading ? (
<ActivityIndicator size="small" color="#ffffff" />
) : (
<Text style={styles.successButtonText}>Finish Setup</Text>
)}
</Pressable>
</ScrollView>
)
}
function SuccessScreen() {
return (
<View style={styles.container}>
<View style={styles.successContainer}>
<View style={styles.successCircle}>
<Text style={styles.successCheckmark}>✓</Text>
</View>
<Text style={styles.successScreenTitle}>All Set!</Text>
<Text style={styles.successScreenText}>
You have successfully added TOTP MFA via an authentication application.
</Text>
<Text style={styles.successScreenText}>Your account is now more secure.</Text>
<Link href="/(account)/manage-mfa" asChild>
<Pressable style={[styles.primaryButton, styles.fullWidthButton]}>
<Text style={styles.primaryButtonText}>Manage MFA Settings</Text>
</Pressable>
</Link>
</View>
</View>
)
}
export default function AddMFaScreen() {
const [step, setStep] = React.useState<AddTotpSteps>('add')
const { isLoaded, isSignedIn } = useUser()
const { session } = useSession()
// Handle loading state
if (!isLoaded) {
return (
<View style={styles.container}>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#6366f1" />
<Text style={styles.loadingText}>Loading...</Text>
</View>
</View>
)
}
// Handle signed out state
if (!isSignedIn && session?.currentTask?.key !== 'setup-mfa') {
return (
<View style={styles.container}>
<View style={styles.errorContainer}>
<Text style={styles.errorTitle}>Access Denied</Text>
<Text style={styles.warningText}>You must be signed in to access this page</Text>
<Link href="/" asChild>
<Pressable style={styles.primaryButton}>
<Text style={styles.primaryButtonText}>Go Home</Text>
</Pressable>
</Link>
</View>
</View>
)
}
return (
<View style={styles.container}>
{step === 'add' && <AddTotpScreen setStep={setStep} />}
{step === 'verify' && <VerifyTotpScreen setStep={setStep} />}
{step === 'backupcodes' && <BackupCodeScreen setStep={setStep} />}
{step === 'success' && <SuccessScreen />}
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f9fafb',
},
contentContainer: {
padding: 20,
paddingBottom: 40,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
fontSize: 14,
color: '#6b7280',
marginTop: 12,
},
errorContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
errorTitle: {
fontSize: 24,
fontWeight: 'bold',
color: '#dc2626',
marginBottom: 12,
},
title: {
fontSize: 28,
fontWeight: 'bold',
color: '#111827',
marginBottom: 8,
},
subtitle: {
fontSize: 16,
color: '#6b7280',
marginBottom: 24,
lineHeight: 24,
},
card: {
backgroundColor: '#ffffff',
borderRadius: 12,
padding: 20,
marginBottom: 16,
borderWidth: 1,
borderColor: '#e5e7eb',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
cardTitle: {
fontSize: 18,
fontWeight: '600',
color: '#111827',
marginBottom: 16,
},
qrSection: {
alignItems: 'center',
},
qrContainer: {
padding: 16,
backgroundColor: '#ffffff',
borderRadius: 12,
marginBottom: 16,
},
uriSection: {
marginBottom: 12,
},
uriLabel: {
fontSize: 14,
color: '#6b7280',
marginBottom: 8,
},
uriContainer: {
padding: 12,
backgroundColor: '#f9fafb',
borderRadius: 8,
borderWidth: 1,
borderColor: '#e5e7eb',
marginBottom: 12,
},
uriText: {
fontSize: 12,
fontFamily: 'monospace',
color: '#111827',
lineHeight: 18,
},
switchButton: {
paddingVertical: 10,
paddingHorizontal: 16,
backgroundColor: '#f9fafb',
borderRadius: 8,
alignItems: 'center',
},
switchButtonText: {
fontSize: 14,
fontWeight: '600',
color: '#6366f1',
},
instructionsSection: {
marginTop: 20,
paddingTop: 20,
borderTopWidth: 1,
borderTopColor: '#e5e7eb',
},
instructionText: {
fontSize: 14,
color: '#6b7280',
lineHeight: 24,
marginBottom: 8,
},
inputGroup: {
marginBottom: 16,
},
label: {
fontSize: 14,
fontWeight: '600',
color: '#111827',
marginBottom: 8,
},
input: {
height: 56,
backgroundColor: '#f9fafb',
borderWidth: 1,
borderColor: '#e5e7eb',
borderRadius: 8,
paddingHorizontal: 16,
fontSize: 24,
fontWeight: '600',
color: '#111827',
textAlign: 'center',
letterSpacing: 8,
},
inputError: {
borderColor: '#dc2626',
},
errorText: {
fontSize: 12,
color: '#dc2626',
marginTop: 6,
},
hintBox: {
padding: 12,
borderRadius: 8,
backgroundColor: '#f3f4f6',
marginTop: 8,
},
hintText: {
fontSize: 13,
color: '#6b7280',
lineHeight: 18,
textAlign: 'center',
},
actionButtons: {
flexDirection: 'row',
gap: 12,
marginTop: 8,
},
primaryButton: {
flex: 1,
paddingVertical: 14,
paddingHorizontal: 20,
backgroundColor: '#6366f1',
borderRadius: 10,
alignItems: 'center',
justifyContent: 'center',
minHeight: 50,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.15,
shadowRadius: 3,
elevation: 3,
},
primaryButtonText: {
fontSize: 16,
fontWeight: '600',
color: '#ffffff',
},
secondaryButton: {
flex: 1,
paddingVertical: 14,
paddingHorizontal: 20,
backgroundColor: '#f9fafb',
borderWidth: 1,
borderColor: '#e5e7eb',
borderRadius: 10,
alignItems: 'center',
justifyContent: 'center',
minHeight: 50,
},
secondaryButtonText: {
fontSize: 16,
fontWeight: '600',
color: '#6b7280',
},
successButton: {
flex: 1,
paddingVertical: 14,
paddingHorizontal: 20,
backgroundColor: '#10b981',
borderRadius: 10,
alignItems: 'center',
justifyContent: 'center',
minHeight: 50,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.15,
shadowRadius: 3,
elevation: 3,
},
successButtonText: {
fontSize: 16,
fontWeight: '600',
color: '#ffffff',
},
fullWidthButton: {
width: '100%',
marginTop: 8,
},
buttonDisabled: {
opacity: 0.6,
},
successBanner: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#d1fae5',
padding: 16,
borderRadius: 12,
marginBottom: 20,
},
successIcon: {
fontSize: 24,
marginRight: 12,
},
successBannerText: {
fontSize: 18,
fontWeight: '600',
color: '#10b981',
},
warningBox: {
padding: 16,
borderRadius: 10,
backgroundColor: '#fef2f2',
marginTop: 16,
marginBottom: 8,
},
warningText: {
fontSize: 14,
color: '#dc2626',
lineHeight: 20,
textAlign: 'center',
},
successContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
successCircle: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: '#10b981',
justifyContent: 'center',
alignItems: 'center',
marginBottom: 24,
},
successCheckmark: {
fontSize: 48,
color: '#ffffff',
fontWeight: 'bold',
},
successScreenTitle: {
fontSize: 32,
fontWeight: 'bold',
color: '#111827',
marginBottom: 16,
},
successScreenText: {
fontSize: 16,
color: '#6b7280',
textAlign: 'center',
marginBottom: 8,
lineHeight: 24,
},
})This example consists of two SwiftUI views:
- A view where users can enable, disable, and regenerate backup codes for authenticator-app MFA
- A view where users can create a TOTP secret, verify it, and show any generated backup codes
Use the following tabs to view the code necessary for each view.
import SwiftUI
import ClerkKit
struct ManageTOTPView: View {
@Environment(Clerk.self) private var clerk
@State private var generatedBackupCodes = [String]()
var body: some View {
List {
Section("Authenticator application") {
if clerk.user?.totpEnabled == true {
Text("Authenticator app MFA is enabled.")
Button("Remove", role: .destructive) {
Task { await disableTOTP() }
}
} else {
NavigationLink("Add authenticator app MFA") {
AddTOTPView()
}
}
}
if clerk.user?.backupCodeEnabled == true && clerk.user?.twoFactorEnabled == true {
Section("Backup codes") {
if generatedBackupCodes.isEmpty {
Button("Generate new backup codes") {
Task { await regenerateBackupCodes() }
}
} else {
ForEach(generatedBackupCodes, id: \.self) { code in
Text(code)
}
Button("Done") {
generatedBackupCodes = []
}
}
}
}
}
}
private func disableTOTP() async {
do {
guard let user = clerk.user else { return }
try await user.disableTOTP()
} catch {
// See https://clerk.com/docs/guides/development/custom-flows/error-handling
// for more info on error handling
dump(error)
}
}
private func regenerateBackupCodes() async {
do {
guard let user = clerk.user else { return }
generatedBackupCodes = try await user.createBackupCodes().codes
} catch {
// See https://clerk.com/docs/guides/development/custom-flows/error-handling
// for more info on error handling
dump(error)
}
}
}import SwiftUI
import ClerkKit
struct AddTOTPView: View {
@Environment(Clerk.self) private var clerk
@State private var totp: TOTPResource?
@State private var code = ""
@State private var backupCodes = [String]()
@State private var step: Step = .create
enum Step {
case create
case verify
case backupCodes
case success
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
switch step {
case .create:
if let secret = totp?.secret {
Text("Enter this key in your authenticator app:")
Text(secret)
.textSelection(.enabled)
Button("Continue to verification") {
step = .verify
}
} else {
ProgressView()
}
case .verify:
Text("Verify the authenticator app")
TextField("Enter code", text: $code)
Button("Verify") {
Task { await verifyTOTP() }
}
case .backupCodes:
Text("Save these backup codes somewhere safe:")
ForEach(backupCodes, id: \.self) { code in
Text(code)
}
Button("Finish") {
step = .success
}
case .success:
Text("You have successfully added TOTP MFA.")
}
}
.navigationTitle("Add TOTP")
.task { await createTOTP() }
}
private func createTOTP() async {
do {
guard let user = clerk.user else { return }
totp = try await user.createTOTP()
} catch {
// See https://clerk.com/docs/guides/development/custom-flows/error-handling
// for more info on error handling
dump(error)
}
}
private func verifyTOTP() async {
do {
guard let user = clerk.user else { return }
backupCodes = try await user.verifyTOTP(code: code).backupCodes ?? []
step = backupCodes.isEmpty ? .success : .backupCodes
} catch {
// See https://clerk.com/docs/guides/development/custom-flows/error-handling
// for more info on error handling
dump(error)
}
}
}This example consists of two Android screens:
- A screen where users can enable or disable authenticator-app MFA and regenerate backup codes
- A screen where users can create a TOTP secret, verify it, and display the generated backup codes
This example uses the shared secret returned by Clerk directly, so you don't need to install an additional QR-code dependency.
Use the following tabs to view the code necessary for each screen.
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.clerk.api.Clerk
import com.clerk.api.network.serialization.errorMessage
import com.clerk.api.network.serialization.onFailure
import com.clerk.api.network.serialization.onSuccess
import com.clerk.api.user.createBackupCodes
import com.clerk.api.user.disableTotp
import com.clerk.api.user.reload
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
class ManageTotpMfaViewModel : ViewModel() {
private val _generatedBackupCodes = MutableStateFlow<List<String>>(emptyList())
private val _isWorking = MutableStateFlow(false)
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
val uiState = _uiState.asStateFlow()
init {
combine(
Clerk.isInitialized,
Clerk.userFlow,
_generatedBackupCodes,
_isWorking,
) { isInitialized, user, generatedBackupCodes, isWorking ->
when {
!isInitialized -> UiState.Loading
user == null -> UiState.SignedOut
else ->
UiState.Ready(
totpEnabled = user.totpEnabled,
canRegenerateBackupCodes = user.backupCodeEnabled == true && user.twoFactorEnabled,
generatedBackupCodes = generatedBackupCodes,
isWorking = isWorking,
)
}
}.onEach { _uiState.value = it }.launchIn(viewModelScope)
}
fun disableTotp() {
_isWorking.value = true
viewModelScope.launch {
val user = Clerk.user ?: run {
_isWorking.value = false
return@launch
}
user
.disableTotp()
.onSuccess { refreshUser() }
.onFailure {
Log.e("ManageTotpMfa", "Failed to disable TOTP: ${it.errorMessage}")
}
_isWorking.value = false
}
}
fun regenerateBackupCodes() {
_isWorking.value = true
viewModelScope.launch {
val user = Clerk.user ?: run {
_isWorking.value = false
return@launch
}
user
.createBackupCodes()
.onSuccess { _generatedBackupCodes.value = it.codes }
.onFailure {
Log.e("ManageTotpMfa", "Failed to create backup codes: ${it.errorMessage}")
}
_isWorking.value = false
}
}
fun clearBackupCodes() {
_generatedBackupCodes.value = emptyList()
}
private suspend fun refreshUser() {
Clerk.user?.reload()?.onFailure {
Log.e("ManageTotpMfa", "Failed to reload user: ${it.errorMessage}")
}
}
sealed interface UiState {
data object Loading : UiState
data object SignedOut : UiState
data class Ready(
val totpEnabled: Boolean,
val canRegenerateBackupCodes: Boolean,
val generatedBackupCodes: List<String>,
val isWorking: Boolean,
) : UiState
}
}import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
class ManageTotpMfaActivity : ComponentActivity() {
private val viewModel: ManageTotpMfaViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val state by viewModel.uiState.collectAsStateWithLifecycle()
MaterialTheme {
Surface(modifier = Modifier.fillMaxSize()) {
when (val uiState = state) {
ManageTotpMfaViewModel.UiState.Loading -> LoadingState()
ManageTotpMfaViewModel.UiState.SignedOut -> SignedOutState()
is ManageTotpMfaViewModel.UiState.Ready ->
ManageTotpMfaScreen(
state = uiState,
onDisableTotp = viewModel::disableTotp,
onGenerateBackupCodes = viewModel::regenerateBackupCodes,
onClearBackupCodes = viewModel::clearBackupCodes,
onAddTotp = {
startActivity(Intent(this, AddTotpActivity::class.java))
},
)
}
}
}
}
}
}
@Composable
private fun ManageTotpMfaScreen(
state: ManageTotpMfaViewModel.UiState.Ready,
onDisableTotp: () -> Unit,
onGenerateBackupCodes: () -> Unit,
onClearBackupCodes: () -> Unit,
onAddTotp: () -> Unit,
) {
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
Text("Manage authenticator app MFA", style = MaterialTheme.typography.headlineMedium)
if (state.totpEnabled) {
Text("Authenticator app MFA is enabled.")
Button(
modifier = Modifier.fillMaxWidth(),
onClick = onDisableTotp,
enabled = !state.isWorking,
) {
Text("Remove authenticator app")
}
} else {
Text("Authenticator app MFA is not enabled.")
Button(
modifier = Modifier.fillMaxWidth(),
onClick = onAddTotp,
enabled = !state.isWorking,
) {
Text("Add authenticator app")
}
}
if (state.canRegenerateBackupCodes) {
if (state.generatedBackupCodes.isEmpty()) {
Button(
modifier = Modifier.fillMaxWidth(),
onClick = onGenerateBackupCodes,
enabled = !state.isWorking,
) {
Text("Generate new backup codes")
}
} else {
Text("Save these backup codes somewhere safe:")
state.generatedBackupCodes.forEach { code ->
Text(code, style = MaterialTheme.typography.bodyLarge)
}
TextButton(
modifier = Modifier.fillMaxWidth(),
onClick = onClearBackupCodes,
enabled = !state.isWorking,
) {
Text("Done")
}
}
}
}
}
@Composable
private fun LoadingState() {
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.Center,
) {
CircularProgressIndicator()
}
}
@Composable
private fun SignedOutState() {
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.Center,
) {
Text("You must be signed in to manage authenticator app MFA.")
}
}import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.clerk.api.Clerk
import com.clerk.api.network.serialization.errorMessage
import com.clerk.api.network.serialization.onFailure
import com.clerk.api.network.serialization.onSuccess
import com.clerk.api.session.hasMfaRequiredTask
import com.clerk.api.user.attemptTotpVerification
import com.clerk.api.user.createTotp
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
class AddTotpViewModel : ViewModel() {
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
val uiState = _uiState.asStateFlow()
private var didCreateTotp = false
init {
combine(Clerk.isInitialized, Clerk.userFlow, Clerk.sessionFlow) { isInitialized, user, session ->
Triple(isInitialized, user != null, session?.hasMfaRequiredTask == true)
}.onEach { (isInitialized, hasUser, hasSetupMfaTask) ->
when {
!isInitialized -> _uiState.value = UiState.Loading
!hasUser && !hasSetupMfaTask -> _uiState.value = UiState.SignedOut
hasUser && !didCreateTotp -> {
didCreateTotp = true
createTotp()
}
}
}.launchIn(viewModelScope)
}
fun updateCode(code: String) {
val state = _uiState.value
if (state is UiState.NeedsVerification) {
_uiState.value = state.copy(code = code)
}
}
fun verifyCode() {
val state = _uiState.value as? UiState.NeedsVerification ?: return
val user = Clerk.user ?: return
viewModelScope.launch {
user
.attemptTotpVerification(state.code)
.onSuccess {
val backupCodes = it.backupCodes.orEmpty()
if (backupCodes.isEmpty()) {
completeSetupTaskIfNeeded()
_uiState.value = UiState.Success
} else {
_uiState.value = UiState.NeedsBackupCodes(backupCodes)
}
}
.onFailure {
Log.e("AddTotpViewModel", "Failed to verify TOTP: ${it.errorMessage}")
}
}
}
fun finish() {
viewModelScope.launch {
completeSetupTaskIfNeeded()
_uiState.value = UiState.Success
}
}
private fun createTotp() {
val user = Clerk.user ?: return
viewModelScope.launch {
user
.createTotp()
.onSuccess {
_uiState.value =
UiState.NeedsVerification(secret = it.secret.orEmpty(), code = "")
}
.onFailure {
Log.e("AddTotpViewModel", "Failed to create TOTP secret: ${it.errorMessage}")
}
}
}
private suspend fun completeSetupTaskIfNeeded() {
val session = Clerk.session ?: return
if (!session.hasMfaRequiredTask) return
Clerk.auth.setActive(sessionId = session.id).onFailure {
Log.e("AddTotpViewModel", "Failed to complete setup-mfa task: ${it.errorMessage}")
}
}
sealed interface UiState {
data object Loading : UiState
data object SignedOut : UiState
data class NeedsVerification(
val secret: String,
val code: String,
) : UiState
data class NeedsBackupCodes(val codes: List<String>) : UiState
data object Success : UiState
}
}import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
class AddTotpActivity : ComponentActivity() {
private val viewModel: AddTotpViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val state by viewModel.uiState.collectAsStateWithLifecycle()
MaterialTheme {
Surface(modifier = Modifier.fillMaxSize()) {
when (val uiState = state) {
AddTotpViewModel.UiState.Loading -> LoadingState()
AddTotpViewModel.UiState.SignedOut -> SignedOutState()
is AddTotpViewModel.UiState.NeedsVerification ->
VerifyTotpScreen(
state = uiState,
onCodeChanged = viewModel::updateCode,
onVerify = viewModel::verifyCode,
)
is AddTotpViewModel.UiState.NeedsBackupCodes ->
BackupCodesScreen(
codes = uiState.codes,
onFinish = viewModel::finish,
)
AddTotpViewModel.UiState.Success -> SuccessScreen()
}
}
}
}
}
}
@Composable
private fun VerifyTotpScreen(
state: AddTotpViewModel.UiState.NeedsVerification,
onCodeChanged: (String) -> Unit,
onVerify: () -> Unit,
) {
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text("Add authenticator app MFA", style = MaterialTheme.typography.headlineMedium)
Text("Enter this key in your authenticator app:")
Text(state.secret, style = MaterialTheme.typography.bodyLarge)
TextField(
modifier = Modifier.fillMaxWidth(),
value = state.code,
onValueChange = onCodeChanged,
label = { Text("Verification code") },
)
Button(
modifier = Modifier.fillMaxWidth(),
onClick = onVerify,
enabled = state.code.isNotBlank(),
) {
Text("Verify code")
}
}
}
@Composable
private fun BackupCodesScreen(codes: List<String>, onFinish: () -> Unit) {
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text("Save these backup codes somewhere safe:")
codes.forEach { code ->
Text(code, style = MaterialTheme.typography.bodyLarge)
}
Button(modifier = Modifier.fillMaxWidth(), onClick = onFinish) {
Text("Finish")
}
}
}
@Composable
private fun SuccessScreen() {
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.Center,
) {
Text("You have successfully added authenticator app MFA.")
}
}
@Composable
private fun LoadingState() {
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.Center,
) {
CircularProgressIndicator()
}
}
@Composable
private fun SignedOutState() {
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.Center,
) {
Text("You must be signed in to add authenticator app MFA.")
}
}Feedback
Last updated on