diff --git a/CHANGELOG.md b/CHANGELOG.md
index b6b39a742f..c9ee145455 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,7 @@
## vNext (TBD)
### Enhancements
+* Added the `App.EmailPasswordAuth.RetryCustomConfirmationAsync` method to be able to run again the confirmation function on the server for a given email. (Issue [#3463](https://github.com/realm/realm-dotnet/issues/3463))
* Added `User.Changed` event that can be used to notify subscribers that something about the user changed - typically this would be the user state or the access token. (Issue [#3429](https://github.com/realm/realm-dotnet/issues/3429))
* Added support for customizing the ignore attribute applied on certain generated properties of Realm models. The configuration option is called `realm.custom_ignore_attribute` and can be set in a global configuration file (more information about global configuration files can be found in the [.NET documentation](https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/configuration-files)). The Realm generator will treat this as an opaque string, that will be appended to the `IgnoreDataMember` and `XmlIgnore` attributes already applied on these members. The attributes must be fully qualified unless the namespace they reside in is added to a global usings file. For example, this is how you would add `JsonIgnore` from `System.Text.Json`:
diff --git a/Realm/Realm/Handles/AppHandle.EmailPassword.cs b/Realm/Realm/Handles/AppHandle.EmailPassword.cs
index 9917f77e86..cd23cbf375 100644
--- a/Realm/Realm/Handles/AppHandle.EmailPassword.cs
+++ b/Realm/Realm/Handles/AppHandle.EmailPassword.cs
@@ -43,6 +43,11 @@ public static extern void resend_confirmation_email(AppHandle app,
[MarshalAs(UnmanagedType.LPWStr)] string email, IntPtr email_len,
IntPtr tcs_ptr, out NativeException ex);
+ [DllImport(InteropConfig.DLL_NAME, EntryPoint = "shared_app_email_retry_custom_confirmation", CallingConvention = CallingConvention.Cdecl)]
+ public static extern void retry_custom_comfirmation(AppHandle app,
+ [MarshalAs(UnmanagedType.LPWStr)] string email, IntPtr email_len,
+ IntPtr tcs_ptr, out NativeException ex);
+
[DllImport(InteropConfig.DLL_NAME, EntryPoint = "shared_app_email_send_reset_password_email", CallingConvention = CallingConvention.Cdecl)]
public static extern void send_reset_password_email(AppHandle app,
[MarshalAs(UnmanagedType.LPWStr)] string email, IntPtr email_len,
@@ -125,6 +130,23 @@ public async Task ResendConfirmationEmailAsync(string email)
}
}
+ public async Task RetryCustomConfirmationAsync(string email)
+ {
+ var tcs = new TaskCompletionSource();
+ var tcsHandle = GCHandle.Alloc(tcs);
+
+ try
+ {
+ EmailNativeMethods.retry_custom_comfirmation(_appHandle, email, (IntPtr)email.Length, GCHandle.ToIntPtr(tcsHandle), out var ex);
+ ex.ThrowIfNecessary();
+ await tcs.Task;
+ }
+ finally
+ {
+ tcsHandle.Free();
+ }
+ }
+
public async Task SendResetPasswordEmailAsync(string username)
{
var tcs = new TaskCompletionSource();
diff --git a/Realm/Realm/Sync/App.cs b/Realm/Realm/Sync/App.cs
index 53abd96088..febfbb525b 100644
--- a/Realm/Realm/Sync/App.cs
+++ b/Realm/Realm/Sync/App.cs
@@ -408,6 +408,21 @@ public Task ResendConfirmationEmailAsync(string email)
return _app.Handle.EmailPassword.ResendConfirmationEmailAsync(email);
}
+ ///
+ /// Rerun the custom confirmation function for the given mail.
+ ///
+ /// The email of the user.
+ ///
+ /// An awaitable representing the asynchronous request to the server that the custom confirmation function is run again. Successful
+ /// completion indicates that the user has been confirmed on the server.
+ ///
+ public Task RetryCustomConfirmationAsync(string email)
+ {
+ Argument.NotNullOrEmpty(email, nameof(email));
+
+ return _app.Handle.EmailPassword.RetryCustomConfirmationAsync(email);
+ }
+
///
/// Sends a password reset email to the specified address.
///
diff --git a/Tests/Realm.Tests/Sync/SyncTestHelpers.cs b/Tests/Realm.Tests/Sync/SyncTestHelpers.cs
index 43b2890311..fae2c57766 100644
--- a/Tests/Realm.Tests/Sync/SyncTestHelpers.cs
+++ b/Tests/Realm.Tests/Sync/SyncTestHelpers.cs
@@ -92,6 +92,8 @@ public static void RunBaasTestAsync(Func testFunc, int timeout = 30000)
public static string GetVerifiedUsername() => $"realm_tests_do_autoverify-{Guid.NewGuid()}";
+ public static string GetUnconfirmedUsername() => $"realm_tests_do_not_confirm-{Guid.NewGuid()}@g.it";
+
public static async Task TriggerClientResetOnServer(SyncConfigurationBase config)
{
var userId = config.User.Id;
diff --git a/Tests/Realm.Tests/Sync/UserManagementTests.cs b/Tests/Realm.Tests/Sync/UserManagementTests.cs
index 1c94a40000..505e4ca335 100644
--- a/Tests/Realm.Tests/Sync/UserManagementTests.cs
+++ b/Tests/Realm.Tests/Sync/UserManagementTests.cs
@@ -399,6 +399,42 @@ public void User_LinkCredentials_WhenInUse_Throws()
});
}
+ [Test]
+ public void User_RetryCustomConfirmationAsync_WorksInAllScenarios()
+ {
+ SyncTestHelpers.RunBaasTestAsync(async () =>
+ {
+ // Standard case
+ var unconfirmedMail = SyncTestHelpers.GetUnconfirmedUsername();
+ var credentials = Credentials.EmailPassword(unconfirmedMail, SyncTestHelpers.DefaultPassword);
+
+ // The first time the confirmation function is called we return "pending", so the user needs to be confirmed.
+ // At the same time we save the user email in a collection.
+ await DefaultApp.EmailPasswordAuth.RegisterUserAsync(unconfirmedMail, SyncTestHelpers.DefaultPassword).Timeout(10_000, detail: "Failed to register user");
+
+ var ex3 = await TestHelpers.AssertThrows(() => DefaultApp.LogInAsync(credentials));
+ Assert.That(ex3.Message, Does.Contain("confirmation required"));
+
+ // The second time we call the confirmation function we find the email we saved in the collection and return "success", so the user
+ // gets confirmed and can log in.
+ await DefaultApp.EmailPasswordAuth.RetryCustomConfirmationAsync(unconfirmedMail);
+ var user = await DefaultApp.LogInAsync(credentials);
+ Assert.That(user.State, Is.EqualTo(UserState.LoggedIn));
+
+ // Logged in user case
+ var loggedInUser = await GetUserAsync();
+ var ex = await TestHelpers.AssertThrows(() => DefaultApp.EmailPasswordAuth.RetryCustomConfirmationAsync(loggedInUser.Profile.Email!));
+ Assert.That(ex.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
+ Assert.That(ex.Message, Does.Contain("already confirmed"));
+
+ // Unknown user case
+ var invalidEmail = "test@gmail.com";
+ var ex2 = await TestHelpers.AssertThrows(() => DefaultApp.EmailPasswordAuth.RetryCustomConfirmationAsync(invalidEmail));
+ Assert.That(ex2.StatusCode, Is.EqualTo(HttpStatusCode.NotFound));
+ Assert.That(ex2.Message, Does.Contain("user not found"));
+ });
+ }
+
[Test]
public void User_JWT_LogsInAndReadsDataFromToken()
{
diff --git a/Tools/DeployApps/BaasClient.cs b/Tools/DeployApps/BaasClient.cs
index fa3ece4eee..dc2998369c 100644
--- a/Tools/DeployApps/BaasClient.cs
+++ b/Tools/DeployApps/BaasClient.cs
@@ -88,12 +88,33 @@ public class FunctionReturn
}
private const string ConfirmFuncSource =
- @"exports = ({ token, tokenId, username }) => {
+ @"exports = async function ({ token, tokenId, username }) {
// process the confirm token, tokenId and username
if (username.includes(""realm_tests_do_autoverify"")) {
- return { status: 'success' }
+ return { status: 'success' };
}
- // do not confirm the user
+
+ if (username.includes(""realm_tests_do_not_confirm"")) {
+ const mongodb = context.services.get('BackingDB');
+ let collection = mongodb.db('test_db').collection('not_confirmed');
+ let result = await collection.findOne({'email': username});
+
+ if(result === null)
+ {
+ let newVal = {
+ 'email': username,
+ 'token': token,
+ 'tokenId': tokenId,
+ }
+
+ await collection.insertOne(newVal);
+ return { status: 'pending' };
+ }
+
+ return { status: 'success' };
+ }
+
+ // fail the user confirmation
return { status: 'fail' };
};";
diff --git a/wrappers/src/app_cs.cpp b/wrappers/src/app_cs.cpp
index ead310cf4f..b7e5d48383 100644
--- a/wrappers/src/app_cs.cpp
+++ b/wrappers/src/app_cs.cpp
@@ -367,6 +367,14 @@ extern "C" {
});
}
+ REALM_EXPORT void shared_app_email_retry_custom_confirmation(SharedApp& app, uint16_t* email_buf, size_t email_len, void* tcs_ptr, NativeException::Marshallable& ex)
+ {
+ handle_errors(ex, [&]() {
+ Utf16StringAccessor email(email_buf, email_len);
+ app->provider_client().retry_custom_confirmation(email, get_callback_handler(tcs_ptr));
+ });
+ }
+
REALM_EXPORT void shared_app_email_send_reset_password_email(SharedApp& app, uint16_t* email_buf, size_t email_len, void* tcs_ptr, NativeException::Marshallable& ex)
{
handle_errors(ex, [&]() {