Eliminar la cuenta
Al terminar: Eliminar la cuenta con una función de la base de datos y un botón en las dos apps (lo que exigen las tiendas), y comprobar que ya no puedes entrar.
Las dos tiendas piden lo mismo: si una app deja crear una cuenta, tiene que dejar eliminarla desde dentro. Sin esa opción, la revisión rechaza la app. Recuperar la contraseña y El enlace del correo y la contraseña nueva dejaron hecha la recuperación y el cambio de contraseña; esta lección añade lo que falta: «Eliminar mi cuenta», con una prueba real de borrado al final.
Lo curioso es que borrar la propia cuenta no tiene endpoint en Supabase. Hay que crear una función en la base de datos, llamarla desde la app, y decidir en qué orden se borra y se cierra la sesión. Y hay un detalle que cuida al usuario: si el borrado falla, las dos apps se lo dicen. No se instala nada.
Paso 1 · La función que borra, en la base de datos
Por qué no hay endpoint. Los usuarios de Supabase viven en una tabla del sistema, auth.users, que ninguna llamada de la app puede tocar. El único endpoint que borra usuarios (DELETE /auth/v1/admin/users/{id}) exige la clave de servicio (service_role), la que Preparar Supabase dijo que no se pone jamás en una app: cualquiera la sacaría del binario y podría borrar a todo el mundo. Y si existiera un endpoint genérico de «borra mi usuario», el problema sería el contrario: daría a cualquier token permiso sobre una tabla del sistema.
La vía documentada es una función de la base de datos que corre con los permisos de quien la creó, mira quién la llama y borra solo a esa persona. La app la llama como un procedimiento remoto (RPC, remote procedure call): una petición POST a /rest/v1/rpc/delete_user.
Cuatro palabras nuevas antes del código. Es la primera vez que aparece una base de datos de servidor, y trae su vocabulario:
- Una base de datos guarda tablas (filas y columnas, como una hoja de cálculo; una fila es un registro, aquí un usuario). SQL es el idioma para pedirle cosas.
- Un esquema es una carpeta dentro de la base de datos que agrupa tablas y funciones:
authguarda lo de los usuarios,publiclo que expone la API del proyecto. - Una función de la base de datos es como una función de Kotlin, pero vive en el servidor de Supabase y la ejecuta él.
- Un rol es un tipo de visitante con sus permisos:
anones quien solo trae la clave pública, sin sesión;authenticated, quien ha iniciado sesión.
Dónde se escribe. En el panel de Supabase, abre tu proyecto, en la barra lateral SQL Editor, crea una consulta nueva, pega esto y pulsa Run. No hace falta entender el SQL entero: los comentarios dicen lo importante.
-- Crea la función `delete_user` (o la sustituye si ya existía) en el esquema
-- `public`, el que expone la API REST del proyecto. No recibe nada: quién eres
-- lo saca de tu token, no lo manda la app.
create or replace function public.delete_user()
-- `returns void`: no devuelve ningún valor. `language plpgsql`: el lenguaje en
-- que está escrito el cuerpo (el de SQL con `if`, `begin` y `end`).
returns void
language plpgsql
-- `security definer`: la función se ejecuta con los permisos de quien la crea
-- (el administrador), no con los de quien la llama. Es lo que le permite
-- tocar `auth.users`.
security definer
-- `set search_path = ''`: sin esto, una función `security definer` puede ser
-- engañada para ejecutar código de otro esquema con permisos elevados. Es la
-- línea que no se quita.
set search_path = ''
-- Lo que va entre los dos `$$` es el cuerpo de la función, como las llaves de
-- Kotlin.
as $$
begin
-- `auth.uid()` es el identificador de quien llama, sacado del token. Si no
-- hay token, es nulo y se corta aquí.
if auth.uid() is null then
raise exception 'no hay sesión';
end if;
-- Borra solo la fila de quien llama. Nadie puede borrar a otro.
delete from auth.users where id = auth.uid();
end;
$$;
-- Quién puede llamarla. Aquí `public` es el rol que agrupa a todo el mundo
-- (no el esquema `public` de arriba, aunque se llamen igual) y `anon` es quien
-- solo trae la clave pública, sin sesión. Se les quita el permiso a los dos y
-- se da solo a quien ha iniciado sesión (`authenticated`): sin esto,
-- cualquiera con la clave pública de la app podría ejecutarla.
revoke all on function public.delete_user() from public, anon;
grant execute on function public.delete_user() to authenticated;El panel contesta «Success. No rows returned». Provócalo tú: en el mismo editor ejecuta select public.delete_user();. Falla con no hay sesión, porque el editor no lleva el token de ningún usuario. Esa es la protección funcionando: sin identidad, la función no borra nada.
Paso 2 · La llamada en la API de la cuenta
El archivo. SupabaseAccountApi.kt ya existe (El enlace del correo y la contraseña nueva, paso 2). Gana una operación, y como la dirección del procedimiento hay que construirla con la URL del proyecto, la clase pasa a recibir también el AppConfig (la configuración de Configuración y secretos por entorno). Cuatro cambios:
// 1. Import nuevo, arriba (Option+Enter sobre `post` en rojo lo añade; si
// pregunta de cuál, el de `io.ktor.client.request`).
// `AppConfig` ya está importado desde `createSupabaseAccountClient`:
import io.ktor.client.request.post
// 2. En la interfaz, debajo de `cambiarPassword`:
suspend fun borrarCuenta()
// 3. La cabecera de la clase pasa a recibir el AppConfig (los dos parámetros):
internal class KtorSupabaseAccountApi(
private val client: HttpClient,
// Hace falta para construir la dirección de borrar la cuenta.
private val config: AppConfig,
) : SupabaseAccountApi {
// 4. Dentro de la clase, debajo de `cambiarPassword`:
// Borra la cuenta de quien tiene la sesión. La dirección es absoluta porque
// el cliente apunta a `/auth/v1/` y esto vive en `/rest/v1/`, otra API del
// mismo proyecto. `exigirExito()` es la función privada que escribiste
// en esta misma clase, junto a `cambiarPassword`: aquí se reutiliza.
// `trimEnd('/')` quita la barra final de la URL, si la tiene, para no
// escribir dos seguidas.
override suspend fun borrarCuenta() {
client.post(config.supabaseUrl.trimEnd('/') + "/rest/v1/rpc/delete_user")
.exigirExito()
}Ese constructor nuevo rompe dos sitios, y Android Studio los marca en rojo: Modules.kt y el test de la API. Los dos se arreglan aquí.
Koin. En Modules.kt, la línea de KtorSupabaseAccountApi gana un get(): el AppConfig.
single<SupabaseAccountApi> { KtorSupabaseAccountApi(get(SUPABASE_CUENTA), get()) }Los tests de la API. SupabaseAccountApiTest.kt construye la API en cinco sitios: los de El cliente que lleva tu sesión que llaman a KtorSupabaseAccountApi. Cada sitio necesita el segundo argumento, ConfigDePrueba() (la configuración de mentira de todos tus tests). El cambio es el mismo en cada uno, una línea debajo del createSupabaseAccountClient(…):
// Antes:
KtorSupabaseAccountApi(
createSupabaseAccountClient(ConfigDePrueba(), proveedor(repo), motor),
)
// Después:
KtorSupabaseAccountApi(
createSupabaseAccountClient(ConfigDePrueba(), proveedor(repo), motor),
ConfigDePrueba(),
)Los cinco están en estos tests (el fichero tiene un sexto, el token no sale hacia otro dominio, que no construye la API y no cambia):
la peticion sale ya con la cabecera y no espera al 401un 401 refresca y repite la peticion con el token nuevotres 401 a la vez refrescan una sola vezun 403 de auth v1 refresca y repite la peticion con el token nuevotras un 403 la siguiente peticion ya sale con el token nuevo
Ejecuta ./gradlew :shared:jvmTest en la Terminal de Android Studio: deben pasar los seis tests de SupabaseAccountApiTest.
Paso 3 · El orden: primero borrar, después salir
El archivo. AccountRepository.kt ya existe y gana borrarCuenta. Para cerrar la sesión necesita el SessionRepository, así que su constructor suma un parámetro. Es el fichero entero, que es corto:
package com.sebastiannarvaez.pokedex.data
import com.sebastiannarvaez.pokedex.core.AppDispatchers
import com.sebastiannarvaez.pokedex.data.network.SupabaseAccountApi
import kotlinx.coroutines.withContext
// Lo que se hace CON la cuenta, ya dentro.
internal class AccountRepository(
private val api: SupabaseAccountApi,
// Nuevo: hace falta para cerrar la sesión tras borrar la cuenta. Puede
// depender de la sesión porque nadie depende de este repositorio.
private val session: SessionRepository,
private val dispatchers: AppDispatchers,
) {
suspend fun cambiarPassword(nueva: String) = withContext(dispatchers.io) {
api.cambiarPassword(nueva)
}
// Nuevo.
suspend fun borrarCuenta() = withContext(dispatchers.io) {
api.borrarCuenta()
// Se cierra la sesión DESPUÉS de borrar, no antes: si el borrado
// falla, el usuario sigue dentro y puede reintentar. Al revés quedaría
// fuera de una cuenta que aún existe, sin token para volver a probar.
session.salir()
}
}Koin. Con el parámetro nuevo, la línea de AccountRepository en Modules.kt gana otro get(), el de SessionRepository:
single { AccountRepository(get(), get(), get()) }El ViewModel. En AccountViewModel.kt, encima de descartarError:
fun borrarCuenta() {
// Una sola petición a la vez: dos toques seguidos no borran dos veces.
if (_state.value.trabajando) return
_state.value = AccountState(trabajando = true)
viewModelScope.launch {
_state.value = try {
repository.borrarCuenta()
// No se pone ningún estado de éxito: al borrar la cuenta se
// cierra la sesión, y la app vuelve sola a la pantalla de
// entrada. Anunciarlo sería un mensaje que nadie llega a leer.
AccountState()
} catch (e: Throwable) {
// Si falla, el error queda en el estado: las dos pantallas lo
// enseñan, y la sesión sigue abierta.
AccountState(error = e.aUiErrorDeAuth())
}
}
}Los tests. Hay uno que fija el orden de arriba, porque es el tipo de cosa que alguien «ordena» en una limpieza del código sin saber lo que rompe: si el borrado falla la sesion sigue viva. El fichero trae cuatro tests: los dos del borrado (cierra la sesión; y el del orden) y otros dos que El enlace del correo y la contraseña nueva dejó sin probar, porque la clase de la cuenta aún no tenía su fichero de tests: que el enlace del correo deja al usuario dentro (adoptar) y que la recuperación apunta al esquema de la app. Viven aquí porque este es el fichero de tests de «la cuenta». Repiten el patrón de los de Autenticación en el núcleo: la API, así que se copia. Clic derecho sobre shared → New → File, ruta src/commonTest/kotlin/com/sebastiannarvaez/pokedex/data/AccountRepositoryTest.kt, Copiar fichero, seleccionar todo y pegar:
Ejecuta ./gradlew :shared:jvmTest en la Terminal de Android Studio. Deben pasar los cuatro tests de AccountRepositoryTest, los seis de EnlaceDeRecuperacionTest y todos los anteriores.
Provócalo tú (opcional). El test del orden existe para protegerlo. Compruébalo: en AccountRepository.kt mueve session.salir() encima de api.borrarCuenta() y ejecuta ./gradlew :shared:jvmTest --tests '*AccountRepositoryTest*'. Falla si el borrado falla la sesion sigue viva, con AssertionError: Expected value to be true.: el test hace que el borrado lance un error y espera que la sesión siga abierta, y con el orden cambiado salir() ya la cerró antes. A una persona le pasaría esto: pulsa «Eliminar», no hay red, el borrado falla, pero la app ya la ha sacado, y se queda fuera de una cuenta que sigue existiendo, sin token para reintentar. Devuelve las dos líneas a su sitio y vuelve a ejecutar los tests. Es un test que no comprueba una función sino una decisión: el orden de dos líneas que cualquiera ordenaría al revés en una limpieza.
Paso 4 · «Eliminar mi cuenta» en Android
El archivo. CuentaHoja.kt es la hoja inferior de La puerta y la cuenta en Android. Gana un botón rojo, un diálogo de confirmación (borrar una cuenta no se deshace, así que se pregunta; es el AlertDialog de Recuperar la contraseña), un aviso si el borrado falla y su propio AccountViewModel. Antes de tocarla, la forma que tiene el fichero que ya tienes (los … son lo que no cambia), para saber dónde cae cada cambio:
@Composable
fun CuentaHoja(session: Session, alCerrar: () -> Unit, alSalir: () -> Unit) { // cambios 1 y 2
ModalBottomSheet(onDismissRequest = alCerrar, sheetState = rememberModalBottomSheetState()) { // cambio 3
Column(…) {
Row { … el círculo con el icono y el correo … }
Text("Tus favoritos se guardan…")
OutlinedButton(onClick = alSalir, …) { Text("Cerrar sesión") }
// aquí, cambio 4
}
}
// aquí, cambio 5
}Son cinco cambios en el fichero, de arriba abajo:
// 1. Imports nuevos, arriba (Option+Enter sobre cada nombre en rojo los añade;
// añade los que falten, algunos pueden estar ya):
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.TextButton
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.sebastiannarvaez.pokedex.feature.auth.AccountViewModel
import org.koin.compose.viewmodel.koinViewModel
// 2. La firma recibe el ViewModel de la cuenta, y dos líneas nuevas encima del
// `ModalBottomSheet`. `koinViewModel()` lo pide al grafo de Koin, como en
// `Raiz.kt`; como tiene valor por defecto, quien llama a `CuentaHoja` no cambia.
@Composable
fun CuentaHoja(
session: Session,
alCerrar: () -> Unit,
alSalir: () -> Unit,
cuenta: AccountViewModel = koinViewModel(),
) {
// Si el diálogo de confirmación está abierto.
var confirmandoBorrado by remember { mutableStateOf(false) }
// Lo que le pasa a la cuenta: trabajando, error...
val estado by cuenta.state.collectAsStateWithLifecycle()
// 3. El `onDismissRequest` de la hoja. Al cerrar la hoja se olvida el error:
// el ViewModel vive lo que la actividad, y si no, reaparecería al volver
// a abrirla. Antes era `onDismissRequest = alCerrar`.
ModalBottomSheet(
onDismissRequest = {
cuenta.descartarError()
alCerrar()
},
sheetState = rememberModalBottomSheetState(),
) {
// 4. Dentro de la `Column`, debajo del `OutlinedButton` de «Cerrar sesión»:
// Eliminar la cuenta es obligatorio en las dos tiendas si la app
// deja crearla. No es una cortesía: sin esta opción, la revisión la
// rechaza.
TextButton(
onClick = { confirmandoBorrado = true },
enabled = !estado.trabajando,
modifier = Modifier.fillMaxWidth(),
) {
Text("Eliminar mi cuenta", color = MaterialTheme.colorScheme.error)
}
// Si el borrado falla, la hoja lo dice y la sesión sigue abierta.
estado.error?.let {
Text(
it.detalle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
// 5. Después de la llave que cierra el `ModalBottomSheet`, antes de la llave
// que cierra `CuentaHoja`:
if (confirmandoBorrado) {
AlertDialog(
onDismissRequest = { confirmandoBorrado = false },
title = { Text("¿Eliminar tu cuenta?") },
text = {
Text(
"Se borra tu usuario en el servidor y no se puede deshacer. " +
"Tus favoritos, que están en este teléfono, no se tocan.",
)
},
confirmButton = {
TextButton(
onClick = {
// Primero se cierra el diálogo, luego se lanza el
// borrado: solo tras confirmar.
confirmandoBorrado = false
cuenta.borrarCuenta()
},
) {
Text("Eliminar", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { confirmandoBorrado = false }) { Text("Cancelar") }
},
)
}Fíjate en el cambio 3: este AccountViewModel lo pide la hoja dentro de la entrada de Ajustes, y esa entrada de Navigation 3 tiene su propio almacén, así que no es la misma instancia que usa el diálogo de contraseña nueva en la raíz (como viste en La puerta y la cuenta en Android). La sesión sí es compartida, porque vive en el repositorio, que es único; el ViewModel de la hoja solo guarda el estado de «eliminar». Aun así dura lo que la entrada de Ajustes, y si la hoja no olvidara el error al cerrarse, un fallo de hoy reaparecería la próxima vez que la abras dentro de esa visita a Ajustes.
No hay que cerrar la hoja a mano tras borrar: al borrarse la cuenta se cierra la sesión, el guardián de la raíz (La puerta y la cuenta en Android) deja de componer la app y aparece el formulario.
Paso 5 · «Eliminar mi cuenta» en iOS
El archivo. SettingsView.swift ya tiene la sección «Cuenta» de La puerta y la cuenta en iOS. Cuatro añadidos, cada uno en su sitio. Los dos últimos hacen que un fallo del borrado se vea, igual que en Android:
// 1. Estado nuevo, debajo de `@State private var confirmandoSalida = false`:
// Lo mismo para borrar la cuenta, con su propio ViewModel (el de la cuenta,
// que ya existe en el núcleo desde la lección anterior) y el texto del
// error, si el borrado falla.
@State private var confirmandoBorrado = false
@State private var cuenta: AccountViewModel?
@State private var errorDeBorrado: String?
// 2. En la sección Cuenta, debajo del botón «Cerrar sesión»:
Button("Eliminar mi cuenta", role: .destructive) {
confirmandoBorrado = true
}
// 3. Debajo del `.confirmationDialog` de «¿Cerrar la sesión?», dos modificadores:
.confirmationDialog(
"¿Eliminar tu cuenta?",
isPresented: $confirmandoBorrado,
titleVisibility: .visible
) {
Button("Eliminar", role: .destructive) { cuenta?.borrarCuenta() }
Button("Cancelar", role: .cancel) { }
} message: {
Text("Se borra tu usuario en el servidor y no se puede deshacer. Tus favoritos, que están en este iPhone, no se tocan.")
}
// La alerta está abierta mientras haya un error. Al cerrarla se
// le dice al ViewModel que lo olvide, y eso la apaga.
.alert(
"No se pudo eliminar la cuenta",
isPresented: Binding(
get: { errorDeBorrado != nil },
set: { if !$0 { cuenta?.descartarError() } }
)
) {
Button("Entendido", role: .cancel) { }
} message: {
Text(errorDeBorrado ?? "")
}
// 4. Una `.task` nueva. Va junto a las otras `.task` de la pantalla, justo
// antes de la de `RecordatorioDiario` (la de `permiso = await …`) y, por
// tanto, después de la que escucha la sesión:
.task {
guard AuthIosKt.hayCuentas() else { return }
// El ViewModel de la cuenta, creado una sola vez.
let vm = cuenta ?? AuthIosKt.accountViewModel(owner: owner)
cuenta = vm
do {
// Cada cambio del estado de la cuenta: si trae error, se enseña.
for try await nuevo in asyncSequence(for: vm.accountStateForIosFlow) {
errorDeBorrado = nuevo.error?.detalle
}
} catch { }
}Dos piezas que conviene mirar despacio. (isPresented: Binding(get:set:) y .alert son los de Ajustes en iOS y El formulario de entrada en iOS; aquí solo cambia de dónde sale el Bool.)
- El
Bindingde la alerta no apunta a un@State(donde bastaría un$variable): se construye a mano conBinding(get:set:), que lee de un sitio y escribe en otro, como en el selector de Ajustes en iOS.getabre la alerta mientras haya un error, yset, cuando el usuario la cierra, le dice al ViewModel que olvide el error. Con eso la alerta se apaga sola, sin un segundo@Stateque mantener sincronizado. - La
.tasknueva escucha el estado de la cuenta con el mismo patrón que la de la sesión:asyncSequence(for: vm.accountStateForIosFlow)convierte el flujo de Kotlin en algo que Swift recorre confor try await. Por eso es una.taskpropia: la de la sesión no sabe nada de la cuenta.
Una rareza de iOS 26: el confirmationDialog sale como un globo junto al botón y el botón con role: .cancel (el «Cancelar») no se ve; se cancela tocando fuera del globo. En versiones anteriores sí aparece. El código lo lleva igual, porque es lo que da el gesto correcto en todas.
Después de borrar no hace falta nada más: igual que en Android, la sesión se cierra y RaizConSesion vuelve al formulario. Son cuatro añadidos, y ninguno cierra pantallas a mano.
AuthIosKt.accountViewModel(owner:) es la fábrica que añadiste a AuthIos.kt en El enlace del correo y la contraseña nueva, paso 4, y accountStateForIosFlow su flujo de estado, con el mismo patrón que authStateForIosFlow; el owner ya existe en esta pantalla. cuenta ?? … usa el valor de la izquierda si no es nil y, si lo es, el de la derecha (Opcionales). Compila con Cmd+B.
Paso 6 · Bórrate de verdad
Esta es la prueba que importa, y se hace con tu proyecto de Supabase real.
- Ejecuta la app (en Android o en iOS) y crea una cuenta con un correo que no te importe perder, por ejemplo
tu.nombre+borrar@gmail.com(el+borrarhace que sea otra cuenta distinta y que el correo siga llegándote a ti). Supabase puede rechazar los dominios inventados comoejemplo.com, y para poder entrar justo después de registrarte la confirmación por correo tiene que estar desactivada en el panel, como la dejaste en Preparar Supabase. - Ve a Ajustes → Cuenta → Eliminar mi cuenta (en iOS, la sección «Cuenta» de Ajustes) y confirma con Eliminar.
- La app vuelve al formulario sola.
- Intenta entrar con ese mismo correo y esa contraseña. El aviso rojo (o la alerta, en iOS) dice
Invalid login credentials: la cuenta ya no existe. - En el panel de Supabase, en Authentication → Users, el usuario ya no aparece en la lista.
Los favoritos que tuvieras marcados siguen en el teléfono: viven en la base de datos del dispositivo, no en la cuenta, y el diálogo lo dice.
Devuelve la caducidad del token a su sitio. En Preparar Supabase la bajaste a 60 segundos para ver el refresco sin esperar. Es solo para aprender: con 60 la app pide un token nuevo en cada petición. En el panel, Authentication → Sessions → JWT expiry limit, pon 3600 (una hora) y guarda.
Provócalo tú: que el borrado falle. Crea otra cuenta de prueba, activa el modo avión (en el emulador, desde los ajustes rápidos de Android; en el simulador de iOS, apaga el Wi-Fi de tu Mac), pulsa Eliminar mi cuenta y confirma. La hoja (o la alerta, en iOS) dice que no se pudo, con el motivo, y sigues dentro, con la cuenta intacta y lista para reintentar: es el orden del paso 3 y el aviso de este paso. Quita el modo avión y bórrala de verdad.
Para dejar los tests verificados, en la Terminal, en la carpeta de tu proyecto: ./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Test, y abre los informes como en Ajustes en el núcleo (open shared/build/reports/tests/jvmTest/index.html, y lo mismo con testAndroidHostTest e iosSimulatorArm64Test). Esta lección añade los cuatro de AccountRepositoryTest: de 123, 85 y 88 pasas a 127, 89 y 92.
Punto de control
select public.delete_user();en el SQL Editor falla conno hay sesión.- Los informes de tests dan 127 (JVM), 89 (Android host) y 92 (iOS): los cuatro de
AccountRepositoryTest(incluido el del orden) sobre los 123 / 85 / 88 de antes. - La compilación de Android y la de Xcode (Cmd+B) terminan sin errores.
- «Eliminar mi cuenta» pide confirmación en las dos apps, y cancelar no borra nada (en Android, el botón «Cancelar»; en iOS 26, tocar fuera del aviso).
- Si el borrado falla, las dos apps lo dicen (el texto del error) y la sesión sigue abierta.
- Tras confirmar, la app vuelve al formulario y entrar con ese correo falla con
Invalid login credentials. - El usuario ha desaparecido de Authentication → Users.
- JWT expiry limit está otra vez en 3600.
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.¿Por qué borrar la propia cuenta se hace con una función de la base de datos y no con una llamada directa de la app?
2.La función lleva
set search_path = ''y unrevoke/grant. ¿Para qué sirven?3.Se borra la cuenta y después se cierra la sesión, no al revés. ¿Por qué?
4.Tras borrar la cuenta con éxito,
AccountViewModelno publica ningún estado de «cuenta eliminada». ¿Cómo sabe la pantalla que debe volver al formulario?5.El borrado falla porque no hay red. ¿Qué ve el usuario?