Documentation Index
Fetch the complete documentation index at: https://docs.prelude.so/llms.txt
Use this file to discover all available pages before exploring further.
Request a scope
Start a step-up flow for a given scope. The returned challenge tells you what the server wants next — typically an OTP delivery step (verify_email or verify_sms). Code delivery is caller-driven: you pass the challenge to sendStepUpOTP when your UI is ready (see the next section).
- iOS
- Android
- Flutter
- React Native
import PreludeSession
do {
let challenge = try await client.requestStepUp(scope: "transfer:write")
switch challenge.status {
case .blocked:
// Your hook denied this scope.
case .continue, .underReview:
// Drive `challenge.currentStep` (typically "verify_sms" or
// "verify_email") and call submitStepUpOTP below.
break
}
} catch PreludeSessionError.forbidden {
// Scope is not in the allowed_scopes configuration.
}
StepUpChallenge is a value type — each caller holds its own copy, so concurrent step-up flows on a single client don’t share state. The most recent challenge is also available for diagnostics:let active = await client.activeStepUp
import so.prelude.android.session.*
try {
val challenge = client.requestStepUp("transfer:write")
when (challenge.status) {
PreludeStepUpStatus.BLOCKED -> {
// Your hook denied this scope.
}
PreludeStepUpStatus.CONTINUE -> {
// Drive `challenge.currentStep` (typically "verify_sms" or
// "verify_email") and call submitStepUpOTP below.
}
PreludeStepUpStatus.UNDER_REVIEW -> {
// The server is still reviewing — there's nothing to
// submit yet. Poll or show a waiting UI until your
// hook updates the status.
}
}
} catch (e: PreludeSessionError.Forbidden) {
// Scope is not in the allowed_scopes configuration.
}
PreludeStepUpChallenge is an immutable value type — each caller holds its own copy, so concurrent step-up flows on a single client don’t share state. The most recent challenge is also available for diagnostics:val active = client.activeStepUp
import 'package:prelude_flutter_session_sdk/prelude_flutter_session_sdk.dart';
try {
final challenge = await client.requestStepUp(scope: 'transfer:write');
switch (challenge.status) {
case StepUpStatus.blocked:
// Your hook denied this scope.
break;
case StepUpStatus.continueStep:
case StepUpStatus.underReview:
// Drive `challenge.currentStep` (typically "verify_sms" or
// "verify_email") and call submitStepUpOTP below.
break;
}
} on ForbiddenException {
// Scope is not in the allowed_scopes configuration.
}
challengeID. The Dart layer never holds the bearer token, so it can’t end up in your logs or debugger.import { ForbiddenError } from "@prelude.so/react-native-session-sdk";
try {
const challenge = await client.requestStepUp("transfer:write");
switch (challenge.status) {
case "block":
// Your hook denied this scope.
break;
case "continue":
case "review":
// Drive `challenge.currentStep` (typically "verify_sms" or
// "verify_email") and call submitStepUpOTP below.
break;
}
} catch (e) {
if (e instanceof ForbiddenError) {
// Scope is not in the allowed_scopes configuration.
} else {
throw e;
}
}
challengeID. The JS layer never holds the bearer token, so it can’t end up in your logs or debugger.- iOS
- Android
- Flutter
- React Native
| Field | Type | Description |
|---|---|---|
status | StepUpStatus | .continue, .underReview, or .blocked. |
challengeID | String | Server-side identifier for this attempt. |
currentStep | String? | Next server step (e.g. "verify_sms", "verify_email", "completed"). nil when blocked. |
requestedScope | String | The scope passed to requestStepUp. |
| Field | Type | Description |
|---|---|---|
status | PreludeStepUpStatus | CONTINUE, UNDER_REVIEW, or BLOCKED. |
challengeId | String | Server-side identifier for this attempt. |
currentStep | String? | Next server step (e.g. "verify_sms", "verify_email", "completed"). null when blocked or omitted by an older server. |
requestedScope | String | The scope passed to requestStepUp. |
| Field | Type | Description |
|---|---|---|
status | StepUpStatus | continueStep, underReview, or blocked. |
challengeID | String | Server-side identifier for this attempt. |
currentStep | String? | Next server step (e.g. "verify_sms", "verify_email", "completed"). null when blocked. |
requestedScope | String | The scope passed to requestStepUp. |
| Field | Type | Description |
|---|---|---|
status | StepUpStatus | "continue", "review", or "block". |
challengeID | string | Server-side identifier for this attempt. |
currentStep | string | null | Next server step (e.g. "verify_sms", "verify_email", "completed"). null when blocked. |
requestedScope | string | The scope passed to requestStepUp. |
Send the step-up OTP
Whenchallenge.currentStep is verify_email or verify_sms, fire delivery by passing the challenge to sendStepUpOTP. The SDK posts to /otp and the user receives the code on the corresponding identifier.
- iOS
- Android
- Flutter
- React Native
if challenge.currentStep == "verify_email"
|| challenge.currentStep == "verify_sms" {
try await client.sendStepUpOTP(challenge)
}
PreludeSessionError.invalidChallengeToken if challenge is blocked (no token to sign with).if (challenge.currentStep in setOf("verify_email", "verify_sms")) {
client.sendStepUpOTP(challenge)
}
PreludeSessionError.InvalidChallengeToken if the challenge is blocked.if (challenge.currentStep == 'verify_email' ||
challenge.currentStep == 'verify_sms') {
await client.sendStepUpOTP(challenge);
}
InvalidChallengeTokenException if the challenge is blocked.The React Native SDK at
v0.1.0 does not yet expose sendStepUpOTP — requestStepUp returns the challenge handle but no JS API fires POST /otp for it. The current workaround is to configure your step-up policy so the server delivers the code as part of the /stepup/request response (grant_mode: "single-use" with status: "review" works for verify_email / verify_sms), and skip straight to submitStepUpOTP. Explicit sendStepUpOTP support is tracked for a follow-up release./otp on its own, so your UI controls timing and can expose a “Resend code” affordance by calling sendStepUpOTP again with the same challenge.
Submit an OTP step
Submit the code the user entered. The SDK signs/otp/check with a challenge-scoped DPoP proof, advances the challenge, and — when the flow reaches completed — automatically refreshes the session so the next access token carries the granted scope.
When submitStepUpOTP returns a non-null next challenge whose currentStep is another OTP delivery (for example, verify_email → verify_sms), call sendStepUpOTP(next) again to fire the next code before prompting the user.
- iOS
- Android
- Flutter
- React Native
do {
let next = try await client.submitStepUpOTP(challenge, code: "123456")
if let next {
// Multi-step flow: drive `next.currentStep` and call
// submitStepUpOTP again with `next`.
} else {
// Flow complete. The session has been refreshed and the
// new access token includes `transfer:write`.
}
} catch PreludeSessionError.invalidOTPCode {
// Wrong code — you can keep retrying with the same `challenge`
// until the server's retry limit is reached.
} catch PreludeSessionError.invalidChallengeToken {
// Challenge expired or unusable. Restart with requestStepUp.
}
try {
val next = client.submitStepUpOTP(challenge, "123456")
if (next != null) {
// Multi-step flow: drive `next.currentStep` and call
// submitStepUpOTP again with `next`.
} else {
// Flow complete. The session has been refreshed and the
// new access token includes `transfer:write`.
}
} catch (e: PreludeSessionError.InvalidOTPCode) {
// Wrong code — you can keep retrying with the same `challenge`
// until the server's retry limit is reached.
} catch (e: PreludeSessionError.InvalidChallengeToken) {
// Challenge expired or unusable. Restart with requestStepUp.
}
try {
final next = await client.submitStepUpOTP(challenge, '123456');
if (next != null) {
// Multi-step flow: drive `next.currentStep` and call
// submitStepUpOTP again with `next`.
} else {
// Flow complete. The session has been refreshed and the
// new access token includes `transfer:write`.
}
} on InvalidOTPCodeException {
// Wrong code — you can keep retrying with the same `challenge`
// until the server's retry limit is reached.
} on InvalidChallengeTokenException {
// Challenge expired or unusable. Restart with requestStepUp.
}
import {
InvalidChallengeTokenError,
InvalidOTPCodeError,
} from "@prelude.so/react-native-session-sdk";
try {
const next = await client.submitStepUpOTP(challenge, "123456");
if (next !== null) {
// Multi-step flow: drive `next.currentStep` and call
// submitStepUpOTP again with `next`.
} else {
// Flow complete. The session has been refreshed and the
// new access token includes `transfer:write`.
}
} catch (e) {
if (e instanceof InvalidOTPCodeError) {
// Wrong code — you can keep retrying with the same `challenge`
// until the server's retry limit is reached.
} else if (e instanceof InvalidChallengeTokenError) {
// Challenge expired or unusable. Restart with requestStepUp.
} else {
throw e;
}
}
Automatic completion
When the last step is completed, the SDK:- Refreshes the session with the challenge token, minting an access token that carries the granted scope. Concurrent
refresh()callers piggyback on the same in-flight refresh. - Clears the active step-up handle.
- Returns no further step from
submitStepUpOTPto signal the flow is done.
Try it
Try it
This example builds on the project from Introduction. Make sure you have a working OTP login first (OTP Login).1. Create a mock hookGo to mockerapi.com and create a mock API that returns the following JSON on Copy the generated mock URL.2. Configure step-up3. Register the scope4. Replace your entry fileBuild and run, log in with your phone number, then tap Request transfer:write. You’ll receive a second OTP to complete the challenge. After verification, the access token will include the
POST:{
"status": "review",
"granted_for": 300,
"grant_mode": "single-use",
"steps": [
{
"order": 1,
"key": "verify_sms",
"expiration_duration": 600
}
]
}
curl -X POST https://api.prelude.dev/v2/session/apps/${APP_ID}/config/stepup \
-H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"jwks_url": "https://example.com/.well-known/jwks.json",
"step_keys": [],
"allowed_scopes": [
{
"scope": "transfer:write",
"mode": "delegated",
"delegated": { "delegation_hook": "YOUR_MOCK_URL" }
}
]
}'
curl -X POST https://api.prelude.dev/v2/session/apps/${APP_ID}/config/scopes \
-H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \
-H "Content-Type: application/json" \
-d '{ "scope": "transfer:write" }'
- iOS
- Android
- Flutter
- React Native
Replace
ContentView.swift:ContentView.swift
import SwiftUI
import PreludeSession
private let appID = "YOUR_APP_ID"
enum Stage { case login, code, logged, stepUpOTP, done }
@MainActor
final class StepUpModel: ObservableObject {
let client = try! PreludeSessionClient(
endpoint: .custom("https://\(appID).session.prelude.dev")
)
@Published var view: Stage = .login
@Published var error: String?
@Published var profile: PreludeProfile?
var challenge: StepUpChallenge?
func sendLoginOTP(to phone: String) async {
error = nil
do {
try await client.startOTPLogin(
StartOTPLoginOptions(
identifier: PreludeIdentifier(type: .phoneNumber, value: phone)
)
)
view = .code
} catch { self.error = error.localizedDescription }
}
func checkLoginOTP(_ code: String) async {
error = nil
do {
let user = try await client.checkOTP(code)
profile = user.profile
view = .logged
} catch PreludeSessionError.invalidOTPCode {
error = "Wrong code."
} catch { self.error = error.localizedDescription }
}
func requestStepUp() async {
error = nil
do {
let c = try await client.requestStepUp(scope: "transfer:write")
if c.status == .blocked {
error = "Your hook denied this scope."
return
}
if c.currentStep == "verify_email" || c.currentStep == "verify_sms" {
try await client.sendStepUpOTP(c)
}
challenge = c
view = .stepUpOTP
} catch { self.error = error.localizedDescription }
}
func submitStepUpOTP(_ code: String) async {
guard let c = challenge else { return }
error = nil
do {
if let next = try await client.submitStepUpOTP(c, code: code) {
if next.currentStep == "verify_email" || next.currentStep == "verify_sms" {
try await client.sendStepUpOTP(next)
}
challenge = next
} else {
// Completed — refresh to pick up the scoped token.
let user = try await client.refresh()
profile = user.profile
view = .done
}
} catch PreludeSessionError.invalidOTPCode {
error = "Wrong code."
} catch { self.error = error.localizedDescription }
}
}
struct ContentView: View {
@StateObject private var model = StepUpModel()
@State private var phone = ""
@State private var loginCode = ""
@State private var stepUpCode = ""
var body: some View {
VStack(spacing: 12) {
Text("Step-Up Demo").font(.title3)
if let error = model.error {
Text(error).foregroundStyle(.red).font(.caption)
}
switch model.view {
case .login:
TextField("+14155551234", text: $phone).keyboardType(.phonePad)
Button("Send OTP") {
Task { await model.sendLoginOTP(to: phone) }
}.buttonStyle(.borderedProminent)
case .code:
TextField("Login code", text: $loginCode).keyboardType(.numberPad)
Button("Verify") {
Task { await model.checkLoginOTP(loginCode) }
}.buttonStyle(.borderedProminent)
case .logged:
Text("Logged in.").font(.headline)
Button("Request transfer:write") {
Task { await model.requestStepUp() }
}.buttonStyle(.borderedProminent)
case .stepUpOTP:
Text("Enter the step-up code.")
TextField("Code", text: $stepUpCode).keyboardType(.numberPad)
Button("Verify") {
Task { await model.submitStepUpOTP(stepUpCode) }
}.buttonStyle(.borderedProminent)
case .done:
Text("Scope granted.").font(.headline)
if let p = model.profile {
Text("session: \(p.sessionID ?? "-")")
.font(.caption.monospaced())
}
}
}
.textFieldStyle(.roundedBorder)
.padding()
}
}
Replace (
MainActivity.kt:MainActivity.kt
package com.example.sessiontest
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardOptions
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.launch
import so.prelude.android.session.*
import java.net.URL
private const val APP_ID = "YOUR_APP_ID"
enum class StepUpView { Login, Code, Logged, StepUpOtp, Done }
class StepUpViewModel(ctx: android.content.Context) : ViewModel() {
val client = PreludeSessionClient(
context = ctx,
baseUrl = URL("https://$APP_ID.session.prelude.dev"),
)
var view by mutableStateOf(StepUpView.Login); private set
var error by mutableStateOf<String?>(null); private set
var profile by mutableStateOf<PreludeProfile?>(null); private set
private var challenge: PreludeStepUpChallenge? = null
fun sendLoginOtp(phone: String) {
error = null
viewModelScope.launch {
try {
client.startOTPLogin(
StartOTPLoginOptions(
identifier = PreludeIdentifier(
type = PreludeIdentifierType.PHONE_NUMBER,
value = phone,
),
),
)
view = StepUpView.Code
} catch (e: PreludeSessionError) { error = e.message }
}
}
fun checkLoginOtp(code: String) {
error = null
viewModelScope.launch {
try {
val user = client.checkOTP(code)
profile = user.profile
view = StepUpView.Logged
} catch (e: PreludeSessionError.InvalidOTPCode) { error = "Wrong code." }
catch (e: PreludeSessionError) { error = e.message }
}
}
fun requestStepUp() {
error = null
viewModelScope.launch {
try {
val c = client.requestStepUp("transfer:write")
if (c.status == PreludeStepUpStatus.BLOCKED) {
error = "Your hook denied this scope."
return@launch
}
if (c.currentStep in setOf("verify_email", "verify_sms")) {
client.sendStepUpOTP(c)
}
challenge = c
view = StepUpView.StepUpOtp
} catch (e: PreludeSessionError) { error = e.message }
}
}
fun submitStepUpOtp(code: String) {
val c = challenge ?: return
error = null
viewModelScope.launch {
try {
val next = client.submitStepUpOTP(c, code)
if (next != null) {
if (next.currentStep in setOf("verify_email", "verify_sms")) {
client.sendStepUpOTP(next)
}
challenge = next
} else {
profile = client.refresh().profile
view = StepUpView.Done
}
} catch (e: PreludeSessionError.InvalidOTPCode) { error = "Wrong code." }
catch (e: PreludeSessionError) { error = e.message }
}
}
}
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme {
Surface {
val vm: StepUpViewModel = viewModel(factory = viewModelFactory(applicationContext))
var phone by remember { mutableStateOf("") }
var loginCode by remember { mutableStateOf("") }
var stepUpCode by remember { mutableStateOf("") }
Column(Modifier.fillMaxSize().padding(24.dp)) {
Text("Step-Up Demo", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(12.dp))
vm.error?.let {
Text(it, color = MaterialTheme.colorScheme.error)
Spacer(Modifier.height(8.dp))
}
when (vm.view) {
StepUpView.Login -> {
OutlinedTextField(
phone, { phone = it },
label = { Text("+14155551234") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
Button(onClick = { vm.sendLoginOtp(phone) }) { Text("Send OTP") }
}
StepUpView.Code -> {
OutlinedTextField(
loginCode, { loginCode = it },
label = { Text("Login code") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
Button(onClick = { vm.checkLoginOtp(loginCode) }) { Text("Verify") }
}
StepUpView.Logged -> {
Text("Logged in.", style = MaterialTheme.typography.titleSmall)
Spacer(Modifier.height(8.dp))
Button(onClick = { vm.requestStepUp() }) {
Text("Request transfer:write")
}
}
StepUpView.StepUpOtp -> {
Text("Enter the step-up code.")
OutlinedTextField(
stepUpCode, { stepUpCode = it },
label = { Text("Code") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
Button(onClick = { vm.submitStepUpOtp(stepUpCode) }) { Text("Verify") }
}
StepUpView.Done -> {
Text("Scope granted.", style = MaterialTheme.typography.titleSmall)
Text("session: ${vm.profile?.sessionId ?: "-"}")
}
}
}
}
}
}
}
}
viewModelFactory is the small helper defined in Introduction.)Replace
lib/main.dart:lib/main.dart
import 'package:flutter/material.dart';
import 'package:prelude_flutter_session_sdk/prelude_flutter_session_sdk.dart';
const appID = 'YOUR_APP_ID';
void main() => runApp(const MyApp());
enum _Screen { login, code, logged, stepUpOtp, done }
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
late final client = PreludeSessionClient(
endpoint: Endpoint.custom('https://$appID.session.prelude.dev'),
);
final _phone = TextEditingController();
final _loginCode = TextEditingController();
final _stepUpCode = TextEditingController();
_Screen _view = _Screen.login;
StepUpChallenge? _challenge;
PreludeProfile? _profile;
String? _error;
Future<void> _sendLoginOtp() async {
setState(() => _error = null);
try {
await client.startOTPLogin(
StartOTPLoginOptions(
identifier: PreludeIdentifier.phoneNumber(_phone.text),
),
);
setState(() => _view = _Screen.code);
} on PreludeSessionException catch (e) {
setState(() => _error = e.message);
}
}
Future<void> _checkLoginOtp() async {
setState(() => _error = null);
try {
final user = await client.checkOTP(_loginCode.text);
setState(() {
_profile = user.profile;
_view = _Screen.logged;
});
} on InvalidOTPCodeException {
setState(() => _error = 'Wrong code.');
} on PreludeSessionException catch (e) {
setState(() => _error = e.message);
}
}
Future<void> _requestStepUp() async {
setState(() => _error = null);
try {
final c = await client.requestStepUp(scope: 'transfer:write');
if (c.status == StepUpStatus.blocked) {
setState(() => _error = 'Your hook denied this scope.');
return;
}
if (c.currentStep == 'verify_email' ||
c.currentStep == 'verify_sms') {
await client.sendStepUpOTP(c);
}
setState(() {
_challenge = c;
_view = _Screen.stepUpOtp;
});
} on PreludeSessionException catch (e) {
setState(() => _error = e.message);
}
}
Future<void> _submitStepUpOtp() async {
final c = _challenge;
if (c == null) return;
setState(() => _error = null);
try {
final next = await client.submitStepUpOTP(c, _stepUpCode.text);
if (next != null) {
if (next.currentStep == 'verify_email' ||
next.currentStep == 'verify_sms') {
await client.sendStepUpOTP(next);
}
setState(() => _challenge = next);
} else {
final user = await client.refresh();
setState(() {
_profile = user.profile;
_view = _Screen.done;
});
}
} on InvalidOTPCodeException {
setState(() => _error = 'Wrong code.');
} on PreludeSessionException catch (e) {
setState(() => _error = e.message);
}
}
@override
void dispose() {
_phone.dispose();
_loginCode.dispose();
_stepUpCode.dispose();
client.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
Widget body;
switch (_view) {
case _Screen.login:
body = Column(mainAxisSize: MainAxisSize.min, children: [
TextField(
controller: _phone,
keyboardType: TextInputType.phone,
decoration: const InputDecoration(labelText: '+14155551234'),
),
const SizedBox(height: 12),
FilledButton(onPressed: _sendLoginOtp, child: const Text('Send OTP')),
]);
case _Screen.code:
body = Column(mainAxisSize: MainAxisSize.min, children: [
TextField(
controller: _loginCode,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: 'Login code'),
),
const SizedBox(height: 12),
FilledButton(onPressed: _checkLoginOtp, child: const Text('Verify')),
]);
case _Screen.logged:
body = Column(mainAxisSize: MainAxisSize.min, children: [
const Text('Logged in.'),
const SizedBox(height: 12),
FilledButton(
onPressed: _requestStepUp,
child: const Text('Request transfer:write'),
),
]);
case _Screen.stepUpOtp:
body = Column(mainAxisSize: MainAxisSize.min, children: [
const Text('Enter the step-up code.'),
TextField(
controller: _stepUpCode,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: 'Code'),
),
const SizedBox(height: 12),
FilledButton(onPressed: _submitStepUpOtp, child: const Text('Verify')),
]);
case _Screen.done:
body = Column(mainAxisSize: MainAxisSize.min, children: [
const Text('Scope granted.', style: TextStyle(fontSize: 18)),
const SizedBox(height: 8),
Text('session: ${_profile?.sessionID ?? "-"}',
style: const TextStyle(fontFamily: 'monospace')),
]);
}
return MaterialApp(
home: Scaffold(
body: Padding(
padding: const EdgeInsets.all(24),
child: Center(
child: Column(mainAxisSize: MainAxisSize.min, children: [
const Text('Step-Up Demo', style: TextStyle(fontSize: 18)),
const SizedBox(height: 12),
if (_error != null) ...[
Text(_error!, style: const TextStyle(color: Colors.red)),
const SizedBox(height: 8),
],
body,
]),
),
),
),
);
}
}
Replace Configure your
app/index.tsx:app/index.tsx
import {
Endpoint,
InvalidOTPCodeError,
PreludeIdentifier,
PreludeProfile,
PreludeSessionClient,
PreludeSessionError,
StepUpChallenge,
} from "@prelude.so/react-native-session-sdk";
import { useEffect, useRef, useState } from "react";
import { Button, Text, TextInput, View } from "react-native";
const APP_ID = "YOUR_APP_ID";
type Stage = "login" | "code" | "logged" | "stepUpOtp" | "done";
export default function Home() {
const clientRef = useRef<PreludeSessionClient | null>(null);
const [stage, setStage] = useState<Stage>("login");
const [phone, setPhone] = useState("");
const [loginCode, setLoginCode] = useState("");
const [stepUpCode, setStepUpCode] = useState("");
const [profile, setProfile] = useState<PreludeProfile | null>(null);
const challengeRef = useRef<StepUpChallenge | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const c = new PreludeSessionClient({
endpoint: Endpoint.custom(`https://${APP_ID}.session.prelude.dev`),
});
clientRef.current = c;
return () => { c.dispose().catch(() => {}); };
}, []);
async function run(fn: () => Promise<void>) {
setError(null);
try { await fn(); }
catch (e) {
if (e instanceof InvalidOTPCodeError) setError("Wrong code.");
else if (e instanceof PreludeSessionError) setError(e.message);
else setError(String(e));
}
}
const sendLoginOtp = () => run(async () => {
await clientRef.current!.startOTPLogin({
identifier: PreludeIdentifier.phoneNumber(phone),
});
setStage("code");
});
const checkLoginOtp = () => run(async () => {
const user = await clientRef.current!.checkOTP(loginCode);
setProfile(user.profile);
setStage("logged");
});
const requestStepUp = () => run(async () => {
const c = await clientRef.current!.requestStepUp("transfer:write");
if (c.status === "block") {
setError("Your hook denied this scope.");
return;
}
challengeRef.current = c;
setStage("stepUpOtp");
});
const submitStepUp = () => run(async () => {
const c = challengeRef.current;
if (!c) return;
const next = await clientRef.current!.submitStepUpOTP(c, stepUpCode);
if (next !== null) {
challengeRef.current = next;
} else {
const user = await clientRef.current!.refresh();
setProfile(user.profile);
setStage("done");
}
});
return (
<View style={{ padding: 24, gap: 8 }}>
<Text style={{ fontSize: 18 }}>Step-Up Demo</Text>
{error && <Text style={{ color: "red" }}>{error}</Text>}
{stage === "login" && (
<>
<TextInput
placeholder="+14155551234"
value={phone}
onChangeText={setPhone}
keyboardType="phone-pad"
style={{ borderWidth: 1, padding: 8 }}
/>
<Button title="Send OTP" onPress={sendLoginOtp} />
</>
)}
{stage === "code" && (
<>
<TextInput
placeholder="Login code"
value={loginCode}
onChangeText={setLoginCode}
keyboardType="number-pad"
style={{ borderWidth: 1, padding: 8 }}
/>
<Button title="Verify" onPress={checkLoginOtp} />
</>
)}
{stage === "logged" && (
<>
<Text style={{ fontWeight: "600" }}>Logged in.</Text>
<Button title="Request transfer:write" onPress={requestStepUp} />
</>
)}
{stage === "stepUpOtp" && (
<>
<Text>Enter the step-up code.</Text>
<TextInput
placeholder="Code"
value={stepUpCode}
onChangeText={setStepUpCode}
keyboardType="number-pad"
style={{ borderWidth: 1, padding: 8 }}
/>
<Button title="Verify" onPress={submitStepUp} />
</>
)}
{stage === "done" && (
<>
<Text style={{ fontWeight: "600" }}>Scope granted.</Text>
<Text>session: {profile?.sessionID ?? "-"}</Text>
</>
)}
</View>
);
}
prld:pwd:write (or transfer:write) step-up policy so the server delivers the OTP as part of /stepup/request — see the note in Send the step-up OTP. Explicit client.sendStepUpOTP arrives in a future RN SDK release.transfer:write scope.