Merge pull request #147 from lanedirt/137-improve-credential-email-generation-ui

This commit is contained in:
Leendert de Borst
2024-07-31 14:31:53 -07:00
committed by GitHub
24 changed files with 832 additions and 128 deletions

View File

@@ -1,4 +1,4 @@
API_URL=
JWT_KEY=
SMTP_ALLOWED_DOMAINS=
PRIVATE_EMAIL_DOMAINS=
SMTP_TLS_ENABLED=false

View File

@@ -25,9 +25,24 @@ jobs:
run: dotnet build
- name: Ensure browsers are installed
run: pwsh src/Tests/AliasVault.E2ETests/bin/Debug/net8.0/playwright.ps1 install --with-deps
- name: Run AdminTests
run: dotnet test src/Tests/AliasVault.E2ETests --no-build --verbosity normal --filter "Category=AdminTests"
- name: Run ClientTests
run: dotnet test src/Tests/AliasVault.E2ETests --no-build --verbosity normal --filter "Category=ClientTests"
- name: Run remaining tests
run: dotnet test src/Tests/AliasVault.E2ETests --no-build --verbosity normal --filter "Category!=AdminTests&Category!=ClientTests"
- name: Run AdminTests with retry
uses: nick-invision/retry@v2
with:
timeout_minutes: 10
max_attempts: 3
command: dotnet test src/Tests/AliasVault.E2ETests --no-build --verbosity normal --filter "Category=AdminTests"
- name: Run ClientTests with retry
uses: nick-invision/retry@v2
with:
timeout_minutes: 10
max_attempts: 3
command: dotnet test src/Tests/AliasVault.E2ETests --no-build --verbosity normal --filter "Category=ClientTests"
- name: Run remaining tests with retry
uses: nick-invision/retry@v2
with:
timeout_minutes: 10
max_attempts: 3
command: dotnet test src/Tests/AliasVault.E2ETests --no-build --verbosity normal --filter "Category!=AdminTests&Category!=ClientTests"

View File

@@ -80,7 +80,7 @@ Here is an example file with the various options explained:
```
{
"ApiUrl": "http://localhost:5092",
"SmtpAllowedDomains": ["example.tld"],
"PrivateEmailDomains": ["example.tld"],
"UseDebugEncryptionKey": "true"
}
```

View File

