Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/fluid and email forms #48

Merged
merged 2 commits into from
Nov 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/Argon.Api/Argon.Api.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
<ItemGroup>
<PackageReference Include="AutoMapper" Version="13.0.1"/>
<PackageReference Include="Aspire.StackExchange.Redis" Version="9.0.0"/>
<PackageReference Include="Fluid.Core" Version="2.12.0"/>
<PackageReference Include="Flurl.Http" Version="4.0.2"/>
<PackageReference Include="Flurl.Http.Newtonsoft" Version="0.9.1"/>
<PackageReference Include="Genbox.SimpleS3.Core" Version="3.2.4"/>
Expand All @@ -23,7 +24,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.10"/>
<PackageReference Include="ActualLab.Fusion" Version="9.5.59"/>
<PackageReference Include="ActualLab.Fusion" Version="9.5.64"/>
<PackageReference Include="Argon.Sfu.Protocol" Version="1.26.0"/>
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.10"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.10">
Expand All @@ -44,7 +45,7 @@
<PackageReference Include="R3" Version="1.2.9"/>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.9.0"/>
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="6.9.0"/>
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.2.0"/>
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.2.1"/>
</ItemGroup>

<ItemGroup>
Expand Down
55 changes: 0 additions & 55 deletions src/Argon.Api/Features/EmailForms/EMailFormStorage.cs

This file was deleted.

65 changes: 65 additions & 0 deletions src/Argon.Api/Features/Template/TemplateFeature.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
namespace Argon.Api.Features.Template;

using System.Collections.Concurrent;
using Fluid;

public static class TemplateFeature
{
public static IServiceCollection AddTemplateEngine(this WebApplicationBuilder builder)
{
builder.Services.AddSingleton<FluidParser>();
builder.Services.AddHostedService<EMailFormLoader>();
builder.Services.AddSingleton<EMailFormStorage>();
return builder.Services;
}
}

public class EMailFormStorage
{
private readonly ConcurrentDictionary<string, IFluidTemplate> htmlForms = new();

public void Load(string name, IFluidTemplate template) => htmlForms.TryAdd(name, template);

public IFluidTemplate GetContentFor(string formKey)
{
if (htmlForms.TryGetValue(formKey, out var form))
return form;
throw new InvalidOperationException($"No '{formKey}' form found");
}

public string Render(string formKey, Dictionary<string, string> values)
{
var template = GetContentFor(formKey);

var context = new TemplateContext();

foreach (var (key, value) in values)
context.SetValue(key, value);

return template.Render(context);
}
}
Comment on lines +17 to +41
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Consider enhancing error handling and API design

Several improvements could make this class more robust:

  1. The storage has no upper bound which could lead to memory issues
  2. No way to check template existence without exception
  3. Values dictionary is restricted to strings

