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, [&]() {