From d7b443e678f5d262cfcde56b76c664c727c63e14 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sun, 29 Jun 2025 00:32:28 +0800 Subject: [PATCH] :sparkles: Web version login support send factor code --- .../Pages/Account/Profile.cshtml | 2 +- .../Pages/Auth/Challenge.cshtml | 55 +----- .../Pages/Auth/Challenge.cshtml.cs | 179 +---------------- DysonNetwork.Sphere/Pages/Auth/Login.cshtml | 2 +- .../Pages/Auth/SelectFactor.cshtml | 65 ++++++ .../Pages/Auth/SelectFactor.cshtml.cs | 78 ++++++++ .../Pages/Auth/VerifyFactor.cshtml | 76 +++++++ .../Pages/Auth/VerifyFactor.cshtml.cs | 187 ++++++++++++++++++ DysonNetwork.Sphere/wwwroot/css/styles.css | 77 ++++++++ 9 files changed, 497 insertions(+), 224 deletions(-) create mode 100644 DysonNetwork.Sphere/Pages/Auth/SelectFactor.cshtml create mode 100644 DysonNetwork.Sphere/Pages/Auth/SelectFactor.cshtml.cs create mode 100644 DysonNetwork.Sphere/Pages/Auth/VerifyFactor.cshtml create mode 100644 DysonNetwork.Sphere/Pages/Auth/VerifyFactor.cshtml.cs diff --git a/DysonNetwork.Sphere/Pages/Account/Profile.cshtml b/DysonNetwork.Sphere/Pages/Account/Profile.cshtml index a303144..78ad11b 100644 --- a/DysonNetwork.Sphere/Pages/Account/Profile.cshtml +++ b/DysonNetwork.Sphere/Pages/Account/Profile.cshtml @@ -4,7 +4,7 @@ ViewData["Title"] = "Profile"; } -
+

User Profile