Suggested improvements:

 public class EMailFormStorage
 {
-    private readonly ConcurrentDictionary<string, IFluidTemplate> htmlForms = new();
+    private readonly ConcurrentDictionary<string, IFluidTemplate> htmlForms;
+    private readonly int maxTemplates;
+
+    public EMailFormStorage(IConfiguration config)
+    {
+        maxTemplates = config.GetValue<int>("Templates:MaxCount", 1000);
+        htmlForms = new(Environment.ProcessorCount, maxTemplates);
+    }

-    public void Load(string name, IFluidTemplate template) => htmlForms.TryAdd(name, template);
+    public bool Load(string name, IFluidTemplate template)
+    {
+        if (htmlForms.Count >= maxTemplates)
+            return false;
+        return htmlForms.TryAdd(name, template);
+    }

+    public bool Exists(string formKey) => htmlForms.ContainsKey(formKey);

     public IFluidTemplate GetContentFor(string formKey)
     {
         if (htmlForms.TryGetValue(formKey, out var form))
             return form;
-        throw new InvalidOperationException($"No '{formKey}' form found");
+        throw new KeyNotFoundException($"Template '{formKey}' not found");
     }

-    public string Render(string formKey, Dictionary<string, string> values)
+    public string Render<T>(string formKey, T values) where T : class
     {
         var template = GetContentFor(formKey);
         var context = new TemplateContext();
-        foreach (var (key, value) in values)
-            context.SetValue(key, value);
+        context.SetValue("Model", values);
         return template.Render(context);
     }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public class EMailFormStorage
{
private readonly ConcurrentDictionary<string, IFluidTemplate> htmlForms = new();
public void Load(string name, IFluidTemplate template) => htmlForms.TryAdd(name, template);
public IFluidTemplate GetContentFor(string formKey)
{
if (htmlForms.TryGetValue(formKey, out var form))
return form;
throw new InvalidOperationException($"No '{formKey}' form found");
}
public string Render(string formKey, Dictionary<string, string> values)
{
var template = GetContentFor(formKey);
var context = new TemplateContext();
foreach (var (key, value) in values)
context.SetValue(key, value);
return template.Render(context);
}
}
public class EMailFormStorage
{
private readonly ConcurrentDictionary<string, IFluidTemplate> htmlForms;
private readonly int maxTemplates;
public EMailFormStorage(IConfiguration config)
{
maxTemplates = config.GetValue<int>("Templates:MaxCount", 1000);
htmlForms = new(Environment.ProcessorCount, maxTemplates);
}
public bool Load(string name, IFluidTemplate template)
{
if (htmlForms.Count >= maxTemplates)
return false;
return htmlForms.TryAdd(name, template);
}
public bool Exists(string formKey) => htmlForms.ContainsKey(formKey);
public IFluidTemplate GetContentFor(string formKey)
{
if (htmlForms.TryGetValue(formKey, out var form))
return form;
throw new KeyNotFoundException($"Template '{formKey}' not found");
}
public string Render<T>(string formKey, T values) where T : class
{
var template = GetContentFor(formKey);
var context = new TemplateContext();
context.SetValue("Model", values);
return template.Render(context);
}
}


public class EMailFormLoader(EMailFormStorage storage, ILogger<EMailFormLoader> logger, FluidParser engine) : BackgroundService
{
protected async override Task ExecuteAsync(CancellationToken stoppingToken)
{
var formFiles = Directory.EnumerateFiles("./Resources", "*.html").ToList();
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codebase verification

Add security measures for template directory access

The code has potential security issues that need to be addressed:

  • The template directory path "./Resources" is hardcoded and accessed without validation
  • No path sanitization or access controls are in place
  • Directory traversal protection is missing

Required changes:

  • Move template directory path to configuration
  • Add path validation to ensure templates are accessed within allowed boundaries
  • Implement sanitization for template file names
  • Consider using Path.GetFullPath() to resolve relative paths securely
🔗 Analysis chain

Verify template directory access security

The file system operations need additional security checks:

  1. Verify template directory is within allowed paths
  2. Check for path traversal in template names

Also applies to: 53-54


Let me gather more information about the template directory security by checking for configuration and validation.

🏁 Scripts executed

The following scripts were executed for the analysis:

Script:

#!/bin/bash
# Description: Check for potential security issues in template handling

# Check if template paths contain parent directory traversal
rg -g '*.cs' '(?i)path\.(?:combine|join).*\.\.' 

# Check for template directory configuration in settings files
fd -e json -e yaml -e xml | xargs rg -i 'template.*(?:directory|path|location)'

# Look for other file system operations that might need security review
ast-grep --pattern 'File.$$$' 

Length of output: 5096


Script:

#!/bin/bash
# Check for template directory configuration and validation

# Look for template directory configuration
rg -g '*.cs' -B 2 -A 2 '"./Resources"'

# Check for any validation or security checks around template access
ast-grep --pattern 'Directory.$$$'

# Check if Resources directory is configured in settings
fd appsettings | xargs rg -i 'resources'

# Look for any path validation or sanitization
rg -g '*.cs' -B 2 -A 2 'Path\.(?:GetFullPath|Combine)'

Length of output: 5174


logger.LogInformation("Found '{count}' email forms", formFiles.Count);

foreach (var file in formFiles)
{
var content = await File.ReadAllTextAsync(file, stoppingToken);
var name = Path.GetFileNameWithoutExtension(file);

if (engine.TryParse(content, out var result, out var error))
{
storage.Load(name, result);
logger.LogInformation("Loaded '{name}' email form", name);
}
else
logger.LogError("Failed load '{name}' email form, error: {error}", name, error);
}
}
}
Comment on lines +43 to +65
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Critical improvements needed for robust template loading

Several critical issues need to be addressed:

  1. Hardcoded path could fail in production
  2. No way to reload templates without service restart
  3. Missing error handling for file system operations
  4. No retry mechanism for failed templates

Here's a suggested implementation with improvements:

-public class EMailFormLoader(EMailFormStorage storage, ILogger<EMailFormLoader> logger, FluidParser engine) 
+public class EMailFormLoader(
+    EMailFormStorage storage,
+    ILogger<EMailFormLoader> logger,
+    FluidParser engine,
+    TemplateOptions options)
     : BackgroundService
 {
+    private readonly FileSystemWatcher? watcher;
+    private readonly HashSet<string> failedTemplates = new();
+    private readonly SemaphoreSlim semaphore = new(1, 1);
+
+    public EMailFormLoader()
+    {
+        if (Directory.Exists(options.TemplateDirectory))
+        {
+            watcher = new(options.TemplateDirectory, options.FilePattern);
+            watcher.Changed += OnTemplateChanged;
+            watcher.Created += OnTemplateChanged;
+        }
+    }

     protected async override Task ExecuteAsync(CancellationToken stoppingToken)
     {
-        var formFiles = Directory.EnumerateFiles("./Resources", "*.html").ToList();
+        await LoadTemplatesAsync(stoppingToken);
+        
+        if (watcher != null)
+        {
+            watcher.EnableRaisingEvents = true;
+            
+            // Periodically retry failed templates
+            while (!stoppingToken.IsCancellationRequested)
+            {
+                await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
+                await RetryFailedTemplatesAsync(stoppingToken);
+            }
+        }
+    }

-        logger.LogInformation("Found '{count}' email forms", formFiles.Count);
+    private async Task LoadTemplatesAsync(CancellationToken stoppingToken)
+    {
+        try
+        {
+            var formFiles = Directory.EnumerateFiles(
+                options.TemplateDirectory, 
+                options.FilePattern
+            ).ToList();

+            logger.LogInformation(
+                "Found {Count} email forms in {Directory}", 
+                formFiles.Count, 
+                options.TemplateDirectory
+            );

+            await LoadTemplatesFromFilesAsync(formFiles, stoppingToken);
+        }
+        catch (Exception ex) when (ex is DirectoryNotFoundException or UnauthorizedAccessException)
+        {
+            logger.LogError(
+                ex,
+                "Failed to access template directory {Directory}",
+                options.TemplateDirectory
+            );
+        }
+    }

-        foreach (var file in formFiles)
-        {
-            var content = await File.ReadAllTextAsync(file, stoppingToken);
-            var name    = Path.GetFileNameWithoutExtension(file);
+    private async Task LoadTemplatesFromFilesAsync(
+        IEnumerable<string> files,
+        CancellationToken stoppingToken)
+    {
+        foreach (var file in files)
+        {
+            await LoadTemplateAsync(file, stoppingToken);
+        }
+    }

+    private async Task LoadTemplateAsync(
+        string file,
+        CancellationToken stoppingToken)
+    {
+        try
+        {
+            var content = await File.ReadAllTextAsync(file, stoppingToken);
+            var name = Path.GetFileNameWithoutExtension(file);

             if (engine.TryParse(content, out var result, out var error))
             {
-                storage.Load(name, result);
+                if (storage.Load(name, result))
+                {
+                    failedTemplates.Remove(name);
                     logger.LogInformation("Loaded '{name}' email form", name);
+                }
+                else
+                {
+                    logger.LogWarning(
+                        "Failed to load '{Name}' - storage capacity reached",
+                        name
+                    );
+                }
             }
             else
+            {
+                failedTemplates.Add(name);
                 logger.LogError("Failed load '{name}' email form, error: {error}", name, error);
+            }
+        }
+        catch (Exception ex) when (
+            ex is IOException or
+            UnauthorizedAccessException)
+        {
+            logger.LogError(
+                ex,
+                "Failed to read template file {File}",
+                file
+            );
         }
     }

+    private async void OnTemplateChanged(object sender, FileSystemEventArgs e)
+    {
+        try
+        {
+            await semaphore.WaitAsync();
+            await LoadTemplateAsync(e.FullPath, CancellationToken.None);
+        }
+        finally
+        {
+            semaphore.Release();
+        }
+    }

+    private async Task RetryFailedTemplatesAsync(CancellationToken stoppingToken)
+    {
+        if (failedTemplates.Count == 0) return;

+        logger.LogInformation(
+            "Retrying {Count} failed templates",
+            failedTemplates.Count
+        );

+        var templates = failedTemplates.ToList();
+        foreach (var template in templates)
+        {
+            var file = Path.Combine(
+                options.TemplateDirectory,
+                template + ".html"
+            );
+            await LoadTemplateAsync(file, stoppingToken);
+        }
+    }

+    public override void Dispose()
+    {
+        watcher?.Dispose();
+        semaphore.Dispose();
+        base.Dispose();
+    }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public class EMailFormLoader(EMailFormStorage storage, ILogger<EMailFormLoader> logger, FluidParser engine) : BackgroundService
{
protected async override Task ExecuteAsync(CancellationToken stoppingToken)
{
var formFiles = Directory.EnumerateFiles("./Resources", "*.html").ToList();
logger.LogInformation("Found '{count}' email forms", formFiles.Count);
foreach (var file in formFiles)
{
var content = await File.ReadAllTextAsync(file, stoppingToken);
var name = Path.GetFileNameWithoutExtension(file);
if (engine.TryParse(content, out var result, out var error))
{
storage.Load(name, result);
logger.LogInformation("Loaded '{name}' email form", name);
}
else
logger.LogError("Failed load '{name}' email form, error: {error}", name, error);
}
}
}
public class EMailFormLoader(
EMailFormStorage storage,
ILogger<EMailFormLoader> logger,
FluidParser engine,
TemplateOptions options)
: BackgroundService
{
private readonly FileSystemWatcher? watcher;
private readonly HashSet<string> failedTemplates = new();
private readonly SemaphoreSlim semaphore = new(1, 1);
public EMailFormLoader()
{
if (Directory.Exists(options.TemplateDirectory))
{
watcher = new(options.TemplateDirectory, options.FilePattern);
watcher.Changed += OnTemplateChanged;
watcher.Created += OnTemplateChanged;
}
}
protected async override Task ExecuteAsync(CancellationToken stoppingToken)
{
await LoadTemplatesAsync(stoppingToken);
if (watcher != null)
{
watcher.EnableRaisingEvents = true;
// Periodically retry failed templates
while (!stoppingToken.IsCancellationRequested)
{
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
await RetryFailedTemplatesAsync(stoppingToken);
}
}
}
private async Task LoadTemplatesAsync(CancellationToken stoppingToken)
{
try
{
var formFiles = Directory.EnumerateFiles(
options.TemplateDirectory,
options.FilePattern
).ToList();
logger.LogInformation(
"Found {Count} email forms in {Directory}",
formFiles.Count,
options.TemplateDirectory
);
await LoadTemplatesFromFilesAsync(formFiles, stoppingToken);
}
catch (Exception ex) when (ex is DirectoryNotFoundException or UnauthorizedAccessException)
{
logger.LogError(
ex,
"Failed to access template directory {Directory}",
options.TemplateDirectory
);
}
}
private async Task LoadTemplatesFromFilesAsync(
IEnumerable<string> files,
CancellationToken stoppingToken)
{
foreach (var file in files)
{
await LoadTemplateAsync(file, stoppingToken);
}
}
private async Task LoadTemplateAsync(
string file,
CancellationToken stoppingToken)
{
try
{
var content = await File.ReadAllTextAsync(file, stoppingToken);
var name = Path.GetFileNameWithoutExtension(file);
if (engine.TryParse(content, out var result, out var error))
{
if (storage.Load(name, result))
{
failedTemplates.Remove(name);
logger.LogInformation("Loaded '{name}' email form", name);
}
else
{
logger.LogWarning(
"Failed to load '{Name}' - storage capacity reached",
name
);
}
}
else
{
failedTemplates.Add(name);
logger.LogError("Failed load '{name}' email form, error: {error}", name, error);
}
}
catch (Exception ex) when (
ex is IOException or
UnauthorizedAccessException)
{
logger.LogError(
ex,
"Failed to read template file {File}",
file
);
}
}
private async void OnTemplateChanged(object sender, FileSystemEventArgs e)
{
try
{
await semaphore.WaitAsync();
await LoadTemplateAsync(e.FullPath, CancellationToken.None);
}
finally
{
semaphore.Release();
}
}
private async Task RetryFailedTemplatesAsync(CancellationToken stoppingToken)
{
if (failedTemplates.Count == 0) return;
logger.LogInformation(
"Retrying {Count} failed templates",
failedTemplates.Count
);
var templates = failedTemplates.ToList();
foreach (var template in templates)
{
var file = Path.Combine(
options.TemplateDirectory,
template + ".html"
);
await LoadTemplateAsync(file, stoppingToken);
}
}
public override void Dispose()
{
watcher?.Dispose();
semaphore.Dispose();
base.Dispose();
}
}

4 changes: 2 additions & 2 deletions src/Argon.Api/Grains/EmailManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ namespace Argon.Api.Grains;

using System.Net;
using System.Net.Mail;
using Features.EmailForms;
using Features.Template;
using Interfaces;
using Microsoft.Extensions.Options;

Expand All @@ -26,7 +26,7 @@ public Task SendEmailAsync(string email, string subject, string message, string

public async Task SendOtpCodeAsync(string email, string otpCode, TimeSpan validity)
{
var form = formStorage.CompileAndGetForm("otp", new Dictionary<string, string>
var form = formStorage.Render("otp", new Dictionary<string, string>
{
{
"otp", otpCode
Expand Down
4 changes: 2 additions & 2 deletions src/Argon.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@
using Argon.Api.Extensions;
using Argon.Api.Features;
using Argon.Api.Features.Captcha;
using Argon.Api.Features.EmailForms;
using Argon.Api.Features.Env;
using Argon.Api.Features.Jwt;
using Argon.Api.Features.MediaStorage;
using Argon.Api.Features.Otp;
using Argon.Api.Features.Pex;
using Argon.Api.Features.Template;
using Argon.Api.Grains.Interfaces;
using Argon.Api.Migrations;
using Argon.Api.Services;
Expand Down Expand Up @@ -49,7 +49,7 @@
builder.Services.AddSingleton<IFusionContext, FusionContext>();
builder.AddOtpCodes();
builder.AddOrleans();
builder.AddEMailForms();
builder.AddTemplateEngine();
builder.AddKubeResources();
builder.AddCaptchaFeature();
builder.Services.AddDataProtection();
Expand Down
84 changes: 84 additions & 0 deletions src/Argon.Api/Resources/new_session.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta content="width=device-width, initial-scale=1.0" name="viewport">
<title>Login to Your Account</title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #1a1a1a;
margin: 0;
padding: 0;
color: #e0e0e0;
}

.container {
max-width: 600px;
margin: 0 auto;
background-color: #2a2a2a;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
}

.header {
font-size: 24px;
font-weight: bold;
text-align: center;
margin-bottom: 20px;
color: #bb86fc;
}

.message {
font-size: 16px;
color: #cccccc;
margin-bottom: 20px;
line-height: 1.5;
}

.button {
display: inline-block;
font-size: 18px;
font-weight: bold;
padding: 10px 20px;
color: #ffffff;
background-color: #6200ea;
border-radius: 4px;
text-decoration: none;
margin-bottom: 20px;
}

.footer {
font-size: 14px;
color: #888888;
text-align: center;
margin-top: 20px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">Login to Argon</div>
<div class="message">
Hello,<br><br>
You have successfully logged in to your account. Your session details are as follows:
</div>
<div style="text-align: center;">
<div class="message">
IP: <strong>{{ user_ip }}</strong><br>
Region: <strong>{{ user_region }}</strong><br>
Host: <strong>{{ user_host }}</strong><br>
Login Time: <strong>{{ login_time }}</strong>
</div>
</div>
<div class="message">
If this login was not made by you, please contact our support team immediately.
</div>
<div class="footer">
Thank you,<br>
The Argon Team
</div>
</div>
</body>
</html>
31 changes: 22 additions & 9 deletions src/Argon.Api/Resources/otp.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,50 +7,63 @@
<style>
body {
font-family: Arial, sans-serif;
background-color: #f4f4f4;
background-color: #1a1a1a;
margin: 0;
padding: 0;
color: #e0e0e0;
}

.container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
background-color: #2a2a2a;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
}

.header {
font-size: 24px;
font-weight: bold;
text-align: center;
margin-bottom: 20px;
color: #333333;
color: #bb86fc;
}

.message {
font-size: 16px;
color: #555555;
color: #cccccc;
margin-bottom: 20px;
line-height: 1.5;
}

.otp-code {
.otp-code {
display: inline-block;
font-size: 28px;
font-weight: bold;
padding: 10px 20px;
color: #ffffff;
background-color: #007bff;
background-color: #6200ea;
border-radius: 4px;
text-decoration: none;
margin-bottom: 20px;
}

.button {
display: inline-block;
font-size: 18px;
font-weight: bold;
padding: 10px 20px;
color: #ffffff;
background-color: #6200ea;
border-radius: 4px;
text-decoration: none;
margin-bottom: 20px;
}
Comment on lines +52 to 62
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Reduce style duplication with a shared base class.

The .button and .otp-code classes share many properties and could be refactored to follow DRY principles.

Consider this refactor:

+.button-base {
+    display: inline-block;
+    font-weight: bold;
+    padding: 10px 20px;
+    color: #ffffff;
+    background-color: #6200ea;
+    border-radius: 4px;
+    text-decoration: none;
+    margin-bottom: 20px;
+    transition: background-color 0.2s ease;
+}
+
+.button-base:hover {
+    background-color: #7722ff;
+}
+
+.button-base:active {
+    background-color: #5000d0;
+}
+
-.otp-code {
-    display: inline-block;
+.otp-code extends .button-base {
     font-size: 28px;
-    font-weight: bold;
-    padding: 10px 20px;
-    color: #ffffff;
-    background-color: #6200ea;
-    border-radius: 4px;
-    text-decoration: none;
-    margin-bottom: 20px;
 }
 
-.button {
-    display: inline-block;
+.button extends .button-base {
     font-size: 18px;
-    font-weight: bold;
-    padding: 10px 20px;
-    color: #ffffff;
-    background-color: #6200ea;
-    border-radius: 4px;
-    text-decoration: none;
-    margin-bottom: 20px;
 }

Committable suggestion skipped: line range outside the PR's diff.


.footer {
font-size: 14px;
color: #777777;
color: #888888;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Fix footer text contrast for accessibility compliance.

The current footer text color (#888888) on background (#2a2a2a) has a contrast ratio of 3.54:1, which fails WCAG AA requirements (minimum 4.5:1 for normal text).

Apply this fix:

-            color: #888888;
+            color: #a7a7a7;

The new color provides a contrast ratio of 4.52:1, meeting WCAG AA requirements.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
color: #888888;
color: #a7a7a7;

text-align: center;
margin-top: 20px;
}
Expand Down
Loading