mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-05-18 21:40:41 -04:00
Add multistep import flow (#542)
This commit is contained in:
committed by
Leendert de Borst
parent
c00e6c6a4d
commit
a2c2caed79
@@ -1,28 +1,54 @@
|
||||
@inject ILogger<ImportServiceAliasVault> Logger
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject GlobalNotificationService GlobalNotificationService
|
||||
@using AliasVault.ImportExport.Models
|
||||
|
||||
<ImportServiceCard
|
||||
ServiceName="AliasVault"
|
||||
Description="Import passwords from another AliasVault instance or manual back-up"
|
||||
LogoUrl="img/logo.svg"
|
||||
OnImportComplete="RefreshVault"
|
||||
>
|
||||
OnImportConfirmed="ProcessFile"
|
||||
@ref="_importServiceCard">
|
||||
<div class="mb-4">
|
||||
<p class="mb-4 text-gray-700 dark:text-gray-300">Upload your AliasVault export file:</p>
|
||||
<InputFile OnChange="HandleFileUpload" class="text-gray-700 dark:text-gray-300 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100 dark:file:bg-gray-700 dark:file:text-gray-300 dark:hover:file:bg-gray-600" />
|
||||
</div>
|
||||
<InputFile OnChange="HandleFileUpload" />
|
||||
</ImportServiceCard>
|
||||
|
||||
@code {
|
||||
private async Task HandleFileUpload(InputFileChangeEventArgs e)
|
||||
private ImportServiceCard _importServiceCard = null!;
|
||||
private IBrowserFile? _selectedFile;
|
||||
|
||||
private void HandleFileUpload(InputFileChangeEventArgs e)
|
||||
{
|
||||
// AliasVault specific file upload and parsing logic
|
||||
await Task.Delay(500);
|
||||
Logger.LogInformation($"Processing AliasVault file: {e.File.Name}");
|
||||
Logger.LogInformation($"File selected: {e.File.Name}");
|
||||
_selectedFile = e.File;
|
||||
}
|
||||
|
||||
private async Task ProcessFile()
|
||||
{
|
||||
if (_selectedFile == null)
|
||||
{
|
||||
throw new ArgumentException("Please select a valid AliasVault export file to import");
|
||||
}
|
||||
|
||||
Logger.LogInformation($"Processing AliasVault file: {_selectedFile.Name}");
|
||||
|
||||
try
|
||||
{
|
||||
await using var stream = _selectedFile.OpenReadStream();
|
||||
using var reader = new StreamReader(stream);
|
||||
var fileContents = await reader.ReadToEndAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
throw new ArgumentException("Error processing AliasVault file. Please check the file format and try again.");
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshVault()
|
||||
{
|
||||
// Refresh the vault
|
||||
// ...
|
||||
GlobalNotificationService.AddSuccessMessage("AliasVault import successful!");
|
||||
}
|
||||
}
|
||||
@@ -12,14 +12,13 @@
|
||||
@ref="_importServiceCard">
|
||||
<div class="mb-4">
|
||||
<p class="mb-4 text-gray-700 dark:text-gray-300">Upload your Bitwarden CSV export file:</p>
|
||||
<InputFile OnChange="HandleFileUpload" class="text-gray-700 dark:text-gray-300 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100 dark:file:bg-gray-700 dark:file:text-gray-300 dark:hover:file:bg-gray-600" />
|
||||
</div>
|
||||
<InputFile OnChange="HandleFileUpload" />
|
||||
</ImportServiceCard>
|
||||
|
||||
@code {
|
||||
private ImportServiceCard _importServiceCard = null!;
|
||||
|
||||
private IBrowserFile? _selectedFile;
|
||||
private IBrowserFile? _selectedFile;
|
||||
|
||||
private void HandleFileUpload(InputFileChangeEventArgs e)
|
||||
{
|
||||
@@ -29,13 +28,12 @@
|
||||
|
||||
private async Task ProcessFile()
|
||||
{
|
||||
if (_selectedFile == null)
|
||||
if (_selectedFile == null || string.IsNullOrEmpty(_selectedFile.Name))
|
||||
{
|
||||
Logger.LogWarning("No file selected for import");
|
||||
return;
|
||||
throw new ArgumentException("Please select a valid Bitwarden CSV file to import");
|
||||
}
|
||||
|
||||
Logger.LogInformation($"Processing KeePass file: {_selectedFile.Name}");
|
||||
Logger.LogInformation($"Processing Bitwarden file: {_selectedFile.Name}");
|
||||
|
||||
try
|
||||
{
|
||||
@@ -46,15 +44,14 @@
|
||||
var importCredentials = await BitwardenImporter.ImportFromCsvAsync(fileContents);
|
||||
_importServiceCard.SetImportedCredentials(importCredentials);
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch
|
||||
{
|
||||
Logger.LogError(ex, "Error processing KeePass CSV file");
|
||||
throw;
|
||||
throw new ArgumentException("Error processing Bitwarden CSV file. Please check the file format and try again.");
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshVault()
|
||||
{
|
||||
GlobalNotificationService.AddSuccessMessage("Bitwarden CSV import successful!");
|
||||
NavigationManager.NavigateTo("/credentials");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject CredentialService CredentialService
|
||||
@inject DbService DbService
|
||||
@inject NavigationManager NavigationManager
|
||||
@using AliasVault.ImportExport.Importers
|
||||
@using AliasVault.ImportExport.Models
|
||||
|
||||
@@ -53,13 +54,70 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
@ChildContent
|
||||
}
|
||||
@switch (CurrentStep)
|
||||
{
|
||||
case ImportStep.FileUpload:
|
||||
@if (!string.IsNullOrEmpty(ImportError))
|
||||
{
|
||||
<div class="mb-4 p-4 text-red-700 bg-red-100 rounded-lg dark:bg-red-200 dark:text-red-800" role="alert">
|
||||
@ImportError
|
||||
</div>
|
||||
}
|
||||
<div class="mb-4">
|
||||
@ChildContent
|
||||
</div>
|
||||
<div class="flex justify-end mt-6 space-x-2">
|
||||
<Button OnClick="@CloseModal" Color="secondary">Cancel</Button>
|
||||
<Button OnClick="@HandleNextStep" Color="primary">Next</Button>
|
||||
</div>
|
||||
break;
|
||||
|
||||
<div class="flex justify-end mt-6 space-x-2">
|
||||
<Button OnClick="@CloseModal" Color="secondary">Cancel</Button>
|
||||
<Button OnClick="@HandleModalConfirm" Color="primary">Import</Button>
|
||||
</div>
|
||||
case ImportStep.Preview:
|
||||
<div class="mb-4">
|
||||
<p class="mb-4 text-gray-700 dark:text-gray-300">Check if the credentials are correct before importing:</p>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Service</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Username</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Password</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
@foreach (var credential in ImportedCredentials.Take(10))
|
||||
{
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">@credential.ServiceName</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">@credential.Username</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">@(new string('*', credential.Password?.Length ?? 0))</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
@if (ImportedCredentials.Count > 10)
|
||||
{
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">... and @(ImportedCredentials.Count - 10) more credentials</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end mt-6 space-x-2">
|
||||
<Button OnClick="@HandlePreviousStep" Color="secondary">Back</Button>
|
||||
<Button OnClick="@HandleNextStep" Color="primary">Next</Button>
|
||||
</div>
|
||||
break;
|
||||
|
||||
case ImportStep.Confirm:
|
||||
<div class="mb-4">
|
||||
<p class="mb-4 text-gray-700 dark:text-gray-300">Are you sure you want to import @ImportedCredentials.Count credentials?</p>
|
||||
</div>
|
||||
<div class="flex justify-end mt-6 space-x-2">
|
||||
<Button OnClick="@HandlePreviousStep" Color="secondary">Back</Button>
|
||||
<Button OnClick="@HandleModalConfirm" Color="primary">Import</Button>
|
||||
</div>
|
||||
break;
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
@@ -67,6 +125,13 @@
|
||||
}
|
||||
|
||||
@code {
|
||||
private enum ImportStep
|
||||
{
|
||||
FileUpload,
|
||||
Preview,
|
||||
Confirm
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
@@ -96,8 +161,9 @@
|
||||
|
||||
private bool IsModalOpen { get; set; } = false;
|
||||
private bool IsImporting { get; set; } = false;
|
||||
private string? ImportErrorMessage { get; set; }
|
||||
private string? ImportError { get; set; }
|
||||
private string? ImportSuccessMessage { get; set; }
|
||||
private ImportStep CurrentStep { get; set; } = ImportStep.FileUpload;
|
||||
|
||||
/// <summary>
|
||||
/// Child content which is shown in the modal popup. This can contain custom instructions.
|
||||
@@ -125,6 +191,7 @@
|
||||
protected virtual void OpenImportModal()
|
||||
{
|
||||
IsModalOpen = true;
|
||||
CurrentStep = ImportStep.FileUpload;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
@@ -134,8 +201,50 @@
|
||||
protected virtual void CloseModal()
|
||||
{
|
||||
IsModalOpen = false;
|
||||
ImportErrorMessage = null;
|
||||
CurrentStep = ImportStep.FileUpload;
|
||||
ImportError = null;
|
||||
ImportSuccessMessage = null;
|
||||
ImportedCredentials.Clear();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
protected virtual async Task HandleNextStep()
|
||||
{
|
||||
if (CurrentStep == ImportStep.FileUpload)
|
||||
{
|
||||
try
|
||||
{
|
||||
ImportError = null;
|
||||
await OnImportConfirmed.InvokeAsync();
|
||||
if (ImportedCredentials.Count > 0)
|
||||
{
|
||||
CurrentStep = ImportStep.Preview;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ImportError = ex.Message;
|
||||
Logger.LogError(ex, "Error during import confirmation");
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
else if (CurrentStep == ImportStep.Preview)
|
||||
{
|
||||
CurrentStep = ImportStep.Confirm;
|
||||
}
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
protected virtual void HandlePreviousStep()
|
||||
{
|
||||
if (CurrentStep == ImportStep.Confirm)
|
||||
{
|
||||
CurrentStep = ImportStep.Preview;
|
||||
}
|
||||
else if (CurrentStep == ImportStep.Preview)
|
||||
{
|
||||
CurrentStep = ImportStep.FileUpload;
|
||||
}
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
@@ -150,15 +259,12 @@
|
||||
}
|
||||
|
||||
IsImporting = true;
|
||||
ImportErrorMessage = null;
|
||||
ImportError = null;
|
||||
ImportSuccessMessage = null;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
// Call the OnImportConfirmed callback first
|
||||
await OnImportConfirmed.InvokeAsync();
|
||||
|
||||
// Convert imported credentials to AliasVault format and insert them to the database.
|
||||
var credentials = BaseImporter.ConvertToCredential(ImportedCredentials);
|
||||
foreach (var credential in credentials)
|
||||
@@ -171,24 +277,23 @@
|
||||
if (success)
|
||||
{
|
||||
ImportSuccessMessage = $"Successfully imported {ImportedCredentials.Count} credentials.";
|
||||
await OnImportComplete.InvokeAsync();
|
||||
NavigationManager.NavigateTo("/credentials");
|
||||
}
|
||||
else
|
||||
{
|
||||
ImportErrorMessage = "Error saving database.";
|
||||
ImportError = "Error saving database.";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Error importing credentials");
|
||||
ImportErrorMessage = $"Error importing credentials: {ex.Message}";
|
||||
ImportError = $"Error importing credentials: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsImporting = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
await OnImportComplete.InvokeAsync();
|
||||
CloseModal();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
@ref="_importServiceCard">
|
||||
<div class="mb-4">
|
||||
<p class="mb-4 text-gray-700 dark:text-gray-300">Upload your KeePass export file:</p>
|
||||
<InputFile OnChange="HandleFileUpload" class="text-gray-700 dark:text-gray-300 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100 dark:file:bg-gray-700 dark:file:text-gray-300 dark:hover:file:bg-gray-600" />
|
||||
</div>
|
||||
<InputFile OnChange="HandleFileUpload" />
|
||||
</ImportServiceCard>
|
||||
|
||||
@code {
|
||||
@@ -28,12 +28,10 @@
|
||||
|
||||
private async Task ProcessFile()
|
||||
{
|
||||
if (_selectedFile == null)
|
||||
if (_selectedFile == null || string.IsNullOrEmpty(_selectedFile.Name))
|
||||
{
|
||||
Logger.LogWarning("No file selected for import");
|
||||
return;
|
||||
throw new ArgumentException("Please select a valid KeePass CSV file to import");
|
||||
}
|
||||
|
||||
Logger.LogInformation($"Processing KeePass file: {_selectedFile.Name}");
|
||||
|
||||
try
|
||||
@@ -45,16 +43,14 @@
|
||||
var importCredentials = await KeePassImporter.ImportFromCsvAsync(fileContents);
|
||||
_importServiceCard.SetImportedCredentials(importCredentials);
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch
|
||||
{
|
||||
Logger.LogError(ex, "Error processing KeePass CSV file");
|
||||
throw;
|
||||
throw new ArgumentException("Error processing KeePass CSV file. Please check the file format and try again.");
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshVault()
|
||||
{
|
||||
GlobalNotificationService.AddSuccessMessage("KeePass CSV import successful!");
|
||||
NavigationManager.NavigateTo("/credentials");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -741,6 +741,10 @@ video {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.mb-5 {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.mb-6 {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
@@ -833,6 +837,10 @@ video {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.mt-auto {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.line-clamp-2 {
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
@@ -1028,6 +1036,10 @@ video {
|
||||
max-width: 80rem;
|
||||
}
|
||||
|
||||
.max-w-lg {
|
||||
max-width: 32rem;
|
||||
}
|
||||
|
||||
.max-w-md {
|
||||
max-width: 28rem;
|
||||
}
|
||||
@@ -1609,6 +1621,11 @@ video {
|
||||
fill: #d68338;
|
||||
}
|
||||
|
||||
.object-contain {
|
||||
-o-object-fit: contain;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.\!p-0 {
|
||||
padding: 0px !important;
|
||||
}
|
||||
@@ -1984,6 +2001,11 @@ video {
|
||||
color: rgb(220 38 38 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-red-700 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(185 28 28 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-red-800 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(153 27 27 / var(--tw-text-opacity));
|
||||
@@ -2109,6 +2131,47 @@ video {
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.file\:mr-4::file-selector-button {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.file\:rounded-lg::file-selector-button {
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.file\:border-0::file-selector-button {
|
||||
border-width: 0px;
|
||||
}
|
||||
|
||||
.file\:bg-blue-50::file-selector-button {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(239 246 255 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.file\:px-4::file-selector-button {
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
.file\:py-2::file-selector-button {
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.file\:text-sm::file-selector-button {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
|
||||
.file\:font-semibold::file-selector-button {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.file\:text-blue-700::file-selector-button {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(29 78 216 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.hover\:scale-105:hover {
|
||||
--tw-scale-x: 1.05;
|
||||
--tw-scale-y: 1.05;
|
||||
@@ -2235,6 +2298,11 @@ video {
|
||||
color: rgb(107 114 128 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.hover\:text-gray-700:hover {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(55 65 81 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.hover\:text-gray-800:hover {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(31 41 55 / var(--tw-text-opacity));
|
||||
@@ -2279,6 +2347,11 @@ video {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
|
||||
.hover\:file\:bg-blue-100::file-selector-button:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(219 234 254 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.focus\:border-blue-500:focus {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(59 130 246 / var(--tw-border-opacity));
|
||||
@@ -2529,6 +2602,11 @@ video {
|
||||
background-color: rgb(123 74 30 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.dark\:bg-red-200:is(.dark *) {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(254 202 202 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.dark\:bg-red-600:is(.dark *) {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(220 38 38 / var(--tw-bg-opacity));
|
||||
@@ -2647,6 +2725,11 @@ video {
|
||||
color: rgb(239 68 68 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.dark\:text-red-800:is(.dark *) {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(153 27 27 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.dark\:text-white:is(.dark *) {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(255 255 255 / var(--tw-text-opacity));
|
||||
@@ -2676,6 +2759,16 @@ video {
|
||||
--tw-ring-offset-color: #1f2937;
|
||||
}
|
||||
|
||||
.dark\:file\:bg-gray-700:is(.dark *)::file-selector-button {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(55 65 81 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.dark\:file\:text-gray-300:is(.dark *)::file-selector-button {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(209 213 219 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.dark\:hover\:bg-blue-500:hover:is(.dark *) {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(59 130 246 / var(--tw-bg-opacity));
|
||||
@@ -2756,6 +2849,11 @@ video {
|
||||
color: rgb(229 231 235 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.dark\:hover\:text-gray-300:hover:is(.dark *) {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(209 213 219 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.dark\:hover\:text-primary-300:hover:is(.dark *) {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(248 185 99 / var(--tw-text-opacity));
|
||||
@@ -2781,6 +2879,11 @@ video {
|
||||
color: rgb(255 255 255 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.dark\:hover\:file\:bg-gray-600:is(.dark *)::file-selector-button:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(75 85 99 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.dark\:focus\:border-blue-500:focus:is(.dark *) {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(59 130 246 / var(--tw-border-opacity));
|
||||
|
||||
Reference in New Issue
Block a user