diff --git a/DysonNetwork.Sphere/Pages/Auth/Challenge.cshtml b/DysonNetwork.Sphere/Pages/Auth/Challenge.cshtml index ebd9925..cec4aab 100644 --- a/DysonNetwork.Sphere/Pages/Auth/Challenge.cshtml +++ b/DysonNetwork.Sphere/Pages/Auth/Challenge.cshtml @@ -1,54 +1,13 @@ @page "/web/auth/challenge/{id:guid}" @model DysonNetwork.Sphere.Pages.Auth.ChallengeModel @{ - ViewData["Title"] = "Challenge"; + // This page is kept for backward compatibility + // It will automatically redirect to the new SelectFactor page + Response.Redirect($"/web/auth/challenge/{Model.Id}/select-factor"); } -
-
-

Authentication Challenge

- - @if (Model.AuthChallenge == null) - { -

Challenge not found or expired.

- } - else - { -

Remaining steps: @Model.AuthChallenge.StepRemain

- - @if (Model.AuthChallenge.StepRemain > 0) - { -
- -
- - -
-
- - - -
- -
- } - else - { -

Challenge completed. Redirecting...

- } - } +
+
+

Redirecting to authentication page...

-
- -@section Scripts { - @{ await Html.RenderPartialAsync("_ValidationScriptsPartial"); } -} \ No newline at end of file +
\ No newline at end of file diff --git a/DysonNetwork.Sphere/Pages/Auth/Challenge.cshtml.cs b/DysonNetwork.Sphere/Pages/Auth/Challenge.cshtml.cs index d5ddd13..a7b0477 100644 --- a/DysonNetwork.Sphere/Pages/Auth/Challenge.cshtml.cs +++ b/DysonNetwork.Sphere/Pages/Auth/Challenge.cshtml.cs @@ -1,185 +1,16 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; -using Microsoft.AspNetCore.Mvc.Rendering; -using System.ComponentModel.DataAnnotations; -using DysonNetwork.Sphere.Auth; -using DysonNetwork.Sphere.Account; -using Microsoft.EntityFrameworkCore; -using NodaTime; namespace DysonNetwork.Sphere.Pages.Auth { - public class ChallengeModel( - AppDatabase db, - AccountService accounts, - AuthService auth, - ActionLogService als, - IConfiguration configuration - ) - : PageModel + public class ChallengeModel() : PageModel { - [BindProperty(SupportsGet = true)] public Guid Id { get; set; } + [BindProperty(SupportsGet = true)] + public Guid Id { get; set; } - public Challenge? AuthChallenge { get; set; } - - [BindProperty] public Guid SelectedFactorId { get; set; } - - [BindProperty] [Required] public string Secret { get; set; } = string.Empty; - - public List AuthFactors { get; set; } = new(); - - public async Task OnGetAsync() + public IActionResult OnGet() { - await LoadChallengeAndFactors(); - if (AuthChallenge == null) return NotFound(); - if (AuthChallenge.StepRemain == 0) return await ExchangeTokenAndRedirect(); - return Page(); - } - - public async Task OnPostAsync() - { - if (!ModelState.IsValid) - { - await LoadChallengeAndFactors(); - return Page(); - } - - var challenge = await db.AuthChallenges.Include(e => e.Account).FirstOrDefaultAsync(e => e.Id == Id); - if (challenge is null) return NotFound("Auth challenge was not found."); - - var factor = await db.AccountAuthFactors.FindAsync(SelectedFactorId); - if (factor is null) return NotFound("Auth factor was not found."); - if (factor.EnabledAt is null) return BadRequest("Auth factor is not enabled."); - if (factor.Trustworthy <= 0) return BadRequest("Auth factor is not trustworthy."); - - if (challenge.StepRemain == 0) return Page(); // Challenge already completed - if (challenge.ExpiredAt.HasValue && challenge.ExpiredAt.Value < Instant.FromDateTimeUtc(DateTime.UtcNow)) - { - ModelState.AddModelError(string.Empty, "Challenge expired."); - await LoadChallengeAndFactors(); - return Page(); - } - - try - { - if (await accounts.VerifyFactorCode(factor, Secret)) - { - challenge.StepRemain -= factor.Trustworthy; - challenge.StepRemain = Math.Max(0, challenge.StepRemain); - challenge.BlacklistFactors.Add(factor.Id); - db.Update(challenge); - als.CreateActionLogFromRequest(ActionLogType.ChallengeSuccess, - new Dictionary - { - { "challenge_id", challenge.Id }, - { "factor_id", factor.Id } - }, Request, challenge.Account - ); - } - else - { - throw new ArgumentException("Invalid password."); - } - } - catch (Exception ex) - { - challenge.FailedAttempts++; - db.Update(challenge); - await db.SaveChangesAsync(); - als.CreateActionLogFromRequest(ActionLogType.ChallengeFailure, - new Dictionary - { - { "challenge_id", challenge.Id }, - { "factor_id", factor.Id } - }, Request, challenge.Account - ); - ModelState.AddModelError(string.Empty, ex.Message); - await LoadChallengeAndFactors(); - return Page(); - } - - if (challenge.StepRemain == 0) - { - als.CreateActionLogFromRequest(ActionLogType.NewLogin, - new Dictionary - { - { "challenge_id", challenge.Id }, - { "account_id", challenge.AccountId } - }, Request, challenge.Account - ); - } - - await db.SaveChangesAsync(); - AuthChallenge = challenge; - - if (AuthChallenge.StepRemain == 0) - { - return await ExchangeTokenAndRedirect(); - } - - await LoadChallengeAndFactors(); - return Page(); - } - - private async Task LoadChallengeAndFactors() - { - var challenge = await db.AuthChallenges - .Include(e => e.Account) - .ThenInclude(e => e.AuthFactors) - .FirstOrDefaultAsync(e => e.Id == Id); - - AuthChallenge = challenge; - - if (AuthChallenge != null) - { - var factorsResponse = AuthChallenge.Account.AuthFactors - .Where(e => e is { EnabledAt: not null, Trustworthy: >= 1 }) - .ToList(); - - AuthFactors = factorsResponse.Select(f => new SelectListItem - { - Value = f.Id.ToString(), - Text = f.Type.ToString() // You might want a more user-friendly display for factor types - }).ToList(); - } - } - - private async Task ExchangeTokenAndRedirect() - { - var challenge = await db.AuthChallenges - .Include(e => e.Account) - .Where(e => e.Id == Id) - .FirstOrDefaultAsync(); - - if (challenge is null) return BadRequest("Authorization code not found or expired."); - if (challenge.StepRemain != 0) return BadRequest("Challenge not yet completed."); - - var session = await db.AuthSessions - .Where(e => e.Challenge == challenge) - .FirstOrDefaultAsync(); - - if (session is not null) return BadRequest("Session already exists for this challenge."); - - session = new Session - { - LastGrantedAt = Instant.FromDateTimeUtc(DateTime.UtcNow), - ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddDays(30)), - Account = challenge.Account, - Challenge = challenge, - }; - - db.AuthSessions.Add(session); - await db.SaveChangesAsync(); - - var tk = auth.CreateToken(session); - HttpContext.Response.Cookies.Append(AuthConstants.CookieTokenName, tk, new CookieOptions() - { - HttpOnly = true, - Secure = !configuration.GetValue("Debug"), - SameSite = SameSiteMode.Strict, - Path = "/" - }); - return RedirectToPage("/Account/Profile"); // Redirect to profile page + return RedirectToPage("SelectFactor", new { id = Id }); } } } \ No newline at end of file diff --git a/DysonNetwork.Sphere/Pages/Auth/Login.cshtml b/DysonNetwork.Sphere/Pages/Auth/Login.cshtml index 1d693a3..7ed43c4 100644 --- a/DysonNetwork.Sphere/Pages/Auth/Login.cshtml +++ b/DysonNetwork.Sphere/Pages/Auth/Login.cshtml @@ -4,7 +4,7 @@ ViewData["Title"] = "Login"; } -
+

Login

diff --git a/DysonNetwork.Sphere/Pages/Auth/SelectFactor.cshtml b/DysonNetwork.Sphere/Pages/Auth/SelectFactor.cshtml new file mode 100644 index 0000000..c06e033 --- /dev/null +++ b/DysonNetwork.Sphere/Pages/Auth/SelectFactor.cshtml @@ -0,0 +1,65 @@ +@page "/web/auth/challenge/{id:guid}/select-factor" +@using DysonNetwork.Sphere.Account +@model DysonNetwork.Sphere.Pages.Auth.SelectFactorModel +@{ + ViewData["Title"] = "Select Authentication Method"; +} + +
+
+

Select Authentication Method

+ + @if (Model.AuthChallenge == null) + { +

Challenge not found or expired.

+ } + else if (Model.AuthChallenge.StepRemain == 0) + { +

Challenge completed. Redirecting...

+ } + else + { +

Please select an authentication method:

+ +
+ @foreach (var factor in Model.AuthFactors) + { +
+ + +
+ } +
+ } +
+
+ +@functions { + + private string GetFactorDisplayName(AccountAuthFactorType type) => type switch + { + AccountAuthFactorType.InAppCode => "Authenticator App", + AccountAuthFactorType.EmailCode => "Email", + AccountAuthFactorType.TimedCode => "Timed Code", + AccountAuthFactorType.PinCode => "PIN Code", + AccountAuthFactorType.Password => "Password", + _ => type.ToString() + }; + + private string GetFactorDescription(AccountAuthFactorType type) => type switch + { + AccountAuthFactorType.InAppCode => "Enter a code from your authenticator app", + AccountAuthFactorType.EmailCode => "Receive a verification code via email", + AccountAuthFactorType.TimedCode => "Use a time-based verification code", + AccountAuthFactorType.PinCode => "Enter your PIN code", + AccountAuthFactorType.Password => "Enter your password", + _ => string.Empty + }; + +} diff --git a/DysonNetwork.Sphere/Pages/Auth/SelectFactor.cshtml.cs b/DysonNetwork.Sphere/Pages/Auth/SelectFactor.cshtml.cs new file mode 100644 index 0000000..d6631b7 --- /dev/null +++ b/DysonNetwork.Sphere/Pages/Auth/SelectFactor.cshtml.cs @@ -0,0 +1,78 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.EntityFrameworkCore; +using DysonNetwork.Sphere.Auth; +using DysonNetwork.Sphere.Account; + +namespace DysonNetwork.Sphere.Pages.Auth; + +public class SelectFactorModel( + AppDatabase db, + AccountService accounts +) + : PageModel +{ + [BindProperty(SupportsGet = true)] public Guid Id { get; set; } + + public Challenge? AuthChallenge { get; set; } + public List AuthFactors { get; set; } = []; + + public async Task OnGetAsync() + { + await LoadChallengeAndFactors(); + if (AuthChallenge == null) return NotFound(); + if (AuthChallenge.StepRemain == 0) return await ExchangeTokenAndRedirect(); + return Page(); + } + + public async Task OnPostSelectFactorAsync(Guid factorId) + { + var challenge = await db.AuthChallenges + .Include(e => e.Account) + .FirstOrDefaultAsync(e => e.Id == Id); + + if (challenge == null) return NotFound(); + + var factor = await db.AccountAuthFactors.FindAsync(factorId); + if (factor?.EnabledAt == null || factor.Trustworthy <= 0) + return BadRequest("Invalid authentication method."); + + // For OTP factors that require code delivery + try + { + await accounts.SendFactorCode(challenge.Account, factor); + } + catch (Exception) + { + ModelState.AddModelError(string.Empty, "An error occurred while sending the verification code."); + await LoadChallengeAndFactors(); + return Page(); + } + + // Redirect to verify the page with the selected factor + return RedirectToPage("VerifyFactor", new { id = Id, factorId }); + } + + private async Task LoadChallengeAndFactors() + { + AuthChallenge = await db.AuthChallenges + .Include(e => e.Account) + .ThenInclude(e => e.AuthFactors) + .FirstOrDefaultAsync(e => e.Id == Id); + + if (AuthChallenge != null) + { + AuthFactors = AuthChallenge.Account.AuthFactors + .Where(e => e is { EnabledAt: not null, Trustworthy: >= 1 }) + .ToList(); + } + } + + private async Task ExchangeTokenAndRedirect() + { + // This method is kept for backward compatibility + // The actual token exchange is now handled in the VerifyFactor page + await Task.CompletedTask; // Add this to fix the async warning + return RedirectToPage("/Account/Profile"); + } +} \ No newline at end of file diff --git a/DysonNetwork.Sphere/Pages/Auth/VerifyFactor.cshtml b/DysonNetwork.Sphere/Pages/Auth/VerifyFactor.cshtml new file mode 100644 index 0000000..d665f2f --- /dev/null +++ b/DysonNetwork.Sphere/Pages/Auth/VerifyFactor.cshtml @@ -0,0 +1,76 @@ +@page "/web/auth/challenge/{id:guid}/verify/{factorId:guid}" +@using DysonNetwork.Sphere.Account +@model DysonNetwork.Sphere.Pages.Auth.VerifyFactorModel +@{ + ViewData["Title"] = "Verify Your Identity"; +} + +
+
+

Verify Your Identity

+

+ @switch (Model.FactorType) + { + case AccountAuthFactorType.EmailCode: + We've sent a verification code to your email. + break; + case AccountAuthFactorType.InAppCode: + Enter the code from your authenticator app. + break; + case AccountAuthFactorType.TimedCode: + Enter your time-based verification code. + break; + case AccountAuthFactorType.PinCode: + Enter your PIN code. + break; + case AccountAuthFactorType.Password: + Enter your password. + break; + default: + Please verify your identity. + break; + } +

+ + @if (Model.AuthChallenge == null) + { +

Challenge not found or expired.

+ } + else if (Model.AuthChallenge.StepRemain == 0) + { +

Verification successful. Redirecting...

+ } + else + { +
+
+ +
+ + + +
+ + + + +
+ } +
+
+ +@section Scripts { + @{ await Html.RenderPartialAsync("_ValidationScriptsPartial"); } +} diff --git a/DysonNetwork.Sphere/Pages/Auth/VerifyFactor.cshtml.cs b/DysonNetwork.Sphere/Pages/Auth/VerifyFactor.cshtml.cs new file mode 100644 index 0000000..3d4fd88 --- /dev/null +++ b/DysonNetwork.Sphere/Pages/Auth/VerifyFactor.cshtml.cs @@ -0,0 +1,187 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.EntityFrameworkCore; +using DysonNetwork.Sphere.Auth; +using DysonNetwork.Sphere.Account; +using NodaTime; + +namespace DysonNetwork.Sphere.Pages.Auth +{ + public class VerifyFactorModel : PageModel + { + private readonly AppDatabase _db; + private readonly AccountService _accounts; + private readonly AuthService _auth; + private readonly ActionLogService _als; + private readonly IConfiguration _configuration; + private readonly IHttpClientFactory _httpClientFactory; + + [BindProperty(SupportsGet = true)] + public Guid Id { get; set; } + + [BindProperty(SupportsGet = true)] + public Guid FactorId { get; set; } + + [BindProperty, Required] + public string Code { get; set; } = string.Empty; + + public Challenge? AuthChallenge { get; set; } + public AccountAuthFactor? Factor { get; set; } + public AccountAuthFactorType FactorType => Factor?.Type ?? AccountAuthFactorType.EmailCode; + + public VerifyFactorModel( + AppDatabase db, + AccountService accounts, + AuthService auth, + ActionLogService als, + IConfiguration configuration, + IHttpClientFactory httpClientFactory) + { + _db = db; + _accounts = accounts; + _auth = auth; + _als = als; + _configuration = configuration; + _httpClientFactory = httpClientFactory; + } + + public async Task OnGetAsync() + { + await LoadChallengeAndFactor(); + if (AuthChallenge == null) return NotFound("Challenge not found or expired."); + if (Factor == null) return NotFound("Authentication method not found."); + if (AuthChallenge.StepRemain == 0) return await ExchangeTokenAndRedirect(); + + return Page(); + } + + public async Task OnPostAsync() + { + if (!ModelState.IsValid) + { + await LoadChallengeAndFactor(); + return Page(); + } + + await LoadChallengeAndFactor(); + if (AuthChallenge == null) return NotFound("Challenge not found or expired."); + if (Factor == null) return NotFound("Authentication method not found."); + + try + { + if (await _accounts.VerifyFactorCode(Factor, Code)) + { + AuthChallenge.StepRemain -= Factor.Trustworthy; + AuthChallenge.StepRemain = Math.Max(0, AuthChallenge.StepRemain); + AuthChallenge.BlacklistFactors.Add(Factor.Id); + _db.Update(AuthChallenge); + + _als.CreateActionLogFromRequest(ActionLogType.ChallengeSuccess, + new Dictionary + { + { "challenge_id", AuthChallenge.Id }, + { "factor_id", Factor?.Id.ToString() ?? string.Empty } + }, Request, AuthChallenge.Account); + + await _db.SaveChangesAsync(); + + if (AuthChallenge.StepRemain == 0) + { + _als.CreateActionLogFromRequest(ActionLogType.NewLogin, + new Dictionary + { + { "challenge_id", AuthChallenge.Id }, + { "account_id", AuthChallenge.AccountId } + }, Request, AuthChallenge.Account); + + return await ExchangeTokenAndRedirect(); + } + + else + { + // If more steps are needed, redirect back to select factor + return RedirectToPage("SelectFactor", new { id = Id }); + } + } + else + { + throw new InvalidOperationException("Invalid verification code."); + } + } + catch (Exception ex) + { + if (AuthChallenge != null) + { + AuthChallenge.FailedAttempts++; + _db.Update(AuthChallenge); + await _db.SaveChangesAsync(); + + _als.CreateActionLogFromRequest(ActionLogType.ChallengeFailure, + new Dictionary + { + { "challenge_id", AuthChallenge.Id }, + { "factor_id", Factor?.Id.ToString() ?? string.Empty } + }, Request, AuthChallenge.Account); + } + + + ModelState.AddModelError(string.Empty, ex.Message); + return Page(); + } + } + + private async Task LoadChallengeAndFactor() + { + AuthChallenge = await _db.AuthChallenges + .Include(e => e.Account) + .FirstOrDefaultAsync(e => e.Id == Id); + + if (AuthChallenge?.Account != null) + { + Factor = await _db.AccountAuthFactors + .FirstOrDefaultAsync(e => e.Id == FactorId && + e.AccountId == AuthChallenge.Account.Id && + e.EnabledAt != null && + e.Trustworthy > 0); + } + } + + private async Task ExchangeTokenAndRedirect() + { + var challenge = await _db.AuthChallenges + .Include(e => e.Account) + .FirstOrDefaultAsync(e => e.Id == Id); + + if (challenge == null) return BadRequest("Authorization code not found or expired."); + if (challenge.StepRemain != 0) return BadRequest("Challenge not yet completed."); + + var session = await _db.AuthSessions + .FirstOrDefaultAsync(e => e.ChallengeId == challenge.Id); + + if (session != null) return BadRequest("Session already exists for this challenge."); + + session = new Session + { + LastGrantedAt = Instant.FromDateTimeUtc(DateTime.UtcNow), + ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddDays(30)), + Account = challenge.Account, + Challenge = challenge, + }; + + _db.AuthSessions.Add(session); + await _db.SaveChangesAsync(); + + var token = _auth.CreateToken(session); + Response.Cookies.Append(AuthConstants.CookieTokenName, token, new() + { + HttpOnly = true, + Secure = !_configuration.GetValue("Debug"), + SameSite = SameSiteMode.Strict, + Path = "/" + }); + + return RedirectToPage("/Account/Profile"); + } + } +} diff --git a/DysonNetwork.Sphere/wwwroot/css/styles.css b/DysonNetwork.Sphere/wwwroot/css/styles.css index 8fac2eb..c2c9a56 100644 --- a/DysonNetwork.Sphere/wwwroot/css/styles.css +++ b/DysonNetwork.Sphere/wwwroot/css/styles.css @@ -20,10 +20,12 @@ --color-green-600: oklch(62.7% 0.194 149.214); --color-green-800: oklch(44.8% 0.119 151.328); --color-green-900: oklch(39.3% 0.095 152.535); + --color-blue-300: oklch(80.9% 0.105 251.813); --color-blue-400: oklch(70.7% 0.165 254.624); --color-blue-500: oklch(62.3% 0.214 259.815); --color-blue-600: oklch(54.6% 0.245 262.881); --color-blue-700: oklch(48.8% 0.243 264.376); + --color-gray-50: oklch(98.5% 0.002 247.839); --color-gray-100: oklch(96.7% 0.003 264.542); --color-gray-200: oklch(92.8% 0.006 264.531); --color-gray-300: oklch(87.2% 0.01 258.338); @@ -349,6 +351,9 @@ .transform { transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,); } + .resize { + resize: both; + } .flex-col { flex-direction: column; } @@ -361,6 +366,13 @@ .justify-center { justify-content: center; } + .space-y-4 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse))); + } + } .gap-x-6 { column-gap: calc(var(--spacing) * 6); } @@ -392,6 +404,9 @@ .bg-blue-600 { background-color: var(--color-blue-600); } + .bg-gray-50 { + background-color: var(--color-gray-50); + } .bg-gray-100 { background-color: var(--color-gray-100); } @@ -425,6 +440,9 @@ .px-6 { padding-inline: calc(var(--spacing) * 6); } + .px-8 { + padding-inline: calc(var(--spacing) * 8); + } .py-2 { padding-block: calc(var(--spacing) * 2); } @@ -437,6 +455,9 @@ .text-center { text-align: center; } + .text-left { + text-align: left; + } .text-2xl { font-size: var(--text-2xl); line-height: var(--tw-leading, var(--text-2xl--line-height)); @@ -481,6 +502,9 @@ --tw-tracking: var(--tracking-tight); letter-spacing: var(--tracking-tight); } + .text-blue-600 { + color: var(--color-blue-600); + } .text-gray-500 { color: var(--color-gray-500); } @@ -536,6 +560,10 @@ --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } + .outline { + outline-style: var(--tw-outline-style); + outline-width: 1px; + } .blur { --tw-blur: blur(8px); filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); @@ -572,6 +600,13 @@ } } } + .hover\:bg-gray-100 { + &:hover { + @media (hover: hover) { + background-color: var(--color-gray-100); + } + } + } .hover\:bg-red-700 { &:hover { @media (hover: hover) { @@ -579,6 +614,13 @@ } } } + .hover\:text-blue-500 { + &:hover { + @media (hover: hover) { + color: var(--color-blue-500); + } + } + } .hover\:text-blue-600 { &:hover { @media (hover: hover) { @@ -667,6 +709,11 @@ background-color: var(--color-yellow-900); } } + .dark\:text-blue-400 { + @media (prefers-color-scheme: dark) { + color: var(--color-blue-400); + } + } .dark\:text-gray-200 { @media (prefers-color-scheme: dark) { color: var(--color-gray-200); @@ -702,6 +749,24 @@ color: var(--color-yellow-200); } } + .dark\:hover\:bg-gray-600 { + @media (prefers-color-scheme: dark) { + &:hover { + @media (hover: hover) { + background-color: var(--color-gray-600); + } + } + } + } + .dark\:hover\:text-blue-300 { + @media (prefers-color-scheme: dark) { + &:hover { + @media (hover: hover) { + color: var(--color-blue-300); + } + } + } + } .dark\:hover\:text-blue-400 { @media (prefers-color-scheme: dark) { &:hover { @@ -962,6 +1027,11 @@ syntax: "*"; inherits: false; } +@property --tw-space-y-reverse { + syntax: "*"; + inherits: false; + initial-value: 0; +} @property --tw-border-style { syntax: "*"; inherits: false; @@ -1044,6 +1114,11 @@ inherits: false; initial-value: 0 0 #0000; } +@property --tw-outline-style { + syntax: "*"; + inherits: false; + initial-value: solid; +} @property --tw-blur { syntax: "*"; inherits: false; @@ -1120,6 +1195,7 @@ --tw-rotate-z: initial; --tw-skew-x: initial; --tw-skew-y: initial; + --tw-space-y-reverse: 0; --tw-border-style: solid; --tw-leading: initial; --tw-font-weight: initial; @@ -1138,6 +1214,7 @@ --tw-ring-offset-width: 0px; --tw-ring-offset-color: #fff; --tw-ring-offset-shadow: 0 0 #0000; + --tw-outline-style: solid; --tw-blur: initial; --tw-brightness: initial; --tw-contrast: initial;