/**
* Step-up (sudo) 2FA composable.
*
* Wraps a sensitive admin action so that when the backend responds with a
* STEP_UP_REQUIRED error, the caller can prompt for a TOTP code, obtain a
* short-lived grant, and transparently retry the original action.
*
* Usage in a view:
* const stepUp = useStepUp()
* async function exportData() {
* await stepUp.run(() => adminAPI.accounts.exportData(...))
* }
* // template:
*/
import { ref } from 'vue'
/** Error codes the backend uses to signal step-up state. */
const STEP_UP_REQUIRED = 'STEP_UP_REQUIRED'
const STEP_UP_TOTP_NOT_ENABLED = 'STEP_UP_TOTP_NOT_ENABLED'
const STEP_UP_ADMIN_API_KEY_FORBIDDEN = 'STEP_UP_ADMIN_API_KEY_FORBIDDEN'
/**
* Thrown by run() when the user dismisses the TOTP dialog.
* Callers should treat it as a silent no-op, not an error to toast.
*/
export class StepUpCancelledError extends Error {
readonly code = 'STEP_UP_CANCELLED'
constructor() {
super('step-up verification cancelled by user')
this.name = 'StepUpCancelledError'
}
}
export function isStepUpCancelled(err: unknown): boolean {
return err instanceof StepUpCancelledError
}
interface ApiError {
status?: number
code?: string | number
reason?: string
message?: string
}
/** Extract the semantic error marker from either envelope shape (code or reason). */
function markerOf(err: unknown): string {
const e = (err ?? {}) as ApiError
const candidates = [e.code, e.reason].map((v) => (typeof v === 'string' ? v : ''))
return candidates.find((v) => v.startsWith('STEP_UP')) || ''
}
export function isStepUpRequired(err: unknown): boolean {
return markerOf(err) === STEP_UP_REQUIRED
}
export function isStepUpBlocked(err: unknown): boolean {
const m = markerOf(err)
return m === STEP_UP_TOTP_NOT_ENABLED || m === STEP_UP_ADMIN_API_KEY_FORBIDDEN
}
export function stepUpBlockReason(err: unknown): string {
return markerOf(err)
}
export type StepUpController = ReturnType
export function useStepUp() {
const visible = ref(false)
const blockedReason = ref('')
let resolver: ((ok: boolean) => void) | null = null
/** Open the TOTP dialog and resolve true once a grant is obtained. */
function prompt(): Promise {
visible.value = true
return new Promise((resolve) => {
resolver = resolve
})
}
function onVerified() {
visible.value = false
resolver?.(true)
resolver = null
}
function onCancel() {
visible.value = false
resolver?.(false)
resolver = null
}
/**
* Run a sensitive action. On STEP_UP_REQUIRED, prompt for a TOTP code and
* retry once. STEP_UP_TOTP_NOT_ENABLED / admin-api-key errors are surfaced
* to the caller (they cannot be resolved by entering a code). If the user
* cancels the prompt, a StepUpCancelledError is thrown so callers can
* distinguish "user changed their mind" from real failures.
*/
async function run(action: () => Promise): Promise {
try {
return await action()
} catch (err) {
if (isStepUpBlocked(err)) {
blockedReason.value = markerOf(err)
throw err
}
if (!isStepUpRequired(err)) {
throw err
}
const ok = await prompt()
if (!ok) {
throw new StepUpCancelledError()
}
// Retry once now that the session holds a step-up grant.
return await action()
}
}
return {
visible,
blockedReason,
prompt,
onVerified,
onCancel,
run
}
}