diff --git a/libs/SalesforceSDK/res/values/sf__strings.xml b/libs/SalesforceSDK/res/values/sf__strings.xml
index dd759506a4..5ebe981dd5 100644
--- a/libs/SalesforceSDK/res/values/sf__strings.xml
+++ b/libs/SalesforceSDK/res/values/sf__strings.xml
@@ -11,6 +11,7 @@
Authentication only allowed from managed device.
JWT authentication error. Please try again.
Lightning URLs are not supported for OAuth code exchange. Use your My Domain URL instead.
+ This app could not be verified. Contact support.
SSL error: %s.
diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt
index 4069244d45..481582ab82 100644
--- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt
+++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt
@@ -106,6 +106,7 @@ import com.salesforce.androidsdk.R.color.sf__background
import com.salesforce.androidsdk.R.color.sf__background_dark
import com.salesforce.androidsdk.R.drawable.sf__action_back
import com.salesforce.androidsdk.R.string.cannot_use_another_apps_login_qr_code
+import com.salesforce.androidsdk.R.string.sf__app_blocked_error
import com.salesforce.androidsdk.R.string.sf__biometric_opt_in_title
import com.salesforce.androidsdk.R.string.sf__generic_authentication_error_title
import com.salesforce.androidsdk.R.string.sf__jwt_authentication_error
@@ -125,6 +126,8 @@ import com.salesforce.androidsdk.app.Features.FEATURE_WELCOME_DISCOVERY_LOGIN
import com.salesforce.androidsdk.app.SalesforceSDKManager
import com.salesforce.androidsdk.app.SalesforceSDKManager.Theme.DARK
import com.salesforce.androidsdk.auth.HttpAccess
+import com.salesforce.androidsdk.auth.OAuth2.CLIENT_BLOCKED_ERROR
+import com.salesforce.androidsdk.auth.OAuth2.CLIENT_BLOCKED_RETRY_ERROR
import com.salesforce.androidsdk.auth.OAuth2.OAuthFailedException
import com.salesforce.androidsdk.auth.OAuth2.TokenEndpointResponse
import com.salesforce.androidsdk.auth.OAuth2.swapJWTForTokens
@@ -228,7 +231,7 @@ open class LoginActivity : FragmentActivity() {
}
// Private variables
- private var baseUserAgentString = "";
+ private var baseUserAgentString = ""
private var wasBackgrounded = false
private var accountAuthenticatorResponse: AccountAuthenticatorResponse? = null
private var accountAuthenticatorResult: Bundle? = null
@@ -584,6 +587,9 @@ open class LoginActivity : FragmentActivity() {
)
viewModel.clearCookies()
+ val isClientBlocked = e is OAuthFailedException
+ && (e.tokenErrorResponse.error == CLIENT_BLOCKED_ERROR
+ || e.tokenErrorResponse.error == CLIENT_BLOCKED_RETRY_ERROR)
val isLightningTokenEndpointFailure = e is OAuthFailedException
&& e.tokenErrorResponse.error == "unsupported_grant_type"
&& viewModel.selectedServer.value?.contains(".lightning.") == true
@@ -592,11 +598,12 @@ open class LoginActivity : FragmentActivity() {
}
// Displays the error in a toast, clears cookies and reloads the login page
runOnUiThread {
- if (isLightningTokenEndpointFailure) {
- makeText(this, getString(sf__lightning_url_code_exchange_error), LENGTH_LONG).show()
- } else {
- makeText(this, "$error : $errorDesc", LENGTH_LONG).show()
+ val message = when {
+ isClientBlocked -> getString(sf__app_blocked_error)
+ isLightningTokenEndpointFailure -> getString(sf__lightning_url_code_exchange_error)
+ else -> "$error : $errorDesc"
}
+ makeText(this, message, LENGTH_LONG).show()
viewModel.reloadWebView()
}
}
@@ -936,12 +943,12 @@ open class LoginActivity : FragmentActivity() {
// Set welcome discovery feature flag if applicable
if (isLoginWithWelcomeDiscovery(intent)) {
SalesforceSDKManager.getInstance()
- .registerUsedAppFeature(FEATURE_WELCOME_DISCOVERY_LOGIN);
+ .registerUsedAppFeature(FEATURE_WELCOME_DISCOVERY_LOGIN)
}
else {
SalesforceSDKManager.getInstance().unregisterUsedAppFeature(
FEATURE_WELCOME_DISCOVERY_LOGIN
- );
+ )
}
// Re-apply user agent to WebView
@@ -1125,7 +1132,7 @@ open class LoginActivity : FragmentActivity() {
loginHint = uri.getQueryParameter(SALESFORCE_WELCOME_DISCOVERY_MOBILE_CALLBACK_URL_QUERY_PARAMETER_KEY_LOGIN_HINT) ?: return false,
loginHost = uri.getQueryParameter(SALESFORCE_WELCOME_DISCOVERY_MOBILE_CALLBACK_URL_QUERY_PARAMETER_KEY_MY_DOMAIN)?.toUri()?.host ?: return false
)
- return true
+ true
} else false
}
@@ -1534,7 +1541,7 @@ open class LoginActivity : FragmentActivity() {
/**
* Determines if the provided URL has the Salesforce Welcome Discovery
* path.
- * @param url The URL to examine for the Salesforce Welcome Discovery
+ * @param uri The URL to examine for the Salesforce Welcome Discovery
* path
* @return Boolean true if the URL has the Salesforce Welcome Discovery
* path or false otherwise
@@ -1549,7 +1556,7 @@ open class LoginActivity : FragmentActivity() {
* Determines if the provided URL has the Salesforce Welcome Discovery
* path and parameters for mobile callback. The client id (consumer
* key) of the URL must match the boot config's consumer key.
- * @param url The URL to examine for the Salesforce Welcome Discovery
+ * @param uri The URL to examine for the Salesforce Welcome Discovery
* path and parameters for mobile callback
* @return Boolean true if the URL has the Salesforce Welcome Discovery
* path and parameters for mobile callback and matches the boot config's
@@ -1575,7 +1582,7 @@ open class LoginActivity : FragmentActivity() {
* Determines if the provided URL has the Salesforce Welcome Discovery
* path and parameters for mobile callback. The client id (consumer
* key) of the URL must match the boot config's consumer key.
- * @param url The URL to examine for the Salesforce Welcome Discovery
+ * @param uri The URL to examine for the Salesforce Welcome Discovery
* path and parameters for mobile callback
* @return Boolean true if the URL has the Salesforce Welcome Discovery
* path and parameters for mobile callback and matches the boot config's
@@ -1654,7 +1661,7 @@ open class LoginActivity : FragmentActivity() {
* Activity result callback for the "Login for Admin" custom tab.
*/
@VisibleForTesting
- internal inner class AdminCustomTabActivityResult : ActivityResultCallback {
+ internal class AdminCustomTabActivityResult : ActivityResultCallback {
override fun onActivityResult(result: ActivityResult) {
// Intentional no-op: keep the existing WebView visible on cancel.
}
diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt
index e8265a6051..56e77380ce 100644
--- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt
+++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelMockTest.kt
@@ -35,7 +35,13 @@ import com.salesforce.androidsdk.accounts.UserAccountBuilder
import com.salesforce.androidsdk.accounts.UserAccountManager
import com.salesforce.androidsdk.accounts.UserAccountTest
import com.salesforce.androidsdk.app.SalesforceSDKManager
+import com.salesforce.androidsdk.auth.OAuth2.CLIENT_BLOCKED_ERROR
+import com.salesforce.androidsdk.auth.OAuth2.CLIENT_BLOCKED_RETRY_ERROR
+import com.salesforce.androidsdk.auth.OAuth2.OAuthFailedException
+import com.salesforce.androidsdk.auth.OAuth2.TIMESTAMP_FORMAT
import com.salesforce.androidsdk.auth.OAuth2.TokenEndpointResponse
+import com.salesforce.androidsdk.auth.OAuth2.TokenErrorResponse
+import com.salesforce.androidsdk.auth.OAuth2.exchangeCode
import com.salesforce.androidsdk.config.BootConfig
import com.salesforce.androidsdk.config.OAuthConfig
import com.salesforce.androidsdk.security.BiometricAuthenticationManager
@@ -60,6 +66,7 @@ import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
+import java.io.IOException
/**
* Tests for LoginViewModel that require mocking.
@@ -87,11 +94,11 @@ class LoginViewModelMockTest {
// Create view model after mocking
viewModel = LoginViewModel(bootConfig)
-
+
// This is required for the LiveData to actually update during the test
viewModel.selectedServer.observeForever { }
viewModel.loginUrl.observeForever { }
-
+
// Give the LiveData sources time to propagate
Thread.sleep(100)
}
@@ -106,7 +113,7 @@ class LoginViewModelMockTest {
@Test
fun onAuthFlowComplete_CallsAuthenticationUtilities_WithCorrectParameters() = runBlocking {
// Mock the AuthenticationUtilities.onAuthFlowComplete function
-
+
// Mock the function to do nothing (just capture parameters)
coEvery {
onAuthFlowComplete(
@@ -132,20 +139,20 @@ class LoginViewModelMockTest {
handleDuplicateUserAccount = any(),
)
} returns Unit
-
+
// Create test data
val testServer = "https://test.salesforce.com"
val mockTokenResponse = mockk(relaxed = true)
val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true)
val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true)
-
+
// Set up the view model state
viewModel.selectedServer.value = testServer
Thread.sleep(100)
-
+
// Call the method under test
viewModel.onAuthFlowComplete(mockTokenResponse, mockOnError, mockOnSuccess)
-
+
// Verify AuthenticationUtilities.onAuthFlowComplete was called with correct parameters
coVerify {
onAuthFlowComplete(
@@ -176,7 +183,7 @@ class LoginViewModelMockTest {
@Test
fun onAuthFlowComplete_CallsAuthenticationUtilitiesSuccessfully() = runBlocking {
// Mock the AuthenticationUtilities.onAuthFlowComplete function
-
+
coEvery {
onAuthFlowComplete(
tokenResponse = any(),
@@ -201,15 +208,15 @@ class LoginViewModelMockTest {
handleDuplicateUserAccount = any(),
)
} returns Unit
-
+
val mockTokenResponse = mockk(relaxed = true)
val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true)
val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true)
-
+
// Set up the view model state
viewModel.selectedServer.value = "https://test.salesforce.com"
Thread.sleep(100)
-
+
// Call the method under test
viewModel.onAuthFlowComplete(mockTokenResponse, mockOnError, mockOnSuccess)
@@ -244,7 +251,7 @@ class LoginViewModelMockTest {
@Test
fun onAuthFlowComplete_ResetsAuthCodeForJwtFlow() = runBlocking {
// Mock the AuthenticationUtilities.onAuthFlowComplete function
-
+
coEvery {
onAuthFlowComplete(
tokenResponse = any(),
@@ -269,22 +276,22 @@ class LoginViewModelMockTest {
handleDuplicateUserAccount = any(),
)
} returns Unit
-
+
val mockTokenResponse = mockk(relaxed = true)
val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true)
val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true)
-
+
// Set up the view model state with JWT flow
viewModel.selectedServer.value = "https://test.salesforce.com"
viewModel.authCodeForJwtFlow = "test_jwt_auth_code"
Thread.sleep(100)
-
+
// Verify authCodeForJwtFlow is set
assertNotNull("authCodeForJwtFlow should be set before call", viewModel.authCodeForJwtFlow)
-
+
// Call the method under test
viewModel.onAuthFlowComplete(mockTokenResponse, mockOnError, mockOnSuccess)
-
+
// Verify authCodeForJwtFlow is reset to null
assertNull("authCodeForJwtFlow should be null after call", viewModel.authCodeForJwtFlow)
}
@@ -292,7 +299,7 @@ class LoginViewModelMockTest {
@Test
fun onAuthFlowComplete_UsesEmptyString_WhenSelectedServerIsNull() = runBlocking {
// Mock the AuthenticationUtilities.onAuthFlowComplete function
-
+
coEvery {
onAuthFlowComplete(
tokenResponse = any(),
@@ -317,18 +324,18 @@ class LoginViewModelMockTest {
handleDuplicateUserAccount = any(),
)
} returns Unit
-
+
val mockTokenResponse = mockk(relaxed = true)
val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true)
val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true)
-
+
// Set selectedServer to null
viewModel.selectedServer.value = null
Thread.sleep(100)
-
+
// Call the method under test
viewModel.onAuthFlowComplete(mockTokenResponse, mockOnError, mockOnSuccess)
-
+
// Verify empty string is used when selectedServer is null
coVerify {
onAuthFlowComplete(
@@ -362,10 +369,10 @@ class LoginViewModelMockTest {
val testCode = "test_auth_code_123"
val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true)
val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true)
-
+
// Create a spy of viewModel to verify and mock doCodeExchange
val spyViewModel = spyk(viewModel)
-
+
// Mock doCodeExchange to prevent actual execution
coEvery {
spyViewModel.doCodeExchange(any(), any(), any(), any(), any())
@@ -374,13 +381,13 @@ class LoginViewModelMockTest {
// Set up the view model state
spyViewModel.selectedServer.value = testServer
Thread.sleep(100)
-
+
// Call the method under test
spyViewModel.onWebServerFlowComplete(testCode, mockOnError, mockOnSuccess)
-
+
// Give time for the coroutine to execute
Thread.sleep(200)
-
+
// Verify doCodeExchange was called with correct parameters
coVerify {
spyViewModel.doCodeExchange(
@@ -445,10 +452,10 @@ class LoginViewModelMockTest {
val testCode = "test_auth_code_123"
val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true)
val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true)
-
+
// Create a spy of viewModel to verify and mock doCodeExchange
val spyViewModel = spyk(viewModel)
-
+
// Mock doCodeExchange to prevent actual execution
coEvery {
spyViewModel.doCodeExchange(any(), any(), any(), any(), any())
@@ -457,10 +464,10 @@ class LoginViewModelMockTest {
// Set up front door bridge
spyViewModel.loginWithFrontDoorBridgeUrl(frontDoorUrl, frontDoorVerifier)
Thread.sleep(100)
-
+
// Call the method under test
spyViewModel.onWebServerFlowComplete(testCode, mockOnError, mockOnSuccess)
-
+
// Give time for the coroutine to execute
Thread.sleep(200)
@@ -481,10 +488,10 @@ class LoginViewModelMockTest {
val testServer = "https://test.salesforce.com"
val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true)
val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true)
-
+
// Create a spy of viewModel to verify and mock doCodeExchange
val spyViewModel = spyk(viewModel)
-
+
// Mock doCodeExchange to prevent actual execution
coEvery {
spyViewModel.doCodeExchange(any(), any(), any(), any(), any())
@@ -493,10 +500,10 @@ class LoginViewModelMockTest {
// Set up the view model state
spyViewModel.selectedServer.value = testServer
Thread.sleep(100)
-
+
// Call with null code
spyViewModel.onWebServerFlowComplete(null, mockOnError, mockOnSuccess)
-
+
// Give time for the coroutine to execute
Thread.sleep(200)
@@ -589,7 +596,7 @@ class LoginViewModelMockTest {
// Create a spy of viewModel to verify and mock doCodeExchange
val spyViewModel = spyk(viewModel)
-
+
// Mock doCodeExchange to prevent actual execution
coEvery {
spyViewModel.doCodeExchange(any(), any(), any(), any(), any())
@@ -634,10 +641,10 @@ class LoginViewModelMockTest {
val spyViewModel = spyk(viewModel)
// Force OAuth2 class initialization before mocking to avoid ExceptionInInitializerError
- OAuth2.TIMESTAMP_FORMAT
+ TIMESTAMP_FORMAT
mockkStatic(OAuth2::class)
every {
- OAuth2.exchangeCode(any(), any(), any(), any(), any(), any())
+ exchangeCode(any(), any(), any(), any(), any(), any())
} returns mockTokenResponse
// Mock doCodeExchange to prevent actual execution
@@ -682,10 +689,10 @@ class LoginViewModelMockTest {
val spyViewModel = spyk(viewModel)
// Force OAuth2 class initialization before mocking to avoid ExceptionInInitializerError
- OAuth2.TIMESTAMP_FORMAT
+ TIMESTAMP_FORMAT
mockkStatic(OAuth2::class)
every {
- OAuth2.exchangeCode(any(), any(), any(), any(), any(), any())
+ exchangeCode(any(), any(), any(), any(), any(), any())
} returns mockTokenResponse
// Mock doCodeExchange to prevent actual execution
@@ -736,10 +743,10 @@ class LoginViewModelMockTest {
val mockTokenResponse: TokenEndpointResponse = mockk(relaxed = true)
// Force OAuth2 class initialization before mocking to avoid ExceptionInInitializerError
- OAuth2.TIMESTAMP_FORMAT
+ TIMESTAMP_FORMAT
mockkStatic(OAuth2::class)
every {
- OAuth2.exchangeCode(any(), any(), any(), any(), any(), any())
+ exchangeCode(any(), any(), any(), any(), any(), any())
} returns mockTokenResponse
// Spy so we can short-circuit account creation, leaving exchangeCode as the observable.
@@ -774,7 +781,7 @@ class LoginViewModelMockTest {
// Token exchange must be performed with MIGRATION credentials.
verify {
- OAuth2.exchangeCode(
+ exchangeCode(
/* httpAccessor = */ any(),
/* loginServer = */ any(),
/* clientId = */ migrationConsumerKey,
@@ -787,6 +794,98 @@ class LoginViewModelMockTest {
// endregion
+ // region doCodeExchange Error Path Tests
+
+ private fun setupExchangeCodeMock(throws: Throwable) {
+ TIMESTAMP_FORMAT
+ mockkStatic(OAuth2::class)
+ every {
+ exchangeCode(any(), any(), any(), any(), any(), any())
+ } throws throws
+ }
+
+ @Test
+ fun doCodeExchange_whenExchangeCodeThrowsClientBlocked_callsOnAuthFlowError() = runBlocking {
+ val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true)
+ val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true)
+ val spyViewModel = spyk(viewModel)
+
+ val tokenErrorResponse = mockk(relaxed = true)
+ tokenErrorResponse.error = CLIENT_BLOCKED_ERROR
+ tokenErrorResponse.errorDescription = "App is blocked"
+ val oauthException = OAuthFailedException(tokenErrorResponse, 403)
+ setupExchangeCodeMock(oauthException)
+
+ spyViewModel.selectedServer.value = "https://test.salesforce.com"
+ spyViewModel.doCodeExchange("test_auth_code", mockOnError, mockOnSuccess)
+
+ verify { mockOnError("Token Request Error", any(), oauthException) }
+ verify(exactly = 0) { mockOnSuccess(any()) }
+ }
+
+ @Test
+ fun doCodeExchange_whenExchangeCodeThrowsClientBlockedRetry_callsOnAuthFlowError() = runBlocking {
+ val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true)
+ val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true)
+ val spyViewModel = spyk(viewModel)
+
+ val tokenErrorResponse = mockk(relaxed = true)
+ tokenErrorResponse.error = CLIENT_BLOCKED_RETRY_ERROR
+ tokenErrorResponse.errorDescription = "App is blocked (retry)"
+ val oauthException = OAuthFailedException(tokenErrorResponse, 403)
+ setupExchangeCodeMock(oauthException)
+
+ spyViewModel.selectedServer.value = "https://test.salesforce.com"
+ spyViewModel.doCodeExchange("test_auth_code", mockOnError, mockOnSuccess)
+
+ verify { mockOnError("Token Request Error", any(), oauthException) }
+ verify(exactly = 0) { mockOnSuccess(any()) }
+ }
+
+ @Test
+ fun doCodeExchange_whenExchangeCodeThrowsIOException_callsOnAuthFlowError() = runBlocking {
+ val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true)
+ val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true)
+ val spyViewModel = spyk(viewModel)
+
+ val ioException = IOException("Network error")
+ setupExchangeCodeMock(ioException)
+
+ spyViewModel.selectedServer.value = "https://test.salesforce.com"
+ spyViewModel.doCodeExchange("test_auth_code", mockOnError, mockOnSuccess)
+
+ verify { mockOnError("Token Request Error", "Network error", ioException) }
+ verify(exactly = 0) { mockOnSuccess(any()) }
+ }
+
+ @Test
+ fun doCodeExchange_whenExchangeCodeThrowsOAuthFailed_neverCallsOnAuthFlowComplete() = runBlocking {
+ val mockOnError: (String, String?, Throwable?) -> Unit = mockk(relaxed = true)
+ val mockOnSuccess: (UserAccount) -> Unit = mockk(relaxed = true)
+ val spyViewModel = spyk(viewModel)
+
+ val tokenErrorResponse = mockk(relaxed = true)
+ tokenErrorResponse.error = "invalid_grant"
+ tokenErrorResponse.errorDescription = "Expired authorization code"
+ val oauthException = OAuthFailedException(tokenErrorResponse, 400)
+ setupExchangeCodeMock(oauthException)
+
+ coEvery {
+ spyViewModel.onAuthFlowComplete(any(), any(), any(), any(), any())
+ } just runs
+
+ spyViewModel.selectedServer.value = "https://test.salesforce.com"
+ spyViewModel.doCodeExchange("test_auth_code", mockOnError, mockOnSuccess)
+
+ verify { mockOnError("Token Request Error", any(), oauthException) }
+ verify(exactly = 0) { mockOnSuccess(any()) }
+ coVerify(exactly = 0) {
+ spyViewModel.onAuthFlowComplete(any(), any(), any(), any(), any())
+ }
+ }
+
+ // endregion
+
// region showBiometricAuthenticationButton Tests
@Test
diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginActivityTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginActivityTest.kt
index 1e96ba1f7d..5569bc3146 100644
--- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginActivityTest.kt
+++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginActivityTest.kt
@@ -129,7 +129,7 @@ class LoginActivityTest {
val activity = mockk(relaxed = true)
every { activity.viewModel } returns viewModel
- val adminResult = activity.AdminCustomTabActivityResult()
+ val adminResult = LoginActivity.AdminCustomTabActivityResult()
adminResult.onActivityResult(ActivityResult(RESULT_CANCELED, Intent()))
// Contrast with CustomTabActivityResult which calls these on cancel; the admin