diff --git a/MyApp.Client/nuget.config b/MyApp.Client/nuget.config deleted file mode 100644 index 6548586..0000000 --- a/MyApp.Client/nuget.config +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/MyApp.Client/package.json b/MyApp.Client/package.json index 20d2c17..8cdc1c1 100644 --- a/MyApp.Client/package.json +++ b/MyApp.Client/package.json @@ -11,45 +11,45 @@ "preview": "vite preview" }, "dependencies": { - "@radix-ui/react-dialog": "^1.0.5", - "@radix-ui/react-dropdown-menu": "^2.0.6", - "@radix-ui/react-slot": "^1.0.2", - "@servicestack/client": "^2.1.1", - "@tanstack/react-table": "^8.12.0", + "@radix-ui/react-dialog": "^1.1.1", + "@radix-ui/react-dropdown-menu": "^2.1.1", + "@radix-ui/react-slot": "^1.1.0", + "@servicestack/client": "^2.1.5", + "@tanstack/react-table": "^8.20.5", "class-variance-authority": "^0.7.0", "classnames": "^2.5.1", - "clsx": "^2.1.0", + "clsx": "^2.1.1", "gray-matter": "^4.0.3", - "lucide-react": "^0.331.0", - "react": "^18.2.0", - "react-dom": "^18.2.0", + "lucide-react": "^0.438.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", "react-helmet": "^6.1.0", - "react-helmet-async": "^2.0.4", - "react-router": "^6.22.0", - "react-router-dom": "^6.22.0", - "swr": "^2.2.4", - "tailwind-merge": "^2.2.1", + "react-helmet-async": "^2.0.5", + "react-router": "^6.26.1", + "react-router-dom": "^6.26.1", + "swr": "^2.2.5", + "tailwind-merge": "^2.5.2", "tailwindcss-animate": "^1.0.7" }, "devDependencies": { - "@iconify/react": "^4.1.1", + "@iconify/react": "^5.0.2", "@mdx-js/rollup": "^3.0.1", - "@tailwindcss/forms": "^0.5.7", - "@tailwindcss/typography": "^0.5.10", - "@types/mdx": "^2.0.11", - "@types/node": "^20.11.19", - "@types/react": "^18.2.55", - "@types/react-dom": "^18.2.19", + "@tailwindcss/forms": "^0.5.8", + "@tailwindcss/typography": "^0.5.15", + "@types/mdx": "^2.0.13", + "@types/node": "^22.5.3", + "@types/react": "^18.3.5", + "@types/react-dom": "^18.3.0", "@types/react-helmet": "^6.1.11", "@types/remark-prism": "^1.3.7", - "@typescript-eslint/eslint-plugin": "^6.21.0", - "@typescript-eslint/parser": "^6.21.0", - "@vitejs/plugin-react": "^4.2.1", - "autoprefixer": "^10.4.17", - "eslint": "^8.56.0", - "eslint-plugin-react-hooks": "^4.6.0", - "eslint-plugin-react-refresh": "^0.4.5", - "postcss": "^8.4.35", + "@typescript-eslint/eslint-plugin": "^8.4.0", + "@typescript-eslint/parser": "^8.4.0", + "@vitejs/plugin-react": "^4.3.1", + "autoprefixer": "^10.4.20", + "eslint": "^9.9.1", + "eslint-plugin-react-hooks": "^4.6.2", + "eslint-plugin-react-refresh": "^0.4.11", + "postcss": "^8.4.45", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.0", "remark-directive": "^3.0.0", @@ -57,10 +57,10 @@ "remark-gfm": "^4.0.0", "remark-parse": "^11.0.0", "remark-prism": "^1.3.6", - "tailwindcss": "^3.4.1", - "typescript": "^5.2.2", - "vite": "^5.1.0", - "vite-plugin-pages": "^0.32.0", + "tailwindcss": "^3.4.10", + "typescript": "^5.5.4", + "vite": "^5.4.3", + "vite-plugin-pages": "^0.32.3", "vite-plugin-press": "^1.0.10", "vite-plugin-svgr": "^4.2.0" } diff --git a/MyApp.Client/src/pages/error.tsx b/MyApp.Client/src/pages/error.tsx new file mode 100644 index 0000000..19adbaa --- /dev/null +++ b/MyApp.Client/src/pages/error.tsx @@ -0,0 +1,17 @@ +import { useSearchParams } from "react-router-dom" + +export default () => { + const [query, _] = useSearchParams() + const message = query.get('message') ?? 'Unknown error' + + return (<> +
+
+
+

{message}

+
+
+
+ + ) +} diff --git a/MyApp.ServiceInterface/EmailServices.cs b/MyApp.ServiceInterface/EmailServices.cs index 5e3974a..dc01b35 100644 --- a/MyApp.ServiceInterface/EmailServices.cs +++ b/MyApp.ServiceInterface/EmailServices.cs @@ -1,7 +1,7 @@ using System.Net.Mail; using Microsoft.Extensions.Logging; using ServiceStack; -using MyApp.ServiceModel; +using ServiceStack.Jobs; namespace MyApp.ServiceInterface; @@ -45,16 +45,26 @@ public class SmtpConfig public string? Bcc { get; set; } } -/// -/// Uses a configured SMTP client to send emails -/// -public class EmailServices(SmtpConfig config, ILogger log) - // TODO: Uncomment to enable sending emails with SMTP - // : Service +public class SendEmail +{ + public string To { get; set; } + public string? ToName { get; set; } + public string Subject { get; set; } + public string? BodyText { get; set; } + public string? BodyHtml { get; set; } +} + +[Worker("smtp")] +public class SendEmailCommand(ILogger logger, IBackgroundJobs jobs, SmtpConfig config) + : SyncCommand { - public object Any(SendEmail request) + private static long count = 0; + protected override void Run(SendEmail request) { - log.LogInformation("Sending email to {Email} with subject {Subject}", request.To, request.Subject); + Interlocked.Increment(ref count); + var log = Request.CreateJobLogger(jobs, logger); + log.LogInformation("Sending {Count} email to {Email} with subject {Subject}", + count, request.To, request.Subject); using var client = new SmtpClient(config.Host, config.Port); client.Credentials = new System.Net.NetworkCredential(config.Username, config.Password); @@ -80,7 +90,5 @@ public object Any(SendEmail request) } client.Send(msg); - - return new EmptyResponse(); } } diff --git a/MyApp.ServiceModel/Emails.cs b/MyApp.ServiceModel/Emails.cs deleted file mode 100644 index 140fcd5..0000000 --- a/MyApp.ServiceModel/Emails.cs +++ /dev/null @@ -1,15 +0,0 @@ -using ServiceStack; -using ServiceStack.DataAnnotations; - -namespace MyApp.ServiceModel; - -[ExcludeMetadata] -[Restrict(InternalOnly = true)] -public class SendEmail : IReturn -{ - public string To { get; set; } - public string? ToName { get; set; } - public string Subject { get; set; } - public string? BodyText { get; set; } - public string? BodyHtml { get; set; } -} diff --git a/MyApp/Configure.BackgroundJobs.cs b/MyApp/Configure.BackgroundJobs.cs new file mode 100644 index 0000000..44d1c7c --- /dev/null +++ b/MyApp/Configure.BackgroundJobs.cs @@ -0,0 +1,62 @@ +using Microsoft.AspNetCore.Identity; +using MyApp.Data; +using MyApp.ServiceInterface; +using ServiceStack.Jobs; + +[assembly: HostingStartup(typeof(MyApp.ConfigureBackgroundJobs))] + +namespace MyApp; + +public class ConfigureBackgroundJobs : IHostingStartup +{ + public void Configure(IWebHostBuilder builder) => builder + .ConfigureServices(services => { + services.AddPlugin(new CommandsFeature()); + services.AddPlugin(new BackgroundsJobFeature()); + services.AddHostedService(); + }).ConfigureAppHost(afterAppHostInit: appHost => { + var services = appHost.GetApplicationServices(); + var jobs = services.GetRequiredService(); + // Example of registering a Recurring Job to run Every Hour + //jobs.RecurringCommand(Schedule.Hourly); + }); +} + +public class JobsHostedService(ILogger log, IBackgroundJobs jobs) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await jobs.StartAsync(stoppingToken); + + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(3)); + while (!stoppingToken.IsCancellationRequested && await timer.WaitForNextTickAsync(stoppingToken)) + { + await jobs.TickAsync(); + } + } +} + +/// +/// Sends emails by executing SendEmailCommand in a background job where it's serially processed by 'smtp' worker +/// +public class EmailSender(IBackgroundJobs jobs) : IEmailSender +{ + public Task SendEmailAsync(string email, string subject, string htmlMessage) + { + jobs.EnqueueCommand(new SendEmail { + To = email, + Subject = subject, + BodyHtml = htmlMessage, + }); + return Task.CompletedTask; + } + + public Task SendConfirmationLinkAsync(ApplicationUser user, string email, string confirmationLink) => + SendEmailAsync(email, "Confirm your email", $"Please confirm your account by clicking here."); + + public Task SendPasswordResetLinkAsync(ApplicationUser user, string email, string resetLink) => + SendEmailAsync(email, "Reset your password", $"Please reset your password by clicking here."); + + public Task SendPasswordResetCodeAsync(ApplicationUser user, string email, string resetCode) => + SendEmailAsync(email, "Reset your password", $"Please reset your password using the following code: {resetCode}"); +} diff --git a/MyApp/Configure.Mq.cs b/MyApp/Configure.Mq.cs deleted file mode 100644 index 7108270..0000000 --- a/MyApp/Configure.Mq.cs +++ /dev/null @@ -1,64 +0,0 @@ -using ServiceStack.Messaging; -using MyApp.ServiceInterface; -using MyApp.ServiceModel; -using Microsoft.AspNetCore.Identity; -using MyApp.Data; - -[assembly: HostingStartup(typeof(MyApp.ConfigureMq))] - -namespace MyApp; - -/** - * Register ServiceStack Services you want to be able to invoke in a managed Background Thread - * https://docs.servicestack.net/background-mq -*/ -public class ConfigureMq : IHostingStartup -{ - public void Configure(IWebHostBuilder builder) => builder - .ConfigureServices((context, services) => { - var smtpConfig = context.Configuration.GetSection(nameof(SmtpConfig))?.Get(); - if (smtpConfig is not null) - { - services.AddSingleton(smtpConfig); - } - services.AddSingleton(c => new BackgroundMqService()); - services.AddPlugin(new CommandsFeature()); - }) - .ConfigureAppHost(afterAppHostInit: appHost => { - var mqService = appHost.Resolve(); - - //Register ServiceStack APIs you want to be able to invoke via MQ - mqService.RegisterHandler(appHost.ExecuteMessage); - mqService.Start(); - }); -} - -// Remove the "else if (EmailSender is IdentityNoOpEmailSender)" block from RegisterConfirmation.razor after updating with a real implementation. - -/// -/// Sends emails by publishing a message to the Background MQ Server where it's processed in the background -/// -public class EmailSender(IMessageService messageService) : IEmailSender -{ - public Task SendEmailAsync(string email, string subject, string htmlMessage) - { - using var mqClient = messageService.CreateMessageProducer(); - mqClient.Publish(new SendEmail - { - To = email, - Subject = subject, - BodyHtml = htmlMessage, - }); - - return Task.CompletedTask; - } - - public Task SendConfirmationLinkAsync(ApplicationUser user, string email, string confirmationLink) => - SendEmailAsync(email, "Confirm your email", $"Please confirm your account by clicking here."); - - public Task SendPasswordResetLinkAsync(ApplicationUser user, string email, string resetLink) => - SendEmailAsync(email, "Reset your password", $"Please reset your password by clicking here."); - - public Task SendPasswordResetCodeAsync(ApplicationUser user, string email, string resetCode) => - SendEmailAsync(email, "Reset your password", $"Please reset your password using the following code: {resetCode}"); -} \ No newline at end of file diff --git a/MyApp/MyApp.csproj b/MyApp/MyApp.csproj index ac17a4b..f23edb2 100644 --- a/MyApp/MyApp.csproj +++ b/MyApp/MyApp.csproj @@ -26,6 +26,7 @@ + diff --git a/MyApp/Program.cs b/MyApp/Program.cs index 443174c..90d2732 100644 --- a/MyApp/Program.cs +++ b/MyApp/Program.cs @@ -26,7 +26,9 @@ .PersistKeysToFileSystem(new DirectoryInfo("App_Data")); // Add application services. -services.AddSingleton, IdentityNoOpEmailSender>(); +// services.AddSingleton, IdentityNoOpEmailSender>(); +// Uncomment to send emails with SMTP, configure SMTP with "SmtpConfig" in appsettings.json +services.AddSingleton, EmailSender>(); services.AddScoped, AdditionalUserClaimsPrincipalFactory>(); // Register all services diff --git a/MyApp/Properties/launchSettings.json b/MyApp/Properties/launchSettings.json index 3bd9867..fc2e56e 100644 --- a/MyApp/Properties/launchSettings.json +++ b/MyApp/Properties/launchSettings.json @@ -14,7 +14,7 @@ "dotnetRunMessages": true, "launchBrowser": true, "launchUrl": "metadata", - "applicationUrl": "http://localhost:5122", + "applicationUrl": "https://localhost:5001", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development", "ASPNETCORE_HOSTINGSTARTUPASSEMBLIES": "Microsoft.AspNetCore.SpaProxy" diff --git a/NuGet.Config b/NuGet.Config new file mode 100644 index 0000000..b06e0d0 --- /dev/null +++ b/NuGet.Config @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file