Compare commits

...

44 Commits
0.4.0 ... 0.5.0

Author SHA1 Message Date
Leendert de Borst
18978b94be Merge pull request #173 from lanedirt/164-add-oobe-beginning-screen-if-user-does-not-have-any-credentials-yet
Out-of-box experience UX tweaks
2024-08-16 06:08:30 -07:00
Leendert de Borst
c989573565 Update Vault.razor (#164) 2024-08-16 14:57:21 +02:00
Leendert de Borst
67ce7da21a Refactor (#164) 2024-08-16 14:48:40 +02:00
Leendert de Borst
fb2972695a Update E2E tests (#164) 2024-08-16 14:35:18 +02:00
Leendert de Borst
2f47f81af8 Fix bug in email credential lookup query (#164) 2024-08-16 13:38:24 +02:00
Leendert de Borst
6d6ee8bf3f Add enter on form submit for AddEdit page, refactor service URL placeholder logic (#164) 2024-08-16 13:34:58 +02:00
Leendert de Borst
881eb58a35 Add focus tweaks to Credentials AddEdit page (#164) 2024-08-16 13:27:07 +02:00
Leendert de Borst
80bc7cd223 Add welcome page for new users for OOBE (#164) 2024-08-16 12:25:52 +02:00
Leendert de Borst
87f494fea8 Layout tweaks (#164) 2024-08-16 12:25:28 +02:00
Leendert de Borst
a24e533e4c Tweak settings page layout (#164) 2024-08-16 12:24:52 +02:00
Leendert de Borst
ebb8b27f85 Update DbStatusIndicator.razor (#164) 2024-08-16 12:24:40 +02:00
Leendert de Borst
41c210e75a Add minimum loading screen delay to blazor bootstrap to improve UX (#164) 2024-08-15 21:52:21 +02:00
Leendert de Borst
2a50a455d8 Merge pull request #170 from lanedirt/165-add-styled-wasm-loading-animation
Updated blazor loading animation to AliasVault style
2024-08-13 11:05:31 -07:00
Leendert de Borst
6896c4cd1d Updated blazor loading animation to AliasVault style (#165) 2024-08-13 19:05:15 +02:00
Leendert de Borst
9560572a40 Merge pull request #169 from lanedirt/144-update-client-side-validation-for-all-form-steps
Update client side validation for all form steps
2024-08-12 11:49:15 -07:00
Leendert de Borst
4dffb9c3c0 Change StartsWith overload (#144) 2024-08-12 20:39:12 +02:00
Leendert de Borst
b8cb3c4d78 Add username generate button, fix form validation bugs, tweak UI (#144) 2024-08-12 19:07:39 +02:00
Leendert de Borst
6f54b05d5a Update email style (#144) 2024-08-12 16:15:27 +02:00
Leendert de Borst
d051d69aea Merge pull request #166 from lanedirt/160-rework-credential-view-page-to-show-most-relevant-data-first
Add email page to browse through all received emails
2024-08-12 04:39:21 -07:00
Leendert de Borst
02f0c43cbd Code style refactor (#160) 2024-08-12 13:31:20 +02:00
Leendert de Borst
14cce42091 Add email page to browser through all received emails for all claimed email addresses(#160) 2024-08-12 13:20:20 +02:00
Leendert de Borst
a1c26cec04 Merge pull request #163 from lanedirt/158-add-global-search-bar
Add global search bar
2024-08-09 08:55:22 -07:00
Leendert de Borst
42fc1c018c Add E2E test for global search bar (#158) 2024-08-09 17:47:42 +02:00
Leendert de Borst
f3e740bab3 Add global search bar widget (#158) 2024-08-09 13:51:02 +02:00
Leendert de Borst
bbdf47d6f4 Merge pull request #162 from lanedirt/161-keyboard-shortcuts-stop-working-when-something-else-has-been-typed-before 2024-08-07 22:15:09 -07:00
Leendert de Borst
5faf93d6be Fix CredentialTest, replace wait text after breadcrumb change (#161) 2024-08-07 23:55:47 +02:00
Leendert de Borst
fa1573ee13 Update keyboardShortcuts.js, fix bug (#161) 2024-08-07 23:27:44 +02:00
Leendert de Borst
50f7866a0b Improve GlobalNotificationDisplay system (#161) 2024-08-07 23:25:25 +02:00
Leendert de Borst
7b1a1e893e Merge pull request #159 from lanedirt/142-design-new-client-datamodel-structure-for-credentialsaliases-with-simplified-user-flow
Add quick create new identity popup
2024-08-07 13:39:59 -07:00
Leendert de Borst
40afea3908 Fix parallel E2E tests race condition (#142) 2024-08-07 22:33:58 +02:00
Leendert de Borst
e1ae260fc5 Code style refactor (#142) 2024-08-07 22:28:46 +02:00
Leendert de Borst
c33399b91d Add E2E test for quick create widget (#142) 2024-08-07 22:24:34 +02:00
Leendert de Borst
f46202223a Fix tests (#142) 2024-08-07 22:01:37 +02:00
Leendert de Borst
0867573f2f Load specific JS via isolated modules, refactor CredentialService (#142) 2024-08-07 20:39:39 +02:00
Leendert de Borst
2becb3aa8f Refactor (#142) 2024-08-06 22:04:12 +02:00
Leendert de Borst
dc2f4dd040 Add quick create new identity popup (#142) 2024-08-06 20:29:48 +02:00
Leendert de Borst
2cf3c142da Merge pull request #157 from lanedirt/156-add-e2e-test-for-generating-identity-via-client-gui
Add E2E test for identity generation in client (#156)
2024-08-05 13:53:21 -07:00
Leendert de Borst
a8d84fd38a Update CredentialTest.cs (#156) 2024-08-05 22:43:03 +02:00
Leendert de Borst
4a207763cc Add E2E test for identity generation in client (#156) 2024-08-05 21:18:21 +02:00
Leendert de Borst
b1ef5c33db Merge pull request #155 from lanedirt/108-add-identity-generator-scaffolding-utility-project
Add identity generator utility project for EN and NL identities
2024-08-05 11:35:56 -07:00
Leendert de Borst
578532efdf Code style refactor (#108) 2024-08-05 20:21:48 +02:00
Leendert de Borst
95fb8baaaa Add nonbacktracking option to regexes (#108) 2024-08-05 20:20:28 +02:00
Leendert de Borst
73e432b2dc Refactor identity generation logic (#108) 2024-08-05 17:24:51 +02:00
Leendert de Borst
f43c3171b0 Add local dictionary based identity generation (#108) 2024-08-05 16:34:22 +02:00
119 changed files with 5073 additions and 1412 deletions

View File

@@ -27,4 +27,15 @@
</PackageReference>
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Identity\Implementations\Dictionaries\en\firstnames_female" />
<EmbeddedResource Include="Identity\Implementations\Dictionaries\en\firstnames_male" />
<EmbeddedResource Include="Identity\Implementations\Dictionaries\en\lastnames" />
<EmbeddedResource Include="Identity\Implementations\Dictionaries\nl\firstnames_female" />
<None Remove="Identity\Implementations\Lists\nl\firstnames" />
<EmbeddedResource Include="Identity\Implementations\Dictionaries\nl\firstnames_male" />
<None Remove="Identity\Implementations\Lists\nl\lastnames" />
<EmbeddedResource Include="Identity\Implementations\Dictionaries\nl\lastnames" />
</ItemGroup>
</Project>

View File

@@ -4,6 +4,7 @@
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasGenerators.Identity;
/// <summary>

View File

@@ -0,0 +1,137 @@
//-----------------------------------------------------------------------
// <copyright file="IdentityGenerator.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 AliasGenerators.Identity.Implementations.Base;
using System.Reflection;
using AliasGenerators.Identity;
using AliasGenerators.Identity.Models;
/// <summary>
/// Abstract identity generator which implements IIdentityGenerator and generates
/// random identities for a certain language.
/// </summary>
public abstract class IdentityGenerator : IIdentityGenerator
{
/// <summary>
/// List of male first names in memory.
/// </summary>
private readonly List<string> _firstNamesMale;
/// <summary>
/// List of female first names in memory.
/// </summary>
private readonly List<string> _firstNamesFemale;
/// <summary>
/// List of last names in memory.
/// </summary>
private readonly List<string> _lastNames;
/// <summary>
/// Random instance.
/// </summary>
private readonly Random _random = new();
/// <summary>
/// Initializes a new instance of the <see cref="IdentityGenerator"/> class.
/// </summary>
protected IdentityGenerator()
{
_firstNamesMale = LoadList(FirstNamesListMale);
_firstNamesFemale = LoadList(FirstNamesListFemale);
_lastNames = LoadList(LastNamesList);
}
/// <summary>
/// Gets namespace path to the male first names list for the correct language.
/// </summary>
protected virtual string FirstNamesListMale => "AliasGenerators.Identity.Implementations.Dictionaries.nl.firstnames_male";
/// <summary>
/// Gets namespace path to the female first names list for the correct language.
/// </summary>
protected virtual string FirstNamesListFemale => "AliasGenerators.Identity.Implementations.Dictionaries.nl.firstnames_female";
/// <summary>
/// Gets namespace path to the last names list for the correct language.
/// </summary>
protected virtual string LastNamesList => "AliasGenerators.Identity.Implementations.Dictionaries.nl.lastnames";
/// <inheritdoc/>
public async Task<Identity> GenerateRandomIdentityAsync()
{
await Task.Yield(); // Add an await statement to make the method truly asynchronous.
// Generate identity.
var identity = new Identity();
// Determine gender.
if (_random.Next(2) == 0)
{
identity.FirstName = _firstNamesMale[_random.Next(_firstNamesMale.Count)];
identity.Gender = Gender.Male;
}
else
{
identity.FirstName = _firstNamesFemale[_random.Next(_firstNamesFemale.Count)];
identity.Gender = Gender.Female;
}
identity.LastName = _lastNames[_random.Next(_lastNames.Count)];
// Generate random date of birth between 21 and 65 years of age.
identity.BirthDate = GenerateRandomDateOfBirth();
identity.EmailPrefix = new UsernameEmailGenerator().GenerateEmailPrefix(identity);
identity.NickName = new UsernameEmailGenerator().GenerateUsername(identity);
return identity;
}
/// <summary>
/// Load a list of words from a resource file.
/// </summary>
/// <param name="resourceName">Name of the resource file to load.</param>
/// <returns>List of words from the resource file.</returns>
/// <exception cref="FileNotFoundException">Thrown if resource file cannot be found.</exception>
private static List<string> LoadList(string resourceName)
{
var assembly = Assembly.GetExecutingAssembly();
using var stream = assembly.GetManifestResourceStream(resourceName);
if (stream == null)
{
throw new FileNotFoundException("Resource '" + resourceName + "' not found.", resourceName);
}
using var reader = new StreamReader(stream);
var words = new List<string>();
while (!reader.EndOfStream)
{
var line = reader.ReadLine();
if (line != null)
{
words.Add(line);
}
}
return words;
}
/// <summary>
/// Generate a random date of birth.
/// </summary>
/// <returns>DateTime representing date of birth.</returns>
private DateTime GenerateRandomDateOfBirth()
{
// Generate random date of birth between 21 and 65 years of age.
var now = DateTime.Now;
var minDob = now.AddYears(-65);
var maxDob = now.AddYears(-21);
return minDob.AddDays(_random.Next((int)(maxDob - minDob).TotalDays));
}
}

View File

@@ -0,0 +1,153 @@
Emily
Emma
Olivia
Ava
Sophia
Isabella
Mia
Charlotte
Amelia
Harper
Evelyn
Abigail
Elizabeth
Sofia
Avery
Ella
Madison
Scarlett
Victoria
Aria
Grace
Chloe
Camila
Penelope
Riley
Layla
Zoey
Nora
Lily
Eleanor
Hannah
Lillian
Addison
Aubrey
Ellie
Stella
Natalie
Zoe
Leah
Hazel
Violet
Aurora
Savannah
Audrey
Brooklyn
Bella
Claire
Skylar
Lucy
Paisley
Everly
Anna
Caroline
Nova
Genesis
Emilia
Kennedy
Samantha
Maya
Willow
Kinsley
Naomi
Aaliyah
Elena
Sarah
Ariana
Allison
Gabriella
Alice
Madelyn
Cora
Ruby
Eva
Serenity
Autumn
Adeline
Hailey
Gianna
Valentina
Isla
Eliana
Quinn
Nevaeh
Ivy
Sadie
Piper
Lydia
Alexa
Josephine
Emery
Julia
Delilah
Arianna
Vivian
Kaylee
Sophie
Brielle
Madeline
Peyton
Rylee
Clara
Hadley
Melanie
Mackenzie
Reagan
Adalyn
Liliana
Aubree
Jade
Katherine
Isabelle
Natalia
Raelynn
Maria
Athena
Ximena
Arya
Leilani
Taylor
Faith
Rose
Kylie
Alexandra
Mary
Margaret
Lyla
Ashley
Amaya
Eliza
Brianna
Bailey
Andrea
Khloe
Jasmine
Melody
Iris
Isabel
Norah
Annabelle
Valeria
Emerson
Adalynn
Ryleigh
Eden
Emersyn
Anastasia
Kayla
Alyssa
Anna
Juliana
Charlie
Lucia
Stella

View File

@@ -0,0 +1,142 @@
Michael
Christopher
Matthew
Joshua
Daniel
David
Andrew
Joseph
James
John
Robert
William
Ryan
Jason
Nicholas
Jonathan
Jacob
Brandon
Tyler
Zachary
Kevin
Justin
Benjamin
Anthony
Samuel
Thomas
Alexander
Ethan
Noah
Dylan
Nathan
Christian
Austin
Adam
Caleb
Cody
Jordan
Logan
Aaron
Kyle
Jose
Brian
Gabriel
Timothy
Luke
Jared
Connor
Sean
Evan
Isaac
Jack
Cameron
Hunter
Jackson
Charles
Devin
Stephen
Patrick
Steven
Elijah
Scott
Mark
Jeffrey
Corey
Juan
Luis
Derek
Chase
Travis
Alex
Spencer
Ian
Trevor
Bryan
Tanner
Marcus
Jeremy
Eric
Jaden
Garrett
Isaiah
Dustin
Jesse
Seth
Blake
Nathaniel
Mason
Liam
Paul
Carlos
Mitchell
Parker
Lucas
Richard
Cole
Adrian
Colin
Bradley
Jesus
Peter
Kenneth
Joel
Victor
Bryce
Casey
Vincent
Edward
Henry
Dominic
Riley
Shane
Dalton
Grant
Shawn
Braden
Caden
Max
Hayden
Owen
Brett
Trevor
Philip
Brendan
Wesley
Aidan
Brady
Colton
Tristan
George
Gavin
Dawson
Miguel
Antonio
Nolan
Dakota
Jace
Collin
Preston
Levi
Alan
Jorge
Carson

View File

@@ -0,0 +1,167 @@
Smith
Johnson
Williams
Brown
Jones
Garcia
Miller
Davis
Rodriguez
Martinez
Hernandez
Lopez
Gonzalez
Wilson
Anderson
Thomas
Taylor
Moore
Jackson
Martin
Lee
Perez
Thompson
White
Harris
Sanchez
Clark
Ramirez
Lewis
Robinson
Walker
Young
Allen
King
Wright
Scott
Torres
Nguyen
Hill
Flores
Green
Adams
Nelson
Baker
Hall
Rivera
Campbell
Mitchell
Carter
Roberts
Gomez
Phillips
Evans
Turner
Diaz
Parker
Cruz
Edwards
Collins
Reyes
Stewart
Morris
Morales
Murphy
Cook
Rogers
Gutierrez
Ortiz
Morgan
Cooper
Peterson
Bailey
Reed
Kelly
Howard
Ramos
Kim
Cox
Ward
Richardson
Watson
Brooks
Chavez
Wood
James
Bennett
Gray
Mendoza
Ruiz
Hughes
Price
Alvarez
Castillo
Sanders
Patel
Myers
Long
Ross
Foster
Jimenez
Powell
Jenkins
Perry
Russell
Sullivan
Bell
Coleman
Butler
Henderson
Barnes
Gonzales
Fisher
Vasquez
Simmons
Romero
Jordan
Patterson
Alexander
Hamilton
Graham
Reynolds
Griffin
Wallace
Moreno
West
Cole
Hayes
Bryant
Herrera
Gibson
Ellis
Tran
Medina
Aguilar
Stevens
Murray
Ford
Castro
Marshall
Owens
Harrison
Fernandez
McDonald
Woods
Washington
Kennedy
Wells
Vargas
Henry
Chen
Freeman
Webb
Tucker
Guzman
Burns
Crawford
Olson
Simpson
Porter
Hunter
Gordon
Mendez
Silva
Shaw
Snyder
Mason
Dixon

View File

@@ -0,0 +1,104 @@
Emma
Sophie
Julia
Mila
Tess
Sara
Anna
Noor
Lotte
Liv
Eva
Nora
Zoë
Evi
Yara
Saar
Nina
Fenna
Lieke
Fleur
Isa
Roos
Lynn
Sofie
Sarah
Milou
Olivia
Maud
Lisa
Vera
Luna
Lina
Noa
Feline
Loïs
Lena
Floor
Charlotte
Esmee
Julie
Iris
Lara
Amber
Hailey
Mia
Lize
Isabelle
Cato
Fenne
Sanne
Norah
Sophia
Ella
Nova
Elin
Femke
Lizzy
Linde
Lauren
Rosalie
Lana
Emily
Elise
Esmée
Anne
Isabelle
Demi
Hannah
Liva
Suze
Fay
Isabel
Benthe
Evi
Amy
Jasmijn
Niene
Sterre
Fenna
Fiene
Liz
Ise
Mara
Nienke
Indy
Romy
Lola
Puck
Nora
Merel
Bente
Eline
Lily
Leah
Naomi
Mirthe
Valerie
Noor
Liva
Jade
Juul
Lise
Myrthe
Veerle

View File

@@ -0,0 +1,101 @@
Daan
Luuk
Sem
Finn
Milan
Levi
Noah
Lucas
Jesse
Thijs
Jayden
Bram
Lars
Ruben
Thomas
Tim
Sam
Liam
Julian
Mees
Ties
Sven
Max
Gijs
David
Stijn
Jasper
Niels
Jens
Timo
Cas
Joep
Roan
Tom
Tygo
Teun
Siem
Mats
Thijmen
Rens
Niek
Tobias
Dex
Hugo
Robin
Nick
Floris
Pepijn
Boaz
Olivier
Luca
Jurre
Jelle
Guus
Koen
Bart
Olaf
Wessel
Daniël
Job
Sander
Tijmen
Kai
Quinten
Owen
Morris
Fedde
Joris
Jesper
Mick
Ryan
Milo
Stan
Benjamin
Melle
Jip
Dylan
Brent
Mick
Dean
Otis
Abel
Luc
Sepp
Vince
Rayan
Noud
Hidde
Fabian
Jort
Damian
Boris
Sil
Moos
Aiden
Sep
Mika
Mijs
Mika
Felix
Merlijn

View File

@@ -0,0 +1,106 @@
de Jong
Jansen
de Vries
van den Berg
van Dijk
Bakker
Janssen
Visser
Smit
Meijer
de Boer
Mulder
de Groot
Bos
Vos
Peters
Hendriks
van Leeuwen
Dekker
Brouwer
de Wit
Dijkstra
Smits
de Graaf
van der Meer
van der Linden
Kok
Jacobs
de Haan
Vermeulen
van den Heuvel
van der Veen
van den Broek
de Bruijn
de Bruin
van der Heijden
Schouten
van Beek
Willems
van Vliet
van de Ven
Hoekstra
Maas
Verhoeven
Koster
van Dam
van der Wal
Prins
Blom
Huisman
Peeters
Kuipers
van Veen
van Dongen
Veenstra
Kramer
van den Bosch
van der Meulen
Mol
Zwart
van der Laan
Martens
van de Pol
Postma
Tromp
Borst
Boon
van Doorn
Jonker
van der Velden
Willemsen
van Wijk
Groen
Gerritsen
Bosch
van Loon
van der Ploeg
de Ruiter
Molenaar
Boer
Klein
de Koning
van de Kamp
van der Horst
Verbeek
Vink
Goossens
Scholten
Hartman
van Dalen
van Elst
Brink
Boekel
van de Berg
Berends
van der Hoek
Kuiper
Kooijman
de Lange
van der Sluis
van Gelder
Martens
van Asselt
Timmermans
van Vliet
van Rijn

View File

@@ -0,0 +1,32 @@
//-----------------------------------------------------------------------
// <copyright file="IdentityGeneratorFactory.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 AliasGenerators.Identity.Implementations;
using AliasGenerators.Identity.Implementations.Base;
/// <summary>
/// Identity generator factory which creates an identity generator based on the language code.
/// </summary>
public static class IdentityGeneratorFactory
{
/// <summary>
/// Creates an identity generator based on the language code.
/// </summary>
/// <param name="languageCode">Two letter language code.</param>
/// <returns>The IdentityGenerator for the requested language.</returns>
/// <exception cref="ArgumentException">Thrown if no identity generator is found for the requested language.</exception>
public static IdentityGenerator CreateIdentityGenerator(string languageCode)
{
return languageCode.ToLower() switch
{
"nl" => new IdentityGeneratorNl(),
"en" => new IdentityGeneratorEn(),
_ => throw new ArgumentException($"Unsupported language code: {languageCode}", nameof(languageCode)),
};
}
}

View File

@@ -1,39 +0,0 @@
//-----------------------------------------------------------------------
// <copyright file="FigIdentityGenerator.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 AliasGenerators.Identity.Implementations;
using System.Text.Json;
/// <summary>
/// Identity generator which generates random identities using the identiteitgenerator.nl semi-public API.
/// </summary>
public class FigIdentityGenerator : IIdentityGenerator
{
private static readonly HttpClient HttpClient = new();
private static readonly string Url = "https://api.identiteitgenerator.nl/generate/identity";
private static readonly JsonSerializerOptions JsonSerializerOptions = new()
{
PropertyNameCaseInsensitive = true,
};
/// <inheritdoc/>
public async Task<Identity.Models.Identity> GenerateRandomIdentityAsync()
{
var response = await HttpClient.GetAsync(Url);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
var identity = JsonSerializer.Deserialize<Identity.Models.Identity>(json, JsonSerializerOptions);
if (identity is null)
{
throw new InvalidOperationException("Failed to deserialize the identity from FIG WebApi.");
}
return identity;
}
}

View File

@@ -0,0 +1,26 @@
//-----------------------------------------------------------------------
// <copyright file="IdentityGeneratorEn.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 AliasGenerators.Identity.Implementations;
using AliasGenerators.Identity.Implementations.Base;
/// <summary>
/// Dutch identity generator which implements IIdentityGenerator and generates
/// random dutch identities.
/// </summary>
public class IdentityGeneratorEn : IdentityGenerator
{
/// <inheritdoc cref="IdentityGenerator.FirstNamesListMale" />
protected override string FirstNamesListMale => "AliasGenerators.Identity.Implementations.Dictionaries.en.firstnames_male";
/// <inheritdoc cref="IdentityGenerator.FirstNamesListFemale" />
protected override string FirstNamesListFemale => "AliasGenerators.Identity.Implementations.Dictionaries.en.firstnames_female";
/// <inheritdoc cref="IdentityGenerator.LastNamesList" />
protected override string LastNamesList => "AliasGenerators.Identity.Implementations.Dictionaries.en.lastnames";
}

View File

@@ -0,0 +1,26 @@
//-----------------------------------------------------------------------
// <copyright file="IdentityGeneratorNl.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 AliasGenerators.Identity.Implementations;
using AliasGenerators.Identity.Implementations.Base;
/// <summary>
/// Dutch identity generator which implements IIdentityGenerator and generates
/// random dutch identities.
/// </summary>
public class IdentityGeneratorNl : IdentityGenerator
{
/// <inheritdoc cref="IdentityGenerator.FirstNamesListMale" />
protected override string FirstNamesListMale => "AliasGenerators.Identity.Implementations.Dictionaries.nl.firstnames_male";
/// <inheritdoc cref="IdentityGenerator.FirstNamesListFemale" />
protected override string FirstNamesListFemale => "AliasGenerators.Identity.Implementations.Dictionaries.nl.firstnames_female";
/// <inheritdoc cref="IdentityGenerator.LastNamesList" />
protected override string LastNamesList => "AliasGenerators.Identity.Implementations.Dictionaries.nl.lastnames";
}

View File

@@ -1,27 +0,0 @@
//-----------------------------------------------------------------------
// <copyright file="StaticIdentityGenerator.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 AliasGenerators.Identity.Implementations;
using AliasGenerators.Identity;
/// <summary>
/// Static identity generator which implements IIdentityGenerator but always returns
/// the same static identity for testing purposes.
/// </summary>
public class StaticIdentityGenerator : IIdentityGenerator
{
/// <inheritdoc/>
public async Task<Identity.Models.Identity> GenerateRandomIdentityAsync()
{
await Task.Yield(); // Add an await statement to make the method truly asynchronous.
return new Identity.Models.Identity
{
FirstName = "John",
LastName = "Doe",
};
}
}

View File

@@ -1,38 +0,0 @@
//-----------------------------------------------------------------------
// <copyright file="Address.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 AliasGenerators.Identity.Models;
/// <summary>
/// Address model.
/// </summary>
public class Address
{
/// <summary>
/// Gets or sets the street.
/// </summary>
public string Street { get; set; } = null!;
/// <summary>
/// Gets or sets the city.
/// </summary>
public string City { get; set; } = null!;
/// <summary>
/// Gets or sets the state.
/// </summary>
public string State { get; set; } = null!;
/// <summary>
/// Gets or sets the zip code.
/// </summary>
public string ZipCode { get; set; } = null!;
/// <summary>
/// Gets or sets the country.
/// </summary>
public string Country { get; set; } = null!;
}

View File

@@ -0,0 +1,24 @@
//-----------------------------------------------------------------------
// <copyright file="Gender.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 AliasGenerators.Identity.Models;
/// <summary>
/// Identity model.
/// </summary>
public enum Gender
{
/// <summary>
/// Male gender.
/// </summary>
Male,
/// <summary>
/// Female gender.
/// </summary>
Female,
}

View File

@@ -4,6 +4,7 @@
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasGenerators.Identity.Models;
/// <summary>
@@ -19,7 +20,7 @@ public class Identity
/// <summary>
/// Gets or sets the gender.
/// </summary>
public int Gender { get; set; }
public Gender Gender { get; set; }
/// <summary>
/// Gets or sets the first name.
@@ -41,48 +42,8 @@ public class Identity
/// </summary>
public DateTime BirthDate { get; set; }
/// <summary>
/// Gets or sets the address.
/// </summary>
public Address Address { get; set; } = null!;
/// <summary>
/// Gets or sets the job.
/// </summary>
public Job Job { get; set; } = null!;
/// <summary>
/// Gets or sets the hobbies.
/// </summary>
public List<string> Hobbies { get; set; } = null!;
/// <summary>
/// Gets or sets the email address prefix.
/// </summary>
public string EmailPrefix { get; set; } = null!;
/// <summary>
/// Gets or sets the password.
/// </summary>
public string Password { get; set; } = null!;
/// <summary>
/// Gets or sets the phone mobile.
/// </summary>
public string PhoneMobile { get; set; } = null!;
/// <summary>
/// Gets or sets the bank account IBAN.
/// </summary>
public string BankAccountIBAN { get; set; } = null!;
/// <summary>
/// Gets or sets the profile photo in base64 format.
/// </summary>
public string ProfilePhotoBase64 { get; set; } = null!;
/// <summary>
/// Gets or sets the profile photo prompt.
/// </summary>
public string ProfilePhotoPrompt { get; set; } = null!;
}

View File

@@ -1,38 +0,0 @@
//-----------------------------------------------------------------------
// <copyright file="Job.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 AliasGenerators.Identity.Models;
/// <summary>
/// Job model.
/// </summary>
public class Job
{
/// <summary>
/// Gets or sets the title.
/// </summary>
public string Title { get; set; } = null!;
/// <summary>
/// Gets or sets the company.
/// </summary>
public string Company { get; set; } = null!;
/// <summary>
/// Gets or sets the salary.
/// </summary>
public string Salary { get; set; } = null!;
/// <summary>
/// Gets or sets the calculated salary.
/// </summary>
public decimal SalaryCalculated { get; set; }
/// <summary>
/// Gets or sets the description.
/// </summary>
public string Description { get; set; } = null!;
}

View File

@@ -0,0 +1,143 @@
//-----------------------------------------------------------------------
// <copyright file="UsernameEmailGenerator.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 AliasGenerators.Identity;
using System.Text.RegularExpressions;
/// <summary>
/// Generates usernames and email prefixes based on an identity.
/// </summary>
public class UsernameEmailGenerator
{
/// <summary>
/// Minimum length of the generated username.
/// </summary>
private const int MinLength = 6;
/// <summary>
/// Maximum length of the generated username.
/// </summary>
private const int MaxLength = 20;
/// <summary>
/// Create a new random instance for generating random values.
/// </summary>
private readonly Random _random = new();
/// <summary>
/// List of allowed symbols to use in usernames.
/// </summary>
private readonly List<string> _symbols = [".", "-"];
/// <summary>
/// Generates a username based on a identity.
/// </summary>
/// <param name="identity">Identity to generate username for.</param>
/// <returns>Username as string.</returns>
public string GenerateUsername(Models.Identity identity)
{
// Generate username based on email prefix but strip all non-alphanumeric characters
string username = GenerateEmailPrefix(identity);
username = Regex.Replace(username, @"[^a-zA-Z0-9]", string.Empty, RegexOptions.NonBacktracking);
// Adjust length
if (username.Length < MinLength)
{
username += GenerateRandomString(MinLength - username.Length);
}
else if (username.Length > MaxLength)
{
username = username.Substring(0, MaxLength);
}
return username;
}
/// <summary>
/// Generates a valid email prefix based on an identity.
/// </summary>
/// <param name="identity">Identity to generate email prefix for.</param>
/// <returns>Valid email prefix as string.</returns>
public string GenerateEmailPrefix(Models.Identity identity)
{
var parts = new List<string>();
// Use first initial + last name
if (_random.Next(2) == 0)
{
parts.Add(identity.FirstName.Substring(0, 1).ToLower() + identity.LastName.ToLower());
}
else
{
// Use full name
parts.Add((identity.FirstName + identity.LastName).ToLower());
}
// Add birth year
if (_random.Next(2) == 0)
{
parts.Add(identity.BirthDate.Year.ToString().Substring(2));
}
// Join parts and sanitize
var emailPrefix = string.Join(GetRandomSymbol(), parts);
emailPrefix = SanitizeEmailPrefix(emailPrefix);
// Adjust length
if (emailPrefix.Length < MinLength)
{
emailPrefix += GenerateRandomString(MinLength - emailPrefix.Length);
}
else if (emailPrefix.Length > MaxLength)
{
emailPrefix = emailPrefix.Substring(0, MaxLength);
}
return emailPrefix;
}
/// <summary>
/// Sanitize the email prefix by removing invalid characters and ensuring it's a valid email prefix.
/// </summary>
/// <param name="input">The input string to sanitize.</param>
/// <returns>The sanitized string.</returns>
private static string SanitizeEmailPrefix(string input)
{
// Remove any character that's not a letter, number, dot, underscore, or hyphen including special characters
string sanitized = Regex.Replace(input, @"[^a-zA-Z0-9._-]", string.Empty, RegexOptions.NonBacktracking);
// Remove consecutive dots, underscores, or hyphens
sanitized = Regex.Replace(sanitized, @"[-_.]{2,}", m => m.Value[0].ToString(), RegexOptions.NonBacktracking);
// Ensure it doesn't start or end with a dot, underscore, or hyphen
sanitized = sanitized.Trim('.', '_', '-');
return sanitized;
}
/// <summary>
/// Get a random symbol from the list of symbols.
/// </summary>
/// <returns>Random symbol.</returns>
private string GetRandomSymbol()
{
return _random.Next(3) == 0 ? _symbols[_random.Next(_symbols.Count)] : string.Empty;
}
/// <summary>
/// Generate a random string of a given length.
/// </summary>
/// <param name="length">Length of string to generate.</param>
/// <returns>String with random characters.</returns>
private string GenerateRandomString(int length)
{
const string chars = "abcdefghijklmnopqrstuvwxyz0123456789";
return new string(Enumerable.Repeat(chars, length)
.Select(s => s[_random.Next(s.Length)]).ToArray());
}
}

View File

@@ -48,4 +48,8 @@
<ProjectReference Include="..\Utilities\AliasVault.RazorComponents\AliasVault.RazorComponents.csproj" />
</ItemGroup>
<ItemGroup>
<_ContentIncludedByDefault Remove="Main\Components\Refresh\RefreshButton.razor" />
</ItemGroup>
</Project>

View File

@@ -1,23 +1,36 @@
@implements IDisposable
@inject NavigationManager NavigationManager
@foreach (var message in Messages)
@if (Messages.Count == 0)
{
if (message.Key == "success")
{
<AlertMessageSuccess Message="@message.Value" />
}
return;
}
@foreach (var message in Messages)
{
if (message.Key == "error")
<div class="messages-container grid px-4 pt-6 lg:gap-4 dark:bg-gray-900">
@foreach (var message in Messages)
{
<AlertMessageError Message="@message.Value" />
if (message.Key == "success")
{
<AlertMessageSuccess Message="@message.Value" />
}
}
}
@foreach (var message in Messages)
{
if (message.Key == "error")
{
<AlertMessageError Message="@message.Value" />
}
}
</div>
<style>
.messages-container > :last-child {
margin-bottom: 0 !important;
}
</style>
@code {
private List<KeyValuePair<string, string>> Messages { get; set; } = new();
private bool _onChangeSubscribed = false;
/// <inheritdoc />
protected override async Task OnAfterRenderAsync(bool firstRender)
@@ -26,22 +39,26 @@
if (firstRender)
{
// We subscribe to the OnChange event of the PortalMessageService to update the UI when a new message is added
RefreshAddMessages();
GlobalNotificationService.OnChange += RefreshAddMessages;
_onChangeSubscribed = true;
NavigationManager.LocationChanged += HandleLocationChanged;
}
}
/// <inheritdoc />
public void Dispose()
{
// We unsubscribe from the OnChange event of the PortalMessageService when the component is disposed
if (_onChangeSubscribed)
{
GlobalNotificationService.OnChange -= RefreshAddMessages;
_onChangeSubscribed = false;
}
GlobalNotificationService.OnChange -= RefreshAddMessages;
NavigationManager.LocationChanged -= HandleLocationChanged;
}
/// <summary>
/// Refreshes the messages on navigation to another page.
/// </summary>
private void HandleLocationChanged(object? sender, LocationChangedEventArgs e)
{
RefreshAddMessages();
InvokeAsync(StateHasChanged);
}
/// <summary>

View File

@@ -31,7 +31,6 @@
}
</ol>
</nav>
<GlobalNotificationDisplay />
@code {
/// <summary>

View File

@@ -8,6 +8,7 @@
<div class="flex pt-16 overflow-hidden bg-gray-50 dark:bg-gray-900">
<div id="main-content" class="relative w-full max-w-screen-2xl mx-auto h-full overflow-y-auto bg-gray-50 dark:bg-gray-900">
<main>
<GlobalNotificationDisplay />
@Body
</main>
<Footer></Footer>

View File

@@ -61,7 +61,7 @@
Logger.LogInformation("User changed their password successfully.");
GlobalNotificationService.AddSuccessMessage("Your password has been changed.", true);
GlobalNotificationService.AddSuccessMessage("Your password has been changed.");
NavigationService.RedirectToCurrentPage();
}

View File

@@ -35,7 +35,7 @@
var userId = await UserManager.GetUserIdAsync(UserService.User());
Logger.LogInformation("User with ID '{UserId}' has reset their authentication app key.", userId);
GlobalNotificationService.AddSuccessMessage("Your authenticator app key has been reset, you will need to re-configure your authenticator app using the new key.", true);
GlobalNotificationService.AddSuccessMessage("Your authenticator app key has been reset, you will need to re-configure your authenticator app using the new key.");
NavigationService.RedirectTo(
"account/manage/2fa");

View File

@@ -4,8 +4,6 @@
<div class="grid grid-cols-1 px-4 pt-6 xl:grid-cols-3 xl:gap-4 dark:bg-gray-900">
<div class="mb-4 col-span-full xl:mb-2">
<GlobalNotificationDisplay />
<div class="flex items-center justify-between">
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">Manage account</h1>
</div>

View File

@@ -17,7 +17,7 @@
@using AliasVault.Admin.Main.Components.Layout
@using AliasVault.Admin.Main.Components.Loading
@using AliasVault.Admin.Main.Components.WorkerStatus
@using AliasVault.Admin.Main.Components.Refresh
@using AliasVault.RazorComponents
@using AliasVault.Admin.Main.Models
@using AliasVault.Admin.Main.Pages
@using AliasVault.Admin.Services

View File

@@ -1,5 +1,5 @@
{
"name": "aliasvault.client",
"name": "aliasvault.admin",
"version": "1.0.0",
"description": "",
"main": "index.js",

View File

@@ -644,14 +644,6 @@ video {
grid-column: 1 / -1;
}
.col-span-2 {
grid-column: span 2 / span 2;
}
.col-span-6 {
grid-column: span 6 / span 6;
}
.mx-3 {
margin-left: 0.75rem;
margin-right: 0.75rem;
@@ -667,6 +659,10 @@ video {
margin-bottom: 1rem;
}
.mb-1 {
margin-bottom: 0.25rem;
}
.mb-2 {
margin-bottom: 0.5rem;
}
@@ -743,10 +739,6 @@ video {
margin-top: 2rem;
}
.mb-1 {
margin-bottom: 0.25rem;
}
.line-clamp-1 {
overflow: hidden;
display: -webkit-box;
@@ -822,10 +814,18 @@ video {
width: 50%;
}
.w-1\/3 {
width: 33.333333%;
}
.w-10 {
width: 2.5rem;
}
.w-2\/3 {
width: 66.666667%;
}
.w-4 {
width: 1rem;
}
@@ -858,14 +858,6 @@ video {
width: 100%;
}
.w-1\/3 {
width: 33.333333%;
}
.w-2\/3 {
width: 66.666667%;
}
.max-w-2xl {
max-width: 42rem;
}
@@ -882,10 +874,6 @@ video {
max-width: 36rem;
}
.flex-1 {
flex: 1 1 0%;
}
.flex-shrink-0 {
flex-shrink: 0;
}
@@ -924,6 +912,10 @@ video {
grid-template-columns: repeat(1, minmax(0, 1fr));
}
.grid-cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.grid-cols-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
@@ -932,14 +924,6 @@ video {
grid-template-columns: repeat(7, minmax(0, 1fr));
}
.grid-cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.grid-cols-6 {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
.flex-col {
flex-direction: column;
}
@@ -972,10 +956,6 @@ video {
justify-content: space-between;
}
.gap-6 {
gap: 1.5rem;
}
.space-x-1 > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0;
margin-right: calc(0.25rem * var(--tw-space-x-reverse));
@@ -1140,11 +1120,6 @@ video {
background-color: rgb(59 130 246 / var(--tw-bg-opacity));
}
.bg-blue-600 {
--tw-bg-opacity: 1;
background-color: rgb(37 99 235 / var(--tw-bg-opacity));
}
.bg-gray-100 {
--tw-bg-opacity: 1;
background-color: rgb(243 244 246 / var(--tw-bg-opacity));
@@ -1344,6 +1319,14 @@ video {
padding-bottom: 2rem;
}
.pl-2 {
padding-left: 0.5rem;
}
.pr-2 {
padding-right: 0.5rem;
}
.ps-2 {
padding-inline-start: 0.5rem;
}
@@ -1360,14 +1343,6 @@ video {
padding-top: 2rem;
}
.pl-2 {
padding-left: 0.5rem;
}
.pr-2 {
padding-right: 0.5rem;
}
.text-left {
text-align: left;
}
@@ -1518,11 +1493,6 @@ video {
color: rgb(133 77 14 / var(--tw-text-opacity));
}
.text-blue-500 {
--tw-text-opacity: 1;
color: rgb(59 130 246 / var(--tw-text-opacity));
}
.underline {
text-decoration-line: underline;
}
@@ -1595,11 +1565,6 @@ video {
transition-duration: 300ms;
}
.hover\:bg-blue-700:hover {
--tw-bg-opacity: 1;
background-color: rgb(29 78 216 / var(--tw-bg-opacity));
}
.hover\:bg-gray-100:hover {
--tw-bg-opacity: 1;
background-color: rgb(243 244 246 / var(--tw-bg-opacity));
@@ -1625,16 +1590,16 @@ video {
background-color: rgb(154 93 38 / var(--tw-bg-opacity));
}
.hover\:bg-red-800:hover {
--tw-bg-opacity: 1;
background-color: rgb(153 27 27 / var(--tw-bg-opacity));
}
.hover\:bg-red-700:hover {
--tw-bg-opacity: 1;
background-color: rgb(185 28 28 / var(--tw-bg-opacity));
}
.hover\:bg-red-800:hover {
--tw-bg-opacity: 1;
background-color: rgb(153 27 27 / var(--tw-bg-opacity));
}
.hover\:text-gray-900:hover {
--tw-text-opacity: 1;
color: rgb(17 24 39 / var(--tw-text-opacity));
@@ -1750,11 +1715,6 @@ video {
border-color: rgb(55 65 81 / var(--tw-border-opacity));
}
.dark\:bg-blue-500:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(59 130 246 / var(--tw-bg-opacity));
}
.dark\:bg-gray-600:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(75 85 99 / var(--tw-bg-opacity));
@@ -1785,6 +1745,11 @@ video {
background-color: rgb(214 131 56 / var(--tw-bg-opacity));
}
.dark\:bg-red-500:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(239 68 68 / var(--tw-bg-opacity));
}
.dark\:bg-red-900:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(127 29 29 / var(--tw-bg-opacity));
@@ -1795,11 +1760,6 @@ video {
background-color: rgb(113 63 18 / var(--tw-bg-opacity));
}
.dark\:bg-red-500:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(239 68 68 / var(--tw-bg-opacity));
}
.dark\:bg-opacity-80:is(.dark *) {
--tw-bg-opacity: 0.8;
}
@@ -1893,11 +1853,6 @@ video {
--tw-ring-offset-color: #1f2937;
}
.dark\:hover\:bg-blue-600:hover:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(37 99 235 / var(--tw-bg-opacity));
}
.dark\:hover\:bg-gray-600:hover:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(75 85 99 / var(--tw-bg-opacity));
@@ -1948,11 +1903,6 @@ video {
--tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity));
}
.dark\:focus\:ring-blue-800:focus:is(.dark *) {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(30 64 175 / var(--tw-ring-opacity));
}
.dark\:focus\:ring-gray-600:focus:is(.dark *) {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(75 85 99 / var(--tw-ring-opacity));
@@ -1978,21 +1928,17 @@ video {
--tw-ring-color: rgb(154 93 38 / var(--tw-ring-opacity));
}
.dark\:focus\:ring-red-900:focus:is(.dark *) {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(127 29 29 / var(--tw-ring-opacity));
}
.dark\:focus\:ring-red-800:focus:is(.dark *) {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(153 27 27 / var(--tw-ring-opacity));
}
@media (min-width: 640px) {
.sm\:col-span-3 {
grid-column: span 3 / span 3;
}
.dark\:focus\:ring-red-900:focus:is(.dark *) {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(127 29 29 / var(--tw-ring-opacity));
}
@media (min-width: 640px) {
.sm\:flex {
display: flex;
}
@@ -2011,18 +1957,18 @@ video {
margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse)));
}
.sm\:space-y-0 > :not([hidden]) ~ :not([hidden]) {
--tw-space-y-reverse: 0;
margin-top: calc(0px * calc(1 - var(--tw-space-y-reverse)));
margin-bottom: calc(0px * var(--tw-space-y-reverse));
}
.sm\:space-x-4 > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0;
margin-right: calc(1rem * var(--tw-space-x-reverse));
margin-left: calc(1rem * calc(1 - var(--tw-space-x-reverse)));
}
.sm\:space-y-0 > :not([hidden]) ~ :not([hidden]) {
--tw-space-y-reverse: 0;
margin-top: calc(0px * calc(1 - var(--tw-space-y-reverse)));
margin-bottom: calc(0px * var(--tw-space-y-reverse));
}
.sm\:p-6 {
padding: 1.5rem;
}
@@ -2116,10 +2062,6 @@ video {
order: 2;
}
.lg\:col-auto {
grid-column: auto;
}
.lg\:mb-10 {
margin-bottom: 2.5rem;
}
@@ -2180,17 +2122,17 @@ video {
gap: 1rem;
}
.xl\:space-x-8 > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0;
margin-right: calc(2rem * var(--tw-space-x-reverse));
margin-left: calc(2rem * calc(1 - var(--tw-space-x-reverse)));
}
.xl\:space-x-0 > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0;
margin-right: calc(0px * var(--tw-space-x-reverse));
margin-left: calc(0px * calc(1 - var(--tw-space-x-reverse)));
}
.xl\:space-x-8 > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0;
margin-right: calc(2rem * var(--tw-space-x-reverse));
margin-left: calc(2rem * calc(1 - var(--tw-space-x-reverse)));
}
}
@media (min-width: 1536px) {
@@ -2198,10 +2140,6 @@ video {
grid-column: span 2 / span 2;
}
.\32xl\:flex {
display: flex;
}
.\32xl\:space-x-4 > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0;
margin-right: calc(1rem * var(--tw-space-x-reverse));

View File

@@ -11,6 +11,7 @@ using AliasServerDb;
using AliasVault.Api.Helpers;
using AliasVault.Shared.Models.Spamok;
using AliasVault.Shared.Models.WebApi;
using AliasVault.Shared.Models.WebApi.Email;
using Asp.Versioning;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
@@ -25,7 +26,7 @@ using Microsoft.EntityFrameworkCore;
public class EmailBoxController(IDbContextFactory<AliasServerDbContext> dbContextFactory, UserManager<AliasVaultUser> userManager) : AuthenticatedRequestController(userManager)
{
/// <summary>
/// Get the newest version of the vault for the current user.
/// Returns a list of emails for the provided email address.
/// </summary>
/// <param name="to">The full email address including @ sign.</param>
/// <returns>List of aliases in JSON format.</returns>
@@ -98,4 +99,72 @@ public class EmailBoxController(IDbContextFactory<AliasServerDbContext> dbContex
return Ok(returnValue);
}
/// <summary>
/// Returns a list of emails for the provided email address.
/// </summary>
/// <param name="model">The request model extracted from POST body.</param>
/// <returns>List of aliases in JSON format.</returns>
[HttpPost(template: "bulk", Name = "GetEmailBoxBulk")]
public async Task<IActionResult> GetEmailBoxBulk([FromBody] MailboxBulkRequest model)
{
await using var context = await dbContextFactory.CreateDbContextAsync();
var user = await GetCurrentUserAsync();
if (user is null)
{
return Unauthorized("Not authenticated.");
}
// Sanitize input.
model.Addresses = model.Addresses.Select(x => x.Trim().ToLower()).ToList();
model.PageSize = Math.Min(model.PageSize, 50);
// Load all email addresses that the user has a claim to where the address is in the list.
var emailClaims = await context.UserEmailClaims
.Where(claim => claim.UserId == user.Id && model.Addresses.Contains(claim.Address))
.ToListAsync();
var query = context.Emails
.AsNoTracking()
.Include(x => x.EncryptionKey)
.Where(email => context.UserEmailClaims
.Any(claim => claim.UserId == user.Id
&& claim.Address == email.To
&& model.Addresses.Contains(claim.Address)));
var totalRecords = await query.CountAsync();
List<MailboxEmailApiModel> emails = await query.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)
.Skip((model.Page - 1) * model.PageSize)
.Take(model.PageSize)
.ToListAsync();
MailboxBulkResponse returnValue = new()
{
Addresses = emailClaims.Select(x => x.Address).ToList(),
Mails = emails,
PageSize = model.PageSize,
CurrentPage = model.Page,
TotalRecords = totalRecords,
};
return Ok(returnValue);
}
}

View File

@@ -21,7 +21,7 @@ using Microsoft.AspNetCore.Mvc;
public class IdentityController(UserManager<AliasVaultUser> userManager) : AuthenticatedRequestController(userManager)
{
/// <summary>
/// Proxies the request to the identity generator to generate a random identity.
/// Proxies the request to the english identity generator to generate a random identity.
/// </summary>
/// <returns>Identity model.</returns>
[HttpGet("Generate")]
@@ -33,7 +33,7 @@ public class IdentityController(UserManager<AliasVaultUser> userManager) : Authe
return Unauthorized();
}
var identityGenerator = new FigIdentityGenerator();
var identityGenerator = new IdentityGeneratorEn();
return Ok(await identityGenerator.GenerateRandomIdentityAsync());
}
}

View File

@@ -151,33 +151,36 @@ public class VaultController(ILogger<VaultController> logger, IDbContextFactory<
// Register new email addresses.
foreach (var email in newEmailAddresses)
{
// Sanitize email address.
var sanitizedEmail = email.Trim().ToLower();
// If email address is invalid according to the EmailAddressAttribute, skip it.
if (!new EmailAddressAttribute().IsValid(email))
if (!new EmailAddressAttribute().IsValid(sanitizedEmail))
{
continue;
}
// Check if the email address is already claimed (by another user).
var existingClaim = await context.UserEmailClaims
.FirstOrDefaultAsync(x => x.Address == email);
.FirstOrDefaultAsync(x => x.Address == sanitizedEmail);
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);
logger.LogWarning("{User} tried to claim email address: {Email} but it is already claimed by another user.", userId, sanitizedEmail);
continue;
}
if (!existingEmailClaims.Contains(email))
if (!existingEmailClaims.Contains(sanitizedEmail))
{
try
{
await context.UserEmailClaims.AddAsync(new UserEmailClaim
{
UserId = userId,
Address = email,
AddressLocal = email.Split('@')[0],
AddressDomain = email.Split('@')[1],
Address = sanitizedEmail,
AddressLocal = sanitizedEmail.Split('@')[0],
AddressDomain = sanitizedEmail.Split('@')[1],
CreatedAt = timeProvider.UtcNow,
UpdatedAt = timeProvider.UtcNow,
});
@@ -185,7 +188,7 @@ public class VaultController(ILogger<VaultController> logger, IDbContextFactory<
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);
logger.LogWarning(ex, "Error while adding UserEmailClaim with email: {Email} for user: {UserId}.", sanitizedEmail, userId);
}
}
}

View File

@@ -76,6 +76,7 @@
<ProjectReference Include="..\Databases\AliasClientDb\AliasClientDb.csproj" />
<ProjectReference Include="..\AliasGenerators\AliasGenerators.csproj" />
<ProjectReference Include="..\AliasVault.Shared\AliasVault.Shared.csproj" />
<ProjectReference Include="..\Utilities\AliasVault.RazorComponents\AliasVault.RazorComponents.csproj" />
<ProjectReference Include="..\Utilities\Cryptography\Cryptography.csproj" />
<ProjectReference Include="..\Utilities\CsvImportExport\CsvImportExport.csproj" />
<ProjectReference Include="..\Utilities\FaviconExtractor\FaviconExtractor.csproj" />

View File

@@ -1,24 +1,37 @@
@inject GlobalNotificationService GlobalNotificationService
@inject NavigationManager NavigationManager
@implements IDisposable
@foreach (var message in Messages)
@if (Messages.Count == 0)
{
if (message.Key == "success")
{
<AlertMessageSuccess Message="@message.Value" />
}
return;
}
@foreach (var message in Messages)
{
if (message.Key == "error")
<div class="messages-container grid px-4 pt-6 lg:gap-4 dark:bg-gray-900">
@foreach (var message in Messages)
{
<AlertMessageError Message="@message.Value" />
if (message.Key == "success")
{
<AlertMessageSuccess Message="@message.Value" />
}
}
}
@foreach (var message in Messages)
{
if (message.Key == "error")
{
<AlertMessageError Message="@message.Value" />
}
}
</div>
<style>
.messages-container > :last-child {
margin-bottom: 0 !important;
}
</style>
@code {
private List<KeyValuePair<string, string>> Messages { get; set; } = new();
private bool _onChangeSubscribed = false;
/// <inheritdoc />
protected override async Task OnAfterRenderAsync(bool firstRender)
@@ -27,22 +40,26 @@
if (firstRender)
{
// We subscribe to the OnChange event of the PortalMessageService to update the UI when a new message is added
RefreshAddMessages();
GlobalNotificationService.OnChange += RefreshAddMessages;
_onChangeSubscribed = true;
NavigationManager.LocationChanged += HandleLocationChanged;
}
}
/// <inheritdoc />
public void Dispose()
{
// We unsubscribe from the OnChange event of the PortalMessageService when the component is disposed
if (_onChangeSubscribed)
{
GlobalNotificationService.OnChange -= RefreshAddMessages;
_onChangeSubscribed = false;
}
GlobalNotificationService.OnChange -= RefreshAddMessages;
NavigationManager.LocationChanged -= HandleLocationChanged;
}
/// <summary>
/// Refreshes the messages on navigation to another page.
/// </summary>
private void HandleLocationChanged(object? sender, LocationChangedEventArgs e)
{
RefreshAddMessages();
InvokeAsync(StateHasChanged);
}
/// <summary>

View File

@@ -23,6 +23,7 @@
</div>
<div class="mt-4">
<p class="text-sm text-gray-500 dark:text-gray-400">From: @Email?.FromDisplay</p>
<p class="text-sm text-gray-500 dark:text-gray-400">To: @(Email?.ToLocal)@@@(Email?.ToDomain)</p>
<p class="text-sm text-gray-500 dark:text-gray-400">Date: @Email?.DateSystem</p>
</div>
<div class="mt-4 text-gray-700 dark:text-gray-300">

View File

@@ -8,6 +8,7 @@
@inject JsInteropService JsInteropService
@inject DbService DbService
@inject Config Config
@inject EmailService EmailService
@using System.Timers
@implements IDisposable
@@ -90,7 +91,7 @@
/// The email address to show recent emails for.
/// </summary>
[Parameter]
public string EmailAddress { get; set; } = string.Empty;
public string? EmailAddress { get; set; } = string.Empty;
private List<MailboxEmailApiModel> MailboxEmails { get; set; } = new();
private bool ShowComponent { get; set; } = false;
@@ -110,6 +111,11 @@
{
await base.OnInitializedAsync();
if (EmailAddress is null)
{
return;
}
// Check if email has a known SpamOK domain, if not, don't show this component.
if (IsSpamOkDomain(EmailAddress) || IsAliasVaultDomain(EmailAddress))
{
@@ -130,6 +136,12 @@
protected override void OnParametersSet()
{
base.OnParametersSet();
if (EmailAddress is null)
{
return;
}
IsSpamOk = IsSpamOkDomain(EmailAddress);
}
@@ -191,7 +203,7 @@
private async Task LoadRecentEmailsAsync()
{
if (!ShowComponent)
if (!ShowComponent || EmailAddress is null)
{
return;
}
@@ -217,6 +229,11 @@
/// </summary>
private async Task OpenEmail(int emailId)
{
if (EmailAddress is null)
{
return;
}
// Get email prefix, which is the part before the @ symbol.
string emailPrefix = EmailAddress.Split('@')[0];
@@ -292,7 +309,7 @@
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.";
"save the credential entry to synchronize the database, then try again.";
break;
default:
throw new ArgumentException(errorResponse.Message);
@@ -325,24 +342,7 @@
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);
}
}
MailboxEmails = await EmailService.DecryptEmailList(MailboxEmails);
}
/// <summary>
@@ -358,28 +358,7 @@
var privateKey = await context.EncryptionKeys.FirstOrDefaultAsync(x => x.PublicKey == mail.EncryptionKey);
if (privateKey is not null)
{
try
{
var decryptedSymmetricKey = await JsInteropService.DecryptWithPrivateKey(mail.EncryptedSymmetricKey, privateKey.PrivateKey);
mail.Subject = await JsInteropService.SymmetricDecrypt(mail.Subject, Convert.ToBase64String(decryptedSymmetricKey));
if (mail.MessageHtml is not null)
{
mail.MessageHtml = await JsInteropService.SymmetricDecrypt(mail.MessageHtml, Convert.ToBase64String(decryptedSymmetricKey));
}
if (mail.MessagePlain is not null)
{
mail.MessagePlain = await JsInteropService.SymmetricDecrypt(mail.MessagePlain, Convert.ToBase64String(decryptedSymmetricKey));
}
mail.FromDisplay = await JsInteropService.SymmetricDecrypt(mail.FromDisplay, Convert.ToBase64String(decryptedSymmetricKey));
mail.FromLocal = await JsInteropService.SymmetricDecrypt(mail.FromLocal, Convert.ToBase64String(decryptedSymmetricKey));
mail.FromDomain = await JsInteropService.SymmetricDecrypt(mail.FromDomain, Convert.ToBase64String(decryptedSymmetricKey));
}
catch (Exception ex)
{
Error = ex.Message;
}
mail = await EmailService.DecryptEmail(mail);
}
Email = mail;

View File

@@ -0,0 +1,88 @@
@using System.Text.RegularExpressions
<div title="@SenderEmail" class="justify-content-center align-middle" style="padding-top:10px;font-size:18px;color:white;text-align:center;border-radius:30px;width: 50px;height: 50px;background-color:@SenderNameColor;">@SenderNameLetters</div>
@code {
/// <summary>
/// The name of the sender.
/// </summary>
[Parameter]
public string SenderName { get; set; } = string.Empty;
/// <summary>
/// The email of the sender.
/// </summary>
[Parameter]
public string SenderEmail { get; set; } = string.Empty;
private string SenderNameLetters { get; set; } = string.Empty;
private string SenderNameColor { get; set; } = "#666";
/// <summary>
/// Mappping of alphabet letters to HSL colors.
/// </summary>
private static readonly Dictionary<string, string> AlphabetColors = new Dictionary<string, string>
{
{ "A", "hsl(175, 50%, 50%)" },
{ "B", "hsl(234, 50%, 50%)" },
{ "C", "hsl(278, 50%, 50%)" },
{ "D", "hsl(191, 50%, 50%)" },
{ "E", "hsl(215, 50%, 50%)" },
{ "F", "hsl(315, 50%, 50%)" },
{ "G", "hsl(247, 50%, 50%)" },
{ "H", "hsl(259, 50%, 50%)" },
{ "I", "hsl(289, 50%, 50%)" },
{ "J", "hsl(206, 50%, 50%)" },
{ "K", "hsl(124, 50%, 50%)" },
{ "L", "hsl(129, 50%, 50%)" },
{ "M", "hsl(69, 50%, 50%)" },
{ "N", "hsl(38, 50%, 50%)" },
{ "O", "hsl(352, 50%, 50%)" },
{ "P", "hsl(311, 50%, 50%)" },
{ "Q", "hsl(332, 50%, 50%)" },
{ "R", "hsl(344, 50%, 50%)" },
{ "S", "hsl(357, 50%, 50%)" },
{ "T", "hsl(23, 50%, 50%)" },
{ "U", "hsl(16, 50%, 50%)" },
{ "V", "hsl(304, 50%, 50%)" },
{ "W", "hsl(300, 50%, 50%)" },
{ "X", "hsl(332, 50%, 50%)" },
{ "Y", "hsl(48, 50%, 50%)" },
{ "Z", "hsl(9, 50%, 50%)" }
};
/// <inheritdoc />
protected override void OnParametersSet()
{
GenerateSenderInitials();
GenerateSenderColor();
}
/// <summary>
/// Extract the initials of the sender.
/// </summary>
private void GenerateSenderInitials()
{
string senderName = Regex.Replace(SenderName, "[^a-zA-Z ]", "", RegexOptions.NonBacktracking);
SenderNameLetters = string.Concat(senderName
.Split(' ')
.Select(n => n.Length > 0 ? n[0].ToString() : "")
);
SenderNameLetters = SenderNameLetters.Substring(0, Math.Min(2, SenderNameLetters.Length)).ToUpper();
if (string.IsNullOrEmpty(SenderNameLetters))
{
SenderNameLetters = "?";
}
}
/// <summary>
/// Pick a unique color based on the first letter of the sender name.
/// </summary>
private void GenerateSenderColor()
{
string firstLetter = SenderNameLetters.Substring(0, 1);
SenderNameColor = AlphabetColors.TryGetValue(firstLetter, out var color) ? color : "#666";
}
}

View File

@@ -82,7 +82,7 @@
/// The value of the input field. This should be the full email address.
/// </summary>
[Parameter]
public string Value { get; set; } = string.Empty;
public string? Value { get; set; } = string.Empty;
/// <summary>
/// Callback that is triggered when the value changes.
@@ -113,6 +113,11 @@
{
base.OnParametersSet();
if (Value is null)
{
return;
}
if (Value.Contains('@'))
{
SelectedDomain = Value.Split('@')[1];
@@ -176,6 +181,11 @@
private void ToggleCustomDomain()
{
if (Value is null)
{
return;
}
IsCustomDomain = !IsCustomDomain;
if (!IsCustomDomain && !Value.Contains('@'))
{

View File

@@ -6,7 +6,7 @@
}
else
{
<input type="text" id="@Id" class="outline-0 shadow-sm bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg block w-full p-2.5 pr-10 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white" value="@Value" @oninput="OnInputChanged">
<input type="text" id="@Id" @onfocus="OnFocusEvent" class="outline-0 shadow-sm bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg block w-full p-2.5 pr-10 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white" value="@Value" @oninput="OnInputChanged">
}
</div>
@@ -35,6 +35,12 @@
[Parameter]
public string Value { get; set; } = string.Empty;
/// <summary>
/// Callback that is triggered when the value changes.
/// </summary>
[Parameter]
public EventCallback<FocusEventArgs> OnFocus { get; set; }
/// <summary>
/// Callback that is triggered when the value changes.
/// </summary>
@@ -46,4 +52,12 @@
Value = e.Value?.ToString() ?? string.Empty;
await ValueChanged.InvokeAsync(Value);
}
private async Task OnFocusEvent(FocusEventArgs e)
{
if (OnFocus.HasDelegate)
{
await OnFocus.InvokeAsync(e);
}
}
}

View File

@@ -15,7 +15,7 @@
<li>
<div class="flex items-center">
<svg class="w-6 h-6 text-gray-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path></svg>
<a href="@item.Url" class="ml-1 text-gray-700 hover:text-primary-600 md:ml-2 dark:text-gray-300 dark:hover:text-primary-500">@item.DisplayName</a>
<a href="@item.Url" class="text-gray-700 hover:text-primary-600 dark:text-gray-300 dark:hover:text-primary-500">@item.DisplayName</a>
</div>
</li>
}
@@ -31,7 +31,6 @@
}
</ol>
</nav>
<GlobalNotificationDisplay />
@code {
/// <summary>

View File

@@ -55,7 +55,7 @@
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (Module != null)
if (Module is not null)
{
await Module.InvokeVoidAsync("unregisterClickOutsideHandler");
await Module.DisposeAsync();
@@ -69,9 +69,9 @@
/// </summary>
private async Task LoadModuleAsync()
{
if (Module == null)
if (Module is null)
{
Module = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/clickOutsideHandler.js");
Module = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/modules/clickOutsideHandler.js");
ObjRef = DotNetObjectReference.Create(this);
}
}

View File

@@ -0,0 +1,209 @@
@using System.ComponentModel.DataAnnotations
@inherits AliasVault.Client.Main.Pages.MainBase
@inject IJSRuntime JSRuntime
@inject CredentialService CredentialService
@implements IAsyncDisposable
<button @ref="buttonRef" @onclick="TogglePopup" id="quickIdentityButton" class="px-4 ms-5 py-2 text-sm font-medium text-white bg-gradient-to-r from-primary-500 to-primary-600 hover:from-primary-600 hover:to-primary-700 focus:outline-none dark:from-primary-400 dark:to-primary-500 dark:hover:from-primary-500 dark:hover:to-primary-600 rounded-md shadow-sm transition duration-150 ease-in-out transform hover:scale-105 active:scale-95 focus:shadow-outline">
+ <span class="hidden md:inline">New identity</span>
</button>
@if (IsPopupVisible)
{
<ClickOutsideHandler OnClose="ClosePopup" ContentId="quickIdentityPopup,quickIdentityButton">
<div id="quickIdentityPopup" class="absolute z-50 mt-2 p-4 bg-white rounded-lg shadow-xl border border-gray-300"
style="@PopupStyle">
<h3 class="text-lg font-semibold mb-4">Create New Identity</h3>
<EditForm Model="Model" OnValidSubmit="CreateIdentity">
<DataAnnotationsValidator />
<div class="mb-4">
<EditFormRow Id="serviceName" Label="Service Name" @bind-Value="Model.ServiceName"></EditFormRow>
<ValidationMessage For="() => Model.ServiceName"/>
</div>
<div class="mb-4">
<EditFormRow Id="serviceUrl" Label="Service URL" OnFocus="OnFocusUrlInput" @bind-Value="Model.ServiceUrl"></EditFormRow>
<ValidationMessage For="() => Model.ServiceUrl"/>
</div>
<div class="flex justify-between items-center">
<button id="quickIdentitySubmit" type="submit" class="bg-green-600 hover:bg-green-700 text-white font-bold py-2 px-4 rounded">
Create
</button>
</div>
<div class="pt-2">
<a href="#" @onclick="OpenAdvancedMode" @onclick:preventDefault class="text-sm text-blue-500 hover:text-blue-700">
Create via advanced mode
</a>
</div>
</EditForm>
</div>
</ClickOutsideHandler>
}
@code {
private bool IsPopupVisible = false;
private bool IsCreating = false;
private CreateModel Model = new();
private string PopupStyle { get; set; } = "";
private ElementReference buttonRef;
private IJSObjectReference? Module;
/// <inheritdoc />
async ValueTask IAsyncDisposable.DisposeAsync()
{
await KeyboardShortcutService.UnregisterShortcutAsync("gc");
if (Module is not null)
{
await Module.DisposeAsync();
}
}
/// <inheritdoc />
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await KeyboardShortcutService.RegisterShortcutAsync("gc", ShowPopup);
Module = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/modules/newIdentityWidget.js");
}
}
/// <summary>
/// When the URL input is focused, place cursor at the end of the default URL to allow for easy typing.
/// </summary>
private void OnFocusUrlInput(FocusEventArgs e)
{
if (Model.ServiceUrl != CredentialService.DefaultServiceUrl)
{
return;
}
// Use a small delay to ensure the focus is set after the browser's default behavior.
Task.Delay(1).ContinueWith(_ =>
{
JSRuntime.InvokeVoidAsync("eval", $"document.getElementById('serviceUrl').setSelectionRange({CredentialService.DefaultServiceUrl.Length}, {CredentialService.DefaultServiceUrl.Length})");
});
}
private async Task TogglePopup()
{
IsPopupVisible = !IsPopupVisible;
if (IsPopupVisible)
{
await ShowPopup();
}
}
private async Task ShowPopup()
{
IsPopupVisible = true;
// Clear the input fields
Model = new();
Model.ServiceUrl = CredentialService.DefaultServiceUrl;
await UpdatePopupStyle();
await Task.Delay(100); // Give time for the DOM to update
await JsInteropService.FocusElementById("serviceName");
}
private void ClosePopup()
{
IsPopupVisible = false;
}
private async Task UpdatePopupStyle()
{
var windowWidth = await JSRuntime.InvokeAsync<int>("getWindowWidth");
var buttonRect = await JSRuntime.InvokeAsync<BoundingClientRect>("getElementRect", buttonRef);
var popupWidth = Math.Min(400, windowWidth - 20); // 20px for some padding
var leftPosition = Math.Max(0, Math.Min(buttonRect.Left, windowWidth - popupWidth - 10));
PopupStyle = $"width: {popupWidth}px; left: {leftPosition}px; top: {buttonRect.Bottom}px;";
StateHasChanged();
}
private async Task CreateIdentity()
{
if (IsCreating)
{
return;
}
IsCreating = true;
GlobalLoadingSpinner.Show();
StateHasChanged();
var credential = new Credential();
credential.Alias = new Alias();
credential.Alias.Email = "@" + CredentialService.GetDefaultEmailDomain();
credential.Service = new Service();
credential.Service.Name = Model.ServiceName;
if (Model.ServiceUrl != CredentialService.DefaultServiceUrl)
{
credential.Service.Url = Model.ServiceUrl;
}
credential.Passwords = new List<Password> { new() };
await CredentialService.GenerateRandomIdentity(credential);
var id = await CredentialService.InsertEntryAsync(credential);
if (id == Guid.Empty)
{
// Error saving.
IsCreating = false;
GlobalLoadingSpinner.Hide();
GlobalNotificationService.AddErrorMessage("Error saving credentials. Please try again.", true);
return;
}
// No error, add success message.
GlobalNotificationService.AddSuccessMessage("Credentials created successfully.");
NavigationManager.NavigateTo("/credentials/" + id);
IsCreating = false;
GlobalLoadingSpinner.Hide();
StateHasChanged();
ClosePopup();
}
private void OpenAdvancedMode()
{
NavigationManager.NavigateTo("/credentials/create");
ClosePopup();
}
/// <summary>
/// Bounding client rectangle returned from JavaScript.
/// </summary>
private sealed class BoundingClientRect
{
public double Left { get; set; }
public double Top { get; set; }
public double Right { get; set; }
public double Bottom { get; set; }
public double Width { get; set; }
public double Height { get; set; }
}
/// <summary>
/// Local model for the form with support for validation.
/// </summary>
private sealed class CreateModel
{
/// <summary>
/// The service name.
/// </summary>
[Required]
[Display(Name = "Service Name")]
public string ServiceName { get; set; } = string.Empty;
/// <summary>
/// The service URL.
/// </summary>
[Display(Name = "Service URL")]
public string ServiceUrl { get; set; } = string.Empty;
}
}

View File

@@ -0,0 +1,178 @@
@inject DbService DbService
@inject NavigationManager NavigationManager
@inject KeyboardShortcutService KeyboardShortcutService
@inject JsInteropService JsInteropService
@implements IAsyncDisposable
<div class="relative" id="searchWidgetContainer">
<input
id="searchWidget"
type="text"
placeholder="Type here to search"
autocomplete="off"
class="w-full px-4 py-2 text-gray-700 bg-white border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600 dark:focus:ring-primary-500"
@bind-value="SearchTerm"
@oninput="SearchTermChanged"
@onfocus="OnFocus"
@onblur="OnBlur"
@onkeydown="HandleKeyDown"/>
@if (ShowHelpText)
{
<div class="absolute z-10 w-full mt-1 bg-white rounded-md shadow-lg dark:bg-gray-800 p-2 text-sm text-gray-600 dark:text-gray-400">
@if (string.IsNullOrEmpty(SearchTerm))
{
<p>Type a term to search for, this can be the service name, description or email address.</p>
}
else if (SearchTerm.Length == 1)
{
<p>Please type more chars</p>
}
else
{
<p>Searching for "@SearchTerm"</p>
}
</div>
}
@if (ShowResults)
{
@if (SearchResults.Any())
{
<div class="absolute z-10 w-full mt-1 bg-white rounded-md shadow-lg dark:bg-gray-800 text-sm text-gray-600 dark:text-gray-400">
@for (int i = 0; i < SearchResults.Count; i++)
{
var result = SearchResults[i];
<div
class="search-result @(i == SelectedIndex ? "bg-gray-100 dark:bg-gray-700" : "") px-4 py-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700"
@onclick="() => SelectResult(result)">
@result.Service.Name <span class="text-gray-500">(@result.Alias.Email)</span>
</div>
}
</div>
}
else
{
<div class="absolute z-10 w-full mt-1 bg-white rounded-md shadow-lg dark:bg-gray-800 text-sm text-gray-600 dark:text-gray-400">
<div class="px-4 py-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700">
No results found
</div>
</div>
}
}
</div>
@code {
private string SearchTerm { get; set; } = string.Empty;
private List<Credential> SearchResults { get; set; } = new();
private bool ShowResults => SearchTerm.Length >= 2;
private bool ShowHelpText { get; set; }
private int SelectedIndex { get; set; } = -1;
/// <inheritdoc />
async ValueTask IAsyncDisposable.DisposeAsync()
{
await KeyboardShortcutService.UnregisterShortcutAsync("gs");
await KeyboardShortcutService.UnregisterShortcutAsync("gc");
NavigationManager.LocationChanged -= ResetSearchField;
}
/// <inheritdoc />
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await KeyboardShortcutService.RegisterShortcutAsync("gs", FocusSearchField);
await KeyboardShortcutService.RegisterShortcutAsync("gf", FocusSearchField);
NavigationManager.LocationChanged += ResetSearchField;
}
}
private void OnFocus()
{
ShowHelpText = true;
}
private void OnBlur()
{
ShowHelpText = false;
}
private async Task SearchTermChanged(ChangeEventArgs e)
{
Console.WriteLine("Search term changed");
SearchTerm = e.Value?.ToString() ?? string.Empty;
await PerformSearch();
}
private async Task PerformSearch()
{
var context = await DbService.GetDbContextAsync();
if (SearchTerm.Length >= 2)
{
var searchTerms = SearchTerm.Trim().ToLowerInvariant().Split(' ', StringSplitOptions.RemoveEmptyEntries);
var query = context.Credentials
.Include(x => x.Service)
.Include(x => x.Alias)
.AsQueryable();
foreach (var term in searchTerms)
{
query = query.Where(x =>
(x.Service.Name != null && EF.Functions.Like(x.Service.Name.ToLower(), $"%{term}%")) ||
(x.Alias.Email != null && EF.Functions.Like(x.Alias.Email.ToLower(), $"%{term}%"))
);
}
SearchResults = await query.Take(10).ToListAsync();
// Select first entry by default so when pressing enter, the first result is immediately selected.
SelectedIndex = 0;
}
else
{
SearchResults.Clear();
}
StateHasChanged();
}
private async Task HandleKeyDown(KeyboardEventArgs e)
{
switch (e.Key)
{
case "ArrowDown":
SelectedIndex = Math.Min(SelectedIndex + 1, SearchResults.Count - 1);
break;
case "ArrowUp":
SelectedIndex = Math.Max(SelectedIndex - 1, -1);
break;
case "Enter":
if (SelectedIndex >= 0 && SelectedIndex < SearchResults.Count)
{
await SelectResult(SearchResults[SelectedIndex]);
}
break;
}
}
private async Task SelectResult(Credential credential)
{
await JsInteropService.BlurElementById("searchWidget");
NavigationManager.NavigateTo($"/credentials/{credential.Id}");
}
private void ResetSearchField(object? sender, LocationChangedEventArgs e)
{
SearchTerm = string.Empty;
SearchResults.Clear();
StateHasChanged();
}
private async Task FocusSearchField()
{
await JsInteropService.FocusElementById("searchWidget");
}
}

View File

@@ -1,25 +1,21 @@
@implements IDisposable
@inject DbService DbService
@if (Loading)
{
<div class="flex items-center justify-center">
<SmallLoadingIndicator Title="@LoadingIndicatorMessage" />
</div>
}
else
{
<SmallLoadingIndicator Title="@LoadingIndicatorMessage" Spinning="false" />
}
<!--
<p>Message: @DbService.GetState().CurrentState.Message</p>
<p>Last Updated: @DbService.GetState().CurrentState.LastUpdated</p>
-->
<div class="ms-2">
@if (Loading)
{
<div class="flex items-center justify-center">
<SmallLoadingIndicator Title="@LoadingIndicatorMessage" />
</div>
}
else
{
<SmallLoadingIndicator Title="@LoadingIndicatorMessage" Spinning="false" />
}
</div>
@code {
private bool Loading { get; set; } = false;
private string Message { get; set; } = "";
private string LoadingIndicatorMessage { get; set; } = "";
private bool DatabaseLoading { get; set; } = false;
@@ -27,21 +23,36 @@ else
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
DbService.GetState().StateChanged += OnDatabaseStateChanged;
UpdateLoadingIndicatorMessage();
}
private async void OnDatabaseStateChanged(object? sender, DbServiceState.DatabaseState newState)
{
await InvokeAsync(StateHasChanged);
UpdateLoadingIndicatorMessage();
if (newState.Status == DbServiceState.DatabaseStatus.SavingToServer)
{
// Show loading indicator for at least 0.5 seconds even if the save operation is faster.
Message = "Saving...";
await ShowLoadingIndicatorAsync();
}
}
LoadingIndicatorMessage = Message + " - " + newState.LastUpdated;
private void UpdateLoadingIndicatorMessage()
{
var currentState = DbService.GetState().CurrentState;
var message = currentState.Status.ToString();
if (currentState.Message != string.Empty)
{
message = currentState.Message;
}
LoadingIndicatorMessage = "Vault status: " + message + " - " + currentState.LastUpdated;
StateHasChanged();
}
private async Task ShowLoadingIndicatorAsync()

View File

@@ -1,83 +0,0 @@
.navbar-toggler {
background-color: rgba(255, 255, 255, 0.1);
}
.top-row {
height: 3.5rem;
background-color: rgba(0,0,0,0.4);
}
.navbar-brand {
font-size: 1.1rem;
}
.bi {
display: inline-block;
position: relative;
width: 1.25rem;
height: 1.25rem;
margin-right: 0.75rem;
top: -1px;
background-size: cover;
}
.bi-house-door-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
}
.bi-plus-square-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E");
}
.bi-list-nested-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
}
.nav-item {
font-size: 0.9rem;
padding-bottom: 0.5rem;
}
.nav-item:first-of-type {
padding-top: 1rem;
}
.nav-item:last-of-type {
padding-bottom: 1rem;
}
.nav-item ::deep a {
color: #d7d7d7;
border-radius: 4px;
height: 3rem;
display: flex;
align-items: center;
line-height: 3rem;
}
.nav-item ::deep a.active {
background-color: rgba(255,255,255,0.37);
color: white;
}
.nav-item ::deep a:hover {
background-color: rgba(255,255,255,0.1);
color: white;
}
@media (min-width: 641px) {
.navbar-toggler {
display: none;
}
.collapse {
/* Never collapse the sidebar for wide screens */
display: block;
}
.nav-scrollable {
/* Allow sidebar to scroll for tall menus */
height: calc(100vh - 3.5rem);
overflow-y: auto;
}
}

View File

@@ -1,10 +1,57 @@
<footer class="md:flex md:items-center md:justify-between px-4 2xl:px-0 py-6 md:py-10">
@inject NavigationManager NavigationManager
@implements IDisposable
<footer class="md:flex md:items-center md:justify-between px-4 2xl:px-0 py-6 md:py-10">
<p class="text-sm text-center text-gray-500 mb-4 md:mb-0">
© 2024 AliasVault. All rights reserved.
</p>
<ul class="flex flex-wrap items-center justify-center">
<li><a href="#" class="mr-4 text-sm font-normal text-gray-500 hover:underline md:mr-6 dark:text-gray-400">Terms and conditions</a></li>
<li><a href="#" class="mr-4 text-sm font-normal text-gray-500 hover:underline md:mr-6 dark:text-gray-400">License</a></li>
<li><a href="https://github.com/lanedirt/AliasVault" target="_blank" class="text-sm font-normal text-gray-500 hover:underline dark:text-gray-400">GitHub</a></li>
<li>
<a href="#" class="mr-4 text-sm font-normal text-gray-500 hover:underline md:mr-6 dark:text-gray-400">Terms and conditions</a>
</li>
<li>
<a href="#" class="mr-4 text-sm font-normal text-gray-500 hover:underline md:mr-6 dark:text-gray-400">License</a>
</li>
<li>
<a href="https://github.com/lanedirt/AliasVault" target="_blank" class="text-sm font-normal text-gray-500 hover:underline dark:text-gray-400">GitHub</a>
</li>
</ul>
</footer>
<div class="text-center text-gray-400 text-sm pt-4 pb-2">@RandomQuote</div>
@code {
private static readonly string[] Quotes =
[
"Tip: Use the g+c (go create) keyboard shortcut to quickly create a new identity.",
"Tip: Use the g+f (go find) keyboard shortcut to focus the search field.",
"Tip: Use the g+h (go home) keyboard shortcut to go to the homepage.",
];
private string RandomQuote = string.Empty;
/// <inheritdoc />
public void Dispose()
{
NavigationManager.LocationChanged -= RefreshQuote;
}
/// <inheritdoc />
protected override void OnAfterRender(bool firstRender)
{
if (firstRender)
{
RandomQuote = Quotes[Random.Shared.Next(Quotes.Length)];
NavigationManager.LocationChanged += RefreshQuote;
}
}
/// <summary>
/// Shows a new random quote.
/// </summary>
private void RefreshQuote(object? sender, LocationChangedEventArgs e)
{
RandomQuote = Quotes[Random.Shared.Next(Quotes.Length)];
StateHasChanged();
}
}

View File

@@ -1,83 +0,0 @@
.navbar-toggler {
background-color: rgba(255, 255, 255, 0.1);
}
.top-row {
height: 3.5rem;
background-color: rgba(0,0,0,0.4);
}
.navbar-brand {
font-size: 1.1rem;
}
.bi {
display: inline-block;
position: relative;
width: 1.25rem;
height: 1.25rem;
margin-right: 0.75rem;
top: -1px;
background-size: cover;
}
.bi-house-door-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
}
.bi-plus-square-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E");
}
.bi-list-nested-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
}
.nav-item {
font-size: 0.9rem;
padding-bottom: 0.5rem;
}
.nav-item:first-of-type {
padding-top: 1rem;
}
.nav-item:last-of-type {
padding-bottom: 1rem;
}
.nav-item ::deep a {
color: #d7d7d7;
border-radius: 4px;
height: 3rem;
display: flex;
align-items: center;
line-height: 3rem;
}
.nav-item ::deep a.active {
background-color: rgba(255,255,255,0.37);
color: white;
}
.nav-item ::deep a:hover {
background-color: rgba(255,255,255,0.1);
color: white;
}
@media (min-width: 641px) {
.navbar-toggler {
display: none;
}
.collapse {
/* Never collapse the sidebar for wide screens */
display: block;
}
.nav-scrollable {
/* Allow sidebar to scroll for tall menus */
height: calc(100vh - 3.5rem);
overflow-y: auto;
}
}

View File

@@ -10,6 +10,7 @@
<div class="flex pt-16 overflow-hidden bg-gray-50 dark:bg-gray-900">
<div id="main-content" class="relative w-full max-w-screen-2xl mx-auto h-full overflow-y-auto bg-gray-50 dark:bg-gray-900">
<main>
<GlobalNotificationDisplay />
@Body
</main>
<Footer />

View File

@@ -5,7 +5,7 @@
<nav class="fixed z-30 w-full bg-white border-b border-gray-200 dark:bg-gray-800 dark:border-gray-700 py-3 px-4">
<div class="flex justify-between items-center max-w-screen-2xl mx-auto">
<div class="flex justify-start items-center">
<a href="/" class="flex mr-14">
<a href="/" class="flex md:mr-10">
<img src="/icon-trimmed.png" class="mr-3 h-8" alt="AliasVault Logo">
<span class="self-center hidden sm:flex text-2xl font-semibold whitespace-nowrap dark:text-white">AliasVault</span>
</a>
@@ -15,47 +15,55 @@
<NavLink href="/credentials" class="block text-gray-700 hover:text-primary-700 dark:text-gray-400 dark:hover:text-white" ActiveClass="text-primary-700 dark:text-primary-500" Match="NavLinkMatch.All">
Credentials
</NavLink>
<NavLink href="/emails" class="block text-gray-700 hover:text-primary-700 dark:text-gray-400 dark:hover:text-white" ActiveClass="text-primary-700 dark:text-primary-500" Match="NavLinkMatch.All">
Emails
</NavLink>
</ul>
</div>
</div>
<!-- New search box -->
<div class="flex-grow mx-4">
<SearchWidget />
</div>
<div class="flex justify-end items-center lg:order-2">
<CreateNewIdentityWidget />
<DbStatusIndicator />
<button id="theme-toggle" data-tooltip-target="tooltip-toggle" type="button" class="text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 rounded-lg text-sm p-2.5">
<svg id="theme-toggle-dark-icon" class="hidden w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"></path></svg>
<svg id="theme-toggle-light-icon" class="hidden w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" fill-rule="evenodd" clip-rule="evenodd"></path></svg>
</button>
<div id="tooltip-toggle" role="tooltip" class="inline-block absolute invisible z-10 py-2 px-3 text-sm font-medium text-white bg-gray-900 rounded-lg shadow-sm opacity-0 transition-opacity duration-300 tooltip" data-popper-placement="bottom" style="position: absolute; inset: 0px auto auto 0px; margin: 0px; transform: translate3d(1377px, 60px, 0px);">
Toggle dark mode
<div class="tooltip-arrow" data-popper-arrow="" style="position: absolute; left: 0px; transform: translate3d(68.5px, 0px, 0px);"></div>
</div>
<button @onclick="ToggleMenu" type="button" class="flex mx-3 text-sm bg-gray-800 rounded-full md:mr-0 flex-shrink-0 focus:ring-4 focus:ring-gray-300 dark:focus:ring-gray-600" id="userMenuDropdownButton" aria-expanded="false" data-dropdown-toggle="userMenuDropdown">
<button @onclick="ToggleMenu" type="button" class="flex ms-3 text-sm bg-gray-800 rounded-full md:mr-0 flex-shrink-0 focus:ring-4 focus:ring-gray-300 dark:focus:ring-gray-600" id="userMenuDropdownButton" aria-expanded="false" data-dropdown-toggle="userMenuDropdown">
<span class="sr-only">Open user menu</span>
<img class="w-8 h-8 rounded-full" src="/img/avatar.webp" alt="user photo">
</button>
@if (IsMenuOpen)
{
<div class="absolute top-10 right-0 z-50 my-4 w-56 text-base list-none bg-white rounded divide-y divide-gray-100 shadow dark:bg-gray-700 dark:divide-gray-600" id="userMenuDropdown" data-popper-placement="bottom">
<div class="py-3 px-4">
<span class="block text-sm font-semibold text-gray-900 dark:text-white">@Username</span>
</div>
<ul class="py-1 font-light text-gray-500 dark:text-gray-400" aria-labelledby="userMenuDropdownButton">
<li>
<a href="/settings/general" class="block py-2 px-4 text-sm hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-400 dark:hover:text-white">General settings</a>
</li>
<li>
<a href="/settings/vault" class="block py-2 px-4 text-sm hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-400 dark:hover:text-white">Vault settings</a>
</li>
</ul>
<ul class="py-1 font-light text-gray-500 dark:text-gray-400" aria-labelledby="dropdown">
<li>
<a href="/user/logout" class="block py-2 px-4 font-bold text-sm text-primary-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-primary-200 dark:hover:text-white">Sign out</a>
</li>
</ul>
<div class="absolute top-10 right-0 z-50 my-4 w-56 text-base list-none bg-white rounded divide-y divide-gray-100 shadow dark:bg-gray-700 dark:divide-gray-600 @(IsMenuOpen ? "block" : "hidden")" id="userMenuDropdown" data-popper-placement="bottom">
<div class="py-3 px-4">
<span class="block text-sm font-semibold text-gray-900 dark:text-white">@Username</span>
</div>
}
<ul class="py-1 font-light text-gray-500 dark:text-gray-400" aria-labelledby="userMenuDropdownButton">
<li>
<a href="/settings/general" class="block py-2 px-4 text-sm hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-400 dark:hover:text-white">General settings</a>
</li>
<li>
<a href="/settings/vault" class="block py-2 px-4 text-sm hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-400 dark:hover:text-white">Vault settings</a>
</li>
</ul>
<ul class="py-1 font-light text-gray-500 dark:text-gray-400" aria-labelledby="dropdown">
<li>
<button id="theme-toggle" data-tooltip-target="tooltip-toggle" type="button" class="w-full text-start py-2 px-4 text-sm hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-400 dark:hover:text-white">
Toggle dark mode
<svg id="theme-toggle-dark-icon" class="hidden w-5 h-5 align-middle inline-block" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"></path></svg>
<svg id="theme-toggle-light-icon" class="hidden w-5 h-5 align-middle inline-block" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" fill-rule="evenodd" clip-rule="evenodd"></path></svg>
</button>
</li>
</ul>
<ul class="py-1 font-light text-gray-500 dark:text-gray-400" aria-labelledby="dropdown">
<li>
<a href="/user/logout" class="block py-2 px-4 font-bold text-sm text-primary-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-primary-200 dark:hover:text-white">Sign out</a>
</li>
</ul>
</div>
<button @onclick="ToggleMobileMenu" type="button" id="toggleMobileMenuButton" class="items-center p-2 text-gray-500 rounded-lg md:ml-2 lg:hidden hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-700 focus:ring-4 focus:ring-gray-300 dark:focus:ring-gray-600">
<span class="sr-only">Open menu</span>

View File

@@ -1,83 +0,0 @@
.navbar-toggler {
background-color: rgba(255, 255, 255, 0.1);
}
.top-row {
height: 3.5rem;
background-color: rgba(0,0,0,0.4);
}
.navbar-brand {
font-size: 1.1rem;
}
.bi {
display: inline-block;
position: relative;
width: 1.25rem;
height: 1.25rem;
margin-right: 0.75rem;
top: -1px;
background-size: cover;
}
.bi-house-door-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
}
.bi-plus-square-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E");
}
.bi-list-nested-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
}
.nav-item {
font-size: 0.9rem;
padding-bottom: 0.5rem;
}
.nav-item:first-of-type {
padding-top: 1rem;
}
.nav-item:last-of-type {
padding-bottom: 1rem;
}
.nav-item ::deep a {
color: #d7d7d7;
border-radius: 4px;
height: 3rem;
display: flex;
align-items: center;
line-height: 3rem;
}
.nav-item ::deep a.active {
background-color: rgba(255,255,255,0.37);
color: white;
}
.nav-item ::deep a:hover {
background-color: rgba(255,255,255,0.1);
color: white;
}
@media (min-width: 641px) {
.navbar-toggler {
display: none;
}
.collapse {
/* Never collapse the sidebar for wide screens */
display: block;
}
.nav-scrollable {
/* Allow sidebar to scroll for tall menus */
height: calc(100vh - 3.5rem);
overflow-y: auto;
}
}

View File

@@ -10,7 +10,7 @@ namespace AliasVault.Client.Main.Models;
/// <summary>
/// Represents a breadcrumb item for the breadcrumb component.
/// </summary>
public class BreadcrumbItem
public sealed class BreadcrumbItem
{
/// <summary>
/// Gets or sets the display name for the breadcrumb item.

View File

@@ -9,6 +9,7 @@ namespace AliasVault.Client.Main.Models;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using AliasClientDb;
using AliasVault.Client.Main.Models.FormValidation;
@@ -16,7 +17,7 @@ using AliasVault.Client.Main.Models.FormValidation;
/// <summary>
/// Credential edit model.
/// </summary>
public class CredentialEdit
public sealed class CredentialEdit
{
/// <summary>
/// Gets or sets the Id of the login.
@@ -37,6 +38,7 @@ public class CredentialEdit
/// Gets or sets the name of the service.
/// </summary>
[Required]
[Display(Name = "Service Name")]
public string ServiceName { get; set; } = null!;
/// <summary>

View File

@@ -10,7 +10,7 @@ namespace AliasVault.Client.Main.Models;
/// <summary>
/// Alias list entry model. This model is used to represent an alias in a list with simplified properties.
/// </summary>
public class CredentialListEntry
public sealed class CredentialListEntry
{
/// <summary>
/// Gets or sets the alias id.

View File

@@ -17,7 +17,7 @@ using System.Globalization;
/// Initializes a new instance of the <see cref="StringDateFormatAttribute"/> class.
/// </remarks>
/// <param name="format">The date format to validate.</param>
public class StringDateFormatAttribute(string format) : ValidationAttribute
public sealed class StringDateFormatAttribute(string format) : ValidationAttribute
{
/// <summary>
/// Check if the date string is in the correct format.

View File

@@ -1,10 +1,15 @@
@page "/add-credentials"
@page "/credentials/create"
@page "/credentials/{id:guid}/edit"
@inherits MainBase
@inject CredentialService CredentialService
@using System.Globalization
@using AliasGenerators.Password
@using AliasGenerators.Password.Implementations
@using System.Text.Json
@using System.Text.Json.Serialization
@using AliasGenerators.Identity
@using AliasGenerators.Identity.Implementations
@using AliasGenerators.Identity.Models
@inject IJSRuntime JSRuntime
@implements IAsyncDisposable
@if (EditMode)
{
@@ -25,6 +30,10 @@ else {
else {
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">Add credentials</h1>
}
<div>
<button type="button" @onclick="TriggerFormSubmit" class="px-4 py-2 text-white bg-green-600 rounded-lg hover:bg-green-700 focus:ring-4 focus:ring-green-300 dark:bg-green-500 dark:hover:bg-green-600 dark:focus:ring-green-800">Save Credentials</button>
<button type="button" @onclick="Cancel" class="px-4 py-2 text-white bg-red-600 rounded-lg hover:bg-red-700 focus:ring-4 focus:ring-red-300 dark:bg-red-500 dark:hover:bg-red-600 dark:focus:ring-red-800">Cancel</button>
</div>
</div>
@if (EditMode)
{
@@ -42,11 +51,11 @@ else {
}
else
{
<EditForm Model="Obj" OnValidSubmit="SaveAlias">
<EditForm @ref="EditFormRef" Model="Obj" OnValidSubmit="SaveAlias">
<DataAnnotationsValidator />
<div class="grid grid-cols-3 px-4 pt-6 lg:gap-4 dark:bg-gray-900">
<div class="col-1">
<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="p-4 mb-4 bg-white border-2 border-primary-600 rounded-lg shadow-sm 2xl:col-span-2 dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<h3 class="mb-4 text-xl font-semibold dark:text-white">Service</h3>
<div class="grid gap-6">
<div class="col-span-6 sm:col-span-3">
@@ -54,7 +63,7 @@ else
<ValidationMessage For="() => Obj.ServiceName"/>
</div>
<div class="col-span-6 sm:col-span-3">
<EditFormRow Id="service-url" Label="Service URL" @bind-Value="Obj.ServiceUrl"></EditFormRow>
<EditFormRow Id="service-url" OnFocus="OnFocusUrlInput" Label="Service URL" @bind-Value="Obj.ServiceUrl"></EditFormRow>
</div>
</div>
</div>
@@ -64,7 +73,7 @@ else
<h3 class="mb-4 text-xl font-semibold dark:text-white">Notes</h3>
<div class="grid gap-6">
<div class="col-span-6 sm:col-span-3">
<EditFormRow Type="textarea" Id="first-name" Label="Notes" @bind-Value="Obj.Notes"></EditFormRow>
<EditFormRow Type="textarea" Id="notes" Label="Notes" @bind-Value="Obj.Notes"></EditFormRow>
</div>
</div>
</div>
@@ -87,20 +96,22 @@ else
<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">
<h3 class="mb-4 text-xl font-semibold dark:text-white">Login credentials</h3>
<div class="mb-4">
<button type="button" class="px-4 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 focus:ring-4 focus:ring-blue-300 dark:bg-blue-500 dark:hover:bg-blue-600 dark:focus:ring-blue-800" @onclick="GenerateRandomIdentity">Generate Random Identity</button>
<button type="submit" class="px-4 py-2 text-white bg-green-600 rounded-lg hover:bg-green-700 focus:ring-4 focus:ring-green-300 dark:bg-green-500 dark:hover:bg-green-600 dark:focus:ring-green-800">Save Credentials</button>
<button type="button" @onclick="GenerateRandomAlias" class="px-4 py-2 text-white bg-primary-600 rounded-lg hover:bg-primary-700 focus:ring-4 focus:ring-primary-300 dark:bg-primary-500 dark:hover:bg-primary-600 dark:focus:ring-primary-800">Generate Random Alias</button>
</div>
<div class="grid gap-6">
<div class="col-span-6 sm:col-span-3">
<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>
<div class="relative">
<EditFormRow Id="username" Label="Username" @bind-Value="Obj.Username"></EditFormRow>
<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="GenerateRandomUsername">New Username</button>
</div>
</div>
<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="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>
<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">New Password</button>
</div>
</div>
</div>
@@ -108,7 +119,7 @@ else
<div class="col">
<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">
<h3 class="mb-4 text-xl font-semibold dark:text-white">Identity</h3>
<h3 class="mb-4 text-xl font-semibold dark:text-white">Alias</h3>
<div class="grid gap-6">
<div class="col-span-6 sm:col-span-3">
<EditFormRow Id="first-name" Label="First Name" @bind-Value="Obj.Alias.FirstName"></EditFormRow>
@@ -126,41 +137,12 @@ else
<EditFormRow Id="birthdate" Label="Birth Date" @bind-Value="Obj.AliasBirthDate"></EditFormRow>
<ValidationMessage For="() => Obj.AliasBirthDate"/>
</div>
<div class="col-span-6 sm:col-span-3">
<EditFormRow Id="street" Label="Address Street" @bind-Value="Obj.Alias.AddressStreet"></EditFormRow>
</div>
<div class="col-span-6 sm:col-span-3">
<EditFormRow Id="city" Label="Address City" @bind-Value="Obj.Alias.AddressCity"></EditFormRow>
</div>
<div class="col-span-6 sm:col-span-3">
<EditFormRow Id="state" Label="Address State" @bind-Value="Obj.Alias.AddressState"></EditFormRow>
</div>
<div class="col-span-6 sm:col-span-3">
<EditFormRow Id="zipcode" Label="Address Zip Code" @bind-Value="Obj.Alias.AddressZipCode"></EditFormRow>
</div>
<div class="col-span-6 sm:col-span-3">
<EditFormRow Id="country" Label="Address Country" @bind-Value="Obj.Alias.AddressCountry"></EditFormRow>
</div>
<div class="col-span-6 sm:col-span-3">
<EditFormRow Id="hobbies" Label="Hobbies" @bind-Value="Obj.Alias.Hobbies"></EditFormRow>
</div>
<div class="col-span-6 sm:col-span-3">
<EditFormRow Id="phone-mobile" Label="Phone Mobile" @bind-Value="Obj.Alias.PhoneMobile"></EditFormRow>
</div>
<div class="col-span-6 sm:col-span-3">
<EditFormRow Id="iban" Label="Bank Account IBAN" @bind-Value="Obj.Alias.BankAccountIBAN"></EditFormRow>
</div>
</div>
</div>
<button type="submit" class="px-4 py-2 text-white bg-green-600 rounded-lg hover:bg-green-700 focus:ring-4 focus:ring-green-300 dark:bg-green-500 dark:hover:bg-green-600 dark:focus:ring-green-800">Save Credentials</button>
</div>
</div>
</div>
<div class="grid px-4 pt-6 lg:gap-4 dark:bg-gray-900">
</div>
<button type="submit" class="hidden">Save Credentials</button>
</EditForm>
}
@@ -172,8 +154,20 @@ else
public Guid? Id { get; set; }
private bool EditMode { get; set; }
private EditForm EditFormRef { get; set; } = null!;
private bool Loading { get; set; } = true;
private CredentialEdit Obj { get; set; } = new();
private IJSObjectReference? Module;
/// <inheritdoc />
async ValueTask IAsyncDisposable.DisposeAsync()
{
await KeyboardShortcutService.UnregisterShortcutAsync("gc");
if (Module is not null)
{
await Module.DisposeAsync();
}
}
/// <inheritdoc />
protected override void OnInitialized()
@@ -197,7 +191,7 @@ else
if (EditMode)
{
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = "View credential", Url = $"/credentials/{Id}" });
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = "View credentials entry", Url = $"/credentials/{Id}" });
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = "Edit credential" });
}
else
@@ -213,6 +207,7 @@ else
if (firstRender)
{
Module = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/modules/newIdentityWidget.js");
if (EditMode)
{
if (Id is null)
@@ -234,72 +229,104 @@ else
}
Obj = CredentialToCredentialEdit(alias);
if (Obj.ServiceUrl is null)
{
Obj.ServiceUrl = CredentialService.DefaultServiceUrl;
}
}
else
{
// Create new Obj
var alias = new Credential();
alias.Alias = new Alias();
alias.Alias.Email = "@" + GetDefaultEmailDomain();
alias.Alias.Email = "@" + CredentialService.GetDefaultEmailDomain();
alias.Service = new Service();
alias.Passwords = new List<Password> { new Password() };
Obj = CredentialToCredentialEdit(alias);
Obj.ServiceUrl = CredentialService.DefaultServiceUrl;
}
// Hide loading spinner
Loading = false;
// Force re-render invoke so the charts can be rendered
StateHasChanged();
if (!EditMode)
{
// When creating a new identity: start with focus on the service name input.
await JsInteropService.FocusElementById("service-name");
}
}
}
/// <summary>
/// When the URL input is focused, place cursor at the end of the default URL to allow for easy typing.
/// </summary>
private void OnFocusUrlInput(FocusEventArgs e)
{
if (Obj.ServiceUrl != CredentialService.DefaultServiceUrl)
{
return;
}
// Use a small delay to ensure the focus is set after the browser's default behavior.
Task.Delay(1).ContinueWith(_ =>
{
JSRuntime.InvokeVoidAsync("eval", $"document.getElementById('service-url').setSelectionRange({CredentialService.DefaultServiceUrl.Length}, {CredentialService.DefaultServiceUrl.Length})");
});
}
private void HandleAttachmentsChanged(List<Attachment> updatedAttachments)
{
Obj.Attachments = updatedAttachments;
StateHasChanged();
}
private async Task GenerateRandomIdentity()
private async Task GenerateRandomAlias()
{
GlobalLoadingSpinner.Show();
StateHasChanged();
// Generate a random identity using the IIdentityGenerator implementation.
var identity = await CredentialService.GenerateRandomIdentityAsync();
// Generate random values for the Identity properties
Obj.Username = identity.NickName;
Obj.Alias.FirstName = identity.FirstName;
Obj.Alias.LastName = identity.LastName;
Obj.Alias.NickName = identity.NickName;
Obj.Alias.Gender = identity.Gender == 1 ? "Male" : "Female";
Obj.AliasBirthDate = identity.BirthDate.ToString("yyyy-MM-dd");
Obj.Alias.AddressStreet = identity.Address.Street;
Obj.Alias.AddressCity = identity.Address.City;
Obj.Alias.AddressState = identity.Address.State;
Obj.Alias.AddressZipCode = identity.Address.ZipCode;
Obj.Alias.AddressCountry = identity.Address.Country;
Obj.Alias.Hobbies = identity.Hobbies[0];
// Set the email
var emailDomain = GetDefaultEmailDomain();
Obj.Alias.Email = $"{identity.EmailPrefix}@{emailDomain}";
Obj.Alias.PhoneMobile = identity.PhoneMobile;
Obj.Alias.BankAccountIBAN = identity.BankAccountIBAN;
// Generate password
GenerateRandomPassword();
Obj = CredentialToCredentialEdit(await CredentialService.GenerateRandomIdentity(CredentialEditToCredential(Obj)));
GlobalLoadingSpinner.Hide();
StateHasChanged();
}
/// <summary>
/// Generate a new random username based on existing identity, or if no identity is present,
/// generate a new random identity.
/// </summary>
private async Task GenerateRandomUsername()
{
// If current object is null, then we create a new random identity.
Identity identity;
if (Obj.Alias.FirstName is null && Obj.Alias.LastName is null && Obj.Alias.BirthDate == DateTime.MinValue)
{
identity = await IdentityGeneratorFactory.CreateIdentityGenerator(DbService.Settings.DefaultIdentityLanguage).GenerateRandomIdentityAsync();
}
else
{
// Assemble identity model with the current values
identity = new Identity
{
FirstName = Obj.Alias.FirstName ?? string.Empty,
LastName = Obj.Alias.LastName ?? string.Empty,
BirthDate = Obj.Alias.BirthDate,
Gender = Obj.Alias.Gender == Gender.Female.ToString() ? Gender.Female : Gender.Male,
NickName = Obj.Alias.NickName ?? string.Empty,
};
}
var generator = new UsernameEmailGenerator();
Obj.Username = generator.GenerateUsername(identity);
}
/// <summary>
/// Generate a new random password.
/// </summary>
private void GenerateRandomPassword()
{
// Generate a random password using a IPasswordGenerator implementation.
IPasswordGenerator passwordGenerator = new SpamOkPasswordGenerator();
Obj.Password.Value = passwordGenerator.GenerateRandomPassword();
Obj.Password.Value = CredentialService.GenerateRandomPassword();
}
private async Task SaveAlias()
@@ -307,8 +334,6 @@ else
GlobalLoadingSpinner.Show();
StateHasChanged();
// Sanity check for unittest. Delete later if not needed.
// Try to parse birthdate as datetime. if it fails, set it to empty.
if (EditMode)
{
if (Id is not null)
@@ -344,30 +369,49 @@ else
NavigationManager.NavigateTo("/credentials/" + Id);
}
/// <summary>
/// Helper method to convert a Credential object to a CredentialEdit object.
/// </summary>
private CredentialEdit CredentialToCredentialEdit(Credential alias)
{
// Create a deep copy of the alias object to prevent changes to the original object
// when editing the alias in the form. We only want to save the changes when the user
// clicks the save button.
var options = new JsonSerializerOptions
{
ReferenceHandler = ReferenceHandler.Preserve,
MaxDepth = 128 // Adjust this value as needed
};
// Create a deep copy of the credential object
var aliasJson = JsonSerializer.Serialize(alias, options);
var aliasCopy = JsonSerializer.Deserialize<Credential>(aliasJson, options)!;
return new CredentialEdit
{
Id = alias.Id,
Notes = alias.Notes ?? string.Empty,
Username = alias.Username,
ServiceName = alias.Service.Name ?? string.Empty,
ServiceUrl = alias.Service.Url,
ServiceLogo = alias.Service.Logo,
Password = alias.Passwords.FirstOrDefault() ?? new Password
Id = aliasCopy.Id,
Notes = aliasCopy.Notes ?? string.Empty,
Username = aliasCopy.Username ?? string.Empty,
ServiceName = aliasCopy.Service.Name ?? string.Empty,
ServiceUrl = aliasCopy.Service.Url,
ServiceLogo = aliasCopy.Service.Logo,
Password = aliasCopy.Passwords.FirstOrDefault() ?? new Password
{
Value = string.Empty,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
},
Alias = alias.Alias,
AliasBirthDate = alias.Alias.BirthDate.ToString("yyyy-MM-dd"),
Attachments = alias.Attachments.ToList(),
CreateDate = alias.CreatedAt,
LastUpdate = alias.UpdatedAt
Alias = aliasCopy.Alias,
AliasBirthDate = aliasCopy.Alias.BirthDate.ToString("yyyy-MM-dd"),
Attachments = aliasCopy.Attachments.ToList(),
CreateDate = aliasCopy.CreatedAt,
LastUpdate = aliasCopy.UpdatedAt
};
}
/// <summary>
/// Helper method to convert a CredentialEdit object to a Credential object.
/// </summary>
private Credential CredentialEditToCredential(CredentialEdit alias)
{
var credential = new Credential()
@@ -402,27 +446,23 @@ else
}
/// <summary>
/// Gets the default email domain based on settings and available domains.
/// Cancel the edit operation and navigate back to the credentials view.
/// </summary>
private string GetDefaultEmailDomain()
private void Cancel()
{
var defaultDomain = DbService.Settings.DefaultEmailDomain;
NavigationManager.NavigateTo("/credentials/" + Id);
}
// Function to check if a domain is valid
bool IsValidDomain(string domain) =>
!string.IsNullOrEmpty(domain) &&
domain != "DISABLED.TLD" &&
(Config.PublicEmailDomains.Contains(domain) || Config.PrivateEmailDomains.Contains(domain));
/// <summary>
/// Trigger the form submit.
/// </summary>
private async Task TriggerFormSubmit()
{
if (EditFormRef.EditContext?.Validate() == false)
{
return;
}
// Get the first valid domain from private or public domains
string GetFirstValidDomain() =>
Config.PrivateEmailDomains.Find(IsValidDomain) ??
Config.PublicEmailDomains.FirstOrDefault() ??
"example.com";
// Use the default domain if it's valid, otherwise get the first valid domain
string domainToUse = IsValidDomain(defaultDomain) ? defaultDomain : GetFirstValidDomain();
return domainToUse;
await SaveAlias();
}
}

View File

@@ -9,9 +9,7 @@
<Breadcrumb BreadcrumbItems="BreadcrumbItems" />
<div class="flex items-center justify-between">
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">Credentials</h1>
<a href="/add-credentials" class="px-4 py-2 text-white bg-primary-600 rounded-lg hover:bg-primary-700 focus:ring-4 focus:ring-primary-300 dark:bg-primary-500 dark:hover:bg-primary-600 dark:focus:ring-primary-800">
+ Add new credentials
</a>
<RefreshButton OnRefresh="LoadCredentialsAsync" ButtonText="Refresh" />
</div>
<p>Find all of your credentials below.</p>
</div>
@@ -22,38 +20,17 @@
<LoadingIndicator />
}
@if (Credentials.Count == 0)
{
<div class="p-4 mx-4 mt-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
<div class="px-4 py-2 text-gray-400 rounded">
<p class="text-gray-500 dark:text-gray-400">You have no credentials yet. Create your first one now by clicking on the + button in the top right corner.</p>
</div>
</div>
}
else
{
<div class="grid gap-4 px-4 mb-4 md:grid-cols-4 xl:grid-cols-6">
@foreach (var credential in Credentials)
{
<CredentialCard Obj="@credential"/>
}
</div>
}
<div class="grid gap-4 px-4 mb-4 md:grid-cols-4 xl:grid-cols-6">
@foreach (var credential in Credentials)
{
<CredentialCard Obj="@credential"/>
}
</div>
@code {
private bool IsLoading { get; set; } = true;
private List<CredentialListEntry> Credentials { get; set; } = new();
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
// Redirect to /credentials.
NavigationManager.NavigateTo("/credentials");
}
/// <inheritdoc />
protected override async Task OnAfterRenderAsync(bool firstRender)
{
@@ -78,6 +55,11 @@ else
GlobalNotificationService.AddErrorMessage("Failed to load credentials.", true);
return;
}
if (credentialListEntries.Count == 0)
{
// Redirect to welcome page.
NavigationManager.NavigateTo("/welcome");
}
Credentials = credentialListEntries;
IsLoading = false;

View File

@@ -74,11 +74,11 @@ else
</form>
</div>
<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">
<h3 class="mb-4 text-xl font-semibold dark:text-white">Name</h3>
<h3 class="mb-4 text-xl font-semibold dark:text-white">Alias</h3>
<form action="#">
<div class="grid grid-cols-6 gap-6">
<div class="col-span-6 sm:col-span-3">
<CopyPasteFormRow Label="Initials" Value="@(Alias.Alias.FirstName?.Substring(0,1))"></CopyPasteFormRow>
<div class="col-span-6">
<CopyPasteFormRow Label="Full name" Value="@(Alias.Alias.FirstName + " " + Alias.Alias.LastName)"></CopyPasteFormRow>
</div>
<div class="col-span-6 sm:col-span-3">
<CopyPasteFormRow Label="First name" Value="@(Alias.Alias.FirstName)"></CopyPasteFormRow>
@@ -95,40 +95,6 @@ else
</div>
</form>
</div>
<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">
<h3 class="mb-4 text-xl font-semibold dark:text-white">Contact</h3>
<form action="#">
<div class="grid grid-cols-6 gap-6">
<div class="col-span-6 sm:col-span-3">
<CopyPasteFormRow Label="Phone" Value="@(Alias.Alias.PhoneMobile)"></CopyPasteFormRow>
</div>
<div class="col-span-6 sm:col-span-3">
<CopyPasteFormRow Label="IBAN" Value="@(Alias.Alias.BankAccountIBAN)"></CopyPasteFormRow>
</div>
</div>
</form>
</div>
<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">
<h3 class="mb-4 text-xl font-semibold dark:text-white">Address</h3>
<form action="#">
<div class="grid grid-cols-6 gap-6">
<div class="col-span-6 sm:col-span-3">
<CopyPasteFormRow Label="Street" Value="@(Alias.Alias.AddressStreet)"></CopyPasteFormRow>
</div>
<div class="col-span-6 sm:col-span-3">
<CopyPasteFormRow Label="Postal code" Value="@(Alias.Alias.AddressZipCode)"></CopyPasteFormRow>
</div>
<div class="col-span-6 sm:col-span-3">
<CopyPasteFormRow Label="City" Value="@(Alias.Alias.AddressCity)"></CopyPasteFormRow>
</div>
<div class="col-span-6 sm:col-span-3">
<CopyPasteFormRow Label="Country" Value="@(Alias.Alias.AddressCountry)"></CopyPasteFormRow>
</div>
</div>
</form>
</div>
</div>
</div>
}
@@ -150,16 +116,15 @@ else
}
/// <inheritdoc />
protected override async Task OnAfterRenderAsync(bool firstRender)
protected override async Task OnParametersSetAsync()
{
await base.OnAfterRenderAsync(firstRender);
if (firstRender)
{
await LoadEntryAsync();
}
await base.OnParametersSetAsync();
await LoadEntryAsync();
}
/// <summary>
/// Loads the credentials entry.
/// </summary>
private async Task LoadEntryAsync()
{
IsLoading = true;

View File

@@ -0,0 +1,282 @@
@page "/emails"
@using System.Net
@using System.Text
@using System.Text.Json
@using AliasVault.Client.Main.Pages.Emails.Models
@using AliasVault.Shared.Models.Spamok
@using AliasVault.Shared.Models.WebApi
@using AliasVault.Shared.Models.WebApi.Email
@inherits MainBase
@inject HttpClient HttpClient
<LayoutPageTitle>Emails</LayoutPageTitle>
@if (EmailModalVisible)
{
<EmailModal Email="EmailModalEmail" IsSpamOk="false" OnClose="CloseEmailModal" />
}
<div class="grid grid-cols-1 px-4 pt-6 xl:grid-cols-3 xl:gap-4 dark:bg-gray-900">
<div class="mb-4 col-span-full xl:mb-2">
<Breadcrumb BreadcrumbItems="BreadcrumbItems"/>
<div class="flex items-center justify-between">
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">Emails</h1>
<RefreshButton OnRefresh="RefreshData" ButtonText="Refresh" />
</div>
<p>Below you can find all recent emails sent to one of the email addresses used in your credentials.</p>
</div>
</div>
@if (IsLoading)
{
<LoadingIndicator/>
}
else if (NoEmailClaims)
{
<div class="p-4 mx-4 mt-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
<div class="px-4 py-2 text-gray-400 rounded">
<p class="text-gray-500 dark:text-gray-400">You are not using any private email addresses (yet). Create a new identity and use a private email address supported by AliasVault. All emails received by these private email addresses will show up here.</p>
</div>
</div>
}
else
{
<div class="overflow-x-auto px-4">
<Paginator CurrentPage="CurrentPage" PageSize="PageSize" TotalRecords="TotalRecords" OnPageChanged="HandlePageChanged"/>
<div class="bg-white shadow rounded-lg overflow-hidden mt-6">
<div class="px-4 py-2 bg-gray-100 border-b">
<h2 class="font-semibold text-gray-800">Inbox</h2>
</div>
<ul class="divide-y divide-gray-200">
@if (EmailList.Count == 0)
{
<li class="p-4 text-center text-gray-500">
No emails have been received yet.
</li>
}
else
{
@foreach (var email in EmailList)
{
<li class="hover:bg-gray-50 transition duration-150 ease-in-out">
<div @onclick="() => ShowAliasVaultEmailInModal(email.Id)" class="p-4 flex justify-start items-start">
<div class="mr-4 flex-shrink-0">
<SenderInitials SenderName="@email.FromName" SenderEmail="@email.FromEmail" />
</div>
<div class="flex-grow">
<div class="flex items-center justify-between mb-2 mr-4">
<div>
<div class="text-gray-800 mb-2">
@email.Subject
</div>
<div class="text-sm text-gray-400 line-clamp-2">
@email.MessagePreview
</div>
</div>
<div class="flex justify-end">
<div @onclick="() => NavigateToCredential(email.CredentialId)" class="text-sm text-gray-700 cursor-pointer mr-4 hover:underline">@email.CredentialName</div>
<div class="text-sm text-gray-500">@email.Date.ToString("yyyy-MM-dd")</div>
</div>
</div>
</div>
</div>
</li>
}
}
</ul>
</div>
</div>
}
@code {
private List<MailListViewModel> EmailList { get; set; } = [];
private bool IsLoading { get; set; } = true;
private int CurrentPage { get; set; } = 1;
private int PageSize { get; set; } = 50;
private int TotalRecords { get; set; }
private bool EmailModalVisible { get; set; }
private bool NoEmailClaims { get; set; }
private EmailApiModel EmailModalEmail { get; set; } = new();
/// <inheritdoc />
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
if (firstRender)
{
await RefreshData();
}
}
private void HandlePageChanged(int newPage)
{
CurrentPage = newPage;
_ = RefreshData();
}
private async Task RefreshData()
{
IsLoading = true;
NoEmailClaims = false;
StateHasChanged();
var emailClaimList = await DbService.GetEmailClaimListAsync();
if (emailClaimList.Count == 0)
{
IsLoading = false;
NoEmailClaims = true;
StateHasChanged();
return;
}
var requestModel = new MailboxBulkRequest
{
Page = CurrentPage,
PageSize = PageSize,
Addresses = emailClaimList,
};
var request = new HttpRequestMessage(HttpMethod.Post, $"api/v1/EmailBox/bulk");
request.Content = new StringContent(JsonSerializer.Serialize(requestModel), Encoding.UTF8, "application/json");
try
{
var response = await HttpClient.SendAsync(request);
if (response.IsSuccessStatusCode)
{
var mailbox = await response.Content.ReadFromJsonAsync<MailboxBulkResponse>();
await UpdateMailboxEmails(mailbox);
}
else
{
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_EXIST":
GlobalNotificationService.AddErrorMessage("An error occurred while trying to load the emails. Please try to edit and " +
"save any credential entry to synchronize the database, then try again.", true);
break;
default:
throw new ArgumentException(errorResponse.Message);
}
}
break;
case HttpStatusCode.Unauthorized:
throw new UnauthorizedAccessException(errorResponse?.Message);
default:
throw new WebException(errorResponse?.Message);
}
}
}
catch (Exception ex)
{
GlobalNotificationService.AddErrorMessage(ex.Message, true);
Console.WriteLine(ex);
}
IsLoading = false;
StateHasChanged();
}
/// <summary>
/// Update the local mailbox emails.
/// </summary>
private async Task UpdateMailboxEmails(MailboxBulkResponse? model)
{
if (model == null)
{
EmailList = [];
TotalRecords = 0;
return;
}
var context = await DbService.GetDbContextAsync();
// Fetch all credentials in a single query and create a lookup dictionary
var credentialLookup = await context.Credentials
.Include(x => x.Service)
.Include(x => x.Alias)
.Where(x => x.Alias.Email != null)
.GroupBy(x => x.Alias.Email!.ToLower())
.ToDictionaryAsync(
g => g.Key,
g => new { Id = g.First().Id, ServiceName = g.First().Service.Name ?? "Unknown" }
);
// Convert the email list to view models and add credential info in a single pass
var decryptedEmailList = await EmailService.DecryptEmailList(model.Mails);
EmailList = decryptedEmailList.Select(email =>
{
var toEmail = email.ToLocal + "@" + email.ToDomain;
var credentialInfo = credentialLookup.TryGetValue(toEmail.ToLower(), out var info)
? info
: new { Id = Guid.Empty, ServiceName = "Unknown" };
return new MailListViewModel
{
Id = email.Id,
Date = email.DateSystem,
FromName = email.FromDisplay,
FromEmail = email.FromLocal + "@" + email.FromDomain,
ToEmail = toEmail,
Subject = email.Subject,
MessagePreview = email.MessagePreview,
CredentialId = credentialInfo.Id,
CredentialName = credentialInfo.ServiceName
};
}).ToList();
CurrentPage = model.CurrentPage;
PageSize = model.PageSize;
TotalRecords = model.TotalRecords;
}
/// <summary>
/// Load recent emails from AliasVault.
/// </summary>
private async Task ShowAliasVaultEmailInModal(int emailId)
{
EmailApiModel? mail = await HttpClient.GetFromJsonAsync<EmailApiModel>($"api/v1/Email/{emailId}");
if (mail != null)
{
// Decrypt the email content locally.
var context = await DbService.GetDbContextAsync();
var privateKey = await context.EncryptionKeys.FirstOrDefaultAsync(x => x.PublicKey == mail.EncryptionKey);
if (privateKey is not null)
{
mail = await EmailService.DecryptEmail(mail);
}
EmailModalEmail = mail;
EmailModalVisible = true;
StateHasChanged();
}
}
/// <summary>
/// Close the email modal.
/// </summary>
private void CloseEmailModal()
{
EmailModalVisible = false;
StateHasChanged();
}
/// <summary>
/// Navigate to the credential page.
/// </summary>
private void NavigateToCredential(Guid credentialId)
{
NavigationManager.NavigateTo($"/credentials/{credentialId}");
}
}

View File

@@ -0,0 +1,59 @@
//-----------------------------------------------------------------------
// <copyright file="MailListViewModel.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.Client.Main.Pages.Emails.Models;
/// <summary>
/// Mail view model.
/// </summary>
public class MailListViewModel
{
/// <summary>
/// Gets or sets the ID of the email.
/// </summary>
public int Id { get; set; }
/// <summary>
/// Gets or sets the ID of the associated credential that uses this email address.
/// </summary>
public Guid CredentialId { get; set; }
/// <summary>
/// Gets or sets the name of the credential that uses this email address.
/// </summary>
public string CredentialName { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the subject of the email.
/// </summary>
public string Subject { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the display name of the sender.
/// </summary>
public string FromName { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the sender's email address.
/// </summary>
public string FromEmail { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the recipients email address.
/// </summary>
public string ToEmail { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the date of the email.
/// </summary>
public DateTime Date { get; set; }
/// <summary>
/// Gets or sets the message preview.
/// </summary>
public string MessagePreview { get; set; } = string.Empty;
}

View File

@@ -60,6 +60,18 @@ public class MainBase : OwningComponentBase
[Inject]
public DbService DbService { get; set; } = null!;
/// <summary>
/// Gets or sets the EmailService.
/// </summary>
[Inject]
public EmailService EmailService { get; set; } = null!;
/// <summary>
/// Gets or sets the KeyboardShortcutService.
/// </summary>
[Inject]
public KeyboardShortcutService KeyboardShortcutService { get; set; } = null!;
/// <summary>
/// Gets or sets the AuthService.
/// </summary>

View File

@@ -11,7 +11,7 @@
</div>
</div>
<div class="p-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<div class="p-4 mb-4 mx-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<h3 class="mb-4 text-lg font-medium text-gray-900 dark:text-white">Email Settings</h3>
<div class="mb-4">
@@ -44,6 +44,21 @@
</div>
</div>
<div class="p-4 mx-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<h3 class="mb-4 text-lg font-medium text-gray-900 dark:text-white">Alias Settings</h3>
<div class="mb-4">
<label for="defaultEmailDomain" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Alias generation language</label>
<select @bind="DefaultIdentityLanguage" @bind:after="UpdateDefaultIdentityLanguage" id="defaultIdentityLanguage" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
<option value="en">English</option>
<option value="nl">Dutch</option>
</select>
<span class="block text-sm font-normal text-gray-500 truncate dark:text-gray-400">
Set the default language that will be used when generating new identities.
</span>
</div>
</div>
@code {
private List<string> PrivateDomains => Config.PrivateEmailDomains;
private List<string> PublicDomains => Config.PublicEmailDomains;
@@ -51,15 +66,28 @@
private string DefaultEmailDomain { get; set; } = string.Empty;
private bool AutoEmailRefresh { get; set; }
private string DefaultIdentityLanguage { get; set; } = string.Empty;
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = "Vault settings" });
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = "General settings" });
DefaultEmailDomain = DbService.Settings.DefaultEmailDomain;
if (DefaultEmailDomain == string.Empty)
{
if (PrivateDomains.Count > 0)
{
DefaultEmailDomain = PrivateDomains[0];
}
else if (PublicDomains.Count > 0)
{
DefaultEmailDomain = PublicDomains[0];
}
}
AutoEmailRefresh = DbService.Settings.AutoEmailRefresh;
DefaultIdentityLanguage = DbService.Settings.DefaultIdentityLanguage;
}
/// <summary>
@@ -79,4 +107,13 @@
await DbService.Settings.SetAutoEmailRefresh(AutoEmailRefresh);
StateHasChanged();
}
/// <summary>
/// Updates the auto email refresh setting.
/// </summary>
private async Task UpdateDefaultIdentityLanguage()
{
await DbService.Settings.SetDefaultIdentityLanguage(DefaultIdentityLanguage);
StateHasChanged();
}
}

View File

@@ -14,7 +14,7 @@
</div>
</div>
<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="p-4 mx-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">
<h3 class="mb-4 text-xl font-semibold dark:text-white">Export vault</h3>
<div class="mb-4">
<div>
@@ -30,7 +30,7 @@
</div>
</div>
<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="p-4 mx-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">
<h3 class="mb-4 text-xl font-semibold dark:text-white">Import vault</h3>
<div class="mb-4">
<div>
@@ -117,9 +117,13 @@ else if (!string.IsNullOrEmpty(ImportSuccessMessage))
{
var file = e.File;
var buffer = new byte[file.Size];
await file.OpenReadStream().ReadAsync(buffer);
var fileContent = System.Text.Encoding.UTF8.GetString(buffer);
var bytesRead = await file.OpenReadStream().ReadAsync(buffer);
if (bytesRead != file.Size)
{
throw new FileLoadException("Error reading file");
}
var fileContent = System.Text.Encoding.UTF8.GetString(buffer);
var importedCredentials = CsvImportExport.CredentialCsvService.ImportCredentialsFromCsv(fileContent);
// Loop through the imported credentials and actually add them to the database
@@ -131,7 +135,7 @@ else if (!string.IsNullOrEmpty(ImportSuccessMessage))
// Save the database.
await DbService.SaveDatabaseAsync();
ImportSuccessMessage = $"Succesfully imported {importedCredentials.Count} credentials.";
ImportSuccessMessage = $"Successfully imported {importedCredentials.Count} credentials.";
}
catch (Exception ex)
{

View File

@@ -8,9 +8,9 @@
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Welcome to AliasVault!</h2>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Welcome to AliasVault</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">
Your new encrypted vault is being created. This process may take a moment. Please wait.
Your new encrypted vault is being initialized. This process may take a moment. Please wait.
</p>
<div>
@@ -47,13 +47,12 @@
private async Task MigrateDatabase()
{
// Simulate a delay.
await Task.Delay(1500);
await Task.Delay(1000);
// Migrate the database
if (await DbService.MigrateDatabaseAsync())
{
// Migration successful
GlobalNotificationService.AddSuccessMessage("Vault successfully created.", true);
}
else
{

View File

@@ -0,0 +1,60 @@
@page "/welcome"
@inherits MainBase
<div class="container pt-10 px-4 mx-auto lg:px-0">
<h1 class="mb-3 text-3xl font-bold text-gray-900 sm:text-4xl sm:leading-none sm:tracking-tight dark:text-white">Welcome to AliasVault</h1>
<p class="mb-6 text-lg font-normal text-gray-500 sm:text-xl dark:text-gray-400">It looks like you are new here. The instructions on this page will help to get you started.</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<div class="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md">
<h2 class="text-2xl font-semibold mb-4 text-gray-900 dark:text-white">Getting Started</h2>
<div class="space-y-4">
<div>
<h3 class="text-lg font-medium text-gray-900 dark:text-white">How do I use AliasVault?</h3>
<p class="text-gray-600 dark:text-gray-400">Create a random identity with an associated email address. To get started, simply click the "+ New Identity" button in the top right corner.</p>
</div>
<div>
<h3 class="text-lg font-medium text-gray-900 dark:text-white">What is the purpose of AliasVault?</h3>
<p class="text-gray-600 dark:text-gray-400">AliasVault keeps you safe online by providing unique passwords and email addresses for every service you use. This prevents your actual email from being used to link your accounts on different websites together.</p>
</div>
<div>
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Is my data secure?</h3>
<p class="text-gray-600 dark:text-gray-400">Yes, AliasVault uses a zero-knowledge architecture. This means that your data is end-to-end encrypted on your device before being stored on the server. No one can see your login information, identities and even emails but you. Not even us.</p>
</div>
</div>
</div>
<div class="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md">
<h2 class="text-2xl font-semibold mb-4 text-gray-900 dark:text-white">Get Started Now</h2>
<div class="mb-4">
<p class="text-gray-600 dark:text-gray-400">
Go ahead and create a new login by clicking "+ New Identity" in the top right. Or explore these options:
</p>
</div>
<div class="space-y-4">
<button @onclick="CreateIdentity" class="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded transition duration-300">
Create First Identity (Advanced)
</button>
<button @onclick="ShowSettings" class="w-full bg-green-600 hover:bg-green-700 text-white font-bold py-2 px-4 rounded transition duration-300">
Choose Default Language for New Identities
</button>
<button class="w-full bg-gray-400 hover:bg-gray-500 text-white font-bold py-2 px-4 rounded transition duration-300 cursor-not-allowed opacity-75">
Import Passwords from Existing Manager (coming soon)
</button>
</div>
</div>
</div>
</div>
@code {
private void CreateIdentity()
{
NavigationManager.NavigateTo("credentials/create");
}
private void ShowSettings()
{
NavigationManager.NavigateTo("settings/general");
}
}

View File

@@ -43,6 +43,7 @@ builder.Services.AddLogging(logging =>
logging.AddFilter("Microsoft.AspNetCore.Identity.DataProtectorTokenProvider", LogLevel.Error);
logging.AddFilter("Microsoft.AspNetCore.Identity.UserManager", LogLevel.Error);
logging.AddFilter("System.Net.Http.HttpClient", LogLevel.Error);
});
builder.RootComponents.Add<App>("#app");
@@ -67,7 +68,9 @@ builder.Services.AddScoped<CredentialService>();
builder.Services.AddScoped<DbService>();
builder.Services.AddScoped<GlobalNotificationService>();
builder.Services.AddScoped<GlobalLoadingService>();
builder.Services.AddScoped<KeyboardShortcutService>();
builder.Services.AddScoped<JsInteropService>();
builder.Services.AddScoped<EmailService>();
builder.Services.AddSingleton<ClipboardCopyService>();
builder.Services.AddAuthorizationCore();

View File

@@ -15,7 +15,7 @@ using Microsoft.AspNetCore.Components;
/// This services handles all API requests to the AliasVault API and will add the access token to the request headers.
/// If a 401 unauthorized is returned by the API it will intercept this response and attempt to automatically refresh the access token.
/// </summary>
public class AliasVaultApiHandlerService(IServiceProvider serviceProvider) : DelegatingHandler
public sealed class AliasVaultApiHandlerService(IServiceProvider serviceProvider) : DelegatingHandler
{
/// <summary>
/// Override the SendAsync method to add the access token to the request headers.

View File

@@ -17,14 +17,11 @@ using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
/// This service is responsible for handling authentication-related operations such as refreshing tokens,
/// storing tokens, and revoking tokens.
/// </summary>
/// <remarks>
/// Initializes a new instance of the <see cref="AuthService"/> class.
/// </remarks>
/// <param name="httpClient">The HTTP client.</param>
/// <param name="localStorage">The local storage service.</param>
/// <param name="environment">IWebAssemblyHostEnvironment instance.</param>
/// <param name="configuration">IConfiguration instance.</param>
public class AuthService(HttpClient httpClient, ILocalStorageService localStorage, IWebAssemblyHostEnvironment environment, IConfiguration configuration)
public sealed class AuthService(HttpClient httpClient, ILocalStorageService localStorage, IWebAssemblyHostEnvironment environment, IConfiguration configuration)
{
private const string AccessTokenKey = "token";
private const string RefreshTokenKey = "refreshToken";

View File

@@ -10,7 +10,7 @@ namespace AliasVault.Client.Services;
/// <summary>
/// Service to manage the clipboard copy operations across the application.
/// </summary>
public class ClipboardCopyService
public sealed class ClipboardCopyService
{
private string _currentCopiedId = string.Empty;

View File

@@ -14,6 +14,9 @@ using System.Net.Http;
using System.Net.Http.Json;
using System.Threading.Tasks;
using AliasClientDb;
using AliasGenerators.Identity.Implementations;
using AliasGenerators.Identity.Models;
using AliasGenerators.Password.Implementations;
using AliasVault.Shared.Models;
using Microsoft.EntityFrameworkCore;
using Identity = AliasGenerators.Identity.Models.Identity;
@@ -21,8 +24,79 @@ using Identity = AliasGenerators.Identity.Models.Identity;
/// <summary>
/// Service class for alias operations.
/// </summary>
public class CredentialService(HttpClient httpClient, DbService dbService)
public sealed class CredentialService(HttpClient httpClient, DbService dbService, Config config)
{
/// <summary>
/// The default service URL used as placeholder in forms. When this value is set, the URL field is considered empty
/// and a null value is stored in the database.
/// </summary>
public const string DefaultServiceUrl = "https://";
/// <summary>
/// Generates a random password for a credential.
/// </summary>
/// <returns>Random password.</returns>
public static string GenerateRandomPassword()
{
// Generate a random password using a IPasswordGenerator implementation.
var passwordGenerator = new SpamOkPasswordGenerator();
return passwordGenerator.GenerateRandomPassword();
}
/// <summary>
/// Generates a random identity for a credential.
/// </summary>
/// <param name="credential">The credential object to update.</param>
/// <returns>Task.</returns>
public async Task<Credential> GenerateRandomIdentity(Credential credential)
{
// Generate a random identity using the IIdentityGenerator implementation.
var identity = await IdentityGeneratorFactory.CreateIdentityGenerator(dbService.Settings.DefaultIdentityLanguage).GenerateRandomIdentityAsync();
// Generate random values for the Identity properties
credential.Username = identity.NickName;
credential.Alias.FirstName = identity.FirstName;
credential.Alias.LastName = identity.LastName;
credential.Alias.NickName = identity.NickName;
credential.Alias.Gender = identity.Gender == Gender.Male ? "Male" : "Female";
credential.Alias.BirthDate = identity.BirthDate;
// Set the email
var emailDomain = GetDefaultEmailDomain();
credential.Alias.Email = $"{identity.EmailPrefix}@{emailDomain}";
// Generate password
credential.Passwords.First().Value = GenerateRandomPassword();
return credential;
}
/// <summary>
/// Gets the default email domain based on settings and available domains.
/// </summary>
/// <returns>Default email domain.</returns>
public string GetDefaultEmailDomain()
{
var defaultDomain = dbService.Settings.DefaultEmailDomain;
// Function to check if a domain is valid
bool IsValidDomain(string domain) =>
!string.IsNullOrEmpty(domain) &&
domain != "DISABLED.TLD" &&
(config.PublicEmailDomains.Contains(domain) || config.PrivateEmailDomains.Contains(domain));
// Get the first valid domain from private or public domains
string GetFirstValidDomain() =>
config.PrivateEmailDomains.Find(IsValidDomain) ??
config.PublicEmailDomains.FirstOrDefault() ??
"example.com";
// Use the default domain if it's valid, otherwise get the first valid domain
string domainToUse = IsValidDomain(defaultDomain) ? defaultDomain : GetFirstValidDomain();
return domainToUse;
}
/// <summary>
/// Generate random identity by calling the IdentityGenerator API.
/// </summary>
@@ -51,6 +125,19 @@ public class CredentialService(HttpClient httpClient, DbService dbService)
// Try to extract favicon from service URL
await ExtractFaviconAsync(loginObject);
// If the email starts with an @ it is most likely still the placeholder which hasn't been filled.
// So we remove it.
if (loginObject.Alias.Email is not null && loginObject.Alias.Email.StartsWith('@'))
{
loginObject.Alias.Email = null;
}
// If the URL equals the placeholder, we set it to null.
if (loginObject.Service.Url == "https://")
{
loginObject.Service.Url = null;
}
var login = new Credential
{
CreatedAt = DateTime.UtcNow,
@@ -64,15 +151,7 @@ public class CredentialService(HttpClient httpClient, DbService dbService)
LastName = loginObject.Alias.LastName,
BirthDate = loginObject.Alias.BirthDate,
Gender = loginObject.Alias.Gender,
AddressStreet = loginObject.Alias.AddressStreet,
AddressCity = loginObject.Alias.AddressCity,
AddressState = loginObject.Alias.AddressState,
AddressZipCode = loginObject.Alias.AddressZipCode,
AddressCountry = loginObject.Alias.AddressCountry,
Hobbies = loginObject.Alias.Hobbies,
Email = loginObject.Alias.Email,
PhoneMobile = loginObject.Alias.PhoneMobile,
BankAccountIBAN = loginObject.Alias.BankAccountIBAN,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
},
@@ -128,6 +207,13 @@ public class CredentialService(HttpClient httpClient, DbService dbService)
throw new InvalidOperationException("Login object not found.");
}
// If the email starts with an @ it is most likely still the placeholder which hasn't been filled.
// So we remove it.
if (loginObject.Alias.Email is not null && loginObject.Alias.Email.StartsWith('@'))
{
loginObject.Alias.Email = null;
}
login.UpdatedAt = DateTime.UtcNow;
login.Notes = loginObject.Notes;
login.Username = loginObject.Username;
@@ -137,15 +223,7 @@ public class CredentialService(HttpClient httpClient, DbService dbService)
login.Alias.LastName = loginObject.Alias.LastName;
login.Alias.BirthDate = loginObject.Alias.BirthDate;
login.Alias.Gender = loginObject.Alias.Gender;
login.Alias.AddressStreet = loginObject.Alias.AddressStreet;
login.Alias.AddressCity = loginObject.Alias.AddressCity;
login.Alias.AddressState = loginObject.Alias.AddressState;
login.Alias.AddressZipCode = loginObject.Alias.AddressZipCode;
login.Alias.AddressCountry = loginObject.Alias.AddressCountry;
login.Alias.Hobbies = loginObject.Alias.Hobbies;
login.Alias.Email = loginObject.Alias.Email;
login.Alias.PhoneMobile = loginObject.Alias.PhoneMobile;
login.Alias.BankAccountIBAN = loginObject.Alias.BankAccountIBAN;
login.Passwords = loginObject.Passwords;
@@ -259,6 +337,20 @@ public class CredentialService(HttpClient httpClient, DbService dbService)
.FirstAsync();
context.Credentials.Remove(login);
// Also remove associated alias and service. Later when
// aliases and services are shared between credentials, this
// should be removed.
var alias = await context.Aliases
.Where(x => x.Id == login.Alias.Id)
.FirstAsync();
context.Aliases.Remove(alias);
var service = await context.Services
.Where(x => x.Id == login.Service.Id)
.FirstAsync();
context.Services.Remove(service);
await context.SaveChangesAsync();
await dbService.SaveDatabaseAsync();
}

View File

@@ -20,7 +20,7 @@ using Microsoft.EntityFrameworkCore;
/// with a AliasClientDb database instance that is only persisted in memory due to the encryption requirements of the
/// database itself. The database should not be persisted to disk when in un-encrypted form.
/// </summary>
public class DbService : IDisposable
public sealed class DbService : IDisposable
{
private readonly AuthService _authService;
private readonly JsInteropService _jsInteropService;
@@ -288,7 +288,7 @@ public class DbService : IDisposable
/// Disposes the service.
/// </summary>
/// <param name="disposing">True if disposing.</param>
protected virtual void Dispose(bool disposing)
public void Dispose(bool disposing)
{
if (_disposed)
{
@@ -303,6 +303,29 @@ public class DbService : IDisposable
_disposed = true;
}
/// <summary>
/// Get a list of private email addresses that are used in aliases by this vault.
/// </summary>
/// <returns>List of email addresses.</returns>
public async Task<List<string>> GetEmailClaimListAsync()
{
// Send list of email addresses that are used in aliases by this vault so they can be
// claimed on the server.
var emailAddresses = await _dbContext.Aliases
.Where(a => a.Email != null)
.Select(a => a.Email)
.Distinct()
.Select(email => email!)
.ToListAsync();
// Filter the list of email addresses to only include those that are in the allowed domains.
emailAddresses = emailAddresses
.Where(email => _config.PrivateEmailDomains.Exists(domain => email.EndsWith(domain)))
.ToList();
return emailAddresses;
}
/// <summary>
/// Loads a SQLite database from a base64 string which represents a .sqlite file.
/// </summary>

View File

@@ -10,7 +10,7 @@ namespace AliasVault.Client.Services.Database;
/// <summary>
/// Class to manage the state of the AliasClientDbService that others can subscribe to events for.
/// </summary>
public class DbServiceState
public sealed class DbServiceState
{
private DatabaseState _currentState = new();
@@ -114,7 +114,7 @@ public class DbServiceState
/// OnStateChanged event handler.
/// </summary>
/// <param name="newState">The new state.</param>
protected virtual void OnStateChanged(DatabaseState newState)
private void OnStateChanged(DatabaseState newState)
{
StateChanged?.Invoke(this, newState);
}

View File

@@ -0,0 +1,111 @@
//-----------------------------------------------------------------------
// <copyright file="EmailService.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.Client.Services;
using AliasClientDb;
using AliasVault.Shared.Models.Spamok;
using Microsoft.EntityFrameworkCore;
/// <summary>
/// Email service that contains utility methods for handling email functionality such as client-side decryption.
/// </summary>
public sealed class EmailService(DbService dbService, JsInteropService jsInteropService, GlobalNotificationService globalNotificationService)
{
private List<EncryptionKey> _encryptionKeys = [];
/// <summary>
/// Decrypts a single email using the private key.
/// </summary>
/// <param name="email">The email object with encrypted fields.</param>
/// <returns>Email with all fields decrypted.</returns>
public async Task<EmailApiModel> DecryptEmail(EmailApiModel email)
{
await EnsureEncryptionKeys();
return await DecryptSingleEmail(email);
}
/// <summary>
/// Decrypts a list of emails using the private key.
/// </summary>
/// <param name="emailList">The email object with encrypted fields.</param>
/// <returns>List of emails with all fields decrypted.</returns>
public async Task<List<MailboxEmailApiModel>> DecryptEmailList(List<MailboxEmailApiModel> emailList)
{
await EnsureEncryptionKeys();
foreach (var email in emailList)
{
var privateKey = _encryptionKeys.First(x => x.PublicKey == email.EncryptionKey);
try
{
var decryptedSymmetricKey = await jsInteropService.DecryptWithPrivateKey(email.EncryptedSymmetricKey, privateKey.PrivateKey);
email.Subject = await jsInteropService.SymmetricDecrypt(email.Subject, Convert.ToBase64String(decryptedSymmetricKey));
email.FromDisplay = await jsInteropService.SymmetricDecrypt(email.FromDisplay, Convert.ToBase64String(decryptedSymmetricKey));
email.FromLocal = await jsInteropService.SymmetricDecrypt(email.FromLocal, Convert.ToBase64String(decryptedSymmetricKey));
email.FromDomain = await jsInteropService.SymmetricDecrypt(email.FromDomain, Convert.ToBase64String(decryptedSymmetricKey));
email.MessagePreview = await jsInteropService.SymmetricDecrypt(email.MessagePreview, Convert.ToBase64String(decryptedSymmetricKey));
}
catch (Exception ex)
{
globalNotificationService.AddErrorMessage(ex.Message, true);
Console.WriteLine(ex);
}
}
return emailList;
}
/// <summary>
/// Decrypt the contents of a single email.
/// </summary>
/// <param name="email">The email object with encrypted fields.</param>
/// <returns>Email with all fields decrypted.</returns>
private async Task<EmailApiModel> DecryptSingleEmail(EmailApiModel email)
{
var privateKey = _encryptionKeys.First(x => x.PublicKey == email.EncryptionKey);
try
{
var decryptedSymmetricKey = await jsInteropService.DecryptWithPrivateKey(email.EncryptedSymmetricKey, privateKey.PrivateKey);
email.Subject = await jsInteropService.SymmetricDecrypt(email.Subject, Convert.ToBase64String(decryptedSymmetricKey));
if (email.MessageHtml is not null)
{
email.MessageHtml = await jsInteropService.SymmetricDecrypt(email.MessageHtml, Convert.ToBase64String(decryptedSymmetricKey));
}
if (email.MessagePlain is not null)
{
email.MessagePlain = await jsInteropService.SymmetricDecrypt(email.MessagePlain, Convert.ToBase64String(decryptedSymmetricKey));
}
email.FromDisplay = await jsInteropService.SymmetricDecrypt(email.FromDisplay, Convert.ToBase64String(decryptedSymmetricKey));
email.FromLocal = await jsInteropService.SymmetricDecrypt(email.FromLocal, Convert.ToBase64String(decryptedSymmetricKey));
email.FromDomain = await jsInteropService.SymmetricDecrypt(email.FromDomain, Convert.ToBase64String(decryptedSymmetricKey));
}
catch (Exception ex)
{
globalNotificationService.AddErrorMessage(ex.Message, true);
Console.WriteLine(ex);
}
return email;
}
/// <summary>
/// Ensure the encryption keys are loaded.
/// </summary>
private async Task EnsureEncryptionKeys()
{
if (_encryptionKeys.Count == 0)
{
var context = await dbService.GetDbContextAsync();
_encryptionKeys = await context.EncryptionKeys.ToListAsync();
}
}
}

View File

@@ -10,7 +10,7 @@ namespace AliasVault.Client.Services;
/// <summary>
/// Global loading service that can be used to show or hide a global layout loading spinner.
/// </summary>
public class GlobalLoadingService
public sealed class GlobalLoadingService
{
private bool _isLoading;

View File

@@ -12,7 +12,7 @@ namespace AliasVault.Client.Services;
/// are stored in this object which is scoped to the current session. This allows the messages to be cached until
/// they actually have been displayed. So they can survive redirects and page reloads.
/// </summary>
public class GlobalNotificationService
public sealed class GlobalNotificationService
{
/// <summary>
/// Allow other components to subscribe to changes in the event object.
@@ -20,14 +20,14 @@ public class GlobalNotificationService
public event Action? OnChange;
/// <summary>
/// Gets or sets success messages that should be displayed to the user.
/// Gets success messages that should be displayed to the user.
/// </summary>
protected List<string> SuccessMessages { get; set; } = [];
private List<string> SuccessMessages { get; } = [];
/// <summary>
/// Gets or sets error messages that should be displayed to the user.
/// Gets error messages that should be displayed to the user.
/// </summary>
protected List<string> ErrorMessages { get; set; } = [];
private List<string> ErrorMessages { get; } = [];
/// <summary>
/// Adds a success message to the list of messages that should be displayed to the user.

View File

@@ -15,7 +15,7 @@ using Microsoft.JSInterop;
/// JavaScript interop service for calling JavaScript functions from C#.
/// </summary>
/// <param name="jsRuntime">IJSRuntime.</param>
public class JsInteropService(IJSRuntime jsRuntime)
public sealed class JsInteropService(IJSRuntime jsRuntime)
{
/// <summary>
/// Symmetrically encrypts a string using the provided encryption key.
@@ -44,6 +44,22 @@ public class JsInteropService(IJSRuntime jsRuntime)
public async Task DownloadFileFromStream(string filename, byte[] blob) =>
await jsRuntime.InvokeVoidAsync("downloadFileFromStream", filename, blob);
/// <summary>
/// Focus an element by its ID.
/// </summary>
/// <param name="elementId">The element ID to focus.</param>
/// <returns>Task.</returns>
public async Task FocusElementById(string elementId) =>
await jsRuntime.InvokeVoidAsync("focusElement", elementId);
/// <summary>
/// Blur (defocus) an element by its ID.
/// </summary>
/// <param name="elementId">The element ID to focus.</param>
/// <returns>Task.</returns>
public async Task BlurElementById(string elementId) =>
await jsRuntime.InvokeVoidAsync("blurElement", elementId);
/// <summary>
/// Copy a string to the browsers clipboard.
/// </summary>

View File

@@ -0,0 +1,125 @@
//-----------------------------------------------------------------------
// <copyright file="KeyboardShortcutService.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.Client.Services;
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
/// <summary>
/// Service class for alias operations.
/// </summary>
public sealed class KeyboardShortcutService : IAsyncDisposable
{
private readonly DotNetObjectReference<CallbackWrapper> _dotNetHelper;
private readonly NavigationManager _navigationManager;
private readonly Lazy<Task<IJSObjectReference>> moduleTask;
/// <summary>
/// Initializes a new instance of the <see cref="KeyboardShortcutService"/> class.
/// </summary>
/// <param name="jsRuntime">IJSRuntime instance.</param>
/// <param name="navigationManager">NavigationManager instance.</param>
public KeyboardShortcutService(IJSRuntime jsRuntime, NavigationManager navigationManager)
{
_dotNetHelper = DotNetObjectReference.Create(new CallbackWrapper());
_navigationManager = navigationManager;
moduleTask = new Lazy<Task<IJSObjectReference>>(() => jsRuntime.InvokeAsync<IJSObjectReference>(
"import", "./js/modules/keyboardShortcuts.js").AsTask());
_ = RegisterStaticShortcuts();
}
/// <summary>
/// Registers a keyboard shortcut with the given keys and callback.
/// </summary>
/// <param name="keys">The keyboard keys.</param>
/// <param name="callback">Callback when shortcut is pressed.</param>
/// <returns>Task.</returns>
public async Task RegisterShortcutAsync(string keys, Func<Task> callback)
{
_dotNetHelper.Value.RegisterCallback(keys, callback);
var module = await moduleTask.Value;
await module.InvokeVoidAsync("registerShortcut", keys, _dotNetHelper);
}
/// <summary>
/// Unregisters a keyboard shortcut with the given keys.
/// </summary>
/// <param name="keys">The keyboard keys.</param>
/// <returns>Task.</returns>
public async Task UnregisterShortcutAsync(string keys)
{
_dotNetHelper.Value.UnregisterCallback(keys);
var module = await moduleTask.Value;
await module!.InvokeVoidAsync("unregisterShortcut", keys);
}
/// <summary>
/// Disposes the service.
/// </summary>
/// <returns>ValueTask.</returns>
public async ValueTask DisposeAsync()
{
var module = await moduleTask.Value;
await module.InvokeVoidAsync("unregisterAllShortcuts");
await module.DisposeAsync();
_dotNetHelper.Dispose();
GC.SuppressFinalize(this);
}
/// <summary>
/// Registers static shortcuts that are always available.
/// </summary>
private async Task RegisterStaticShortcuts()
{
// Global shortcut: Go home
await RegisterShortcutAsync("gh", () =>
{
_navigationManager.NavigateTo("/");
return Task.CompletedTask;
});
// Global shortcut: Go to email page
await RegisterShortcutAsync("ge", () =>
{
_navigationManager.NavigateTo("/emails");
return Task.CompletedTask;
});
}
/// <summary>
/// Wrapper class for callback functions that are invoked from JavaScript.
/// </summary>
private sealed class CallbackWrapper
{
private readonly Dictionary<string, Func<Task>> _callbacks = new Dictionary<string, Func<Task>>();
public void RegisterCallback(string keys, Func<Task> callback)
{
_callbacks[keys] = callback;
}
public void UnregisterCallback(string keys)
{
_callbacks.Remove(keys);
}
[JSInvokable]
public async Task Invoke(string keys)
{
if (_callbacks.TryGetValue(keys, out var callback))
{
await callback();
}
}
}
}

View File

@@ -20,14 +20,14 @@ using Microsoft.EntityFrameworkCore;
/// This is done because the SettingsService requires a DbContext during initialization and the context is not yet
/// available during application boot because of encryption/decryption of remote database file. When accessing the
/// settings through the DbService we can ensure proper data flow.</remarks>
public class SettingsService
public sealed class SettingsService
{
private readonly Dictionary<string, string?> _settings = new();
private DbService? _dbService;
private bool _initialized;
/// <summary>
/// Gets the DefaultEmailDomain setting asynchronously.
/// Gets the DefaultEmailDomain setting.
/// </summary>
/// <returns>Default email domain as string.</returns>
public string DefaultEmailDomain => GetSetting("DefaultEmailDomain");
@@ -39,19 +39,32 @@ public class SettingsService
public bool AutoEmailRefresh => GetSetting<bool>("AutoEmailRefresh", true);
/// <summary>
/// Sets the DefaultEmailDomain setting asynchronously.
/// Gets the DefaultIdentityLanguage setting.
/// </summary>
/// <param name="value">The new DeafultEmailDomain setting.</param>
/// <returns>Default identity language as two-letter code.</returns>
public string DefaultIdentityLanguage => GetSetting<string>("DefaultIdentityLanguage", "en")!;
/// <summary>
/// Sets the DefaultEmailDomain setting.
/// </summary>
/// <param name="value">The new DefaultEmailDomain setting.</param>
/// <returns>Task.</returns>
public Task SetDefaultEmailDomain(string value) => SetSettingAsync("DefaultEmailDomain", value);
/// <summary>
/// Sets the AutoEmailRefresh setting asynchronously as a string.
/// Sets the AutoEmailRefresh setting as a string.
/// </summary>
/// <param name="value">The new value.</param>
/// <returns>Task.</returns>
public Task SetAutoEmailRefresh(bool value) => SetSettingAsync<bool>("AutoEmailRefresh", value);
/// <summary>
/// Sets the DefaultIdentityLanguage setting.
/// </summary>
/// <param name="value">The new value.</param>
/// <returns>Task.</returns>
public Task SetDefaultIdentityLanguage(string value) => SetSettingAsync("DefaultIdentityLanguage", value);
/// <summary>
/// Initializes the settings service asynchronously.
/// </summary>
@@ -171,11 +184,7 @@ public class SettingsService
{
string value = GetSetting(key);
try
{
return CastSetting<T>(value);
}
catch (InvalidOperationException ex)
if (string.IsNullOrEmpty(value))
{
// If no value is available in database but default value is set, return default value.
if (defaultValue is not null)
@@ -184,6 +193,15 @@ public class SettingsService
}
// No value in database and no default value set, throw exception.
throw new InvalidOperationException($"Setting {key} is not set and no default value is provided");
}
try
{
return CastSetting<T>(value);
}
catch (InvalidOperationException ex)
{
throw new InvalidOperationException($"Failed to cast setting {key} to type {typeof(T)}", ex);
}
}

View File

@@ -19,10 +19,12 @@
@using AliasVault.Client.Main.Components.Forms
@using AliasVault.Client.Main.Components.Layout
@using AliasVault.Client.Main.Components.Loading
@using AliasVault.Client.Main.Components.Widgets
@using AliasVault.Client.Main.Models
@using AliasVault.Client.Services
@using AliasVault.Client.Services.Auth
@using AliasVault.Client.Services.Database
@using AliasVault.RazorComponents
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication
@using Blazored.LocalStorage

View File

@@ -3,6 +3,7 @@ module.exports = {
content: [
'./**/*.html',
'./**/*.razor',
'../Utilities/AliasVault.RazorComponents/**/*.razor',
],
safelist: [
'w-64',

View File

@@ -1,41 +1,3 @@
html, body {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
}
h1:focus {
outline: none;
}
a, .btn-link {
color: #0071c1;
}
.btn-primary {
color: #fff;
background-color: #1b6ec2;
border-color: #1861ac;
}
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
}
.content {
padding-top: 1.1rem;
}
.valid.modified:not([type=checkbox]) {
outline: 1px solid #26b050;
}
.invalid {
outline: 1px solid red;
}
.validation-message {
color: red;
}
#blazor-error-ui {
background: lightyellow;
bottom: 0;
@@ -48,12 +10,12 @@ a, .btn-link {
z-index: 1000;
}
#blazor-error-ui .dismiss {
cursor: pointer;
position: absolute;
right: 0.75rem;
top: 0.5rem;
}
#blazor-error-ui .dismiss {
cursor: pointer;
position: absolute;
right: 0.75rem;
top: 0.5rem;
}
.blazor-error-boundary {
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
@@ -61,43 +23,14 @@ a, .btn-link {
color: white;
}
.blazor-error-boundary::after {
content: "An error has occurred."
}
.loading-progress {
position: relative;
display: block;
width: 8rem;
height: 8rem;
margin: 20vh auto 1rem auto;
.blazor-error-boundary::after {
content: "An error has occurred."
}
.loading-progress circle {
fill: none;
stroke: #e0e0e0;
stroke-width: 0.6rem;
transform-origin: 50% 50%;
transform: rotate(-90deg);
}
.loading-progress circle:last-child {
stroke: #1b6ec2;
stroke-dasharray: calc(3.141 * var(--blazor-load-percentage, 0%) * 0.8), 500%;
transition: stroke-dasharray 0.05s ease-in-out;
}
.loading-progress-text {
position: absolute;
text-align: center;
font-weight: bold;
inset: calc(20vh + 3.25rem) 0 auto 0.2rem;
.loading-progress-text:after {
content: var(--blazor-load-percentage-text, "Loading");
}
.loading-progress-text:after {
content: var(--blazor-load-percentage-text, "Loading");
}
code {
color: #c02d76;
.validation-message {
color: red;
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -33,12 +33,26 @@
</head>
<body class="bg-gray-50 dark:bg-gray-800">
<div id="loading-screen">
<div class="fixed inset-0 flex items-center justify-center">
<div class="relative p-8 bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md mx-auto">
<div class="text-center">
<svg class="mx-auto animate-spin h-12 w-12 text-primary-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<h2 class="mt-4 text-xl font-semibold text-gray-900 dark:text-white">AliasVault is loading</h2>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Initializing secure environment. AliasVault prioritizes your privacy, which may take a few seconds.</p>
<div class="loading-progress-text text-sm font-medium text-gray-700 mt-4"></div>
<div class="mt-6 text-center">
<p id="security-quote" class="text-sm text-primary-600 italic"></p>
</div>
</div>
</div>
</div>
</div>
<div id="app">
<svg class="loading-progress">
<circle r="40%" cx="50%" cy="50%" />
<circle r="40%" cx="50%" cy="50%" />
</svg>
<div class="loading-progress-text"></div>
</div>
<div id="blazor-error-ui">
@@ -47,8 +61,48 @@
<a class="dismiss">🗙</a>
</div>
<script src="js/dark-mode.js?v=@CacheBuster"></script>
<script src="js/cryptoInterop.js?v=@CacheBuster"></script>
<script>
const securityQuotes = [
"Your identity is your most valuable asset. Protect it like one.",
"In the digital world, a strong password is your first line of defense.",
"Security is not a product, but a process.",
"The weakest link in the security chain is the human element.",
"Security is always excessive until it's not enough.",
"Trust, but verify - especially online.",
"Your data is only as secure as your weakest password.",
"The most secure password is the one you can't remember.",
];
const quoteElement = document.getElementById('security-quote');
const randomIndex = Math.floor(Math.random() * securityQuotes.length);
quoteElement.textContent = `"${securityQuotes[randomIndex]}"`;
window.addEventListener('load', function() {
const startTime = new Date().getTime();
const minDisplayTime = 1000;
function hideLoadingScreen() {
document.getElementById('loading-screen').style.display = 'none';
document.getElementById('app').style.removeProperty('visibility');
}
function checkElapsedTime() {
const currentTime = new Date().getTime();
const elapsedTime = currentTime - startTime;
if (elapsedTime >= minDisplayTime) {
hideLoadingScreen();
} else {
setTimeout(hideLoadingScreen, minDisplayTime - elapsedTime);
}
}
document.getElementById('app').style.visibility = 'hidden';
document.getElementById('loading-screen').style.display = 'flex';
checkElapsedTime();
});
</script>
<script src="js/crypto.js?v=@CacheBuster"></script>
<script src="js/utilities.js?v=@CacheBuster"></script>
<script src="_framework/blazor.webassembly.js?v=@CacheBuster"></script>
</body>

View File

@@ -1,53 +0,0 @@
function initDarkModeSwitcher() {
const themeToggleDarkIcon = document.getElementById('theme-toggle-dark-icon');
const themeToggleLightIcon = document.getElementById('theme-toggle-light-icon');
if (themeToggleDarkIcon === null && themeToggleLightIcon === null) {
return;
}
if (localStorage.getItem('color-theme')) {
if (localStorage.getItem('color-theme') === 'light') {
document.documentElement.classList.remove('dark');
themeToggleLightIcon.classList.remove('hidden');
} else {
document.documentElement.classList.add('dark');
themeToggleDarkIcon.classList.remove('hidden');
}
}
else {
// Default to light mode if not set.
document.documentElement.classList.remove('dark');
themeToggleLightIcon.classList.remove('hidden');
}
const themeToggleBtn = document.getElementById('theme-toggle');
let event = new Event('dark-mode');
themeToggleBtn.addEventListener('click', function () {
// toggle icons
themeToggleDarkIcon.classList.toggle('hidden');
themeToggleLightIcon.classList.toggle('hidden');
// if set via local storage previously
if (localStorage.getItem('color-theme')) {
if (localStorage.getItem('color-theme') === 'light') {
document.documentElement.classList.add('dark');
localStorage.setItem('color-theme', 'dark');
} else {
document.documentElement.classList.remove('dark');
localStorage.setItem('color-theme', 'light');
}
// if NOT set via local storage previously
} else if (document.documentElement.classList.contains('dark')) {
document.documentElement.classList.remove('dark');
localStorage.setItem('color-theme', 'light');
} else {
document.documentElement.classList.add('dark');
localStorage.setItem('color-theme', 'dark');
}
document.dispatchEvent(event);
});
}

View File

@@ -1,16 +1,18 @@
// clickOutsideHandler.js
let currentHandler = null;
let currentDotNetHelper = null;
export function registerClickOutsideHandler(dotNetHelper, contentId, methodName) {
export function registerClickOutsideHandler(dotNetHelper, contentIds, methodName) {
unregisterClickOutsideHandler();
currentDotNetHelper = dotNetHelper;
currentHandler = (event) => {
const content = document.getElementById(contentId);
if (!content) return;
const idArray = Array.isArray(contentIds) ? contentIds : contentIds.split(',').map(id => id.trim());
currentHandler = (event) => {
const isOutside = idArray.every(id => {
const content = document.getElementById(id);
return !content?.contains(event.target);
});
const isOutside = !content.contains(event.target);
const isEscapeKey = event.type === 'keydown' && event.key === 'Escape';
if (isOutside || isEscapeKey) {

View File

@@ -0,0 +1,40 @@
let shortcuts = {};
let lastKeyPressed = '';
let lastKeyPressTime = 0;
document.addEventListener('keydown', handleKeyPress);
export function handleKeyPress(event) {
if (event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA') {
return;
}
const currentTime = new Date().getTime();
const key = event.key.toLowerCase();
if (currentTime - lastKeyPressTime > 1000) {
lastKeyPressed = '';
}
lastKeyPressed += key;
lastKeyPressTime = currentTime;
const shortcut = shortcuts[lastKeyPressed];
if (shortcut) {
event.preventDefault();
shortcut.dotNetHelper.invokeMethodAsync('Invoke', lastKeyPressed);
lastKeyPressed = '';
}
}
export function registerShortcut(keys, dotNetHelper) {
shortcuts[keys.toLowerCase()] = { dotNetHelper: dotNetHelper };
}
export function unregisterShortcut(keys) {
delete shortcuts[keys.toLowerCase()];
}
export function unregisterAllShortcuts() {
shortcuts = {};
}

View File

@@ -0,0 +1,25 @@
window.getWindowWidth = function() {
return window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth;
};
window.getElementRect = function(element) {
if (element) {
const rect = {
left: element.offsetLeft,
top: element.offsetTop,
right: element.offsetLeft + element.offsetWidth,
bottom: element.offsetTop + element.offsetHeight,
width: element.offsetWidth,
height: element.offsetHeight
};
let parent = element.offsetParent;
while (parent) {
rect.left += parent.offsetLeft;
rect.top += parent.offsetTop;
parent = parent.offsetParent;
}
rect.right = rect.left + rect.width;
rect.bottom = rect.top + rect.height;
return rect;
}
return null;
};

View File

@@ -10,10 +10,6 @@ function downloadFileFromStream(fileName, contentStreamReference) {
URL.revokeObjectURL(url);
}
window.initTopMenu = function() {
initDarkModeSwitcher();
};
window.topMenuClickOutsideHandler = (dotNetHelper) => {
document.addEventListener('click', (event) => {
const menu = document.getElementById('userMenuDropdown');
@@ -43,3 +39,76 @@ window.clipboardCopy = {
window.blazorNavigate = (url) => {
Blazor.navigateTo(url);
};
window.focusElement = (elementId) => {
const element = document.getElementById(elementId);
if (element) {
element.focus();
}
};
window.blurElement = (elementId) => {
const element = document.getElementById(elementId);
if (element) {
element.blur();
}
};
function initDarkModeSwitcher() {
const themeToggleDarkIcon = document.getElementById('theme-toggle-dark-icon');
const themeToggleLightIcon = document.getElementById('theme-toggle-light-icon');
if (themeToggleDarkIcon === null && themeToggleLightIcon === null) {
return;
}
if (localStorage.getItem('color-theme')) {
if (localStorage.getItem('color-theme') === 'light') {
document.documentElement.classList.remove('dark');
themeToggleLightIcon.classList.remove('hidden');
} else {
document.documentElement.classList.add('dark');
themeToggleDarkIcon.classList.remove('hidden');
}
}
else {
// Default to light mode if not set.
document.documentElement.classList.remove('dark');
themeToggleLightIcon.classList.remove('hidden');
}
const themeToggleBtn = document.getElementById('theme-toggle');
let event = new Event('dark-mode');
themeToggleBtn.addEventListener('click', function () {
// toggle icons
themeToggleDarkIcon.classList.toggle('hidden');
themeToggleLightIcon.classList.toggle('hidden');
// if set via local storage previously
if (localStorage.getItem('color-theme')) {
if (localStorage.getItem('color-theme') === 'light') {
document.documentElement.classList.add('dark');
localStorage.setItem('color-theme', 'dark');
} else {
document.documentElement.classList.remove('dark');
localStorage.setItem('color-theme', 'light');
}
// if NOT set via local storage previously
} else if (document.documentElement.classList.contains('dark')) {
document.documentElement.classList.remove('dark');
localStorage.setItem('color-theme', 'light');
} else {
document.documentElement.classList.add('dark');
localStorage.setItem('color-theme', 'dark');
}
document.dispatchEvent(event);
});
}
window.initTopMenu = function() {
initDarkModeSwitcher();
};

View File

@@ -0,0 +1,29 @@
//-----------------------------------------------------------------------
// <copyright file="MailboxBulkRequest.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.Email;
/// <summary>
/// MailboxBulkRequest model for retrieving recent emails from multiple emailboxes.
/// </summary>
public class MailboxBulkRequest
{
/// <summary>
/// Gets or sets the mailbox addresses that client wants to retrieve emails for.
/// </summary>
public List<string> Addresses { get; set; } = [];
/// <summary>
/// Gets or sets requested page number.
/// </summary>
public int Page { get; set; }
/// <summary>
/// Gets or sets requested page size.
/// </summary>
public int PageSize { get; set; }
}

View File

@@ -0,0 +1,41 @@
//-----------------------------------------------------------------------
// <copyright file="MailboxBulkResponse.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.Email;
using AliasVault.Shared.Models.Spamok;
/// <summary>
/// Represents a mailbox API model.
/// </summary>
public class MailboxBulkResponse
{
/// <summary>
/// Gets or sets the mailbox addresses that client wants to retrieve emails for.
/// </summary>
public List<string> Addresses { get; set; } = new();
/// <summary>
/// Gets or sets requested page number.
/// </summary>
public int CurrentPage { get; set; }
/// <summary>
/// Gets or sets requested page size.
/// </summary>
public int PageSize { get; set; }
/// <summary>
/// Gets or sets total number of emails.
/// </summary>
public int TotalRecords { get; set; }
/// <summary>
/// Gets or sets the list of mailbox email API models.
/// </summary>
public List<MailboxEmailApiModel> Mails { get; set; } = [];
}

View File

@@ -53,65 +53,12 @@ public class Alias
/// </summary>
public DateTime BirthDate { get; set; }
/// <summary>
/// Gets or sets the address street.
/// </summary>
[StringLength(255)]
[Column(TypeName = "VARCHAR")]
public string? AddressStreet { get; set; }
/// <summary>
/// Gets or sets the address city.
/// </summary>
[StringLength(255)]
[Column(TypeName = "VARCHAR")]
public string? AddressCity { get; set; }
/// <summary>
/// Gets or sets the address state.
/// </summary>
[StringLength(255)]
[Column(TypeName = "VARCHAR")]
public string? AddressState { get; set; }
/// <summary>
/// Gets or sets the address zip code.
/// </summary>
[StringLength(255)]
[Column(TypeName = "VARCHAR")]
public string? AddressZipCode { get; set; }
/// <summary>
/// Gets or sets the address country.
/// </summary>
[StringLength(255)]
[Column(TypeName = "VARCHAR")]
public string? AddressCountry { get; set; }
/// <summary>
/// Gets or sets the hobbies in CSV format, can contain multiple values separated by ";".
/// </summary>
[StringLength(255)]
public string? Hobbies { get; set; }
/// <summary>
/// Gets or sets the generated email.
/// </summary>
[StringLength(255)]
public string? Email { get; set; }
/// <summary>
/// Gets or sets the random generated mobile phone number.
/// </summary>
[StringLength(255)]
public string? PhoneMobile { get; set; }
/// <summary>
/// Gets or sets the generated IBAN bank account number.
/// </summary>
[StringLength(255)]
public string? BankAccountIBAN { get; set; }
/// <summary>
/// Gets or sets the created timestamp.
/// </summary>

View File

@@ -39,7 +39,7 @@ public class Credential
/// <summary>
/// Gets or sets the username field.
/// </summary>
public string Username { get; set; } = null!;
public string? Username { get; set; }
/// <summary>
/// Gets or sets the password objects.

View File

@@ -0,0 +1,296 @@
// <auto-generated />
using System;
using AliasClientDb;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace AliasClientDb.Migrations
{
[DbContext(typeof(AliasClientDbContext))]
[Migration("20240805122422_1.3.0-UpdateIdentityStructure")]
partial class _130UpdateIdentityStructure
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.7")
.HasAnnotation("Proxies:ChangeTracking", false)
.HasAnnotation("Proxies:CheckEquality", false)
.HasAnnotation("Proxies:LazyLoading", true);
modelBuilder.Entity("AliasClientDb.Alias", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("BirthDate")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("FirstName")
.HasMaxLength(255)
.HasColumnType("VARCHAR");
b.Property<string>("Gender")
.HasMaxLength(255)
.HasColumnType("VARCHAR");
b.Property<string>("LastName")
.HasMaxLength(255)
.HasColumnType("VARCHAR");
b.Property<string>("NickName")
.HasMaxLength(255)
.HasColumnType("VARCHAR");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Aliases");
});
modelBuilder.Entity("AliasClientDb.Attachment", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<byte[]>("Blob")
.IsRequired()
.HasColumnType("BLOB");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("CredentialId")
.HasColumnType("TEXT");
b.Property<string>("Filename")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CredentialId");
b.ToTable("Attachment");
});
modelBuilder.Entity("AliasClientDb.Credential", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("AliasId")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasColumnType("TEXT");
b.Property<Guid>("ServiceId")
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("AliasId");
b.HasIndex("ServiceId");
b.ToTable("Credentials");
});
modelBuilder.Entity("AliasClientDb.EncryptionKey", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsPrimary")
.HasColumnType("INTEGER");
b.Property<string>("PrivateKey")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<string>("PublicKey")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("EncryptionKeys");
});
modelBuilder.Entity("AliasClientDb.Password", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("CredentialId")
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CredentialId");
b.ToTable("Passwords");
});
modelBuilder.Entity("AliasClientDb.Service", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<byte[]>("Logo")
.HasColumnType("BLOB");
b.Property<string>("Name")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Url")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Services");
});
modelBuilder.Entity("AliasClientDb.Setting", b =>
{
b.Property<string>("Key")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("Key");
b.ToTable("Settings");
});
modelBuilder.Entity("AliasClientDb.Attachment", b =>
{
b.HasOne("AliasClientDb.Credential", "Credential")
.WithMany("Attachments")
.HasForeignKey("CredentialId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Credential");
});
modelBuilder.Entity("AliasClientDb.Credential", b =>
{
b.HasOne("AliasClientDb.Alias", "Alias")
.WithMany("Credentials")
.HasForeignKey("AliasId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("AliasClientDb.Service", "Service")
.WithMany("Credentials")
.HasForeignKey("ServiceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Alias");
b.Navigation("Service");
});
modelBuilder.Entity("AliasClientDb.Password", b =>
{
b.HasOne("AliasClientDb.Credential", "Credential")
.WithMany("Passwords")
.HasForeignKey("CredentialId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Credential");
});
modelBuilder.Entity("AliasClientDb.Alias", b =>
{
b.Navigation("Credentials");
});
modelBuilder.Entity("AliasClientDb.Credential", b =>
{
b.Navigation("Attachments");
b.Navigation("Passwords");
});
modelBuilder.Entity("AliasClientDb.Service", b =>
{
b.Navigation("Credentials");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,107 @@
// <auto-generated />
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace AliasClientDb.Migrations
{
/// <inheritdoc />
public partial class _130UpdateIdentityStructure : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AddressCity",
table: "Aliases");
migrationBuilder.DropColumn(
name: "AddressCountry",
table: "Aliases");
migrationBuilder.DropColumn(
name: "AddressState",
table: "Aliases");
migrationBuilder.DropColumn(
name: "AddressStreet",
table: "Aliases");
migrationBuilder.DropColumn(
name: "AddressZipCode",
table: "Aliases");
migrationBuilder.DropColumn(
name: "BankAccountIBAN",
table: "Aliases");
migrationBuilder.DropColumn(
name: "Hobbies",
table: "Aliases");
migrationBuilder.DropColumn(
name: "PhoneMobile",
table: "Aliases");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "AddressCity",
table: "Aliases",
type: "VARCHAR",
maxLength: 255,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "AddressCountry",
table: "Aliases",
type: "VARCHAR",
maxLength: 255,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "AddressState",
table: "Aliases",
type: "VARCHAR",
maxLength: 255,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "AddressStreet",
table: "Aliases",
type: "VARCHAR",
maxLength: 255,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "AddressZipCode",
table: "Aliases",
type: "VARCHAR",
maxLength: 255,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "BankAccountIBAN",
table: "Aliases",
type: "TEXT",
maxLength: 255,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Hobbies",
table: "Aliases",
type: "TEXT",
maxLength: 255,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "PhoneMobile",
table: "Aliases",
type: "TEXT",
maxLength: 255,
nullable: true);
}
}
}

View File

@@ -0,0 +1,295 @@
// <auto-generated />
using System;
using AliasClientDb;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace AliasClientDb.Migrations
{
[DbContext(typeof(AliasClientDbContext))]
[Migration("20240812141727_1.3.1-MakeUsernameOptional")]
partial class _131MakeUsernameOptional
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.7")
.HasAnnotation("Proxies:ChangeTracking", false)
.HasAnnotation("Proxies:CheckEquality", false)
.HasAnnotation("Proxies:LazyLoading", true);
modelBuilder.Entity("AliasClientDb.Alias", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("BirthDate")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("FirstName")
.HasMaxLength(255)
.HasColumnType("VARCHAR");
b.Property<string>("Gender")
.HasMaxLength(255)
.HasColumnType("VARCHAR");
b.Property<string>("LastName")
.HasMaxLength(255)
.HasColumnType("VARCHAR");
b.Property<string>("NickName")
.HasMaxLength(255)
.HasColumnType("VARCHAR");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Aliases");
});
modelBuilder.Entity("AliasClientDb.Attachment", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<byte[]>("Blob")
.IsRequired()
.HasColumnType("BLOB");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("CredentialId")
.HasColumnType("TEXT");
b.Property<string>("Filename")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CredentialId");
b.ToTable("Attachment");
});
modelBuilder.Entity("AliasClientDb.Credential", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("AliasId")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasColumnType("TEXT");
b.Property<Guid>("ServiceId")
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Username")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("AliasId");
b.HasIndex("ServiceId");
b.ToTable("Credentials");
});
modelBuilder.Entity("AliasClientDb.EncryptionKey", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsPrimary")
.HasColumnType("INTEGER");
b.Property<string>("PrivateKey")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<string>("PublicKey")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("EncryptionKeys");
});
modelBuilder.Entity("AliasClientDb.Password", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("CredentialId")
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CredentialId");
b.ToTable("Passwords");
});
modelBuilder.Entity("AliasClientDb.Service", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<byte[]>("Logo")
.HasColumnType("BLOB");
b.Property<string>("Name")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Url")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Services");
});
modelBuilder.Entity("AliasClientDb.Setting", b =>
{
b.Property<string>("Key")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("Key");
b.ToTable("Settings");
});
modelBuilder.Entity("AliasClientDb.Attachment", b =>
{
b.HasOne("AliasClientDb.Credential", "Credential")
.WithMany("Attachments")
.HasForeignKey("CredentialId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Credential");
});
modelBuilder.Entity("AliasClientDb.Credential", b =>
{
b.HasOne("AliasClientDb.Alias", "Alias")
.WithMany("Credentials")
.HasForeignKey("AliasId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("AliasClientDb.Service", "Service")
.WithMany("Credentials")
.HasForeignKey("ServiceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Alias");
b.Navigation("Service");
});
modelBuilder.Entity("AliasClientDb.Password", b =>
{
b.HasOne("AliasClientDb.Credential", "Credential")
.WithMany("Passwords")
.HasForeignKey("CredentialId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Credential");
});
modelBuilder.Entity("AliasClientDb.Alias", b =>
{
b.Navigation("Credentials");
});
modelBuilder.Entity("AliasClientDb.Credential", b =>
{
b.Navigation("Attachments");
b.Navigation("Passwords");
});
modelBuilder.Entity("AliasClientDb.Service", b =>
{
b.Navigation("Credentials");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,37 @@
// <auto-generated />
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace AliasClientDb.Migrations
{
/// <inheritdoc />
public partial class _131MakeUsernameOptional : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Username",
table: "Credentials",
type: "TEXT",
nullable: true,
oldClrType: typeof(string),
oldType: "TEXT");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Username",
table: "Credentials",
type: "TEXT",
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldType: "TEXT",
oldNullable: true);
}
}
}

View File

@@ -27,30 +27,6 @@ namespace AliasClientDb.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("AddressCity")
.HasMaxLength(255)
.HasColumnType("VARCHAR");
b.Property<string>("AddressCountry")
.HasMaxLength(255)
.HasColumnType("VARCHAR");
b.Property<string>("AddressState")
.HasMaxLength(255)
.HasColumnType("VARCHAR");
b.Property<string>("AddressStreet")
.HasMaxLength(255)
.HasColumnType("VARCHAR");
b.Property<string>("AddressZipCode")
.HasMaxLength(255)
.HasColumnType("VARCHAR");
b.Property<string>("BankAccountIBAN")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<DateTime>("BirthDate")
.HasColumnType("TEXT");
@@ -69,10 +45,6 @@ namespace AliasClientDb.Migrations
.HasMaxLength(255)
.HasColumnType("VARCHAR");
b.Property<string>("Hobbies")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("LastName")
.HasMaxLength(255)
.HasColumnType("VARCHAR");
@@ -81,10 +53,6 @@ namespace AliasClientDb.Migrations
.HasMaxLength(255)
.HasColumnType("VARCHAR");
b.Property<string>("PhoneMobile")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
@@ -146,7 +114,6 @@ namespace AliasClientDb.Migrations
.HasColumnType("TEXT");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");

View File

@@ -0,0 +1,16 @@
This directory contains various scripts for the SMTP service.
In order to use the scripts you will need to give execute permissions to the scripts. You can do this by running the following command in the terminal:
```bash
$ chmod +x sendEmailCLI.sh
```
Then you can run the script by running the following command in the terminal:
```bash
$ ./sendEmailCLI.sh
```
## Scripts
- `sendEmailCLI.sh`: A script to send an email from the command line to the local SMTP server for testing purposes.

View File

@@ -1 +0,0 @@
curl --url "smtp://localhost:25" --mail-from "sender@example.com" --mail-rcpt "test@example.tld" --upload-file testEmail1.txt

View File

@@ -0,0 +1,67 @@
#!/bin/bash
generate_random_string() {
cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w ${1:-10} | head -n 1
}
print_logo() {
printf "${MAGENTA}\n"
printf "=========================================================\n"
printf " _ _ __ __ _ _ \n"
printf " /\ | (_) \ \ / / | | | \n"
printf " / \ | |_ __ _ __\ \ / /_ _ _ _| | |_\n"
printf " / /\ \ | | |/ _ / __\ \/ / _ | | | | | __|\n"
printf " / ____ \| | | (_| \__ \\ / (_| | |_| | | |_ \n"
printf " /_/ \_\_|_|\__,_|___/ \/ \__,_|\__,_|_|\__|\n"
printf "\n"
printf " Email sender DevTool\n"
printf "=========================================================\n"
printf "This tool sends an email to the recipient of your choice\n"
printf "and delivers it to the local SMTP server running on localhost:25.\n"
printf "${NC}\n"
}
send_email() {
local recipient="$1"
local subject_suffix=$(generate_random_string 8)
local content_suffix=$(generate_random_string 20)
cat > temp_email.txt << EOF
From: sender@example.com
To: $recipient
Subject: Test Email - $subject_suffix
This is a test email.
Random content: $content_suffix
EOF
curl --url "smtp://localhost:25" \
--mail-from "sender@example.com" \
--mail-rcpt "$recipient" \
--upload-file temp_email.txt
rm temp_email.txt
}
print_logo
while true; do
if [[ -z "$recipient" ]]; then
read -p "Enter the recipient's email address: " recipient
fi
send_email "$recipient"
read -p "Send another email? (Press Enter for same recipient, or type a new email, or 'q' to quit): " next_action
if [[ "$next_action" == "q" ]]; then
echo "Exiting the script. Goodbye!"
exit 0
elif [[ -n "$next_action" ]]; then
recipient="$next_action"
else
# If next_action is empty (user pressed Enter), keep the same recipient
:
fi
done

Some files were not shown because too many files have changed in this diff Show More