@@ -30,11 +30,11 @@ This README provides step-by-step instructions for manually setting up AliasVaul
```
JWT_KEY=your_32_char_string_here
3. **Set SMTP_ALLOWED_DOMAINS**
3. **Set PRIVATE_EMAIL_DOMAINS**
Update the .env file and set the SMTP_ALLOWED_DOMAINS value the allowed domains that can be used for email addresses. Separate multiple domains with commas.
Update the .env file and set the PRIVATE_EMAIL_DOMAINS value the allowed domains that can be used for email addresses. Separate multiple domains with commas.
```
SMTP_ALLOWED_DOMAINS=yourdomain.com,anotherdomain.com
PRIVATE_EMAIL_DOMAINS=yourdomain.com,anotherdomain.com
```
Replace `yourdomain.com,anotherdomain.com` with your actual allowed domains.

View File

@@ -170,33 +170,33 @@ populate_jwt_key() {
fi
}
# Function to ask the user for SMTP_ALLOWED_DOMAINS
set_smtp_allowed_domains() {
printf "${CYAN}> Setting SMTP_ALLOWED_DOMAINS...${NC}\n"
if ! grep -q "^SMTP_ALLOWED_DOMAINS=" "$ENV_FILE" || [ -z "$(grep "^SMTP_ALLOWED_DOMAINS=" "$ENV_FILE" | cut -d '=' -f2)" ]; then
# Function to ask the user for PRIVATE_EMAIL_DOMAINS
set_private_email_domains() {
printf "${CYAN}> Setting PRIVATE_EMAIL_DOMAINS...${NC}\n"
if ! grep -q "^PRIVATE_EMAIL_DOMAINS=" "$ENV_FILE" || [ -z "$(grep "^PRIVATE_EMAIL_DOMAINS=" "$ENV_FILE" | cut -d '=' -f2)" ]; then
printf "Please enter the domains that should be allowed to receive email, separated by commas (press Enter to disable email support): "
read -r smtp_allowed_domains
read -r private_email_domains
# Set default value if user input is empty
smtp_allowed_domains=${smtp_allowed_domains:-"DISABLED.TLD"}
private_email_domains=${private_email_domains:-"DISABLED.TLD"}
if grep -q "^SMTP_ALLOWED_DOMAINS=" "$ENV_FILE"; then
awk -v domains="$smtp_allowed_domains" '/^SMTP_ALLOWED_DOMAINS=/ {$0="SMTP_ALLOWED_DOMAINS="domains} 1' "$ENV_FILE" > "$ENV_FILE.tmp" && mv "$ENV_FILE.tmp" "$ENV_FILE"
if grep -q "^PRIVATE_EMAIL_DOMAINS=" "$ENV_FILE"; then
awk -v domains="$private_email_domains" '/^PRIVATE_EMAIL_DOMAINS=/ {$0="PRIVATE_EMAIL_DOMAINS="domains} 1' "$ENV_FILE" > "$ENV_FILE.tmp" && mv "$ENV_FILE.tmp" "$ENV_FILE"
else
echo "SMTP_ALLOWED_DOMAINS=${smtp_allowed_domains}" >> "$ENV_FILE"
echo "PRIVATE_EMAIL_DOMAINS=${private_email_domains}" >> "$ENV_FILE"
fi
if [ "$smtp_allowed_domains" = "DISABLED.TLD" ]; then
printf "${GREEN}> SMTP_ALLOWED_DOMAINS has been set to 'DISABLED.TLD' in $ENV_FILE.${NC} ${RED}SMTP is disabled.${NC}\n"
if [ "$private_email_domains" = "DISABLED.TLD" ]; then
printf "${GREEN}> PRIVATE_EMAIL_DOMAINS has been set to 'DISABLED.TLD' in $ENV_FILE.${NC} ${RED}SMTP is disabled.${NC}\n"
else
printf "${GREEN}> SMTP_ALLOWED_DOMAINS has been set to '${smtp_allowed_domains}' in $ENV_FILE.${NC}\n"
printf "${GREEN}> PRIVATE_EMAIL_DOMAINS has been set to '${private_email_domains}' in $ENV_FILE.${NC}\n"
fi
else
smtp_allowed_domains=$(grep "^SMTP_ALLOWED_DOMAINS=" "$ENV_FILE" | cut -d '=' -f2)
if [ "$smtp_allowed_domains" = "DISABLED.TLD" ]; then
printf "${GREEN}> SMTP_ALLOWED_DOMAINS already exists in $ENV_FILE.${NC} ${RED}SMTP is disabled.${NC}\n"
private_email_domains=$(grep "^private_email_domains=" "$ENV_FILE" | cut -d '=' -f2)
if [ "$private_email_domains" = "DISABLED.TLD" ]; then
printf "${GREEN}> PRIVATE_EMAIL_DOMAINS already exists in $ENV_FILE.${NC} ${RED}SMTP is disabled.${NC}\n"
else
printf "${GREEN}> SMTP_ALLOWED_DOMAINS already exists in $ENV_FILE with value: ${smtp_allowed_domains}${NC}\n"
printf "${GREEN}> PRIVATE_EMAIL_DOMAINS already exists in $ENV_FILE with value: ${private_email_domains}${NC}\n"
fi
fi
}
@@ -316,7 +316,7 @@ main() {
create_env_file || exit $?
populate_api_url || exit $?
populate_jwt_key || exit $?
set_smtp_allowed_domains || exit $?
set_private_email_domains || exit $?
set_smtp_tls_enabled || exit $?
generate_admin_password || exit $?
printf "\n${YELLOW}+++ Building Docker containers +++${NC}\n"

View File

@@ -10,6 +10,7 @@ namespace AliasVault.Api.Controllers;
using AliasServerDb;
using AliasVault.Api.Helpers;
using AliasVault.Shared.Models.Spamok;
using AliasVault.Shared.Models.WebApi;
using Asp.Versioning;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
@@ -41,30 +42,54 @@ public class EmailBoxController(IDbContextFactory<AliasServerDbContext> dbContex
// See if this user has a valid claim to the email address.
var emailClaim = await context.UserEmailClaims
.FirstOrDefaultAsync(x => x.UserId == user.Id && x.Address == to);
.FirstOrDefaultAsync(x => x.Address == to);
if (emailClaim is null)
{
return Unauthorized("User does not have a claim to this email address.");
return BadRequest(new ApiErrorResponse
{
Message = "No claim exists for this email address.",
Code = "CLAIM_DOES_NOT_EXIST",
Details = new { ProvidedEmail = to },
StatusCode = StatusCodes.Status400BadRequest,
Timestamp = DateTime.UtcNow,
});
}
if (emailClaim.UserId != user.Id)
{
return BadRequest(new ApiErrorResponse
{
Message = "Claim does not match user.",
Code = "CLAIM_DOES_NOT_MATCH_USER",
Details = new { ProvidedEmail = to },
StatusCode = StatusCodes.Status400BadRequest,
Timestamp = DateTime.UtcNow,
});
}
// Retrieve emails from database.
List<MailboxEmailApiModel> emails = await context.Emails.AsNoTracking().Select(x => new MailboxEmailApiModel()
{
Id = x.Id,
Subject = x.Subject,
FromDisplay = ConversionHelper.ConvertFromToFromDisplay(x.From),
FromDomain = x.FromDomain,
FromLocal = x.FromLocal,
ToDomain = x.ToDomain,
ToLocal = x.ToLocal,
Date = x.Date,
DateSystem = x.DateSystem,
SecondsAgo = (int)DateTime.UtcNow.Subtract(x.DateSystem).TotalSeconds,
MessagePreview = x.MessagePreview ?? string.Empty,
EncryptedSymmetricKey = x.EncryptedSymmetricKey,
EncryptionKey = x.EncryptionKey.PublicKey,
}).OrderByDescending(x => x.DateSystem).Take(75).ToListAsync();
List<MailboxEmailApiModel> emails = await context.Emails.AsNoTracking()
.Where(x => x.To == to)
.Select(x => new MailboxEmailApiModel()
{
Id = x.Id,
Subject = x.Subject,
FromDisplay = ConversionHelper.ConvertFromToFromDisplay(x.From),
FromDomain = x.FromDomain,
FromLocal = x.FromLocal,
ToDomain = x.ToDomain,
ToLocal = x.ToLocal,
Date = x.Date,
DateSystem = x.DateSystem,
SecondsAgo = (int)DateTime.UtcNow.Subtract(x.DateSystem).TotalSeconds,
MessagePreview = x.MessagePreview ?? string.Empty,
EncryptedSymmetricKey = x.EncryptedSymmetricKey,
EncryptionKey = x.EncryptionKey.PublicKey,
})
.OrderByDescending(x => x.DateSystem)
.Take(50)
.ToListAsync();
MailboxApiModel returnValue = new MailboxApiModel();
returnValue.Address = to;

View File

@@ -7,6 +7,7 @@
namespace AliasVault.Api.Controllers;
using System.ComponentModel.DataAnnotations;
using AliasServerDb;
using AliasVault.Api.Helpers;
using AliasVault.Api.Vault;
@@ -20,11 +21,12 @@ using Microsoft.EntityFrameworkCore;
/// <summary>
/// Vault controller for handling CRUD operations on the database for encrypted vault entities.
/// </summary>
/// <param name="logger">ILogger instance.</param>
/// <param name="dbContextFactory">DbContext instance.</param>
/// <param name="userManager">UserManager instance.</param>
/// <param name="timeProvider">ITimeProvider instance.</param>
[ApiVersion("1")]
public class VaultController(IDbContextFactory<AliasServerDbContext> dbContextFactory, UserManager<AliasVaultUser> userManager, ITimeProvider timeProvider) : AuthenticatedRequestController(userManager)
public class VaultController(ILogger<VaultController> logger, IDbContextFactory<AliasServerDbContext> dbContextFactory, UserManager<AliasVaultUser> userManager, ITimeProvider timeProvider) : AuthenticatedRequestController(userManager)
{
/// <summary>
/// Default retention policy for vaults.
@@ -149,17 +151,42 @@ public class VaultController(IDbContextFactory<AliasServerDbContext> dbContextFa
// Register new email addresses.
foreach (var email in newEmailAddresses)
{
// If email address is invalid according to the EmailAddressAttribute, skip it.
if (!new EmailAddressAttribute().IsValid(email))
{
continue;
}
// Check if the email address is already claimed (by another user).
var existingClaim = await context.UserEmailClaims
.FirstOrDefaultAsync(x => x.Address == email);
if (existingClaim != null && existingClaim.UserId != userId)
{
// Email address is already claimed by another user. Log the error and continue.
logger.LogWarning("{User} tried to claim email address: {Email} but it is already claimed by another user.", userId, email);
continue;
}
if (!existingEmailClaims.Contains(email))
{
await context.UserEmailClaims.AddAsync(new UserEmailClaim
try
{
UserId = userId,
Address = email,
AddressLocal = email.Split('@')[0],
AddressDomain = email.Split('@')[1],
CreatedAt = timeProvider.UtcNow,
UpdatedAt = timeProvider.UtcNow,
});
await context.UserEmailClaims.AddAsync(new UserEmailClaim
{
UserId = userId,
Address = email,
AddressLocal = email.Split('@')[0],
AddressDomain = email.Split('@')[1],
CreatedAt = timeProvider.UtcNow,
UpdatedAt = timeProvider.UtcNow,
});
}
catch (DbUpdateException ex)
{
// Error while adding email claim. Log the error and continue.
logger.LogWarning(ex, "Error while adding UserEmailClaim with email: {Email} for user: {UserId}.", email, userId);
}
}
}

View File

@@ -23,5 +23,22 @@ public class Config
/// Email addresses that client vault users use will be registered at the server
/// to get exclusive access to the email address.
/// </summary>
public List<string> SmtpAllowedDomains { get; set; } = [];
public List<string> PrivateEmailDomains { get; set; } = [];
/// <summary>
/// Gets or sets the public email domains that are allowed to be used by the client vault users.
/// </summary>
public List<string> PublicEmailDomains { get; set; } =
[
"spamok.com",
"solarflarecorp.com",
"spamok.nl",
"3060.nl",
"landmail.nl",
"asdasd.nl",
"spamok.de",
"spamok.com.ua",
"spamok.es",
"spamok.fr",
];
}

View File

@@ -1,10 +1,15 @@
@using AliasVault.Shared.Models.Spamok
@using System.Net
@using System.Text.Json
@using AliasVault.Shared.Models.Spamok
@using AliasVault.Shared.Models.WebApi
@inherits ComponentBase
@inject IHttpClientFactory HttpClientFactory
@inject HttpClient HttpClient
@inject JsInteropService JsInteropService
@inject DbService DbService
@inject Config Config
@using System.Timers
@implements IDisposable
@if (EmailModalVisible)
{
@@ -15,10 +20,15 @@
{
<div class="p-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm 2xl:col-span-2 dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<div class="flex justify-between">
<h3 class="mb-4 text-xl font-semibold dark:text-white">Email</h3>
<button id="recent-email-refresh" @onclick="LoadRecentEmailsAsync" type="button" class="text-blue-700 border border-blue-700 hover:bg-blue-700 hover:text-white focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-full text-sm p-2.5 text-center inline-flex items-center dark:border-blue-500 dark:text-blue-500 dark:hover:text-white dark:focus:ring-blue-800 dark:hover:bg-blue-500">
Refresh
</button>
<div>
<h3 class="mb-4 text-xl font-semibold dark:text-white">Email</h3>
</div>
<div class="flex justify-end items-center space-x-2">
<div class="w-3 h-3 mr-2 rounded-full bg-primary-300 border-2 border-primary-100 animate-pulse" title="Auto-refresh enabled"></div>
<button id="recent-email-refresh" @onclick="ManualRefresh" type="button" class="text-blue-700 border border-blue-700 hover:bg-blue-700 hover:text-white focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-full text-sm p-2.5 text-center inline-flex items-center dark:border-blue-500 dark:text-blue-500 dark:hover:text-white dark:focus:ring-blue-800 dark:hover:bg-blue-500">
Refresh
</button>
</div>
</div>
@if (IsLoading)
@@ -80,11 +90,19 @@
public string EmailAddress { get; set; } = string.Empty;
private List<MailboxEmailApiModel> MailboxEmails { get; set; } = new();
private bool IsLoading { get; set; } = true;
private bool ShowComponent { get; set; } = false;
private EmailApiModel Email { get; set; } = new();
private bool EmailModalVisible { get; set; }
private string Error { get; set; } = string.Empty;
private Timer? RefreshTimer { get; set; }
private bool IsRefreshing { get; set; } = true;
/// <summary>
/// Boolean for UI to indicate refreshing. This value is switched between false/true every 2 seconds.
/// </summary>
private bool RefreshTick { get; set; } = false;
private bool IsLoading { get; set; } = true;
/// <inheritdoc />
protected override async Task OnInitializedAsync()
@@ -96,6 +114,16 @@
{
ShowComponent = true;
}
RefreshTimer = new Timer(2000);
RefreshTimer.Elapsed += async (sender, e) => await TimerRefresh();
RefreshTimer.Start();
}
/// <inheritdoc />
public void Dispose()
{
RefreshTimer?.Dispose();
}
/// <inheritdoc />
@@ -110,7 +138,7 @@
if (firstRender)
{
await LoadRecentEmailsAsync();
await ManualRefresh();
}
}
@@ -119,17 +147,7 @@
/// </summary>
private bool IsSpamOkDomain(string email)
{
return email.EndsWith("@spamok.nl") ||
email.EndsWith("@spamok.de") ||
email.EndsWith("@spamok.es") ||
email.EndsWith("@spamok.fr") ||
email.EndsWith("@spamok.com") ||
email.EndsWith("@spamok.com.ua") ||
email.EndsWith("@landmail.nl") ||
email.EndsWith("@landmeel.nl") ||
email.EndsWith("@asdasd.nl") ||
email.EndsWith("@sdfsdf.nl") ||
email.EndsWith("@solarflarecorp.com");
return Config.PublicEmailDomains.Exists(x => email.EndsWith(x));
}
/// <summary>
@@ -137,7 +155,25 @@
/// </summary>
private bool IsAliasVaultDomain(string email)
{
return Config.SmtpAllowedDomains.Exists(x => email.EndsWith(x));
return Config.PrivateEmailDomains.Exists(x => email.EndsWith(x));
}
private async Task TimerRefresh()
{
IsRefreshing = true;
StateHasChanged();
await LoadRecentEmailsAsync();
IsRefreshing = false;
StateHasChanged();
}
private async Task ManualRefresh()
{
IsLoading = true;
StateHasChanged();
await LoadRecentEmailsAsync();
IsLoading = false;
StateHasChanged();
}
private async Task LoadRecentEmailsAsync()
@@ -148,7 +184,6 @@
}
Error = string.Empty;
IsLoading = true;
StateHasChanged();
// Get email prefix, which is the part before the @ symbol.
@@ -162,9 +197,6 @@
{
await LoadAliasVaultEmails();
}
IsLoading = false;
StateHasChanged();
}
/// <summary>
@@ -175,7 +207,6 @@
// Get email prefix, which is the part before the @ symbol.
string emailPrefix = EmailAddress.Split('@')[0];
if (IsSpamOkDomain(EmailAddress))
{
await ShowSpamOkEmailInModal(emailPrefix, emailId);
@@ -223,31 +254,43 @@
/// </summary>
private async Task LoadAliasVaultEmails()
{
var request = new HttpRequestMessage(HttpMethod.Get, $"api/v1/EmailBox/{EmailAddress}");
try
{
var mailbox = await HttpClient.GetFromJsonAsync<MailboxApiModel>($"api/v1/EmailBox/{EmailAddress}");
if (mailbox?.Mails != null)
var response = await HttpClient.SendAsync(request);
if (response.IsSuccessStatusCode)
{
// Show maximum of 10 recent emails.
MailboxEmails = mailbox.Mails.Take(10).ToList();
var mailbox = await response.Content.ReadFromJsonAsync<MailboxApiModel>();
await UpdateMailboxEmails(mailbox);
}
// Loop through emails and decrypt the subject locally.
var context = await DbService.GetDbContextAsync();
var privateKeys = await context.EncryptionKeys.ToListAsync();
foreach (var mail in MailboxEmails)
else
{
var privateKey = privateKeys.First(x => x.PublicKey == mail.EncryptionKey);
var errorContent = await response.Content.ReadAsStringAsync();
var errorResponse = JsonSerializer.Deserialize<ApiErrorResponse>(errorContent);
switch (response.StatusCode)
{
case HttpStatusCode.BadRequest:
if (errorResponse != null)
{
switch (errorResponse.Code)
{
case "CLAIM_DOES_NOT_MATCH_USER":
Error = "The current chosen email address is already in use. Please change the email address by editing this credential.";
break;
case "CLAIM_DOES_NOT_EXIST":
Error = "An error occurred while trying to load the emails. Please try to edit and" +
"save the credential entry to synchronize the database, then again.";
break;
default:
throw new ArgumentException(errorResponse.Message);
}
}
try
{
var decryptedSymmetricKey = await JsInteropService.DecryptWithPrivateKey(mail.EncryptedSymmetricKey, privateKey.PrivateKey);
mail.Subject = await JsInteropService.SymmetricDecrypt(mail.Subject, Convert.ToBase64String(decryptedSymmetricKey));
}
catch (Exception ex)
{
Error = ex.Message;
Console.WriteLine(ex);
break;
case HttpStatusCode.Unauthorized:
throw new UnauthorizedAccessException(errorResponse?.Message);
default:
throw new WebException(errorResponse?.Message);
}
}
}
@@ -258,6 +301,37 @@
}
}
/// <summary>
/// Update the mailbox emails and decrypt the subject locally.
/// </summary>
private async Task UpdateMailboxEmails(MailboxApiModel? mailbox)
{
if (mailbox?.Mails != null)
{
// Show maximum of 10 recent emails.
MailboxEmails = mailbox.Mails.Take(10).ToList();
}
// Loop through emails and decrypt the subject locally.
var context = await DbService.GetDbContextAsync();
var privateKeys = await context.EncryptionKeys.ToListAsync();
foreach (var mail in MailboxEmails)
{
var privateKey = privateKeys.First(x => x.PublicKey == mail.EncryptionKey);
try
{
var decryptedSymmetricKey = await JsInteropService.DecryptWithPrivateKey(mail.EncryptedSymmetricKey, privateKey.PrivateKey);
mail.Subject = await JsInteropService.SymmetricDecrypt(mail.Subject, Convert.ToBase64String(decryptedSymmetricKey));
}
catch (Exception ex)
{
Error = ex.Message;
Console.WriteLine(ex);
}
}
}
/// <summary>
/// Load recent emails from AliasVault.
/// </summary>

View File

@@ -0,0 +1,80 @@
@inject ClipboardCopyService ClipboardCopyService
@inject JsInteropService JsInteropService
@implements IDisposable
<label for="@_inputId" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">@Label</label>
<div class="relative">
<input type="@(_isPasswordVisible ? "text" : "password")" id="@_inputId" class="outline-0 shadow-sm bg-gray-50 border @(_copied ? "border-green-500 border-2" : "border-gray-300") text-gray-900 sm:text-sm rounded-lg block w-full p-2.5 pr-20 dark:bg-gray-700 dark:border-@(_copied ? "green-500" : "gray-600") dark:placeholder-gray-400 dark:text-white" value="@Value" @onclick="CopyToClipboard" readonly>
<button type="button" class="absolute inset-y-1 right-1 flex items-center justify-center w-10 h-8 text-gray-500 bg-gray-200 rounded-md shadow-sm hover:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-colors duration-200 dark:bg-gray-600 dark:hover:bg-gray-500 dark:text-gray-300" @onclick="TogglePasswordVisibility">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
@if (_isPasswordVisible)
{
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
}
else
{
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"></path>
}
</svg>
</button>
@if (_copied)
{
<span class="absolute inset-y-0 right-10 flex items-center pr-3 text-green-500 dark:text-green-400">
Copied!
</span>
}
</div>
@code {
/// <summary>
/// The label for the input.
/// </summary>
[Parameter]
public string Label { get; set; } = "Password";
/// <summary>
/// The value to copy to the clipboard.
/// </summary>
[Parameter]
public string Value { get; set; } = string.Empty;
private bool _copied => ClipboardCopyService.GetCopiedId() == _inputId;
private readonly string _inputId = Guid.NewGuid().ToString();
private bool _isPasswordVisible = false;
/// <inheritdoc />
protected override void OnInitialized()
{
ClipboardCopyService.OnCopy += HandleCopy;
}
private async Task CopyToClipboard()
{
await JsInteropService.CopyToClipboard(Value);
ClipboardCopyService.SetCopied(_inputId);
// After 2 seconds, reset the copied state if it's still the same element
await Task.Delay(2000);
if (ClipboardCopyService.GetCopiedId() == _inputId)
{
ClipboardCopyService.SetCopied(string.Empty);
}
}
private void TogglePasswordVisibility()
{
_isPasswordVisible = !_isPasswordVisible;
}
private void HandleCopy(string copiedElementId)
{
StateHasChanged();
}
/// <inheritdoc />
public void Dispose()
{
ClipboardCopyService.OnCopy -= HandleCopy;
}
}

View File

@@ -0,0 +1,186 @@
@inject Config Config
@inject JsInteropService JsInteropService
<label for="@Id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">@Label</label>
<div class="relative">
<div class="flex">
<input type="text" id="@Id" class="outline-0 shadow-sm bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-l-lg block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white" value="@LocalPart" @oninput="OnLocalPartChanged">
@if (!IsCustomDomain)
{
<span class="inline-flex items-center p-2.5 text-sm text-gray-900 bg-gray-200 border border-l-0 border-gray-300 rounded-r-lg dark:bg-gray-600 dark:text-gray-400 dark:border-gray-600 cursor-pointer" @onclick="TogglePopup">
<span class="text-gray-500">@@</span>@SelectedDomain
</span>
}
</div>
</div>
<div class="mt-2">
@if (IsCustomDomain)
{
<button type="button" class="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200" @onclick="TogglePopup">
Use domain chooser
</button>
}
else
{
<button type="button" class="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200" @onclick="ToggleCustomDomain">
Enter custom domain
</button>
}
</div>
@if (IsPopupVisible)
{
<div class="fixed inset-0 bg-gray-600 bg-opacity-50 z-30 overflow-y-auto h-full w-full" @onclick="ClosePopup">
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white dark:bg-gray-800" @onclick:stopPropagation>
<div class="mt-3 text-center">
<h3 class="text-lg leading-6 font-medium text-gray-900 dark:text-white">Select Email Domain</h3>
<div class="mt-2 px-7 py-3">
@if (ShowPrivateDomains)
{
<div class="mb-4">
<h4 class="text-md font-semibold text-gray-700 dark:text-gray-300">Private Email (AliasVault server)</h4>
<p class="text-sm text-gray-500 dark:text-gray-400">E2E encrypted, fully private.</p>
@foreach (var domain in PrivateDomains)
{
<button class="mt-2 px-4 py-2 bg-primary-300 text-gray-700 rounded hover:bg-primary-400 focus:outline-none focus:ring-2 focus:ring-gray-400 mr-2" @onclick="() => SelectDomain(domain)">
@domain
</button>
}
</div>
}
<div class="@(ShowPrivateDomains ? "border-t border-gray-200 dark:border-gray-600 pt-4" : "")">
<h4 class="text-md font-semibold text-gray-700 dark:text-gray-300">Public Temp Email Providers</h4>
<p class="text-sm text-gray-500 dark:text-gray-400">Anonymous but limited privacy. Accessible to anyone that knows the address.</p>
@foreach (var domain in PublicDomains)
{
<button class="mt-2 px-4 py-2 bg-gray-200 text-gray-700 rounded hover:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-gray-400 mr-2" @onclick="() => SelectDomain(domain)">
@domain
</button>
}
</div>
</div>
</div>
</div>
</div>
}
@code {
/// <summary>
/// The id for the input field.
/// </summary>
[Parameter]
public string Id { get; set; } = string.Empty;
/// <summary>
/// The label for the input field.
/// </summary>
[Parameter]
public string Label { get; set; } = "Email Address";
/// <summary>
/// The value of the input field. This should be the full email address.
/// </summary>
[Parameter]
public string Value { get; set; } = string.Empty;
/// <summary>
/// Callback that is triggered when the value changes.
/// </summary>
[Parameter]
public EventCallback<string> ValueChanged { get; set; }
private bool IsCustomDomain { get; set; } = false;
private string LocalPart { get; set; } = string.Empty;
private string SelectedDomain = string.Empty;
private bool IsPopupVisible = false;
private List<string> PrivateDomains => Config.PrivateEmailDomains;
private List<string> PublicDomains => Config.PublicEmailDomains;
private bool ShowPrivateDomains => PrivateDomains.Count > 0 && !(PrivateDomains.Count == 1 && PrivateDomains[0] == "DISABLED.TLD");
/// <inheritdoc />
protected override void OnInitialized()
{
base.OnInitialized();
IsCustomDomain = !PublicDomains.Contains(SelectedDomain) && !PrivateDomains.Contains(SelectedDomain);
}
/// <inheritdoc />
protected override void OnParametersSet()
{
base.OnParametersSet();
if (Value.Contains('@'))
{
SelectedDomain = Value.Split('@')[1];
}
else if (ShowPrivateDomains)
{
SelectedDomain = PrivateDomains[0];
}
else
{
SelectedDomain = PublicDomains[0];
}
IsCustomDomain = !PublicDomains.Contains(SelectedDomain) && !PrivateDomains.Contains(SelectedDomain);
if (IsCustomDomain)
{
LocalPart = Value;
}
else
{
LocalPart = Value.Contains('@') ? Value.Split('@')[0] : Value;
}
}
private async Task OnLocalPartChanged(ChangeEventArgs e)
{
string newLocalPart = e.Value?.ToString() ?? string.Empty;
// Check if new value contains '@' symbol, if so, switch to custom domain mode.
if (newLocalPart.Contains('@'))
{
IsCustomDomain = true;
Value = newLocalPart;
await ValueChanged.InvokeAsync(Value);
return;
}
Value = $"{newLocalPart}@{SelectedDomain}";
await ValueChanged.InvokeAsync(Value);
}
private void TogglePopup()
{
IsPopupVisible = !IsPopupVisible;
}
private void ClosePopup()
{
IsPopupVisible = false;
}
private async Task SelectDomain(string domain)
{
// Remove the '@' symbol and everything after if it exists.
LocalPart = LocalPart.Contains('@') ? LocalPart.Split('@')[0] : LocalPart;
Value = $"{LocalPart}@{domain}";
await ValueChanged.InvokeAsync(Value);
IsCustomDomain = false;
ClosePopup();
}
private void ToggleCustomDomain()
{
IsCustomDomain = !IsCustomDomain;
if (!IsCustomDomain && !Value.Contains('@'))
{
Value = $"{Value}@{(ShowPrivateDomains ? PrivateDomains[0] : PublicDomains[0])}";
ValueChanged.InvokeAsync(Value);
}
}
}

View File

@@ -1,6 +1,4 @@
@inject ClipboardCopyService ClipboardCopyService
<label for="@Id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">@Label</label>
<label for="@Id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">@Label</label>
<div class="relative">
@if (Type == "textarea")
{

View File

@@ -92,7 +92,7 @@ else
</div>
<div class="grid gap-6">
<div class="col-span-6 sm:col-span-3">
<EditFormRow Id="email" Label="Email" @bind-Value="Obj.Alias.Email"></EditFormRow>
<EditEmailFormRow Id="email" Label="Email" @bind-Value="Obj.Alias.Email"></EditEmailFormRow>
</div>
<div class="col-span-6 sm:col-span-3">
<EditFormRow Id="username" Label="Username" @bind-Value="Obj.Username"></EditFormRow>
@@ -100,7 +100,7 @@ else
<div class="col-span-6 sm:col-span-3">
<div class="relative">
<EditFormRow Id="password" Label="Password" @bind-Value="Obj.Password.Value"></EditFormRow>
<button type="submit" class="text-white absolute end-1 bottom-1 bg-gray-700 hover:bg-gray-800 focus:ring-4 focus:outline-none focus:ring-gray-300 font-medium rounded-lg text-sm px-4 py-2 dark:bg-gray-600 dark:hover:bg-gray-700 dark:focus:ring-gray-800" @onclick="GenerateRandomPassword">(Re)generate Random Password</button>
<button type="button" class="text-white absolute end-1 bottom-1 bg-gray-700 hover:bg-gray-800 focus:ring-4 focus:outline-none focus:ring-gray-300 font-medium rounded-lg text-sm px-4 py-2 dark:bg-gray-600 dark:hover:bg-gray-700 dark:focus:ring-gray-800" @onclick="GenerateRandomPassword">(Re)generate Random Password</button>
</div>
</div>
</div>
@@ -240,6 +240,7 @@ else
// Create new Obj
var alias = new Credential();
alias.Alias = new Alias();
alias.Alias.Email = string.Empty;
alias.Service = new Service();
alias.Passwords = new List<Password> { new Password() };
@@ -280,7 +281,24 @@ else
Obj.Alias.AddressZipCode = identity.Address.ZipCode;
Obj.Alias.AddressCountry = identity.Address.Country;
Obj.Alias.Hobbies = identity.Hobbies[0];
Obj.Alias.Email = identity.EmailPrefix + "@landmail.nl";
var defaultDomain = Config.PrivateEmailDomains[0];
if (defaultDomain == "DISABLED.TLD")
{
if (Config.PublicEmailDomains.Count == 0)
{
Obj.Alias.Email = identity.EmailPrefix + "@example.com";
}
else
{
Obj.Alias.Email = identity.EmailPrefix + "@" + Config.PublicEmailDomains[0];
}
}
else
{
Obj.Alias.Email = identity.EmailPrefix + "@" + defaultDomain;
}
Obj.Alias.PhoneMobile = identity.PhoneMobile;
Obj.Alias.BankAccountIBAN = identity.BankAccountIBAN;

View File

@@ -68,7 +68,7 @@ else
<CopyPasteFormRow Label="Username" Value="@(Alias.Username)"></CopyPasteFormRow>
</div>
<div class="col-span-6 sm:col-span-3">
<CopyPasteFormRow Label="Password" Value="@(Alias.Passwords.FirstOrDefault()?.Value ?? string.Empty)"></CopyPasteFormRow>
<CopyPastePasswordFormRow Label="Password" Value="@(Alias.Passwords.FirstOrDefault()?.Value ?? string.Empty)"></CopyPastePasswordFormRow>
</div>
</div>
</form>

View File

@@ -66,6 +66,12 @@ public class MainBase : OwningComponentBase
[Inject]
public AuthService AuthService { get; set; } = null!;
/// <summary>
/// Gets or sets the Config instance with values from appsettings.json.
/// </summary>
[Inject]
public Config Config { get; set; } = null!;
/// <summary>
/// Gets or sets the LocalStorage.
/// </summary>

View File

@@ -23,9 +23,9 @@ if (string.IsNullOrEmpty(config.ApiUrl))
throw new KeyNotFoundException("ApiUrl is not set in the configuration.");
}
if (config.SmtpAllowedDomains == null || config.SmtpAllowedDomains.Count == 0)
if (config.PrivateEmailDomains == null || config.PrivateEmailDomains.Count == 0)
{
throw new KeyNotFoundException("SmtpAllowedDomains is not set in the configuration.");
throw new KeyNotFoundException("PrivateEmailDomains is not set in the configuration.");
}
builder.Services.AddSingleton(config);

View File

@@ -458,11 +458,23 @@ public class DbService : IDisposable
.Select(email => email!)
.ToListAsync();
Console.WriteLine("Before filtering email addresses:");
foreach (var email in emailAddresses)
{
Console.WriteLine(email);
}
// Filter the list of email addresses to only include those that are in the allowed domains.
emailAddresses = emailAddresses
.Where(email => _config.SmtpAllowedDomains.Exists(domain => email.EndsWith(domain)))
.Where(email => _config.PrivateEmailDomains.Exists(domain => email.EndsWith(domain)))
.ToList();
Console.WriteLine("After filtering email addresses:");
foreach (var email in emailAddresses)
{
Console.WriteLine(email);
}
var databaseVersion = await GetCurrentDatabaseVersionAsync();
var vaultObject = new Vault(encryptedDatabase, databaseVersion, publicEncryptionKey, emailAddresses, DateTime.Now, DateTime.Now);

View File

@@ -1,11 +1,11 @@
#!/bin/sh
# Set the default API URL for localhost debugging
DEFAULT_API_URL="http://localhost:81"
DEFAULT_SMTP_ALLOWED_DOMAINS="localmail.tld"
DEFAULT_PRIVATE_EMAIL_DOMAINS="localmail.tld"
# Use the provided API_URL environment variable if it exists, otherwise use the default
API_URL=${API_URL:-$DEFAULT_API_URL}
SMTP_ALLOWED_DOMAINS=${SMTP_ALLOWED_DOMAINS:-$DEFAULT_SMTP_ALLOWED_DOMAINS}
PRIVATE_EMAIL_DOMAINS=${PRIVATE_EMAIL_DOMAINS:-$DEFAULT_PRIVATE_EMAIL_DOMAINS}
# Replace the default URL with the actual API URL
sed -i "s|http://localhost:5092|${API_URL}|g" /usr/share/nginx/html/appsettings.json
@@ -14,10 +14,10 @@ sed -i "s|http://localhost:5092|${API_URL}|g" /usr/share/nginx/html/appsettings.
# in order to be able to receive emails.
# Convert comma-separated list to JSON array
json_array=$(echo $SMTP_ALLOWED_DOMAINS | awk '{split($0,a,","); printf "["; for(i=1;i<=length(a);i++) {printf "\"%s\"", a[i]; if(i<length(a)) printf ","} printf "]"}')
json_array=$(echo $PRIVATE_EMAIL_DOMAINS | awk '{split($0,a,","); printf "["; for(i=1;i<=length(a);i++) {printf "\"%s\"", a[i]; if(i<length(a)) printf ","} printf "]"}')
# Use sed to update the SmtpAllowedDomains field in appsettings.json
sed -i.bak "s|\"SmtpAllowedDomains\": \[.*\]|\"SmtpAllowedDomains\": $json_array|" /usr/share/nginx/html/appsettings.json
# Use sed to update the PrivateEmailDomains field in appsettings.json
sed -i.bak "s|\"PrivateEmailDomains\": \[.*\]|\"PrivateEmailDomains\": $json_array|" /usr/share/nginx/html/appsettings.json
# Start the application
nginx -g "daemon off;"

View File

@@ -1,4 +1,4 @@
{
"ApiUrl": "http://localhost:5092",
"SmtpAllowedDomains": ["example.tld"]
"PrivateEmailDomains": ["example.tld"]
}

View File

@@ -566,6 +566,10 @@ video {
border-width: 0;
}
.visible {
visibility: visible;
}
.invisible {
visibility: hidden;
}
@@ -591,6 +595,11 @@ video {
bottom: 0px;
}
.inset-y-1 {
top: 0.25rem;
bottom: 0.25rem;
}
.bottom-1 {
bottom: 0.25rem;
}
@@ -603,10 +612,30 @@ video {
right: 0px;
}
.right-10 {
right: 2.5rem;
}
.top-10 {
top: 2.5rem;
}
.right-4 {
right: 1rem;
}
.right-1 {
right: 0.25rem;
}
.right-5 {
right: 1.25rem;
}
.top-20 {
top: 5rem;
}
.z-10 {
z-index: 10;
}
@@ -619,6 +648,10 @@ video {
z-index: 50;
}
.z-20 {
z-index: 20;
}
.col-span-2 {
grid-column: span 2 / span 2;
}
@@ -731,6 +764,14 @@ video {
margin-top: 2rem;
}
.mt-3 {
margin-top: 0.75rem;
}
.mr-1 {
margin-right: 0.25rem;
}
.block {
display: block;
}
@@ -803,6 +844,10 @@ video {
height: 100%;
}
.h-3 {
height: 0.75rem;
}
.w-1\/2 {
width: 50%;
}
@@ -851,6 +896,14 @@ video {
width: 100%;
}
.w-96 {
width: 24rem;
}
.w-3 {
width: 0.75rem;
}
.min-w-full {
min-width: 100%;
}
@@ -885,6 +938,43 @@ video {
animation: spin 1s linear infinite;
}
@keyframes pulse {
50% {
opacity: .5;
}
}
.animate-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes ping {
75%, 100% {
transform: scale(2);
opacity: 0;
}
}
.animate-ping {
animation: ping 1s cubic-bezier(0, 0, 0.2, 1) infinite;
}
@keyframes bounce {
0%, 100% {
transform: translateY(-25%);
animation-timing-function: cubic-bezier(0.8,0,1,1);
}
50% {
transform: none;
animation-timing-function: cubic-bezier(0,0,0.2,1);
}
}
.animate-bounce {
animation: bounce 1s infinite;
}
.cursor-pointer {
cursor: pointer;
}
@@ -1001,6 +1091,12 @@ video {
margin-bottom: calc(1.5rem * var(--tw-space-y-reverse));
}
.space-x-2 > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0;
margin-right: calc(0.5rem * var(--tw-space-x-reverse));
margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse)));
}
.divide-y > :not([hidden]) ~ :not([hidden]) {
--tw-divide-y-reverse: 0;
border-top-width: calc(1px * calc(1 - var(--tw-divide-y-reverse)));
@@ -1053,6 +1149,10 @@ video {
border-radius: 0.5rem;
}
.rounded-md {
border-radius: 0.375rem;
}
.rounded-l-lg {
border-top-left-radius: 0.5rem;
border-bottom-left-radius: 0.5rem;
@@ -1075,6 +1175,14 @@ video {
border-bottom-width: 1px;
}
.border-l-0 {
border-left-width: 0px;
}
.border-t {
border-top-width: 1px;
}
.border-blue-700 {
--tw-border-opacity: 1;
border-color: rgb(29 78 216 / var(--tw-border-opacity));
@@ -1095,6 +1203,21 @@ video {
border-color: rgb(34 197 94 / var(--tw-border-opacity));
}
.border-red-300 {
--tw-border-opacity: 1;
border-color: rgb(252 165 165 / var(--tw-border-opacity));
}
.border-primary-300 {
--tw-border-opacity: 1;
border-color: rgb(248 185 99 / var(--tw-border-opacity));
}
.border-primary-100 {
--tw-border-opacity: 1;
border-color: rgb(253 222 133 / var(--tw-border-opacity));
}
.bg-blue-500 {
--tw-bg-opacity: 1;
background-color: rgb(59 130 246 / var(--tw-bg-opacity));
@@ -1120,6 +1243,11 @@ video {
background-color: rgb(249 250 251 / var(--tw-bg-opacity));
}
.bg-gray-500 {
--tw-bg-opacity: 1;
background-color: rgb(107 114 128 / var(--tw-bg-opacity));
}
.bg-gray-700 {
--tw-bg-opacity: 1;
background-color: rgb(55 65 81 / var(--tw-bg-opacity));
@@ -1145,11 +1273,6 @@ video {
background-color: rgb(22 163 74 / var(--tw-bg-opacity));
}
.bg-primary-200 {
--tw-bg-opacity: 1;
background-color: rgb(251 203 116 / var(--tw-bg-opacity));
}
.bg-primary-600 {
--tw-bg-opacity: 1;
background-color: rgb(214 131 56 / var(--tw-bg-opacity));
@@ -1175,9 +1298,19 @@ video {
background-color: rgb(255 255 255 / var(--tw-bg-opacity));
}
.bg-gray-500 {
.bg-gray-300 {
--tw-bg-opacity: 1;
background-color: rgb(107 114 128 / var(--tw-bg-opacity));
background-color: rgb(209 213 219 / var(--tw-bg-opacity));
}
.bg-gray-600 {
--tw-bg-opacity: 1;
background-color: rgb(75 85 99 / var(--tw-bg-opacity));
}
.bg-primary-300 {
--tw-bg-opacity: 1;
background-color: rgb(248 185 99 / var(--tw-bg-opacity));
}
.bg-opacity-50 {
@@ -1212,6 +1345,10 @@ video {
padding: 2rem;
}
.p-5 {
padding: 1.25rem;
}
.px-2 {
padding-left: 0.5rem;
padding-right: 0.5rem;
@@ -1267,10 +1404,19 @@ video {
padding-bottom: 1.5rem;
}
.px-7 {
padding-left: 1.75rem;
padding-right: 1.75rem;
}
.pr-10 {
padding-right: 2.5rem;
}
.pr-20 {
padding-right: 5rem;
}
.pr-3 {
padding-right: 0.75rem;
}
@@ -1287,6 +1433,10 @@ video {
padding-top: 2rem;
}
.pt-4 {
padding-top: 1rem;
}
.text-left {
text-align: left;
}
@@ -1565,6 +1715,16 @@ video {
background-color: rgb(153 27 27 / var(--tw-bg-opacity));
}
.hover\:bg-gray-300:hover {
--tw-bg-opacity: 1;
background-color: rgb(209 213 219 / var(--tw-bg-opacity));
}
.hover\:bg-primary-400:hover {
--tw-bg-opacity: 1;
background-color: rgb(246 167 82 / var(--tw-bg-opacity));
}
.hover\:text-gray-500:hover {
--tw-text-opacity: 1;
color: rgb(107 114 128 / var(--tw-text-opacity));
@@ -1595,6 +1755,11 @@ video {
color: rgb(255 255 255 / var(--tw-text-opacity));
}
.hover\:text-blue-800:hover {
--tw-text-opacity: 1;
color: rgb(30 64 175 / var(--tw-text-opacity));
}
.hover\:underline:hover {
text-decoration-line: underline;
}
@@ -1615,6 +1780,12 @@ video {
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
}
.focus\:ring-2:focus {
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
}
.focus\:ring-blue-300:focus {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(147 197 253 / var(--tw-ring-opacity));
@@ -1650,6 +1821,20 @@ video {
--tw-ring-color: rgb(252 165 165 / var(--tw-ring-opacity));
}
.focus\:ring-indigo-500:focus {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(99 102 241 / var(--tw-ring-opacity));
}
.focus\:ring-gray-400:focus {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(156 163 175 / var(--tw-ring-opacity));
}
.focus\:ring-offset-2:focus {
--tw-ring-offset-width: 2px;
}
.dark\:divide-gray-600:is(.dark *) > :not([hidden]) ~ :not([hidden]) {
--tw-divide-opacity: 1;
border-color: rgb(75 85 99 / var(--tw-divide-opacity));
@@ -1823,6 +2008,11 @@ video {
background-color: rgb(220 38 38 / var(--tw-bg-opacity));
}
.dark\:hover\:bg-gray-500:hover:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(107 114 128 / var(--tw-bg-opacity));
}
.dark\:hover\:text-primary-500:hover:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(244 149 65 / var(--tw-text-opacity));
@@ -1833,6 +2023,11 @@ video {
color: rgb(255 255 255 / var(--tw-text-opacity));
}
.dark\:hover\:text-blue-200:hover:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(191 219 254 / var(--tw-text-opacity));
}
.dark\:focus\:border-primary-500:focus:is(.dark *) {
--tw-border-opacity: 1;
border-color: rgb(244 149 65 / var(--tw-border-opacity));

View File

@@ -0,0 +1,51 @@
//-----------------------------------------------------------------------
// <copyright file="ApiErrorResponse.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Shared.Models.WebApi;
using System.Text.Json.Serialization;
/// <summary>
/// Represents the error response returned by the API.
/// </summary>
public class ApiErrorResponse
{
/// <summary>
/// Gets or sets the main error message.
/// </summary>
/// <value>A string containing a brief description of the error.</value>
[JsonPropertyName("message")]
public string Message { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the error code associated with this error.
/// </summary>
/// <value>A string representing a unique identifier for this type of error.</value>
[JsonPropertyName("code")]
public string Code { get; set; } = string.Empty;
/// <summary>
/// Gets or sets additional details about the error.
/// </summary>
/// <value>An object containing any additional information about the error.</value>
[JsonPropertyName("details")]
public object Details { get; set; } = new { };
/// <summary>
/// Gets or sets the HTTP status code associated with this error.
/// </summary>
/// <value>An integer representing the HTTP status code.</value>
[JsonPropertyName("statusCode")]
public int StatusCode { get; set; } = 500;
/// <summary>
/// Gets or sets the timestamp when the error occurred.
/// </summary>
/// <value>A DateTime representing when the error was generated.</value>
[JsonPropertyName("timestamp")]
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
}

View File

@@ -26,8 +26,8 @@ builder.Services.ConfigureLogging(builder.Configuration, Assembly.GetExecutingAs
// Create global config object, get values from environment variables.
Config config = new Config();
var emailDomains = Environment.GetEnvironmentVariable("SMTP_ALLOWED_DOMAINS")
?? throw new KeyNotFoundException("SMTP_ALLOWED_DOMAINS environment variable is not set.");
var emailDomains = Environment.GetEnvironmentVariable("PRIVATE_EMAIL_DOMAINS")
?? throw new KeyNotFoundException("PRIVATE_EMAIL_DOMAINS environment variable is not set.");
config.AllowedToDomains = emailDomains.Split(',').ToList();
var tlsEnabled = Environment.GetEnvironmentVariable("SMTP_TLS_ENABLED")

View File

@@ -4,7 +4,7 @@
"commandName": "Project",
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Development",
"SMTP_ALLOWED_DOMAINS": "example.tld",
"PRIVATE_EMAIL_DOMAINS": "example.tld",
"SMTP_TLS_ENABLED": "false"
},
"dotnetRunMessages": true

View File

@@ -89,7 +89,7 @@ public class ClientPlaywrightTest : PlaywrightTest
await SetupPlaywrightBrowserAndContext();
// Intercept Blazor WASM app requests to override appsettings.json
string[] smtpAllowedDomains = ["example.tld"];
string[] privateEmailDomains = ["example.tld"];
await Context.RouteAsync(
"**/appsettings.json",
@@ -98,7 +98,7 @@ public class ClientPlaywrightTest : PlaywrightTest
var response = new
{
ApiUrl = ApiBaseUrl.TrimEnd('/'),
SmtpAllowedDomains = smtpAllowedDomains,
PrivateEmailDomains = privateEmailDomains,
};
await route.FulfillAsync(
new RouteFulfillOptions
@@ -114,7 +114,7 @@ public class ClientPlaywrightTest : PlaywrightTest
var response = new
{
ApiUrl = ApiBaseUrl.TrimEnd('/'),
SmtpAllowedDomains = smtpAllowedDomains,
PrivateEmailDomains = privateEmailDomains,
};
await route.FulfillAsync(
new RouteFulfillOptions