Compare commits
3 Commits
ee5d6ef821
...
d4fa08d320
Author | SHA1 | Date | |
---|---|---|---|
d4fa08d320 | |||
8bd0ea0fa1 | |||
9ab31d79ce |
@ -196,6 +196,41 @@ public class AppDatabase(
|
|||||||
.HasGeneratedTsVectorColumn(p => p.SearchVector, "simple", p => new { p.Title, p.Description, p.Content })
|
.HasGeneratedTsVectorColumn(p => p.SearchVector, "simple", p => new { p.Title, p.Description, p.Content })
|
||||||
.HasIndex(p => p.SearchVector)
|
.HasIndex(p => p.SearchVector)
|
||||||
.HasMethod("GIN");
|
.HasMethod("GIN");
|
||||||
|
|
||||||
|
modelBuilder.Entity<CustomApp>()
|
||||||
|
.Property(c => c.RedirectUris)
|
||||||
|
.HasConversion(
|
||||||
|
v => string.Join(",", v),
|
||||||
|
v => v.Split(",", StringSplitOptions.RemoveEmptyEntries).ToArray());
|
||||||
|
|
||||||
|
modelBuilder.Entity<CustomApp>()
|
||||||
|
.Property(c => c.PostLogoutRedirectUris)
|
||||||
|
.HasConversion(
|
||||||
|
v => v != null ? string.Join(",", v) : "",
|
||||||
|
v => !string.IsNullOrEmpty(v) ? v.Split(",", StringSplitOptions.RemoveEmptyEntries) : Array.Empty<string>());
|
||||||
|
|
||||||
|
modelBuilder.Entity<CustomApp>()
|
||||||
|
.Property(c => c.AllowedScopes)
|
||||||
|
.HasConversion(
|
||||||
|
v => v != null ? string.Join(" ", v) : "",
|
||||||
|
v => !string.IsNullOrEmpty(v) ? v.Split(" ", StringSplitOptions.RemoveEmptyEntries) : new[] { "openid", "profile", "email" });
|
||||||
|
|
||||||
|
modelBuilder.Entity<CustomApp>()
|
||||||
|
.Property(c => c.AllowedGrantTypes)
|
||||||
|
.HasConversion(
|
||||||
|
v => v != null ? string.Join(" ", v) : "",
|
||||||
|
v => !string.IsNullOrEmpty(v) ? v.Split(" ", StringSplitOptions.RemoveEmptyEntries) : new[] { "authorization_code", "refresh_token" });
|
||||||
|
|
||||||
|
modelBuilder.Entity<CustomAppSecret>()
|
||||||
|
.HasIndex(s => s.Secret)
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
modelBuilder.Entity<CustomApp>()
|
||||||
|
.HasMany(c => c.Secrets)
|
||||||
|
.WithOne(s => s.App)
|
||||||
|
.HasForeignKey(s => s.AppId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
modelBuilder.Entity<Post.Post>()
|
modelBuilder.Entity<Post.Post>()
|
||||||
.HasOne(p => p.RepliedPost)
|
.HasOne(p => p.RepliedPost)
|
||||||
.WithMany()
|
.WithMany()
|
||||||
|
@ -0,0 +1,305 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using DysonNetwork.Sphere.Developer;
|
||||||
|
using DysonNetwork.Sphere.Auth.OidcProvider.Options;
|
||||||
|
using DysonNetwork.Sphere.Auth.OidcProvider.Responses;
|
||||||
|
using DysonNetwork.Sphere.Auth.OidcProvider.Services;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Sphere.Auth.OidcProvider.Controllers;
|
||||||
|
|
||||||
|
[Route("connect")]
|
||||||
|
[ApiController]
|
||||||
|
public class OidcProviderController(
|
||||||
|
OidcProviderService oidcService,
|
||||||
|
IOptions<OidcProviderOptions> options,
|
||||||
|
ILogger<OidcProviderController> logger
|
||||||
|
)
|
||||||
|
: ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet("authorize")]
|
||||||
|
public async Task<IActionResult> Authorize(
|
||||||
|
[Required][FromQuery(Name = "client_id")] Guid clientId,
|
||||||
|
[Required][FromQuery(Name = "response_type")] string responseType,
|
||||||
|
[FromQuery(Name = "redirect_uri")] string? redirectUri,
|
||||||
|
[FromQuery] string? scope,
|
||||||
|
[FromQuery] string? state,
|
||||||
|
[FromQuery] string? nonce,
|
||||||
|
[FromQuery(Name = "code_challenge")] string? codeChallenge,
|
||||||
|
[FromQuery(Name = "code_challenge_method")] string? codeChallengeMethod,
|
||||||
|
[FromQuery(Name = "response_mode")] string? responseMode
|
||||||
|
)
|
||||||
|
{
|
||||||
|
// Check if user is authenticated
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser)
|
||||||
|
{
|
||||||
|
// Not authenticated - redirect to login with return URL
|
||||||
|
var loginUrl = "/Auth/Login";
|
||||||
|
var returnUrl = $"{Request.Path}{Request.QueryString}";
|
||||||
|
return Redirect($"{loginUrl}?returnUrl={Uri.EscapeDataString(returnUrl)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate client
|
||||||
|
var client = await oidcService.FindClientByIdAsync(clientId);
|
||||||
|
if (client == null)
|
||||||
|
return BadRequest(new ErrorResponse { Error = "invalid_client", ErrorDescription = "Client not found" });
|
||||||
|
|
||||||
|
// Check if user has already granted permission to this client
|
||||||
|
// For now, we'll always show the consent page. In a real app, you might store consent decisions.
|
||||||
|
// If you want to implement "remember my decision", you would check that here.
|
||||||
|
var consentRequired = true;
|
||||||
|
|
||||||
|
if (consentRequired)
|
||||||
|
{
|
||||||
|
// Redirect to consent page with all the OAuth parameters
|
||||||
|
var consentUrl = $"/Auth/Authorize?client_id={clientId}";
|
||||||
|
if (!string.IsNullOrEmpty(responseType)) consentUrl += $"&response_type={Uri.EscapeDataString(responseType)}";
|
||||||
|
if (!string.IsNullOrEmpty(redirectUri)) consentUrl += $"&redirect_uri={Uri.EscapeDataString(redirectUri)}";
|
||||||
|
if (!string.IsNullOrEmpty(scope)) consentUrl += $"&scope={Uri.EscapeDataString(scope)}";
|
||||||
|
if (!string.IsNullOrEmpty(state)) consentUrl += $"&state={Uri.EscapeDataString(state)}";
|
||||||
|
if (!string.IsNullOrEmpty(nonce)) consentUrl += $"&nonce={Uri.EscapeDataString(nonce)}";
|
||||||
|
if (!string.IsNullOrEmpty(codeChallenge)) consentUrl += $"&code_challenge={Uri.EscapeDataString(codeChallenge)}";
|
||||||
|
if (!string.IsNullOrEmpty(codeChallengeMethod)) consentUrl += $"&code_challenge_method={Uri.EscapeDataString(codeChallengeMethod)}";
|
||||||
|
if (!string.IsNullOrEmpty(responseMode)) consentUrl += $"&response_mode={Uri.EscapeDataString(responseMode)}";
|
||||||
|
|
||||||
|
return Redirect(consentUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip redirect_uri validation for apps in Developing status
|
||||||
|
if (client.Status != CustomAppStatus.Developing)
|
||||||
|
{
|
||||||
|
// Validate redirect URI for non-Developing apps
|
||||||
|
if (!string.IsNullOrEmpty(redirectUri) && !(client.RedirectUris?.Contains(redirectUri) ?? false))
|
||||||
|
return BadRequest(
|
||||||
|
new ErrorResponse { Error = "invalid_request", ErrorDescription = "Invalid redirect_uri" });
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
logger.LogWarning("Skipping redirect_uri validation for app {AppId} in Developing status", clientId);
|
||||||
|
|
||||||
|
// If no redirect_uri is provided and we're in development, use the first one
|
||||||
|
if (string.IsNullOrEmpty(redirectUri) && client.RedirectUris?.Any() == true)
|
||||||
|
{
|
||||||
|
redirectUri = client.RedirectUris.First();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate authorization code
|
||||||
|
var code = Guid.NewGuid().ToString("N");
|
||||||
|
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
|
||||||
|
// In a real implementation, you'd store this code with the user's consent and requested scopes
|
||||||
|
// and validate it in the token endpoint
|
||||||
|
|
||||||
|
// For now, we'll just return the code directly (simplified for example)
|
||||||
|
var response = new AuthorizationResponse
|
||||||
|
{
|
||||||
|
Code = code,
|
||||||
|
State = state,
|
||||||
|
Scope = scope,
|
||||||
|
Issuer = options.Value.IssuerUri
|
||||||
|
};
|
||||||
|
|
||||||
|
// Redirect back to the client with the authorization code
|
||||||
|
var finalRedirectUri = new UriBuilder(redirectUri ?? client.RedirectUris?.First() ?? throw new InvalidOperationException("No redirect URI provided and no default redirect URI found"));
|
||||||
|
var query = System.Web.HttpUtility.ParseQueryString(finalRedirectUri.Query);
|
||||||
|
query["code"] = response.Code;
|
||||||
|
if (!string.IsNullOrEmpty(response.State))
|
||||||
|
query["state"] = response.State;
|
||||||
|
if (!string.IsNullOrEmpty(response.Scope))
|
||||||
|
query["scope"] = response.Scope;
|
||||||
|
|
||||||
|
finalRedirectUri.Query = query.ToString();
|
||||||
|
return Redirect(finalRedirectUri.Uri.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("token")]
|
||||||
|
[Consumes("application/x-www-form-urlencoded")]
|
||||||
|
public async Task<IActionResult> Token([FromForm] TokenRequest request)
|
||||||
|
{
|
||||||
|
if (request.GrantType == "authorization_code")
|
||||||
|
{
|
||||||
|
// Validate client credentials
|
||||||
|
if (request.ClientId == null || string.IsNullOrEmpty(request.ClientSecret))
|
||||||
|
return BadRequest(new ErrorResponse { Error = "invalid_client", ErrorDescription = "Client credentials are required" });
|
||||||
|
|
||||||
|
var client = await oidcService.FindClientByIdAsync(request.ClientId.Value);
|
||||||
|
if (client == null || !await oidcService.ValidateClientCredentialsAsync(request.ClientId.Value, request.ClientSecret))
|
||||||
|
return BadRequest(new ErrorResponse { Error = "invalid_client", ErrorDescription = "Invalid client credentials" });
|
||||||
|
|
||||||
|
// Validate the authorization code
|
||||||
|
var authCode = await oidcService.ValidateAuthorizationCodeAsync(
|
||||||
|
request.Code ?? string.Empty,
|
||||||
|
request.ClientId.Value,
|
||||||
|
request.RedirectUri,
|
||||||
|
request.CodeVerifier);
|
||||||
|
|
||||||
|
if (authCode == null)
|
||||||
|
{
|
||||||
|
logger.LogWarning("Invalid or expired authorization code: {Code}", request.Code);
|
||||||
|
return BadRequest(new ErrorResponse { Error = "invalid_grant", ErrorDescription = "Invalid or expired authorization code" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate tokens
|
||||||
|
var tokenResponse = await oidcService.GenerateTokenResponseAsync(
|
||||||
|
clientId: request.ClientId.Value,
|
||||||
|
subjectId: authCode.UserId,
|
||||||
|
scopes: authCode.Scopes,
|
||||||
|
authorizationCode: request.Code);
|
||||||
|
|
||||||
|
return Ok(tokenResponse);
|
||||||
|
}
|
||||||
|
else if (request.GrantType == "refresh_token")
|
||||||
|
{
|
||||||
|
// Handle refresh token request
|
||||||
|
// In a real implementation, you would validate the refresh token
|
||||||
|
// and issue a new access token
|
||||||
|
return BadRequest(new ErrorResponse { Error = "unsupported_grant_type" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return BadRequest(new ErrorResponse { Error = "unsupported_grant_type" });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("userinfo")]
|
||||||
|
[Authorize(AuthenticationSchemes = "Bearer")]
|
||||||
|
public async Task<IActionResult> UserInfo()
|
||||||
|
{
|
||||||
|
var authHeader = HttpContext.Request.Headers.Authorization.ToString();
|
||||||
|
if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Bearer "))
|
||||||
|
{
|
||||||
|
var loginUrl = "/Account/Login"; // Update this path to your actual login page path
|
||||||
|
var returnUrl = $"{Request.Scheme}://{Request.Host}{Request.Path}{Request.QueryString}";
|
||||||
|
return Redirect($"{loginUrl}?returnUrl={Uri.EscapeDataString(returnUrl)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var token = authHeader["Bearer ".Length..].Trim();
|
||||||
|
var jwtToken = oidcService.ValidateToken(token);
|
||||||
|
|
||||||
|
if (jwtToken == null)
|
||||||
|
{
|
||||||
|
var loginUrl = "/Account/Login"; // Update this path to your actual login page path
|
||||||
|
var returnUrl = $"{Request.Scheme}://{Request.Host}{Request.Path}{Request.QueryString}";
|
||||||
|
return Redirect($"{loginUrl}?returnUrl={Uri.EscapeDataString(returnUrl)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user info based on the subject claim from the token
|
||||||
|
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
var userName = User.FindFirstValue(ClaimTypes.Name);
|
||||||
|
var userEmail = User.FindFirstValue(ClaimTypes.Email);
|
||||||
|
|
||||||
|
// Get requested scopes from the token
|
||||||
|
var scopes = jwtToken.Claims
|
||||||
|
.Where(c => c.Type == "scope")
|
||||||
|
.SelectMany(c => c.Value.Split(' '))
|
||||||
|
.ToHashSet();
|
||||||
|
|
||||||
|
var userInfo = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["sub"] = userId ?? "anonymous"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Include standard claims based on scopes
|
||||||
|
if (scopes.Contains("profile") || scopes.Contains("name"))
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(userName))
|
||||||
|
userInfo["name"] = userName;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scopes.Contains("email") && !string.IsNullOrEmpty(userEmail))
|
||||||
|
{
|
||||||
|
userInfo["email"] = userEmail;
|
||||||
|
userInfo["email_verified"] = true; // In a real app, check if email is verified
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(userInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet(".well-known/openid-configuration")]
|
||||||
|
public IActionResult GetConfiguration()
|
||||||
|
{
|
||||||
|
var baseUrl = $"{Request.Scheme}://{Request.Host}{Request.PathBase}".TrimEnd('/');
|
||||||
|
var issuer = options.Value.IssuerUri.TrimEnd('/');
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
issuer = issuer,
|
||||||
|
authorization_endpoint = $"{baseUrl}/connect/authorize",
|
||||||
|
token_endpoint = $"{baseUrl}/connect/token",
|
||||||
|
userinfo_endpoint = $"{baseUrl}/connect/userinfo",
|
||||||
|
jwks_uri = $"{baseUrl}/.well-known/openid-configuration/jwks",
|
||||||
|
scopes_supported = new[] { "openid", "profile", "email" },
|
||||||
|
response_types_supported = new[] { "code", "token", "id_token", "code token", "code id_token", "token id_token", "code token id_token" },
|
||||||
|
grant_types_supported = new[] { "authorization_code", "refresh_token" },
|
||||||
|
token_endpoint_auth_methods_supported = new[] { "client_secret_basic", "client_secret_post" },
|
||||||
|
id_token_signing_alg_values_supported = new[] { "HS256" },
|
||||||
|
subject_types_supported = new[] { "public" },
|
||||||
|
claims_supported = new[] { "sub", "name", "email", "email_verified" },
|
||||||
|
code_challenge_methods_supported = new[] { "S256" },
|
||||||
|
response_modes_supported = new[] { "query", "fragment", "form_post" },
|
||||||
|
request_parameter_supported = true,
|
||||||
|
request_uri_parameter_supported = true,
|
||||||
|
require_request_uri_registration = false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("jwks")]
|
||||||
|
public IActionResult Jwks()
|
||||||
|
{
|
||||||
|
// In a production environment, you should use asymmetric keys (RSA or EC)
|
||||||
|
// and expose only the public key here. This is a simplified example using HMAC.
|
||||||
|
// For production, consider using RSA or EC keys and proper key rotation.
|
||||||
|
|
||||||
|
var keyBytes = Encoding.UTF8.GetBytes(options.Value.SigningKey);
|
||||||
|
var keyId = Convert.ToBase64String(SHA256.HashData(keyBytes)[..8])
|
||||||
|
.Replace("+", "-")
|
||||||
|
.Replace("/", "_")
|
||||||
|
.Replace("=", "");
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
keys = new[]
|
||||||
|
{
|
||||||
|
new
|
||||||
|
{
|
||||||
|
kty = "oct",
|
||||||
|
use = "sig",
|
||||||
|
kid = keyId,
|
||||||
|
k = Convert.ToBase64String(keyBytes),
|
||||||
|
alg = "HS256"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TokenRequest
|
||||||
|
{
|
||||||
|
[JsonPropertyName("grant_type")]
|
||||||
|
public string? GrantType { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("code")]
|
||||||
|
public string? Code { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("redirect_uri")]
|
||||||
|
public string? RedirectUri { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("client_id")]
|
||||||
|
public Guid? ClientId { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("client_secret")]
|
||||||
|
public string? ClientSecret { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("refresh_token")]
|
||||||
|
public string? RefreshToken { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("scope")]
|
||||||
|
public string? Scope { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("code_verifier")]
|
||||||
|
public string? CodeVerifier { get; set; }
|
||||||
|
}
|
@ -0,0 +1,18 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Sphere.Auth.OidcProvider.Models;
|
||||||
|
|
||||||
|
public class AuthorizationCodeInfo
|
||||||
|
{
|
||||||
|
public Guid ClientId { get; set; }
|
||||||
|
public string UserId { get; set; } = string.Empty;
|
||||||
|
public string RedirectUri { get; set; } = string.Empty;
|
||||||
|
public List<string> Scopes { get; set; } = new();
|
||||||
|
public string? CodeChallenge { get; set; }
|
||||||
|
public string? CodeChallengeMethod { get; set; }
|
||||||
|
public string? Nonce { get; set; }
|
||||||
|
public Instant Expiration { get; set; }
|
||||||
|
public Instant CreatedAt { get; set; }
|
||||||
|
}
|
@ -0,0 +1,13 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Sphere.Auth.OidcProvider.Options;
|
||||||
|
|
||||||
|
public class OidcProviderOptions
|
||||||
|
{
|
||||||
|
public string IssuerUri { get; set; } = "https://your-issuer-uri.com";
|
||||||
|
public string SigningKey { get; set; } = "replace-with-a-secure-random-key";
|
||||||
|
public TimeSpan AccessTokenLifetime { get; set; } = TimeSpan.FromHours(1);
|
||||||
|
public TimeSpan RefreshTokenLifetime { get; set; } = TimeSpan.FromDays(30);
|
||||||
|
public TimeSpan AuthorizationCodeLifetime { get; set; } = TimeSpan.FromMinutes(5);
|
||||||
|
public bool RequireHttpsMetadata { get; set; } = true;
|
||||||
|
}
|
@ -0,0 +1,23 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Sphere.Auth.OidcProvider.Responses;
|
||||||
|
|
||||||
|
public class AuthorizationResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("code")]
|
||||||
|
public string Code { get; set; } = null!;
|
||||||
|
|
||||||
|
[JsonPropertyName("state")]
|
||||||
|
public string? State { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("scope")]
|
||||||
|
public string? Scope { get; set; }
|
||||||
|
|
||||||
|
|
||||||
|
[JsonPropertyName("session_state")]
|
||||||
|
public string? SessionState { get; set; }
|
||||||
|
|
||||||
|
|
||||||
|
[JsonPropertyName("iss")]
|
||||||
|
public string? Issuer { get; set; }
|
||||||
|
}
|
@ -0,0 +1,20 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Sphere.Auth.OidcProvider.Responses;
|
||||||
|
|
||||||
|
public class ErrorResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("error")]
|
||||||
|
public string Error { get; set; } = null!;
|
||||||
|
|
||||||
|
[JsonPropertyName("error_description")]
|
||||||
|
public string? ErrorDescription { get; set; }
|
||||||
|
|
||||||
|
|
||||||
|
[JsonPropertyName("error_uri")]
|
||||||
|
public string? ErrorUri { get; set; }
|
||||||
|
|
||||||
|
|
||||||
|
[JsonPropertyName("state")]
|
||||||
|
public string? State { get; set; }
|
||||||
|
}
|
@ -0,0 +1,26 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Sphere.Auth.OidcProvider.Responses;
|
||||||
|
|
||||||
|
public class TokenResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("access_token")]
|
||||||
|
public string AccessToken { get; set; } = null!;
|
||||||
|
|
||||||
|
[JsonPropertyName("expires_in")]
|
||||||
|
public int ExpiresIn { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("token_type")]
|
||||||
|
public string TokenType { get; set; } = "Bearer";
|
||||||
|
|
||||||
|
[JsonPropertyName("refresh_token")]
|
||||||
|
public string? RefreshToken { get; set; }
|
||||||
|
|
||||||
|
|
||||||
|
[JsonPropertyName("scope")]
|
||||||
|
public string? Scope { get; set; }
|
||||||
|
|
||||||
|
|
||||||
|
[JsonPropertyName("id_token")]
|
||||||
|
public string? IdToken { get; set; }
|
||||||
|
}
|
@ -0,0 +1,301 @@
|
|||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Encodings.Web;
|
||||||
|
using System.Text.Json;
|
||||||
|
using DysonNetwork.Sphere.Auth.OidcProvider.Models;
|
||||||
|
using DysonNetwork.Sphere.Auth.OidcProvider.Options;
|
||||||
|
using DysonNetwork.Sphere.Auth.OidcProvider.Responses;
|
||||||
|
using DysonNetwork.Sphere.Developer;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Sphere.Auth.OidcProvider.Services;
|
||||||
|
|
||||||
|
public class OidcProviderService(
|
||||||
|
AppDatabase db,
|
||||||
|
IClock clock,
|
||||||
|
IOptions<OidcProviderOptions> options,
|
||||||
|
ILogger<OidcProviderService> logger
|
||||||
|
)
|
||||||
|
{
|
||||||
|
private readonly OidcProviderOptions _options = options.Value;
|
||||||
|
|
||||||
|
public async Task<CustomApp?> FindClientByIdAsync(Guid clientId)
|
||||||
|
{
|
||||||
|
return await db.CustomApps
|
||||||
|
.Include(c => c.Secrets)
|
||||||
|
.FirstOrDefaultAsync(c => c.Id == clientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CustomApp?> FindClientByAppIdAsync(Guid appId)
|
||||||
|
{
|
||||||
|
return await db.CustomApps
|
||||||
|
.Include(c => c.Secrets)
|
||||||
|
.FirstOrDefaultAsync(c => c.Id == appId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> ValidateClientCredentialsAsync(Guid clientId, string clientSecret)
|
||||||
|
{
|
||||||
|
var client = await FindClientByIdAsync(clientId);
|
||||||
|
if (client == null) return false;
|
||||||
|
|
||||||
|
var secret = client.Secrets
|
||||||
|
.Where(s => s.IsOidc && (s.ExpiredAt == null || s.ExpiredAt > clock.GetCurrentInstant()))
|
||||||
|
.FirstOrDefault(s => s.Secret == clientSecret); // In production, use proper hashing
|
||||||
|
|
||||||
|
return secret != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TokenResponse> GenerateTokenResponseAsync(
|
||||||
|
Guid clientId,
|
||||||
|
string subjectId,
|
||||||
|
IEnumerable<string>? scopes = null,
|
||||||
|
string? authorizationCode = null)
|
||||||
|
{
|
||||||
|
var client = await FindClientByIdAsync(clientId);
|
||||||
|
if (client == null)
|
||||||
|
throw new InvalidOperationException("Client not found");
|
||||||
|
|
||||||
|
var now = clock.GetCurrentInstant();
|
||||||
|
var expiresIn = (int)_options.AccessTokenLifetime.TotalSeconds;
|
||||||
|
var expiresAt = now.Plus(Duration.FromSeconds(expiresIn));
|
||||||
|
|
||||||
|
// Generate access token
|
||||||
|
var accessToken = GenerateJwtToken(client, subjectId, expiresAt, scopes);
|
||||||
|
var refreshToken = GenerateRefreshToken();
|
||||||
|
|
||||||
|
// In a real implementation, you would store the token in the database
|
||||||
|
// For this example, we'll just return the token without storing it
|
||||||
|
// as we don't have a dedicated OIDC token table
|
||||||
|
|
||||||
|
return new TokenResponse
|
||||||
|
{
|
||||||
|
AccessToken = accessToken,
|
||||||
|
ExpiresIn = expiresIn,
|
||||||
|
TokenType = "Bearer",
|
||||||
|
RefreshToken = refreshToken,
|
||||||
|
Scope = scopes != null ? string.Join(" ", scopes) : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GenerateJwtToken(CustomApp client, string subjectId, Instant expiresAt, IEnumerable<string>? scopes = null)
|
||||||
|
{
|
||||||
|
var tokenHandler = new JwtSecurityTokenHandler();
|
||||||
|
var key = Encoding.ASCII.GetBytes(_options.SigningKey);
|
||||||
|
|
||||||
|
var tokenDescriptor = new SecurityTokenDescriptor
|
||||||
|
{
|
||||||
|
Subject = new ClaimsIdentity(new[]
|
||||||
|
{
|
||||||
|
new Claim(JwtRegisteredClaimNames.Sub, subjectId),
|
||||||
|
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||||
|
new Claim(JwtRegisteredClaimNames.Iat, clock.GetCurrentInstant().ToUnixTimeSeconds().ToString(),
|
||||||
|
ClaimValueTypes.Integer64),
|
||||||
|
new Claim("client_id", client.Id.ToString())
|
||||||
|
}),
|
||||||
|
Expires = expiresAt.ToDateTimeUtc(),
|
||||||
|
Issuer = _options.IssuerUri,
|
||||||
|
Audience = client.Id.ToString(),
|
||||||
|
SigningCredentials = new SigningCredentials(
|
||||||
|
new SymmetricSecurityKey(key),
|
||||||
|
SecurityAlgorithms.HmacSha256Signature)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add scopes as claims if provided, otherwise use client's default scopes
|
||||||
|
var effectiveScopes = scopes?.ToList() ?? client.AllowedScopes?.ToList() ?? new List<string>();
|
||||||
|
if (effectiveScopes.Any())
|
||||||
|
{
|
||||||
|
tokenDescriptor.Subject.AddClaims(
|
||||||
|
effectiveScopes.Select(scope => new Claim("scope", scope)));
|
||||||
|
}
|
||||||
|
|
||||||
|
var token = tokenHandler.CreateToken(tokenDescriptor);
|
||||||
|
return tokenHandler.WriteToken(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GenerateRefreshToken()
|
||||||
|
{
|
||||||
|
using var rng = RandomNumberGenerator.Create();
|
||||||
|
var bytes = new byte[32];
|
||||||
|
rng.GetBytes(bytes);
|
||||||
|
return Convert.ToBase64String(bytes)
|
||||||
|
.Replace("+", "-")
|
||||||
|
.Replace("/", "_")
|
||||||
|
.Replace("=", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool VerifyHashedSecret(string secret, string hashedSecret)
|
||||||
|
{
|
||||||
|
// In a real implementation, you'd use a proper password hashing algorithm like PBKDF2, bcrypt, or Argon2
|
||||||
|
// For now, we'll do a simple comparison, but you should replace this with proper hashing
|
||||||
|
return string.Equals(secret, hashedSecret, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
public JwtSecurityToken? ValidateToken(string token)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var tokenHandler = new JwtSecurityTokenHandler();
|
||||||
|
var key = Encoding.ASCII.GetBytes(_options.SigningKey);
|
||||||
|
|
||||||
|
tokenHandler.ValidateToken(token, new TokenValidationParameters
|
||||||
|
{
|
||||||
|
ValidateIssuerSigningKey = true,
|
||||||
|
IssuerSigningKey = new SymmetricSecurityKey(key),
|
||||||
|
ValidateIssuer = true,
|
||||||
|
ValidIssuer = _options.IssuerUri,
|
||||||
|
ValidateAudience = true,
|
||||||
|
ValidateLifetime = true,
|
||||||
|
ClockSkew = TimeSpan.Zero
|
||||||
|
}, out var validatedToken);
|
||||||
|
|
||||||
|
return (JwtSecurityToken)validatedToken;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Token validation failed");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly Dictionary<string, AuthorizationCodeInfo> _authorizationCodes = new();
|
||||||
|
|
||||||
|
public async Task<string> GenerateAuthorizationCodeAsync(
|
||||||
|
Guid clientId,
|
||||||
|
string userId,
|
||||||
|
string redirectUri,
|
||||||
|
IEnumerable<string> scopes,
|
||||||
|
string? codeChallenge = null,
|
||||||
|
string? codeChallengeMethod = null,
|
||||||
|
string? nonce = null)
|
||||||
|
{
|
||||||
|
// Generate a random code
|
||||||
|
var code = GenerateRandomString(32);
|
||||||
|
var now = clock.GetCurrentInstant();
|
||||||
|
|
||||||
|
// Store the code with its metadata
|
||||||
|
_authorizationCodes[code] = new AuthorizationCodeInfo
|
||||||
|
{
|
||||||
|
ClientId = clientId,
|
||||||
|
UserId = userId,
|
||||||
|
RedirectUri = redirectUri,
|
||||||
|
Scopes = scopes.ToList(),
|
||||||
|
CodeChallenge = codeChallenge,
|
||||||
|
CodeChallengeMethod = codeChallengeMethod,
|
||||||
|
Nonce = nonce,
|
||||||
|
Expiration = now.Plus(Duration.FromTimeSpan(_options.AuthorizationCodeLifetime)),
|
||||||
|
CreatedAt = now
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.LogInformation("Generated authorization code for client {ClientId} and user {UserId}", clientId, userId);
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AuthorizationCodeInfo?> ValidateAuthorizationCodeAsync(
|
||||||
|
string code,
|
||||||
|
Guid clientId,
|
||||||
|
string? redirectUri = null,
|
||||||
|
string? codeVerifier = null)
|
||||||
|
{
|
||||||
|
if (!_authorizationCodes.TryGetValue(code, out var authCode) || authCode == null)
|
||||||
|
{
|
||||||
|
logger.LogWarning("Authorization code not found: {Code}", code);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = clock.GetCurrentInstant();
|
||||||
|
|
||||||
|
// Check if code has expired
|
||||||
|
if (now > authCode.Expiration)
|
||||||
|
{
|
||||||
|
logger.LogWarning("Authorization code expired: {Code}", code);
|
||||||
|
_authorizationCodes.Remove(code);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify client ID matches
|
||||||
|
if (authCode.ClientId != clientId)
|
||||||
|
{
|
||||||
|
logger.LogWarning("Client ID mismatch for code {Code}. Expected: {ExpectedClientId}, Actual: {ActualClientId}",
|
||||||
|
code, authCode.ClientId, clientId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify redirect URI if provided
|
||||||
|
if (!string.IsNullOrEmpty(redirectUri) && authCode.RedirectUri != redirectUri)
|
||||||
|
{
|
||||||
|
logger.LogWarning("Redirect URI mismatch for code {Code}", code);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify PKCE code challenge if one was provided during authorization
|
||||||
|
if (!string.IsNullOrEmpty(authCode.CodeChallenge))
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(codeVerifier))
|
||||||
|
{
|
||||||
|
logger.LogWarning("PKCE code verifier is required but not provided for code {Code}", code);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var isValid = authCode.CodeChallengeMethod?.ToUpperInvariant() switch
|
||||||
|
{
|
||||||
|
"S256" => VerifyCodeChallenge(codeVerifier, authCode.CodeChallenge, "S256"),
|
||||||
|
"PLAIN" => VerifyCodeChallenge(codeVerifier, authCode.CodeChallenge, "PLAIN"),
|
||||||
|
_ => false // Unsupported code challenge method
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isValid)
|
||||||
|
{
|
||||||
|
logger.LogWarning("PKCE code verifier validation failed for code {Code}", code);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Code is valid, remove it from the store (codes are single-use)
|
||||||
|
_authorizationCodes.Remove(code);
|
||||||
|
|
||||||
|
return authCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GenerateRandomString(int length)
|
||||||
|
{
|
||||||
|
const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~";
|
||||||
|
var random = RandomNumberGenerator.Create();
|
||||||
|
var result = new char[length];
|
||||||
|
|
||||||
|
for (int i = 0; i < length; i++)
|
||||||
|
{
|
||||||
|
var randomNumber = new byte[4];
|
||||||
|
random.GetBytes(randomNumber);
|
||||||
|
var index = (int)(BitConverter.ToUInt32(randomNumber, 0) % chars.Length);
|
||||||
|
result[i] = chars[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
return new string(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool VerifyCodeChallenge(string codeVerifier, string codeChallenge, string method)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(codeVerifier)) return false;
|
||||||
|
|
||||||
|
if (method == "S256")
|
||||||
|
{
|
||||||
|
using var sha256 = SHA256.Create();
|
||||||
|
var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(codeVerifier));
|
||||||
|
var base64 = Base64UrlEncoder.Encode(hash);
|
||||||
|
return string.Equals(base64, codeChallenge, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method == "PLAIN")
|
||||||
|
{
|
||||||
|
return string.Equals(codeVerifier, codeChallenge, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
4000
DysonNetwork.Sphere/Data/Migrations/20250628172328_AddOidcProviderSupport.Designer.cs
generated
Normal file
4000
DysonNetwork.Sphere/Data/Migrations/20250628172328_AddOidcProviderSupport.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,139 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DysonNetwork.Sphere.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddOidcProviderSupport : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.RenameColumn(
|
||||||
|
name: "remarks",
|
||||||
|
table: "custom_app_secrets",
|
||||||
|
newName: "description");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "allow_offline_access",
|
||||||
|
table: "custom_apps",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "allowed_grant_types",
|
||||||
|
table: "custom_apps",
|
||||||
|
type: "character varying(256)",
|
||||||
|
maxLength: 256,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "allowed_scopes",
|
||||||
|
table: "custom_apps",
|
||||||
|
type: "character varying(256)",
|
||||||
|
maxLength: 256,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "client_uri",
|
||||||
|
table: "custom_apps",
|
||||||
|
type: "character varying(1024)",
|
||||||
|
maxLength: 1024,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "logo_uri",
|
||||||
|
table: "custom_apps",
|
||||||
|
type: "character varying(4096)",
|
||||||
|
maxLength: 4096,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "post_logout_redirect_uris",
|
||||||
|
table: "custom_apps",
|
||||||
|
type: "character varying(4096)",
|
||||||
|
maxLength: 4096,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "redirect_uris",
|
||||||
|
table: "custom_apps",
|
||||||
|
type: "character varying(4096)",
|
||||||
|
maxLength: 4096,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "require_pkce",
|
||||||
|
table: "custom_apps",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "is_oidc",
|
||||||
|
table: "custom_app_secrets",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_custom_app_secrets_secret",
|
||||||
|
table: "custom_app_secrets",
|
||||||
|
column: "secret",
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "ix_custom_app_secrets_secret",
|
||||||
|
table: "custom_app_secrets");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "allow_offline_access",
|
||||||
|
table: "custom_apps");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "allowed_grant_types",
|
||||||
|
table: "custom_apps");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "allowed_scopes",
|
||||||
|
table: "custom_apps");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "client_uri",
|
||||||
|
table: "custom_apps");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "logo_uri",
|
||||||
|
table: "custom_apps");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "post_logout_redirect_uris",
|
||||||
|
table: "custom_apps");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "redirect_uris",
|
||||||
|
table: "custom_apps");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "require_pkce",
|
||||||
|
table: "custom_apps");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "is_oidc",
|
||||||
|
table: "custom_app_secrets");
|
||||||
|
|
||||||
|
migrationBuilder.RenameColumn(
|
||||||
|
name: "description",
|
||||||
|
table: "custom_app_secrets",
|
||||||
|
newName: "remarks");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -21,7 +21,17 @@ public class CustomApp : ModelBase
|
|||||||
public Instant? VerifiedAt { get; set; }
|
public Instant? VerifiedAt { get; set; }
|
||||||
[MaxLength(4096)] public string? VerifiedAs { get; set; }
|
[MaxLength(4096)] public string? VerifiedAs { get; set; }
|
||||||
|
|
||||||
[JsonIgnore] private ICollection<CustomAppSecret> Secrets { get; set; } = new List<CustomAppSecret>();
|
// OIDC/OAuth specific properties
|
||||||
|
[MaxLength(4096)] public string? LogoUri { get; set; }
|
||||||
|
[MaxLength(1024)] public string? ClientUri { get; set; }
|
||||||
|
[MaxLength(4096)] public string[] RedirectUris { get; set; } = [];
|
||||||
|
[MaxLength(4096)] public string[]? PostLogoutRedirectUris { get; set; }
|
||||||
|
[MaxLength(256)] public string[]? AllowedScopes { get; set; } = ["openid", "profile", "email"];
|
||||||
|
[MaxLength(256)] public string[] AllowedGrantTypes { get; set; } = ["authorization_code", "refresh_token"];
|
||||||
|
public bool RequirePkce { get; set; } = true;
|
||||||
|
public bool AllowOfflineAccess { get; set; } = false;
|
||||||
|
|
||||||
|
[JsonIgnore] public ICollection<CustomAppSecret> Secrets { get; set; } = new List<CustomAppSecret>();
|
||||||
|
|
||||||
public Guid PublisherId { get; set; }
|
public Guid PublisherId { get; set; }
|
||||||
public Publisher.Publisher Developer { get; set; } = null!;
|
public Publisher.Publisher Developer { get; set; } = null!;
|
||||||
@ -31,8 +41,9 @@ public class CustomAppSecret : ModelBase
|
|||||||
{
|
{
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
[MaxLength(1024)] public string Secret { get; set; } = null!;
|
[MaxLength(1024)] public string Secret { get; set; } = null!;
|
||||||
[MaxLength(4096)] public string? Remarks { get; set; } = null!;
|
[MaxLength(4096)] public string? Description { get; set; } = null!;
|
||||||
public Instant? ExpiredAt { get; set; }
|
public Instant? ExpiredAt { get; set; }
|
||||||
|
public bool IsOidc { get; set; } = false; // Indicates if this secret is for OIDC/OAuth
|
||||||
|
|
||||||
public Guid AppId { get; set; }
|
public Guid AppId { get; set; }
|
||||||
public CustomApp App { get; set; } = null!;
|
public CustomApp App { get; set; } = null!;
|
||||||
|
@ -1500,6 +1500,26 @@ namespace DysonNetwork.Sphere.Migrations
|
|||||||
.HasColumnType("uuid")
|
.HasColumnType("uuid")
|
||||||
.HasColumnName("id");
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<bool>("AllowOfflineAccess")
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasColumnName("allow_offline_access");
|
||||||
|
|
||||||
|
b.Property<string>("AllowedGrantTypes")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)")
|
||||||
|
.HasColumnName("allowed_grant_types");
|
||||||
|
|
||||||
|
b.Property<string>("AllowedScopes")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)")
|
||||||
|
.HasColumnName("allowed_scopes");
|
||||||
|
|
||||||
|
b.Property<string>("ClientUri")
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("character varying(1024)")
|
||||||
|
.HasColumnName("client_uri");
|
||||||
|
|
||||||
b.Property<Instant>("CreatedAt")
|
b.Property<Instant>("CreatedAt")
|
||||||
.HasColumnType("timestamp with time zone")
|
.HasColumnType("timestamp with time zone")
|
||||||
.HasColumnName("created_at");
|
.HasColumnName("created_at");
|
||||||
@ -1508,16 +1528,36 @@ namespace DysonNetwork.Sphere.Migrations
|
|||||||
.HasColumnType("timestamp with time zone")
|
.HasColumnType("timestamp with time zone")
|
||||||
.HasColumnName("deleted_at");
|
.HasColumnName("deleted_at");
|
||||||
|
|
||||||
|
b.Property<string>("LogoUri")
|
||||||
|
.HasMaxLength(4096)
|
||||||
|
.HasColumnType("character varying(4096)")
|
||||||
|
.HasColumnName("logo_uri");
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(1024)
|
.HasMaxLength(1024)
|
||||||
.HasColumnType("character varying(1024)")
|
.HasColumnType("character varying(1024)")
|
||||||
.HasColumnName("name");
|
.HasColumnName("name");
|
||||||
|
|
||||||
|
b.Property<string>("PostLogoutRedirectUris")
|
||||||
|
.HasMaxLength(4096)
|
||||||
|
.HasColumnType("character varying(4096)")
|
||||||
|
.HasColumnName("post_logout_redirect_uris");
|
||||||
|
|
||||||
b.Property<Guid>("PublisherId")
|
b.Property<Guid>("PublisherId")
|
||||||
.HasColumnType("uuid")
|
.HasColumnType("uuid")
|
||||||
.HasColumnName("publisher_id");
|
.HasColumnName("publisher_id");
|
||||||
|
|
||||||
|
b.Property<string>("RedirectUris")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(4096)
|
||||||
|
.HasColumnType("character varying(4096)")
|
||||||
|
.HasColumnName("redirect_uris");
|
||||||
|
|
||||||
|
b.Property<bool>("RequirePkce")
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasColumnName("require_pkce");
|
||||||
|
|
||||||
b.Property<string>("Slug")
|
b.Property<string>("Slug")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(1024)
|
.HasMaxLength(1024)
|
||||||
@ -1569,14 +1609,18 @@ namespace DysonNetwork.Sphere.Migrations
|
|||||||
.HasColumnType("timestamp with time zone")
|
.HasColumnType("timestamp with time zone")
|
||||||
.HasColumnName("deleted_at");
|
.HasColumnName("deleted_at");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasMaxLength(4096)
|
||||||
|
.HasColumnType("character varying(4096)")
|
||||||
|
.HasColumnName("description");
|
||||||
|
|
||||||
b.Property<Instant?>("ExpiredAt")
|
b.Property<Instant?>("ExpiredAt")
|
||||||
.HasColumnType("timestamp with time zone")
|
.HasColumnType("timestamp with time zone")
|
||||||
.HasColumnName("expired_at");
|
.HasColumnName("expired_at");
|
||||||
|
|
||||||
b.Property<string>("Remarks")
|
b.Property<bool>("IsOidc")
|
||||||
.HasMaxLength(4096)
|
.HasColumnType("boolean")
|
||||||
.HasColumnType("character varying(4096)")
|
.HasColumnName("is_oidc");
|
||||||
.HasColumnName("remarks");
|
|
||||||
|
|
||||||
b.Property<string>("Secret")
|
b.Property<string>("Secret")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
@ -1594,6 +1638,10 @@ namespace DysonNetwork.Sphere.Migrations
|
|||||||
b.HasIndex("AppId")
|
b.HasIndex("AppId")
|
||||||
.HasDatabaseName("ix_custom_app_secrets_app_id");
|
.HasDatabaseName("ix_custom_app_secrets_app_id");
|
||||||
|
|
||||||
|
b.HasIndex("Secret")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("ix_custom_app_secrets_secret");
|
||||||
|
|
||||||
b.ToTable("custom_app_secrets", (string)null);
|
b.ToTable("custom_app_secrets", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -3444,7 +3492,7 @@ namespace DysonNetwork.Sphere.Migrations
|
|||||||
modelBuilder.Entity("DysonNetwork.Sphere.Developer.CustomAppSecret", b =>
|
modelBuilder.Entity("DysonNetwork.Sphere.Developer.CustomAppSecret", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("DysonNetwork.Sphere.Developer.CustomApp", "App")
|
b.HasOne("DysonNetwork.Sphere.Developer.CustomApp", "App")
|
||||||
.WithMany()
|
.WithMany("Secrets")
|
||||||
.HasForeignKey("AppId")
|
.HasForeignKey("AppId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
@ -3895,6 +3943,11 @@ namespace DysonNetwork.Sphere.Migrations
|
|||||||
b.Navigation("Articles");
|
b.Navigation("Articles");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Sphere.Developer.CustomApp", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Secrets");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("DysonNetwork.Sphere.Permission.PermissionGroup", b =>
|
modelBuilder.Entity("DysonNetwork.Sphere.Permission.PermissionGroup", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Members");
|
b.Navigation("Members");
|
||||||
|
@ -4,75 +4,340 @@
|
|||||||
ViewData["Title"] = "Profile";
|
ViewData["Title"] = "Profile";
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="h-full flex items-center justify-center bg-gray-100 dark:bg-gray-900 py-12">
|
|
||||||
<div class="bg-white dark:bg-gray-800 p-8 rounded-lg shadow-md w-full max-w-2xl">
|
|
||||||
<h1 class="text-3xl font-bold text-center text-gray-900 dark:text-white mb-8">User Profile</h1>
|
|
||||||
|
|
||||||
@if (Model.Account != null)
|
@if (Model.Account != null)
|
||||||
{
|
{
|
||||||
<div class="mb-6">
|
<div class="h-full bg-gray-100 dark:bg-gray-900 py-8 px-4">
|
||||||
<h2 class="text-2xl font-semibold text-gray-800 dark:text-gray-200 mb-4">Account Information</h2>
|
<div class="max-w-6xl mx-auto">
|
||||||
<p class="text-gray-700 dark:text-gray-300 mb-2"><strong>Username:</strong> @Model.Account.Name</p>
|
<!-- Header -->
|
||||||
<p class="text-gray-700 dark:text-gray-300 mb-2"><strong>Nickname:</strong> @Model.Account.Nick</p>
|
<div class="mb-8">
|
||||||
<p class="text-gray-700 dark:text-gray-300 mb-2"><strong>Language:</strong> @Model.Account.Language</p>
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Profile Settings</h1>
|
||||||
<p class="text-gray-700 dark:text-gray-300 mb-2">
|
<p class="text-gray-600 dark:text-gray-400 mt-2">Manage your account information and preferences</p>
|
||||||
<strong>Activated:</strong> @Model.Account.ActivatedAt?.ToString("yyyy-MM-dd HH:mm", System.Globalization.CultureInfo.InvariantCulture)
|
|
||||||
</p>
|
|
||||||
<p class="text-gray-700 dark:text-gray-300 mb-2"><strong>Superuser:</strong> @Model.Account.IsSuperuser
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-6">
|
<!-- Two Column Layout -->
|
||||||
<h2 class="text-2xl font-semibold text-gray-800 dark:text-gray-200 mb-4">Profile Details</h2>
|
<div class="flex flex-col md:flex-row gap-8">
|
||||||
<p class="text-gray-700 dark:text-gray-300 mb-2">
|
<!-- Left Pane - Profile Card -->
|
||||||
<strong>Name:</strong> @Model.Account.Profile.FirstName @Model.Account.Profile.MiddleName @Model.Account.Profile.LastName
|
<div class="w-full md:w-1/3 lg:w-1/4">
|
||||||
</p>
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 sticky top-8">
|
||||||
<p class="text-gray-700 dark:text-gray-300 mb-2"><strong>Bio:</strong> @Model.Account.Profile.Bio</p>
|
<div class="flex flex-col items-center text-center">
|
||||||
<p class="text-gray-700 dark:text-gray-300 mb-2"><strong>Gender:</strong> @Model.Account.Profile.Gender
|
<!-- Avatar -->
|
||||||
</p>
|
<div
|
||||||
<p class="text-gray-700 dark:text-gray-300 mb-2">
|
class="w-32 h-32 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center mb-4 overflow-hidden">
|
||||||
<strong>Location:</strong> @Model.Account.Profile.Location</p>
|
<span class="text-4xl text-gray-500 dark:text-gray-400">
|
||||||
<p class="text-gray-700 dark:text-gray-300 mb-2">
|
@Model.Account.Name?.Substring(0, 1).ToUpper()
|
||||||
<strong>Birthday:</strong> @Model.Account.Profile.Birthday?.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture)
|
</span>
|
||||||
</p>
|
|
||||||
<p class="text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
<strong>Experience:</strong> @Model.Account.Profile.Experience</p>
|
|
||||||
<p class="text-gray-700 dark:text-gray-300 mb-2"><strong>Level:</strong> @Model.Account.Profile.Level
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-6">
|
<!-- Basic Info -->
|
||||||
<h2 class="text-2xl font-semibold text-gray-800 dark:text-gray-200 mb-4">Access Token</h2>
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||||
|
@Model.Account.Nick
|
||||||
|
</h2>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400">@Model.Account.Name</p>
|
||||||
|
|
||||||
|
<!-- Stats -->
|
||||||
|
<div
|
||||||
|
class="mt-4 flex justify-around w-full border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||||
|
<div class="text-center">
|
||||||
|
<div
|
||||||
|
class="text-lg font-semibold text-gray-900 dark:text-white">@Model.Account.Profile.Level</div>
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">Level</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<div
|
||||||
|
class="text-lg font-semibold text-gray-900 dark:text-white">@Model.Account.Profile.Experience</div>
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">XP</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<div
|
||||||
|
class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
@Model.Account.CreatedAt.ToDateTimeUtc().ToString("yyyy/MM/dd")
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">Member since</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Pane - Tabbed Content -->
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden">
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<nav class="flex -mb-px">
|
||||||
|
<button type="button"
|
||||||
|
class="tab-button active py-4 px-6 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-200"
|
||||||
|
data-tab="profile">
|
||||||
|
Profile
|
||||||
|
</button>
|
||||||
|
<button type="button"
|
||||||
|
class="tab-button py-4 px-6 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-200"
|
||||||
|
data-tab="security">
|
||||||
|
Security
|
||||||
|
</button>
|
||||||
|
<button type="button"
|
||||||
|
class="tab-button py-4 px-6 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-200"
|
||||||
|
data-tab="sessions">
|
||||||
|
Sessions
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab Content -->
|
||||||
|
<div class="p-6">
|
||||||
|
<!-- Profile Tab -->
|
||||||
|
<div id="profile-tab" class="tab-content">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">Profile
|
||||||
|
Information</h2>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Basic
|
||||||
|
Information</h3>
|
||||||
|
<dl class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Full
|
||||||
|
Name
|
||||||
|
</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
|
||||||
|
@($"{Model.Account.Profile.FirstName} {Model.Account.Profile.MiddleName} {Model.Account.Profile.LastName}".Trim())
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||||
|
Username
|
||||||
|
</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900 dark:text-white">@Model.Account.Name</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||||
|
Nickname
|
||||||
|
</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900 dark:text-white">@Model.Account.Nick</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||||
|
Gender
|
||||||
|
</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900 dark:text-white">@Model.Account.Profile.Gender</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Additional
|
||||||
|
Details</h3>
|
||||||
|
<dl class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||||
|
Location
|
||||||
|
</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900 dark:text-white">@Model.Account.Profile.Location</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||||
|
Birthday
|
||||||
|
</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
|
||||||
|
@Model.Account.Profile.Birthday?.ToString("MMMM d, yyyy", System.Globalization.CultureInfo.InvariantCulture)
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Bio
|
||||||
|
</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
|
||||||
|
@(string.IsNullOrEmpty(Model.Account.Profile.Bio) ? "No bio provided" : Model.Account.Profile.Bio)
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<button type="button"
|
||||||
|
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||||
|
Edit Profile
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Security Tab -->
|
||||||
|
<div id="security-tab" class="tab-content hidden">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">Security
|
||||||
|
Settings</h2>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="bg-white dark:bg-gray-800 shadow overflow-hidden sm:rounded-lg">
|
||||||
|
<div class="px-4 py-5 sm:px-6">
|
||||||
|
<h3 class="text-lg leading-6 font-medium text-gray-900 dark:text-white">
|
||||||
|
Access Token</h3>
|
||||||
|
<p class="mt-1 max-w-2xl text-sm text-gray-500 dark:text-gray-400">Use this
|
||||||
|
token to authenticate with the API</p>
|
||||||
|
</div>
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700 px-4 py-5 sm:px-6">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<input type="text" id="accessToken" value="@Model.AccessToken" readonly
|
<input type="password" id="accessToken" value="@Model.AccessToken"
|
||||||
class="form-input flex-grow rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50 dark:bg-gray-700 dark:border-gray-600 dark:text-white"/>
|
readonly
|
||||||
|
class="form-input flex-grow rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50 dark:bg-gray-700 dark:border-gray-600 dark:text-white py-2 px-4"/>
|
||||||
<button onclick="copyAccessToken()"
|
<button onclick="copyAccessToken()"
|
||||||
class="ml-4 bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50">
|
class="ml-4 bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50">
|
||||||
Copy
|
Copy
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Keep this token secure and do not share it with anyone.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="post" asp-page-handler="Logout" class="text-center">
|
<!-- Sessions Tab -->
|
||||||
|
<div id="sessions-tab" class="tab-content hidden">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">Active
|
||||||
|
Sessions</h2>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mb-6">This is a list of devices that have
|
||||||
|
logged into your account. Revoke any sessions that you do not recognize.</p>
|
||||||
|
|
||||||
|
<div class="bg-white dark:bg-gray-800 shadow overflow-hidden sm:rounded-lg">
|
||||||
|
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<li class="px-4 py-4 sm:px-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div
|
||||||
|
class="flex-shrink-0 h-10 w-10 rounded-full bg-blue-100 dark:bg-blue-900 flex items-center justify-center">
|
||||||
|
<svg class="h-6 w-6 text-blue-600 dark:text-blue-400"
|
||||||
|
fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd"
|
||||||
|
d="M4 4a2 2 0 012-2h8a2 2 0 012 2v12a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 0v12h8V4H6z"
|
||||||
|
clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
Current Session
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
@($"{Request.Headers["User-Agent"]} • {DateTime.Now:MMMM d, yyyy 'at' h:mm tt}")
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4 flex-shrink-0">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||||
|
Active now
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-800 px-4 py-4 sm:px-6 text-right">
|
||||||
|
<button type="button"
|
||||||
|
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500">
|
||||||
|
Sign out all other sessions
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Logout Button -->
|
||||||
|
<div class="mt-6 flex justify-end">
|
||||||
|
<form method="post" asp-page-handler="Logout">
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
class="bg-red-600 text-white py-2 px-4 rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-opacity-50">
|
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500">
|
||||||
Logout
|
Sign out
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<p class="text-red-500 text-center">User profile not found. Please log in.</p>
|
<div class="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900">
|
||||||
|
<div class="max-w-md w-full p-8 bg-white dark:bg-gray-800 rounded-lg shadow-md text-center">
|
||||||
|
<div class="text-red-500 text-5xl mb-4">
|
||||||
|
<i class="fas fa-exclamation-circle"></i>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">Profile Not Found</h2>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mb-6">User profile not found. Please log in to continue.</p>
|
||||||
|
<a href="/auth/login"
|
||||||
|
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||||
|
Go to Login
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
@section Scripts {
|
||||||
<script>
|
<script>
|
||||||
|
// Tab functionality
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
// Get all tab buttons and content
|
||||||
|
const tabButtons = document.querySelectorAll('.tab-button');
|
||||||
|
const tabContents = document.querySelectorAll('.tab-content');
|
||||||
|
|
||||||
|
// Add click event listeners to tab buttons
|
||||||
|
tabButtons.forEach(button => {
|
||||||
|
button.addEventListener('click', function () {
|
||||||
|
const tabId = this.getAttribute('data-tab');
|
||||||
|
|
||||||
|
// Update active tab button
|
||||||
|
tabButtons.forEach(btn => btn.classList.remove('border-blue-500', 'text-blue-600', 'dark:text-blue-400'));
|
||||||
|
this.classList.add('border-blue-500', 'text-blue-600', 'dark:text-blue-400');
|
||||||
|
|
||||||
|
// Show corresponding tab content
|
||||||
|
tabContents.forEach(content => {
|
||||||
|
content.classList.add('hidden');
|
||||||
|
if (content.id === `${tabId}-tab`) {
|
||||||
|
content.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show first tab by default
|
||||||
|
if (tabButtons.length > 0) {
|
||||||
|
tabButtons[0].click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Copy access token to clipboard
|
||||||
function copyAccessToken() {
|
function copyAccessToken() {
|
||||||
var copyText = document.getElementById("accessToken");
|
const copyText = document.getElementById("accessToken");
|
||||||
copyText.select();
|
copyText.select();
|
||||||
copyText.setSelectionRange(0, 99999); /* For mobile devices */
|
copyText.setSelectionRange(0, 99999);
|
||||||
document.execCommand("copy");
|
document.execCommand("copy");
|
||||||
alert("Access Token copied to clipboard!");
|
|
||||||
|
// Show tooltip or notification
|
||||||
|
const originalText = event.target.innerHTML;
|
||||||
|
event.target.innerHTML = '<i class="fas fa-check mr-1"></i> Copied!';
|
||||||
|
event.target.disabled = true;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
event.target.innerHTML = originalText;
|
||||||
|
event.target.disabled = false;
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle password visibility
|
||||||
|
function togglePasswordVisibility(inputId) {
|
||||||
|
const input = document.getElementById(inputId);
|
||||||
|
const icon = document.querySelector(`[onclick="togglePasswordVisibility('${inputId}')"] i`);
|
||||||
|
|
||||||
|
if (input.type === 'password') {
|
||||||
|
input.type = 'text';
|
||||||
|
icon.classList.remove('fa-eye');
|
||||||
|
icon.classList.add('fa-eye-slash');
|
||||||
|
} else {
|
||||||
|
input.type = 'password';
|
||||||
|
icon.classList.remove('fa-eye-slash');
|
||||||
|
icon.classList.add('fa-eye');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
}
|
127
DysonNetwork.Sphere/Pages/Auth/Authorize.cshtml
Normal file
127
DysonNetwork.Sphere/Pages/Auth/Authorize.cshtml
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
@page "/auth/authorize"
|
||||||
|
@model DysonNetwork.Sphere.Pages.Auth.AuthorizeModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Authorize Application";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 transition-colors duration-200 py-12 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="max-w-md w-full space-y-8 bg-white dark:bg-gray-800 p-8 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 transition-colors duration-200">
|
||||||
|
<div class="text-center">
|
||||||
|
<h2 class="mt-6 text-3xl font-extrabold text-gray-900 dark:text-white">
|
||||||
|
Authorize Application
|
||||||
|
</h2>
|
||||||
|
@if (!string.IsNullOrEmpty(Model.AppName))
|
||||||
|
{
|
||||||
|
<div class="mt-6">
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
@if (!string.IsNullOrEmpty(Model.AppLogo))
|
||||||
|
{
|
||||||
|
<div class="relative h-16 w-16 flex-shrink-0">
|
||||||
|
<img class="h-16 w-16 rounded-lg object-cover border border-gray-200 dark:border-gray-700"
|
||||||
|
src="@Model.AppLogo"
|
||||||
|
alt="@Model.AppName logo"
|
||||||
|
onerror="this.onerror=null; this.src='data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iY3VycmVudENvbG9yIiBjbGFzczz0idy02IGgtNiB0ZXh0LWdyYXktNDAwIj48cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xOS42MDMgMy4wMDRjMS4xNTMgMCAyLjI2LjE4IDMuMzI5LjUxM2EuNzUuNzUgMCAwMS0uMzI4IDEuNDQ1IDE2LjkzIDE2LjkzIDAgMDAtNi4wMS0xuMTAzLjc1Ljc1IDAgMDEtLjY2OC0uNzQ4VjMuNzVBLjc1Ljc1IDAgMDExNi41IDNoMy4xMDN6TTMxLjUgMjEuMDA4Yy0uU0UuNzUgNzUuNzUgMCAwMTEuOTQ4LjA1MWMuMDgxLjkxOC4wNTIgMS44MzgtLjA4NiAyLjczNGEuNzUuNzUgMCAwMS0xLjQ5LS4xNTkgMjEuMzg2IDIxLjM4NiAwIDAwLjA2LTIuNTc1Ljc1Ljc1IDAgMDExLjU3OC0uMDQzem0tMy4wMi0xOC4wMmMuMDYgMS4wOTIuMDQgMi4xODctLjA1NiAzLjI3MmEuNzUuNzUgMCAwMS0xLjQ5Mi0uMTY0Yy4wOTItLjg3NC4xMDctMS43NTYuMDUxLTIuNjA3YS43NS43NSAwIDAxMS40OTctLjEwMXpNNS42MzcgNS42MzJjLS4zMzQuMS0uNjc2LjE4NS0xLjAyNi4yNTdhLjc1Ljc1IDAgMDEuMTQ5LTEuNDljLjQyNS0uMDg1Ljg1Mi0uMTg5IDEuMjY4LS4zMDRhLjc1Ljc1IDAgMDEuMzYgMS40Mzd6TTMuMzMgMTkuNjUzYy0uMTY1LS4zNS0uMzA4LS43MDctNDIuNjUzLjc1Ljc1IDAgMS4zODgtLjU0MiAxLjQ5LTEuMjg1LjA0Ni0uMzI2LjEwNi0uNjUyLjE4LS45NzZhLjc1Ljc1IDAgMTExLjQ2LS41M2MuMTA2LjQzNy4xODkuODgxLjI0NSAxLjM0OGEuNzUuNzUgMCAwMS0xLjQ5LjIzM3pNTEuMzUzIDIuNzY3YS43NS43NSAwIDAxLjc1LS4wMTdsLjAwMS4wMDFoLjAwMWwuMDAyLjAwMWwuMDA3LjAwM2wuMDI0LjAxM2MuMDIuMDEuMDQ1LjAyNS4wNzkuMDQ2LjA2Ny4wNDIuMTYxLjEwMi4yNzUuMTc4LjIzMi4xNTEuNTUuMzgzLjg1Ni42NjdsLjAyNy4wMjRjLjYxNi41NTggMS4yMTIgMS4xNzYgMS43MzMgMS44NDNhLjc1Ljc1IDAgMDEtMS4yNC44N2MtLjQ3LS42NzEtMS4wMzEtMS4yOTItMS42LFsxLjYxNi0xLjYxNmEzLjc1IDMuNzUgMCAwMC01LjMwNS01LjMwNGwtMS4wNi0xuMDZBNy4yNDMgNy4yNDMgMCAwMTUxLjM1MyAyLjc2N3pNNDQuMzc5IDkuNjRsLTEuNTYgMS41NmE2Ljk5IDYuOTkgMCAwMTIuMjMgNC4zMzcgNi45OSA2Ljk5IDAgMDEtMi4yMyA1LjE3NmwtMS41Ni0xLjU2QTguNDkgOC40OSAwIDAwNDUuNSAxNS41YzAtMi4yOTYtLjg3NC00LjQzLTIuMTIxLTYuMDF6bS0zLjUzLTMuNTNsLTEuMDYxLTEuMDZhNy4yNDMgNy4yNDMgMCAwMTkuMTkyIDkuE2x0LTEuMDYgMS4wNjFhNS43NDkgNS43NDkgMCAwMC04LjEzLTguMTN6TTM0LjUgMTUuNWE4Ljk5IDguOTkgMCAwMC0yLjYzMSA2LjM2OS43NS43NSAwIDExLTEuNDk0LS43MzlDNzIuMzkgMjAuMDYgMzMuNSAxNy41NzUgMzMuNSAxNS41YzAtMi4zNzYgMS4wOTktNC40MzggMi44MTEtNS44MTJsLS4zOTYtLjM5NmEuNzUuNzUgMCAwMTEuMDYtMS4wNkwzNy41IDkuNDRWMmgtL4wMDJhLjc1Ljc1IDAgMDEtLjc0OC0uNzVWMS41YS43NS43NSAwIDAxLjc1LS43NWg2YS43NS43NSAwIDAxLjc1Ljc1di4yNWEwIC43NS0uNzUuNzVoLS4wMDF2Ny40NGwzLjUzNy0zLjUzN2EuNzUuNzUgMCAwMTEuMDYgMS4wNmwtLjM5Ni4zOTZDMzUuNDAxIDExLjA2MiAzNC41IDEzLjEyNCAzNC41IDE1LjV6TTM5IDIuMjV2Ni4wMy0uMDAyYTEuNSAxLjUgMCAwMS0uNDQ0IDEuMDZsLTEuMDYxIDEuMDZBOC40OSA4LjQ5IDAgMDAzOSAxNS41YzAgMi4yOTYtLjg3NCA0LjQzLTIuMTIxIDYuMDFsMS41NiAxLjU2QTYuOTkgNi45OSAwIDAwNDIgMTUuNWE2Ljk5IDYuOTkgMCAwMC0yLjIzLTUuMTc2bC0xLjU2LTEuNTZhMS41IDEuNSAwIDAxLS40NC0xLjA2VjIuMjVoLTF6TTI0IDkuNzVhLjc1Ljc1IDAgMDEtLjc1Ljc1di0uNWMwLS40MTQuMzM2LS43NS43NS0uNzVoLjVjLjQxNCAwIC43NS4zMzYuNzUuNzV2LjVhLjc1Ljc1IDAgMDEtLjc1Ljc1aC0uNXpNMTkuNSAxM2EuNzUuNzUgMCAwMS0uNzUtLjc1di0uNWMwLS40MTQuMzM2LS43NS43NS0uNzVoLjVjLjQxNCAwIC43NS4zMzYuNzUuNzV2LjVhLjc1Ljc1IDAgMDEtLjc1Ljc1aC0uNXpNMTUgMTYuMjVhLjc1Ljc1IDAgMDEuNzUtLjc1aC41Yy40MTQgMCAuNzUuMzM2Ljc1Ljc1di41YS43NS43NSAwIDAxLS43NS43NWgtLjVhLjc1Ljc1IDAgMDEtLjc1LS43NXYtLjV6IiBjbGlwLXJ1bGU9ImV2ZW5vZGQiLz48L3N2Zz4=';">
|
||||||
|
<div class="absolute inset-0 flex items-center justify-center bg-gray-100 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600">
|
||||||
|
<span class="text-xs font-medium text-gray-500 dark:text-gray-300">@Model.AppName?[0]</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="ml-4 text-left">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 dark:text-white">@Model.AppName</h3>
|
||||||
|
@if (!string.IsNullOrEmpty(Model.AppUri))
|
||||||
|
{
|
||||||
|
<a href="@Model.AppUri" class="text-sm text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300 transition-colors duration-200" target="_blank" rel="noopener noreferrer">
|
||||||
|
@Model.AppUri
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<p class="mt-6 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
wants to access your account with the following permissions:
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<ul class="border border-gray-200 dark:border-gray-700 rounded-lg divide-y divide-gray-200 dark:divide-gray-700 overflow-hidden">
|
||||||
|
@if (Model.Scope != null)
|
||||||
|
{
|
||||||
|
var scopeDescriptions = new Dictionary<string, (string Name, string Description)>
|
||||||
|
{
|
||||||
|
["openid"] = ("OpenID", "Read your basic profile information"),
|
||||||
|
["profile"] = ("Profile", "View your basic profile information"),
|
||||||
|
["email"] = ("Email", "View your email address"),
|
||||||
|
["offline_access"] = ("Offline Access", "Access your data while you're not using the application")
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var scope in Model.Scope.Split(' ').Where(s => !string.IsNullOrWhiteSpace(s)))
|
||||||
|
{
|
||||||
|
var scopeInfo = scopeDescriptions.GetValueOrDefault(scope, (scope, scope.Replace('_', ' ')));
|
||||||
|
<li class="px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-150">
|
||||||
|
<div class="flex items-start">
|
||||||
|
<div class="flex-shrink-0 pt-0.5">
|
||||||
|
<svg class="h-5 w-5 text-green-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3">
|
||||||
|
<p class="text-sm font-medium text-gray-900 dark:text-white">@scopeInfo.Item1</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">@scopeInfo.Item2</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="mt-4 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<p>By authorizing, you allow this application to access your information on your behalf.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" class="mt-8 space-y-4">
|
||||||
|
<input type="hidden" asp-for="ClientIdString" />
|
||||||
|
<input type="hidden" asp-for="ResponseType" name="response_type" />
|
||||||
|
<input type="hidden" asp-for="RedirectUri" name="redirect_uri" />
|
||||||
|
<input type="hidden" asp-for="Scope" name="scope" />
|
||||||
|
<input type="hidden" asp-for="State" name="state" />
|
||||||
|
<input type="hidden" asp-for="Nonce" name="nonce" />
|
||||||
|
<input type="hidden" asp-for="ReturnUrl" name="returnUrl" />
|
||||||
|
<input type="hidden" name="code_challenge" value="@HttpContext.Request.Query["code_challenge"]" />
|
||||||
|
<input type="hidden" name="code_challenge_method" value="@HttpContext.Request.Query["code_challenge_method"]" />
|
||||||
|
<input type="hidden" name="response_mode" value="@HttpContext.Request.Query["response_mode"]" />
|
||||||
|
|
||||||
|
<div class="flex flex-col space-y-3">
|
||||||
|
<button type="submit" name="allow" value="true"
|
||||||
|
class="w-full flex justify-center py-2.5 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-offset-gray-800 transition-colors duration-200">
|
||||||
|
Allow
|
||||||
|
</button>
|
||||||
|
<button type="submit" name="allow" value="false"
|
||||||
|
class="w-full flex justify-center py-2.5 px-4 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-offset-gray-800 transition-colors duration-200">
|
||||||
|
Deny
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<p class="text-xs text-center text-gray-500 dark:text-gray-400">
|
||||||
|
You can change these permissions later in your account settings.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@functions {
|
||||||
|
private string GetScopeDisplayName(string scope)
|
||||||
|
{
|
||||||
|
return scope switch
|
||||||
|
{
|
||||||
|
"openid" => "View your basic profile information",
|
||||||
|
"profile" => "View your profile information (name, picture, etc.)",
|
||||||
|
"email" => "View your email address",
|
||||||
|
"offline_access" => "Access your information while you're not using the app",
|
||||||
|
_ => scope
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
148
DysonNetwork.Sphere/Pages/Auth/Authorize.cshtml.cs
Normal file
148
DysonNetwork.Sphere/Pages/Auth/Authorize.cshtml.cs
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
using DysonNetwork.Sphere.Auth.OidcProvider.Services;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Sphere.Pages.Auth;
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
public class AuthorizeModel(OidcProviderService oidcService) : PageModel
|
||||||
|
{
|
||||||
|
[BindProperty(SupportsGet = true)]
|
||||||
|
public string? ReturnUrl { get; set; }
|
||||||
|
|
||||||
|
[BindProperty(SupportsGet = true, Name = "client_id")]
|
||||||
|
[Required(ErrorMessage = "The client_id parameter is required")]
|
||||||
|
public string? ClientIdString { get; set; }
|
||||||
|
|
||||||
|
public Guid ClientId { get; set; }
|
||||||
|
|
||||||
|
[BindProperty(SupportsGet = true, Name = "response_type")]
|
||||||
|
public string ResponseType { get; set; } = "code";
|
||||||
|
|
||||||
|
[BindProperty(SupportsGet = true, Name = "redirect_uri")]
|
||||||
|
public string? RedirectUri { get; set; }
|
||||||
|
|
||||||
|
[BindProperty(SupportsGet = true)]
|
||||||
|
public string? Scope { get; set; }
|
||||||
|
|
||||||
|
[BindProperty(SupportsGet = true)]
|
||||||
|
public string? State { get; set; }
|
||||||
|
|
||||||
|
[BindProperty(SupportsGet = true)]
|
||||||
|
public string? Nonce { get; set; }
|
||||||
|
|
||||||
|
|
||||||
|
[BindProperty(SupportsGet = true, Name = "code_challenge")]
|
||||||
|
public string? CodeChallenge { get; set; }
|
||||||
|
|
||||||
|
[BindProperty(SupportsGet = true, Name = "code_challenge_method")]
|
||||||
|
public string? CodeChallengeMethod { get; set; }
|
||||||
|
|
||||||
|
[BindProperty(SupportsGet = true, Name = "response_mode")]
|
||||||
|
public string? ResponseMode { get; set; }
|
||||||
|
|
||||||
|
public string? AppName { get; set; }
|
||||||
|
public string? AppLogo { get; set; }
|
||||||
|
public string? AppUri { get; set; }
|
||||||
|
public string[]? RequestedScopes { get; set; }
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnGetAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(ClientIdString) || !Guid.TryParse(ClientIdString, out var clientId))
|
||||||
|
{
|
||||||
|
ModelState.AddModelError("client_id", "Invalid client_id format");
|
||||||
|
return BadRequest("Invalid client_id format");
|
||||||
|
}
|
||||||
|
|
||||||
|
ClientId = clientId;
|
||||||
|
|
||||||
|
var client = await oidcService.FindClientByIdAsync(ClientId);
|
||||||
|
if (client == null)
|
||||||
|
{
|
||||||
|
ModelState.AddModelError("client_id", "Client not found");
|
||||||
|
return NotFound("Client not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
AppName = client.Name;
|
||||||
|
AppLogo = client.LogoUri;
|
||||||
|
AppUri = client.ClientUri;
|
||||||
|
RequestedScopes = (Scope ?? "openid profile").Split(' ').Distinct().ToArray();
|
||||||
|
|
||||||
|
return Page();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostAsync(bool allow)
|
||||||
|
{
|
||||||
|
// First validate the client ID
|
||||||
|
if (string.IsNullOrEmpty(ClientIdString) || !Guid.TryParse(ClientIdString, out var clientId))
|
||||||
|
{
|
||||||
|
ModelState.AddModelError("client_id", "Invalid client_id format");
|
||||||
|
return BadRequest("Invalid client_id format");
|
||||||
|
}
|
||||||
|
|
||||||
|
ClientId = clientId;
|
||||||
|
|
||||||
|
// Check if client exists
|
||||||
|
var client = await oidcService.FindClientByIdAsync(ClientId);
|
||||||
|
if (client == null)
|
||||||
|
{
|
||||||
|
ModelState.AddModelError("client_id", "Client not found");
|
||||||
|
return NotFound("Client not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allow)
|
||||||
|
{
|
||||||
|
// User denied the authorization request
|
||||||
|
if (string.IsNullOrEmpty(RedirectUri))
|
||||||
|
return BadRequest("No redirect_uri provided");
|
||||||
|
|
||||||
|
var deniedUriBuilder = new UriBuilder(RedirectUri);
|
||||||
|
var deniedQuery = System.Web.HttpUtility.ParseQueryString(deniedUriBuilder.Query);
|
||||||
|
deniedQuery["error"] = "access_denied";
|
||||||
|
deniedQuery["error_description"] = "The user denied the authorization request";
|
||||||
|
if (!string.IsNullOrEmpty(State)) deniedQuery["state"] = State;
|
||||||
|
deniedUriBuilder.Query = deniedQuery.ToString();
|
||||||
|
|
||||||
|
return Redirect(deniedUriBuilder.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// User approved the request
|
||||||
|
if (string.IsNullOrEmpty(RedirectUri))
|
||||||
|
{
|
||||||
|
ModelState.AddModelError("redirect_uri", "No redirect_uri provided");
|
||||||
|
return BadRequest("No redirect_uri provided");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate authorization code
|
||||||
|
var authCode = await oidcService.GenerateAuthorizationCodeAsync(
|
||||||
|
clientId: ClientId,
|
||||||
|
userId: User.Identity?.Name ?? string.Empty,
|
||||||
|
redirectUri: RedirectUri,
|
||||||
|
scopes: Scope?.Split(' ', StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty<string>(),
|
||||||
|
codeChallenge: CodeChallenge,
|
||||||
|
codeChallengeMethod: CodeChallengeMethod,
|
||||||
|
nonce: Nonce);
|
||||||
|
|
||||||
|
// Build the redirect URI with the authorization code
|
||||||
|
var redirectUri = new UriBuilder(RedirectUri);
|
||||||
|
var query = System.Web.HttpUtility.ParseQueryString(redirectUri.Query);
|
||||||
|
|
||||||
|
// Add the authorization code
|
||||||
|
query["code"] = authCode;
|
||||||
|
|
||||||
|
// Add state if provided (for CSRF protection)
|
||||||
|
if (!string.IsNullOrEmpty(State))
|
||||||
|
{
|
||||||
|
query["state"] = State;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the query string
|
||||||
|
redirectUri.Query = query.ToString();
|
||||||
|
|
||||||
|
// Redirect back to the client with the authorization code
|
||||||
|
return Redirect(redirectUri.ToString());
|
||||||
|
}
|
||||||
|
}
|
@ -8,9 +8,12 @@ namespace DysonNetwork.Sphere.Pages.Auth
|
|||||||
[BindProperty(SupportsGet = true)]
|
[BindProperty(SupportsGet = true)]
|
||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
|
|
||||||
|
[BindProperty(SupportsGet = true)]
|
||||||
|
public string? ReturnUrl { get; set; }
|
||||||
|
|
||||||
public IActionResult OnGet()
|
public IActionResult OnGet()
|
||||||
{
|
{
|
||||||
return RedirectToPage("SelectFactor", new { id = Id });
|
return RedirectToPage("SelectFactor", new { id = Id, returnUrl = ReturnUrl });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -2,6 +2,7 @@
|
|||||||
@model DysonNetwork.Sphere.Pages.Auth.LoginModel
|
@model DysonNetwork.Sphere.Pages.Auth.LoginModel
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = "Login";
|
ViewData["Title"] = "Login";
|
||||||
|
var returnUrl = Model.ReturnUrl ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="h-full flex items-center justify-center bg-gray-100 dark:bg-gray-900">
|
<div class="h-full flex items-center justify-center bg-gray-100 dark:bg-gray-900">
|
||||||
@ -9,6 +10,7 @@
|
|||||||
<h1 class="text-2xl font-bold text-center text-gray-900 dark:text-white mb-6">Login</h1>
|
<h1 class="text-2xl font-bold text-center text-gray-900 dark:text-white mb-6">Login</h1>
|
||||||
|
|
||||||
<form method="post">
|
<form method="post">
|
||||||
|
<input type="hidden" asp-for="ReturnUrl" value="@returnUrl" />
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label asp-for="Username"
|
<label asp-for="Username"
|
||||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"></label>
|
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"></label>
|
||||||
@ -21,6 +23,15 @@
|
|||||||
Next
|
Next
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<div class="mt-8 flex flex-col text-sm text-center">
|
||||||
|
<span class="text-gray-900 dark:text-white opacity-80">Have no account?</span>
|
||||||
|
|
||||||
|
<a href="https://solian.app/#/auth/create-account"
|
||||||
|
class="text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300">
|
||||||
|
Create a new account →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -19,6 +19,10 @@ namespace DysonNetwork.Sphere.Pages.Auth
|
|||||||
{
|
{
|
||||||
[BindProperty] [Required] public string Username { get; set; } = string.Empty;
|
[BindProperty] [Required] public string Username { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[BindProperty]
|
||||||
|
[FromQuery]
|
||||||
|
public string? ReturnUrl { get; set; }
|
||||||
|
|
||||||
public void OnGet()
|
public void OnGet()
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
@ -37,6 +41,12 @@ namespace DysonNetwork.Sphere.Pages.Auth
|
|||||||
return Page();
|
return Page();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Store the return URL in TempData to preserve it during the login flow
|
||||||
|
if (!string.IsNullOrEmpty(ReturnUrl) && Url.IsLocalUrl(ReturnUrl))
|
||||||
|
{
|
||||||
|
TempData["ReturnUrl"] = ReturnUrl;
|
||||||
|
}
|
||||||
|
|
||||||
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
|
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||||
var userAgent = HttpContext.Request.Headers.UserAgent.ToString();
|
var userAgent = HttpContext.Request.Headers.UserAgent.ToString();
|
||||||
var now = Instant.FromDateTimeUtc(DateTime.UtcNow);
|
var now = Instant.FromDateTimeUtc(DateTime.UtcNow);
|
||||||
@ -71,11 +81,13 @@ namespace DysonNetwork.Sphere.Pages.Auth
|
|||||||
await db.AuthChallenges.AddAsync(challenge);
|
await db.AuthChallenges.AddAsync(challenge);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
als.CreateActionLogFromRequest(ActionLogType.ChallengeAttempt,
|
// If we have a return URL, pass it to the verify page
|
||||||
new Dictionary<string, object> { { "challenge_id", challenge.Id } }, Request, account
|
if (TempData.TryGetValue("ReturnUrl", out var returnUrl) && returnUrl is string url)
|
||||||
);
|
{
|
||||||
|
return RedirectToPage("SelectFactor", new { id = challenge.Id, returnUrl = url });
|
||||||
|
}
|
||||||
|
|
||||||
return RedirectToPage("Challenge", new { id = challenge.Id });
|
return RedirectToPage("SelectFactor", new { id = challenge.Id });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -13,6 +13,9 @@ public class SelectFactorModel(
|
|||||||
: PageModel
|
: PageModel
|
||||||
{
|
{
|
||||||
[BindProperty(SupportsGet = true)] public Guid Id { get; set; }
|
[BindProperty(SupportsGet = true)] public Guid Id { get; set; }
|
||||||
|
[BindProperty(SupportsGet = true)] public string? ReturnUrl { get; set; }
|
||||||
|
[BindProperty] public Guid SelectedFactorId { get; set; }
|
||||||
|
[BindProperty] public string? Hint { get; set; }
|
||||||
|
|
||||||
public Challenge? AuthChallenge { get; set; }
|
public Challenge? AuthChallenge { get; set; }
|
||||||
public List<AccountAuthFactor> AuthFactors { get; set; } = [];
|
public List<AccountAuthFactor> AuthFactors { get; set; } = [];
|
||||||
@ -25,7 +28,7 @@ public class SelectFactorModel(
|
|||||||
return Page();
|
return Page();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IActionResult> OnPostSelectFactorAsync(Guid factorId, string? hint = null)
|
public async Task<IActionResult> OnPostSelectFactorAsync()
|
||||||
{
|
{
|
||||||
var challenge = await db.AuthChallenges
|
var challenge = await db.AuthChallenges
|
||||||
.Include(e => e.Account)
|
.Include(e => e.Account)
|
||||||
@ -33,23 +36,29 @@ public class SelectFactorModel(
|
|||||||
|
|
||||||
if (challenge == null) return NotFound();
|
if (challenge == null) return NotFound();
|
||||||
|
|
||||||
var factor = await db.AccountAuthFactors.FindAsync(factorId);
|
var factor = await db.AccountAuthFactors.FindAsync(SelectedFactorId);
|
||||||
if (factor?.EnabledAt == null || factor.Trustworthy <= 0)
|
if (factor?.EnabledAt == null || factor.Trustworthy <= 0)
|
||||||
return BadRequest("Invalid authentication method.");
|
return BadRequest("Invalid authentication method.");
|
||||||
|
|
||||||
|
// Store return URL in TempData to pass to the next step
|
||||||
|
if (!string.IsNullOrEmpty(ReturnUrl))
|
||||||
|
{
|
||||||
|
TempData["ReturnUrl"] = ReturnUrl;
|
||||||
|
}
|
||||||
|
|
||||||
// For OTP factors that require code delivery
|
// For OTP factors that require code delivery
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Validate hint for factors that require it
|
// For OTP factors that require code delivery
|
||||||
if (factor.Type == AccountAuthFactorType.EmailCode
|
if (factor.Type == AccountAuthFactorType.EmailCode
|
||||||
&& string.IsNullOrWhiteSpace(hint))
|
&& string.IsNullOrWhiteSpace(Hint))
|
||||||
{
|
{
|
||||||
ModelState.AddModelError(string.Empty, $"Please provide a {factor.Type.ToString().ToLower().Replace("code", "")} to send the code to.");
|
ModelState.AddModelError(string.Empty, $"Please provide a {factor.Type.ToString().ToLower().Replace("code", "")} to send the code to.");
|
||||||
await LoadChallengeAndFactors();
|
await LoadChallengeAndFactors();
|
||||||
return Page();
|
return Page();
|
||||||
}
|
}
|
||||||
|
|
||||||
await accounts.SendFactorCode(challenge.Account, factor, hint);
|
await accounts.SendFactorCode(challenge.Account, factor, Hint);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@ -58,8 +67,12 @@ public class SelectFactorModel(
|
|||||||
return Page();
|
return Page();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redirect to verify the page with the selected factor
|
// Redirect to verify page with return URL if available
|
||||||
return RedirectToPage("VerifyFactor", new { id = Id, factorId });
|
if (!string.IsNullOrEmpty(ReturnUrl))
|
||||||
|
{
|
||||||
|
return RedirectToPage("VerifyFactor", new { id = Id, factorId = factor.Id, returnUrl = ReturnUrl });
|
||||||
|
}
|
||||||
|
return RedirectToPage("VerifyFactor", new { id = Id, factorId = factor.Id });
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task LoadChallengeAndFactors()
|
private async Task LoadChallengeAndFactors()
|
||||||
|
@ -52,6 +52,7 @@
|
|||||||
<input asp-for="Code"
|
<input asp-for="Code"
|
||||||
class="form-input mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50 dark:bg-gray-700 dark:border-gray-600 dark:text-white px-4 py-2"
|
class="form-input mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50 dark:bg-gray-700 dark:border-gray-600 dark:text-white px-4 py-2"
|
||||||
autocomplete="one-time-code"
|
autocomplete="one-time-code"
|
||||||
|
type="password"
|
||||||
autofocus />
|
autofocus />
|
||||||
<span asp-validation-for="Code" class="text-red-500 text-sm mt-1"></span>
|
<span asp-validation-for="Code" class="text-red-500 text-sm mt-1"></span>
|
||||||
</div>
|
</div>
|
||||||
|
@ -23,6 +23,9 @@ namespace DysonNetwork.Sphere.Pages.Auth
|
|||||||
[BindProperty(SupportsGet = true)]
|
[BindProperty(SupportsGet = true)]
|
||||||
public Guid FactorId { get; set; }
|
public Guid FactorId { get; set; }
|
||||||
|
|
||||||
|
[BindProperty(SupportsGet = true)]
|
||||||
|
public string? ReturnUrl { get; set; }
|
||||||
|
|
||||||
[BindProperty, Required]
|
[BindProperty, Required]
|
||||||
public string Code { get; set; } = string.Empty;
|
public string Code { get; set; } = string.Empty;
|
||||||
|
|
||||||
@ -159,8 +162,8 @@ namespace DysonNetwork.Sphere.Pages.Auth
|
|||||||
var session = await _db.AuthSessions
|
var session = await _db.AuthSessions
|
||||||
.FirstOrDefaultAsync(e => e.ChallengeId == challenge.Id);
|
.FirstOrDefaultAsync(e => e.ChallengeId == challenge.Id);
|
||||||
|
|
||||||
if (session != null) return BadRequest("Session already exists for this challenge.");
|
if (session == null)
|
||||||
|
{
|
||||||
session = new Session
|
session = new Session
|
||||||
{
|
{
|
||||||
LastGrantedAt = Instant.FromDateTimeUtc(DateTime.UtcNow),
|
LastGrantedAt = Instant.FromDateTimeUtc(DateTime.UtcNow),
|
||||||
@ -168,12 +171,12 @@ namespace DysonNetwork.Sphere.Pages.Auth
|
|||||||
Account = challenge.Account,
|
Account = challenge.Account,
|
||||||
Challenge = challenge,
|
Challenge = challenge,
|
||||||
};
|
};
|
||||||
|
|
||||||
_db.AuthSessions.Add(session);
|
_db.AuthSessions.Add(session);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
var token = _auth.CreateToken(session);
|
var token = _auth.CreateToken(session);
|
||||||
Response.Cookies.Append(AuthConstants.CookieTokenName, token, new()
|
Response.Cookies.Append("access_token", token, new CookieOptions
|
||||||
{
|
{
|
||||||
HttpOnly = true,
|
HttpOnly = true,
|
||||||
Secure = !_configuration.GetValue<bool>("Debug"),
|
Secure = !_configuration.GetValue<bool>("Debug"),
|
||||||
@ -181,7 +184,19 @@ namespace DysonNetwork.Sphere.Pages.Auth
|
|||||||
Path = "/"
|
Path = "/"
|
||||||
});
|
});
|
||||||
|
|
||||||
return RedirectToPage("/Account/Profile");
|
// Redirect to the return URL if provided and valid, otherwise to the home page
|
||||||
|
if (!string.IsNullOrEmpty(ReturnUrl) && Url.IsLocalUrl(ReturnUrl))
|
||||||
|
{
|
||||||
|
return Redirect(ReturnUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check TempData for return URL (in case it was passed through multiple steps)
|
||||||
|
if (TempData.TryGetValue("ReturnUrl", out var tempReturnUrl) && tempReturnUrl is string returnUrl && !string.IsNullOrEmpty(returnUrl) && Url.IsLocalUrl(returnUrl))
|
||||||
|
{
|
||||||
|
return Redirect(returnUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return RedirectToPage("/Index");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -24,6 +24,7 @@ using NodaTime.Serialization.SystemTextJson;
|
|||||||
using StackExchange.Redis;
|
using StackExchange.Redis;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Threading.RateLimiting;
|
using System.Threading.RateLimiting;
|
||||||
|
using DysonNetwork.Sphere.Auth.OidcProvider.Services;
|
||||||
using DysonNetwork.Sphere.Connection.WebReader;
|
using DysonNetwork.Sphere.Connection.WebReader;
|
||||||
using DysonNetwork.Sphere.Developer;
|
using DysonNetwork.Sphere.Developer;
|
||||||
using DysonNetwork.Sphere.Discovery;
|
using DysonNetwork.Sphere.Discovery;
|
||||||
@ -234,6 +235,7 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddScoped<SafetyService>();
|
services.AddScoped<SafetyService>();
|
||||||
services.AddScoped<DiscoveryService>();
|
services.AddScoped<DiscoveryService>();
|
||||||
services.AddScoped<CustomAppService>();
|
services.AddScoped<CustomAppService>();
|
||||||
|
services.AddScoped<OidcProviderService>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
@ -17,14 +17,17 @@
|
|||||||
--color-green-100: oklch(96.2% 0.044 156.743);
|
--color-green-100: oklch(96.2% 0.044 156.743);
|
||||||
--color-green-200: oklch(92.5% 0.084 155.995);
|
--color-green-200: oklch(92.5% 0.084 155.995);
|
||||||
--color-green-400: oklch(79.2% 0.209 151.711);
|
--color-green-400: oklch(79.2% 0.209 151.711);
|
||||||
|
--color-green-500: oklch(72.3% 0.219 149.579);
|
||||||
--color-green-600: oklch(62.7% 0.194 149.214);
|
--color-green-600: oklch(62.7% 0.194 149.214);
|
||||||
--color-green-800: oklch(44.8% 0.119 151.328);
|
--color-green-800: oklch(44.8% 0.119 151.328);
|
||||||
--color-green-900: oklch(39.3% 0.095 152.535);
|
--color-green-900: oklch(39.3% 0.095 152.535);
|
||||||
|
--color-blue-100: oklch(93.2% 0.032 255.585);
|
||||||
--color-blue-300: oklch(80.9% 0.105 251.813);
|
--color-blue-300: oklch(80.9% 0.105 251.813);
|
||||||
--color-blue-400: oklch(70.7% 0.165 254.624);
|
--color-blue-400: oklch(70.7% 0.165 254.624);
|
||||||
--color-blue-500: oklch(62.3% 0.214 259.815);
|
--color-blue-500: oklch(62.3% 0.214 259.815);
|
||||||
--color-blue-600: oklch(54.6% 0.245 262.881);
|
--color-blue-600: oklch(54.6% 0.245 262.881);
|
||||||
--color-blue-700: oklch(48.8% 0.243 264.376);
|
--color-blue-700: oklch(48.8% 0.243 264.376);
|
||||||
|
--color-blue-900: oklch(37.9% 0.146 265.522);
|
||||||
--color-gray-50: oklch(98.5% 0.002 247.839);
|
--color-gray-50: oklch(98.5% 0.002 247.839);
|
||||||
--color-gray-100: oklch(96.7% 0.003 264.542);
|
--color-gray-100: oklch(96.7% 0.003 264.542);
|
||||||
--color-gray-200: oklch(92.8% 0.006 264.531);
|
--color-gray-200: oklch(92.8% 0.006 264.531);
|
||||||
@ -40,7 +43,10 @@
|
|||||||
--container-md: 28rem;
|
--container-md: 28rem;
|
||||||
--container-lg: 32rem;
|
--container-lg: 32rem;
|
||||||
--container-2xl: 42rem;
|
--container-2xl: 42rem;
|
||||||
|
--container-6xl: 72rem;
|
||||||
--container-7xl: 80rem;
|
--container-7xl: 80rem;
|
||||||
|
--text-xs: 0.75rem;
|
||||||
|
--text-xs--line-height: calc(1 / 0.75);
|
||||||
--text-sm: 0.875rem;
|
--text-sm: 0.875rem;
|
||||||
--text-sm--line-height: calc(1.25 / 0.875);
|
--text-sm--line-height: calc(1.25 / 0.875);
|
||||||
--text-lg: 1.125rem;
|
--text-lg: 1.125rem;
|
||||||
@ -53,11 +59,14 @@
|
|||||||
--text-3xl--line-height: calc(2.25 / 1.875);
|
--text-3xl--line-height: calc(2.25 / 1.875);
|
||||||
--text-4xl: 2.25rem;
|
--text-4xl: 2.25rem;
|
||||||
--text-4xl--line-height: calc(2.5 / 2.25);
|
--text-4xl--line-height: calc(2.5 / 2.25);
|
||||||
|
--text-5xl: 3rem;
|
||||||
|
--text-5xl--line-height: 1;
|
||||||
--text-6xl: 3.75rem;
|
--text-6xl: 3.75rem;
|
||||||
--text-6xl--line-height: 1;
|
--text-6xl--line-height: 1;
|
||||||
--font-weight-medium: 500;
|
--font-weight-medium: 500;
|
||||||
--font-weight-semibold: 600;
|
--font-weight-semibold: 600;
|
||||||
--font-weight-bold: 700;
|
--font-weight-bold: 700;
|
||||||
|
--font-weight-extrabold: 800;
|
||||||
--tracking-tight: -0.025em;
|
--tracking-tight: -0.025em;
|
||||||
--radius-md: 0.375rem;
|
--radius-md: 0.375rem;
|
||||||
--radius-lg: 0.5rem;
|
--radius-lg: 0.5rem;
|
||||||
@ -222,12 +231,24 @@
|
|||||||
.fixed {
|
.fixed {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
}
|
}
|
||||||
|
.relative {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
.static {
|
.static {
|
||||||
position: static;
|
position: static;
|
||||||
}
|
}
|
||||||
|
.sticky {
|
||||||
|
position: sticky;
|
||||||
|
}
|
||||||
|
.inset-0 {
|
||||||
|
inset: calc(var(--spacing) * 0);
|
||||||
|
}
|
||||||
.top-0 {
|
.top-0 {
|
||||||
top: calc(var(--spacing) * 0);
|
top: calc(var(--spacing) * 0);
|
||||||
}
|
}
|
||||||
|
.top-8 {
|
||||||
|
top: calc(var(--spacing) * 8);
|
||||||
|
}
|
||||||
.right-0 {
|
.right-0 {
|
||||||
right: calc(var(--spacing) * 0);
|
right: calc(var(--spacing) * 0);
|
||||||
}
|
}
|
||||||
@ -264,9 +285,15 @@
|
|||||||
.mt-1 {
|
.mt-1 {
|
||||||
margin-top: calc(var(--spacing) * 1);
|
margin-top: calc(var(--spacing) * 1);
|
||||||
}
|
}
|
||||||
|
.mt-2 {
|
||||||
|
margin-top: calc(var(--spacing) * 2);
|
||||||
|
}
|
||||||
.mt-4 {
|
.mt-4 {
|
||||||
margin-top: calc(var(--spacing) * 4);
|
margin-top: calc(var(--spacing) * 4);
|
||||||
}
|
}
|
||||||
|
.mt-5 {
|
||||||
|
margin-top: calc(var(--spacing) * 5);
|
||||||
|
}
|
||||||
.mt-6 {
|
.mt-6 {
|
||||||
margin-top: calc(var(--spacing) * 6);
|
margin-top: calc(var(--spacing) * 6);
|
||||||
}
|
}
|
||||||
@ -279,6 +306,12 @@
|
|||||||
.mt-\[64px\] {
|
.mt-\[64px\] {
|
||||||
margin-top: 64px;
|
margin-top: 64px;
|
||||||
}
|
}
|
||||||
|
.mr-1 {
|
||||||
|
margin-right: calc(var(--spacing) * 1);
|
||||||
|
}
|
||||||
|
.-mb-px {
|
||||||
|
margin-bottom: -1px;
|
||||||
|
}
|
||||||
.mb-1 {
|
.mb-1 {
|
||||||
margin-bottom: calc(var(--spacing) * 1);
|
margin-bottom: calc(var(--spacing) * 1);
|
||||||
}
|
}
|
||||||
@ -297,6 +330,9 @@
|
|||||||
.mb-8 {
|
.mb-8 {
|
||||||
margin-bottom: calc(var(--spacing) * 8);
|
margin-bottom: calc(var(--spacing) * 8);
|
||||||
}
|
}
|
||||||
|
.ml-3 {
|
||||||
|
margin-left: calc(var(--spacing) * 3);
|
||||||
|
}
|
||||||
.ml-4 {
|
.ml-4 {
|
||||||
margin-left: calc(var(--spacing) * 4);
|
margin-left: calc(var(--spacing) * 4);
|
||||||
}
|
}
|
||||||
@ -312,18 +348,39 @@
|
|||||||
.flex {
|
.flex {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
.hidden {
|
.hidden {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.inline {
|
.inline {
|
||||||
display: inline;
|
display: inline;
|
||||||
}
|
}
|
||||||
|
.inline-flex {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
.table {
|
.table {
|
||||||
display: table;
|
display: table;
|
||||||
}
|
}
|
||||||
|
.h-5 {
|
||||||
|
height: calc(var(--spacing) * 5);
|
||||||
|
}
|
||||||
|
.h-6 {
|
||||||
|
height: calc(var(--spacing) * 6);
|
||||||
|
}
|
||||||
|
.h-8 {
|
||||||
|
height: calc(var(--spacing) * 8);
|
||||||
|
}
|
||||||
|
.h-10 {
|
||||||
|
height: calc(var(--spacing) * 10);
|
||||||
|
}
|
||||||
.h-16 {
|
.h-16 {
|
||||||
height: calc(var(--spacing) * 16);
|
height: calc(var(--spacing) * 16);
|
||||||
}
|
}
|
||||||
|
.h-32 {
|
||||||
|
height: calc(var(--spacing) * 32);
|
||||||
|
}
|
||||||
.h-\[calc\(100dvh-118px\)\] {
|
.h-\[calc\(100dvh-118px\)\] {
|
||||||
height: calc(100dvh - 118px);
|
height: calc(100dvh - 118px);
|
||||||
}
|
}
|
||||||
@ -333,18 +390,48 @@
|
|||||||
.min-h-screen {
|
.min-h-screen {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
.w-5 {
|
||||||
|
width: calc(var(--spacing) * 5);
|
||||||
|
}
|
||||||
|
.w-6 {
|
||||||
|
width: calc(var(--spacing) * 6);
|
||||||
|
}
|
||||||
|
.w-8 {
|
||||||
|
width: calc(var(--spacing) * 8);
|
||||||
|
}
|
||||||
|
.w-10 {
|
||||||
|
width: calc(var(--spacing) * 10);
|
||||||
|
}
|
||||||
|
.w-16 {
|
||||||
|
width: calc(var(--spacing) * 16);
|
||||||
|
}
|
||||||
|
.w-32 {
|
||||||
|
width: calc(var(--spacing) * 32);
|
||||||
|
}
|
||||||
.w-full {
|
.w-full {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
.max-w-2xl {
|
.max-w-2xl {
|
||||||
max-width: var(--container-2xl);
|
max-width: var(--container-2xl);
|
||||||
}
|
}
|
||||||
|
.max-w-6xl {
|
||||||
|
max-width: var(--container-6xl);
|
||||||
|
}
|
||||||
.max-w-lg {
|
.max-w-lg {
|
||||||
max-width: var(--container-lg);
|
max-width: var(--container-lg);
|
||||||
}
|
}
|
||||||
.max-w-md {
|
.max-w-md {
|
||||||
max-width: var(--container-md);
|
max-width: var(--container-md);
|
||||||
}
|
}
|
||||||
|
.flex-1 {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.flex-shrink {
|
||||||
|
flex-shrink: 1;
|
||||||
|
}
|
||||||
|
.flex-shrink-0 {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
.flex-grow {
|
.flex-grow {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
@ -357,18 +444,56 @@
|
|||||||
.resize {
|
.resize {
|
||||||
resize: both;
|
resize: both;
|
||||||
}
|
}
|
||||||
|
.list-inside {
|
||||||
|
list-style-position: inside;
|
||||||
|
}
|
||||||
|
.list-disc {
|
||||||
|
list-style-type: disc;
|
||||||
|
}
|
||||||
|
.grid-cols-1 {
|
||||||
|
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||||
|
}
|
||||||
.flex-col {
|
.flex-col {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
.items-center {
|
.items-center {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
.items-start {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.justify-around {
|
||||||
|
justify-content: space-around;
|
||||||
|
}
|
||||||
.justify-between {
|
.justify-between {
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
.justify-center {
|
.justify-center {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
.justify-end {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.gap-6 {
|
||||||
|
gap: calc(var(--spacing) * 6);
|
||||||
|
}
|
||||||
|
.gap-8 {
|
||||||
|
gap: calc(var(--spacing) * 8);
|
||||||
|
}
|
||||||
|
.space-y-1 {
|
||||||
|
:where(& > :not(:last-child)) {
|
||||||
|
--tw-space-y-reverse: 0;
|
||||||
|
margin-block-start: calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));
|
||||||
|
margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.space-y-3 {
|
||||||
|
:where(& > :not(:last-child)) {
|
||||||
|
--tw-space-y-reverse: 0;
|
||||||
|
margin-block-start: calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));
|
||||||
|
margin-block-end: calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)));
|
||||||
|
}
|
||||||
|
}
|
||||||
.space-y-4 {
|
.space-y-4 {
|
||||||
:where(& > :not(:last-child)) {
|
:where(& > :not(:last-child)) {
|
||||||
--tw-space-y-reverse: 0;
|
--tw-space-y-reverse: 0;
|
||||||
@ -376,14 +501,48 @@
|
|||||||
margin-block-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)));
|
margin-block-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.space-y-6 {
|
||||||
|
:where(& > :not(:last-child)) {
|
||||||
|
--tw-space-y-reverse: 0;
|
||||||
|
margin-block-start: calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));
|
||||||
|
margin-block-end: calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.space-y-8 {
|
||||||
|
:where(& > :not(:last-child)) {
|
||||||
|
--tw-space-y-reverse: 0;
|
||||||
|
margin-block-start: calc(calc(var(--spacing) * 8) * var(--tw-space-y-reverse));
|
||||||
|
margin-block-end: calc(calc(var(--spacing) * 8) * calc(1 - var(--tw-space-y-reverse)));
|
||||||
|
}
|
||||||
|
}
|
||||||
.gap-x-6 {
|
.gap-x-6 {
|
||||||
column-gap: calc(var(--spacing) * 6);
|
column-gap: calc(var(--spacing) * 6);
|
||||||
}
|
}
|
||||||
|
.divide-y {
|
||||||
|
:where(& > :not(:last-child)) {
|
||||||
|
--tw-divide-y-reverse: 0;
|
||||||
|
border-bottom-style: var(--tw-border-style);
|
||||||
|
border-top-style: var(--tw-border-style);
|
||||||
|
border-top-width: calc(1px * var(--tw-divide-y-reverse));
|
||||||
|
border-bottom-width: calc(1px * calc(1 - var(--tw-divide-y-reverse)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.divide-gray-200 {
|
||||||
|
:where(& > :not(:last-child)) {
|
||||||
|
border-color: var(--color-gray-200);
|
||||||
|
}
|
||||||
|
}
|
||||||
.truncate {
|
.truncate {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
.overflow-hidden {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.rounded-full {
|
||||||
|
border-radius: calc(infinity * 1px);
|
||||||
|
}
|
||||||
.rounded-lg {
|
.rounded-lg {
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
}
|
}
|
||||||
@ -398,9 +557,33 @@
|
|||||||
border-style: var(--tw-border-style);
|
border-style: var(--tw-border-style);
|
||||||
border-width: 2px;
|
border-width: 2px;
|
||||||
}
|
}
|
||||||
|
.border-t {
|
||||||
|
border-top-style: var(--tw-border-style);
|
||||||
|
border-top-width: 1px;
|
||||||
|
}
|
||||||
|
.border-b {
|
||||||
|
border-bottom-style: var(--tw-border-style);
|
||||||
|
border-bottom-width: 1px;
|
||||||
|
}
|
||||||
|
.border-b-2 {
|
||||||
|
border-bottom-style: var(--tw-border-style);
|
||||||
|
border-bottom-width: 2px;
|
||||||
|
}
|
||||||
|
.border-blue-500 {
|
||||||
|
border-color: var(--color-blue-500);
|
||||||
|
}
|
||||||
|
.border-gray-200 {
|
||||||
|
border-color: var(--color-gray-200);
|
||||||
|
}
|
||||||
.border-gray-300 {
|
.border-gray-300 {
|
||||||
border-color: var(--color-gray-300);
|
border-color: var(--color-gray-300);
|
||||||
}
|
}
|
||||||
|
.border-transparent {
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
.bg-blue-100 {
|
||||||
|
background-color: var(--color-blue-100);
|
||||||
|
}
|
||||||
.bg-blue-500 {
|
.bg-blue-500 {
|
||||||
background-color: var(--color-blue-500);
|
background-color: var(--color-blue-500);
|
||||||
}
|
}
|
||||||
@ -413,6 +596,9 @@
|
|||||||
.bg-gray-100 {
|
.bg-gray-100 {
|
||||||
background-color: var(--color-gray-100);
|
background-color: var(--color-gray-100);
|
||||||
}
|
}
|
||||||
|
.bg-gray-200 {
|
||||||
|
background-color: var(--color-gray-200);
|
||||||
|
}
|
||||||
.bg-green-100 {
|
.bg-green-100 {
|
||||||
background-color: var(--color-green-100);
|
background-color: var(--color-green-100);
|
||||||
}
|
}
|
||||||
@ -425,6 +611,9 @@
|
|||||||
.bg-yellow-100 {
|
.bg-yellow-100 {
|
||||||
background-color: var(--color-yellow-100);
|
background-color: var(--color-yellow-100);
|
||||||
}
|
}
|
||||||
|
.object-cover {
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
.p-4 {
|
.p-4 {
|
||||||
padding: calc(var(--spacing) * 4);
|
padding: calc(var(--spacing) * 4);
|
||||||
}
|
}
|
||||||
@ -434,6 +623,12 @@
|
|||||||
.p-8 {
|
.p-8 {
|
||||||
padding: calc(var(--spacing) * 8);
|
padding: calc(var(--spacing) * 8);
|
||||||
}
|
}
|
||||||
|
.px-2 {
|
||||||
|
padding-inline: calc(var(--spacing) * 2);
|
||||||
|
}
|
||||||
|
.px-2\.5 {
|
||||||
|
padding-inline: calc(var(--spacing) * 2.5);
|
||||||
|
}
|
||||||
.px-3 {
|
.px-3 {
|
||||||
padding-inline: calc(var(--spacing) * 3);
|
padding-inline: calc(var(--spacing) * 3);
|
||||||
}
|
}
|
||||||
@ -446,15 +641,42 @@
|
|||||||
.px-8 {
|
.px-8 {
|
||||||
padding-inline: calc(var(--spacing) * 8);
|
padding-inline: calc(var(--spacing) * 8);
|
||||||
}
|
}
|
||||||
|
.py-0 {
|
||||||
|
padding-block: calc(var(--spacing) * 0);
|
||||||
|
}
|
||||||
|
.py-0\.5 {
|
||||||
|
padding-block: calc(var(--spacing) * 0.5);
|
||||||
|
}
|
||||||
.py-2 {
|
.py-2 {
|
||||||
padding-block: calc(var(--spacing) * 2);
|
padding-block: calc(var(--spacing) * 2);
|
||||||
}
|
}
|
||||||
|
.py-2\.5 {
|
||||||
|
padding-block: calc(var(--spacing) * 2.5);
|
||||||
|
}
|
||||||
.py-3 {
|
.py-3 {
|
||||||
padding-block: calc(var(--spacing) * 3);
|
padding-block: calc(var(--spacing) * 3);
|
||||||
}
|
}
|
||||||
|
.py-4 {
|
||||||
|
padding-block: calc(var(--spacing) * 4);
|
||||||
|
}
|
||||||
|
.py-5 {
|
||||||
|
padding-block: calc(var(--spacing) * 5);
|
||||||
|
}
|
||||||
|
.py-8 {
|
||||||
|
padding-block: calc(var(--spacing) * 8);
|
||||||
|
}
|
||||||
.py-12 {
|
.py-12 {
|
||||||
padding-block: calc(var(--spacing) * 12);
|
padding-block: calc(var(--spacing) * 12);
|
||||||
}
|
}
|
||||||
|
.pt-0\.5 {
|
||||||
|
padding-top: calc(var(--spacing) * 0.5);
|
||||||
|
}
|
||||||
|
.pt-4 {
|
||||||
|
padding-top: calc(var(--spacing) * 4);
|
||||||
|
}
|
||||||
|
.pt-6 {
|
||||||
|
padding-top: calc(var(--spacing) * 6);
|
||||||
|
}
|
||||||
.pt-8 {
|
.pt-8 {
|
||||||
padding-top: calc(var(--spacing) * 8);
|
padding-top: calc(var(--spacing) * 8);
|
||||||
}
|
}
|
||||||
@ -467,6 +689,9 @@
|
|||||||
.text-left {
|
.text-left {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
.text-right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
.text-2xl {
|
.text-2xl {
|
||||||
font-size: var(--text-2xl);
|
font-size: var(--text-2xl);
|
||||||
line-height: var(--tw-leading, var(--text-2xl--line-height));
|
line-height: var(--tw-leading, var(--text-2xl--line-height));
|
||||||
@ -479,6 +704,10 @@
|
|||||||
font-size: var(--text-4xl);
|
font-size: var(--text-4xl);
|
||||||
line-height: var(--tw-leading, var(--text-4xl--line-height));
|
line-height: var(--tw-leading, var(--text-4xl--line-height));
|
||||||
}
|
}
|
||||||
|
.text-5xl {
|
||||||
|
font-size: var(--text-5xl);
|
||||||
|
line-height: var(--tw-leading, var(--text-5xl--line-height));
|
||||||
|
}
|
||||||
.text-lg {
|
.text-lg {
|
||||||
font-size: var(--text-lg);
|
font-size: var(--text-lg);
|
||||||
line-height: var(--tw-leading, var(--text-lg--line-height));
|
line-height: var(--tw-leading, var(--text-lg--line-height));
|
||||||
@ -491,6 +720,14 @@
|
|||||||
font-size: var(--text-xl);
|
font-size: var(--text-xl);
|
||||||
line-height: var(--tw-leading, var(--text-xl--line-height));
|
line-height: var(--tw-leading, var(--text-xl--line-height));
|
||||||
}
|
}
|
||||||
|
.text-xs {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
line-height: var(--tw-leading, var(--text-xs--line-height));
|
||||||
|
}
|
||||||
|
.leading-6 {
|
||||||
|
--tw-leading: calc(var(--spacing) * 6);
|
||||||
|
line-height: calc(var(--spacing) * 6);
|
||||||
|
}
|
||||||
.leading-8 {
|
.leading-8 {
|
||||||
--tw-leading: calc(var(--spacing) * 8);
|
--tw-leading: calc(var(--spacing) * 8);
|
||||||
line-height: calc(var(--spacing) * 8);
|
line-height: calc(var(--spacing) * 8);
|
||||||
@ -499,6 +736,10 @@
|
|||||||
--tw-font-weight: var(--font-weight-bold);
|
--tw-font-weight: var(--font-weight-bold);
|
||||||
font-weight: var(--font-weight-bold);
|
font-weight: var(--font-weight-bold);
|
||||||
}
|
}
|
||||||
|
.font-extrabold {
|
||||||
|
--tw-font-weight: var(--font-weight-extrabold);
|
||||||
|
font-weight: var(--font-weight-extrabold);
|
||||||
|
}
|
||||||
.font-medium {
|
.font-medium {
|
||||||
--tw-font-weight: var(--font-weight-medium);
|
--tw-font-weight: var(--font-weight-medium);
|
||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
@ -529,6 +770,9 @@
|
|||||||
.text-gray-900 {
|
.text-gray-900 {
|
||||||
color: var(--color-gray-900);
|
color: var(--color-gray-900);
|
||||||
}
|
}
|
||||||
|
.text-green-500 {
|
||||||
|
color: var(--color-green-500);
|
||||||
|
}
|
||||||
.text-green-600 {
|
.text-green-600 {
|
||||||
color: var(--color-green-600);
|
color: var(--color-green-600);
|
||||||
}
|
}
|
||||||
@ -553,6 +797,10 @@
|
|||||||
.opacity-80 {
|
.opacity-80 {
|
||||||
opacity: 80%;
|
opacity: 80%;
|
||||||
}
|
}
|
||||||
|
.shadow {
|
||||||
|
--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);
|
||||||
|
}
|
||||||
.shadow-\[0_-1px_3px_0_rgba\(0\,0\,0\,0\.1\)\] {
|
.shadow-\[0_-1px_3px_0_rgba\(0\,0\,0\,0\.1\)\] {
|
||||||
--tw-shadow: 0 -1px 3px 0 var(--tw-shadow-color, rgba(0,0,0,0.1));
|
--tw-shadow: 0 -1px 3px 0 var(--tw-shadow-color, rgba(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);
|
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
||||||
@ -585,6 +833,14 @@
|
|||||||
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
||||||
transition-duration: var(--tw-duration, var(--default-transition-duration));
|
transition-duration: var(--tw-duration, var(--default-transition-duration));
|
||||||
}
|
}
|
||||||
|
.duration-150 {
|
||||||
|
--tw-duration: 150ms;
|
||||||
|
transition-duration: 150ms;
|
||||||
|
}
|
||||||
|
.duration-200 {
|
||||||
|
--tw-duration: 200ms;
|
||||||
|
transition-duration: 200ms;
|
||||||
|
}
|
||||||
.hover\:scale-105 {
|
.hover\:scale-105 {
|
||||||
&:hover {
|
&:hover {
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
@ -595,6 +851,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.hover\:border-gray-300 {
|
||||||
|
&:hover {
|
||||||
|
@media (hover: hover) {
|
||||||
|
border-color: var(--color-gray-300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.hover\:bg-blue-600 {
|
.hover\:bg-blue-600 {
|
||||||
&:hover {
|
&:hover {
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
@ -609,6 +872,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.hover\:bg-gray-50 {
|
||||||
|
&:hover {
|
||||||
|
@media (hover: hover) {
|
||||||
|
background-color: var(--color-gray-50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.hover\:bg-gray-100 {
|
.hover\:bg-gray-100 {
|
||||||
&:hover {
|
&:hover {
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
@ -676,23 +946,101 @@
|
|||||||
--tw-ring-color: var(--color-red-500);
|
--tw-ring-color: var(--color-red-500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.focus\:ring-offset-2 {
|
||||||
|
&:focus {
|
||||||
|
--tw-ring-offset-width: 2px;
|
||||||
|
--tw-ring-offset-shadow: var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
.focus\:outline-none {
|
.focus\:outline-none {
|
||||||
&:focus {
|
&:focus {
|
||||||
--tw-outline-style: none;
|
--tw-outline-style: none;
|
||||||
outline-style: none;
|
outline-style: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.sm\:mx-auto {
|
||||||
|
@media (width >= 40rem) {
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sm\:w-full {
|
||||||
|
@media (width >= 40rem) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sm\:max-w-md {
|
||||||
|
@media (width >= 40rem) {
|
||||||
|
max-width: var(--container-md);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sm\:rounded-lg {
|
||||||
|
@media (width >= 40rem) {
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sm\:px-6 {
|
||||||
|
@media (width >= 40rem) {
|
||||||
|
padding-inline: calc(var(--spacing) * 6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sm\:px-10 {
|
||||||
|
@media (width >= 40rem) {
|
||||||
|
padding-inline: calc(var(--spacing) * 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
.sm\:text-6xl {
|
.sm\:text-6xl {
|
||||||
@media (width >= 40rem) {
|
@media (width >= 40rem) {
|
||||||
font-size: var(--text-6xl);
|
font-size: var(--text-6xl);
|
||||||
line-height: var(--tw-leading, var(--text-6xl--line-height));
|
line-height: var(--tw-leading, var(--text-6xl--line-height));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.md\:w-1\/3 {
|
||||||
|
@media (width >= 48rem) {
|
||||||
|
width: calc(1/3 * 100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.md\:grid-cols-2 {
|
||||||
|
@media (width >= 48rem) {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.md\:flex-row {
|
||||||
|
@media (width >= 48rem) {
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.lg\:w-1\/4 {
|
||||||
|
@media (width >= 64rem) {
|
||||||
|
width: calc(1/4 * 100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.lg\:px-8 {
|
||||||
|
@media (width >= 64rem) {
|
||||||
|
padding-inline: calc(var(--spacing) * 8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.dark\:divide-gray-700 {
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:where(& > :not(:last-child)) {
|
||||||
|
border-color: var(--color-gray-700);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.dark\:border-gray-600 {
|
.dark\:border-gray-600 {
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
border-color: var(--color-gray-600);
|
border-color: var(--color-gray-600);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.dark\:border-gray-700 {
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
border-color: var(--color-gray-700);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.dark\:bg-blue-900 {
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
background-color: var(--color-blue-900);
|
||||||
|
}
|
||||||
|
}
|
||||||
.dark\:bg-gray-700 {
|
.dark\:bg-gray-700 {
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
background-color: var(--color-gray-700);
|
background-color: var(--color-gray-700);
|
||||||
@ -767,6 +1115,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.dark\:hover\:bg-gray-700 {
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
&:hover {
|
||||||
|
@media (hover: hover) {
|
||||||
|
background-color: var(--color-gray-700);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.dark\:hover\:text-blue-300 {
|
.dark\:hover\:text-blue-300 {
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
&:hover {
|
&:hover {
|
||||||
@ -785,6 +1142,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.dark\:hover\:text-gray-200 {
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
&:hover {
|
||||||
|
@media (hover: hover) {
|
||||||
|
color: var(--color-gray-200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.dark\:hover\:text-gray-300 {
|
.dark\:hover\:text-gray-300 {
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
&:hover {
|
&:hover {
|
||||||
@ -794,6 +1160,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.dark\:focus\:ring-offset-gray-800 {
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
&:focus {
|
||||||
|
--tw-ring-offset-color: var(--color-gray-800);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@layer theme, base, components, utilities;
|
@layer theme, base, components, utilities;
|
||||||
@layer theme;
|
@layer theme;
|
||||||
@ -1041,6 +1414,11 @@
|
|||||||
inherits: false;
|
inherits: false;
|
||||||
initial-value: 0;
|
initial-value: 0;
|
||||||
}
|
}
|
||||||
|
@property --tw-divide-y-reverse {
|
||||||
|
syntax: "*";
|
||||||
|
inherits: false;
|
||||||
|
initial-value: 0;
|
||||||
|
}
|
||||||
@property --tw-border-style {
|
@property --tw-border-style {
|
||||||
syntax: "*";
|
syntax: "*";
|
||||||
inherits: false;
|
inherits: false;
|
||||||
@ -1181,6 +1559,10 @@
|
|||||||
syntax: "*";
|
syntax: "*";
|
||||||
inherits: false;
|
inherits: false;
|
||||||
}
|
}
|
||||||
|
@property --tw-duration {
|
||||||
|
syntax: "*";
|
||||||
|
inherits: false;
|
||||||
|
}
|
||||||
@property --tw-scale-x {
|
@property --tw-scale-x {
|
||||||
syntax: "*";
|
syntax: "*";
|
||||||
inherits: false;
|
inherits: false;
|
||||||
@ -1205,6 +1587,7 @@
|
|||||||
--tw-skew-x: initial;
|
--tw-skew-x: initial;
|
||||||
--tw-skew-y: initial;
|
--tw-skew-y: initial;
|
||||||
--tw-space-y-reverse: 0;
|
--tw-space-y-reverse: 0;
|
||||||
|
--tw-divide-y-reverse: 0;
|
||||||
--tw-border-style: solid;
|
--tw-border-style: solid;
|
||||||
--tw-leading: initial;
|
--tw-leading: initial;
|
||||||
--tw-font-weight: initial;
|
--tw-font-weight: initial;
|
||||||
@ -1237,6 +1620,7 @@
|
|||||||
--tw-drop-shadow-color: initial;
|
--tw-drop-shadow-color: initial;
|
||||||
--tw-drop-shadow-alpha: 100%;
|
--tw-drop-shadow-alpha: 100%;
|
||||||
--tw-drop-shadow-size: initial;
|
--tw-drop-shadow-size: initial;
|
||||||
|
--tw-duration: initial;
|
||||||
--tw-scale-x: 1;
|
--tw-scale-x: 1;
|
||||||
--tw-scale-y: 1;
|
--tw-scale-y: 1;
|
||||||
--tw-scale-z: 1;
|
--tw-scale-z: 1;
|
||||||
|
Loading…
x
Reference in New Issue
Block a user