d4fee8d1d7
Build backend images / build content-svc (push) Failing after 1m59s
Build backend images / build file-svc (push) Failing after 3m18s
Build backend images / build gateway (push) Failing after 3m28s
Build backend images / build identity-svc (push) Failing after 2m1s
Build backend images / build notification-svc (push) Failing after 4m45s
Build backend images / build render-svc (push) Failing after 5m18s
Build backend images / build studio-svc (push) Failing after 2m12s
Navigation: - UserMenu (avatar + role-aware dropdown: Dashboard, Admin Panel for admins, Profile, Sign out) replaces Sign In/Try Free when logged in (desktop + mobile). - Real avatars in dashboard sidebar + a new admin-shell profile section. - Shared Avatar primitive (image with initials fallback). SiteChrome excludes /admin. Profile (data-collection surface for future AI video generation): - SettingsProfile rebuilt: avatar upload + slogan, about, company, website, country, national code, birthdate, gender. No resume builder (per scope change). - /api/profile forwards all fields; new user-scoped /api/profile/upload (avatar → MinIO via file-svc, sets avatar). Identity UpdateUserRequest/UserResponse widened (country/national/method); no DB migration (columns already exist). - fa+en strings; verified GET/PATCH round-trip + logged-in SSR render. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
508 lines
20 KiB
C#
508 lines
20 KiB
C#
using FlatRender.IdentitySvc.Application.Services.Interfaces;
|
|
using FlatRender.IdentitySvc.Domain.Entities;
|
|
using FlatRender.IdentitySvc.Domain.Enums;
|
|
using FlatRender.IdentitySvc.Infrastructure.Data;
|
|
using FlatRender.IdentitySvc.Models.Requests;
|
|
using FlatRender.IdentitySvc.Models.Responses;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using OtpNet; // Otp.NET package
|
|
|
|
namespace FlatRender.IdentitySvc.Application.Services;
|
|
|
|
public class AuthService(
|
|
IdentityDbContext db,
|
|
ITokenService tokenService,
|
|
IConfiguration config) : IAuthService
|
|
{
|
|
private readonly int _refreshTokenDays = int.Parse(config["Jwt:RefreshTokenDays"] ?? "30");
|
|
|
|
public async Task<RegisterResponse> RegisterAsync(RegisterRequest request, string? ipAddress)
|
|
{
|
|
var tenant = await db.Tenants.FirstOrDefaultAsync(t => t.Slug == request.TenantSlug && t.DeletedAt == null)
|
|
?? throw new InvalidOperationException("Tenant not found");
|
|
|
|
if (string.IsNullOrEmpty(request.Email) && string.IsNullOrEmpty(request.PhoneNumber))
|
|
throw new ArgumentException("Email or phone number is required");
|
|
|
|
if (!string.IsNullOrEmpty(request.Email))
|
|
{
|
|
var exists = await db.Users.AnyAsync(u => u.TenantId == tenant.Id && u.Email == request.Email && u.DeletedAt == null);
|
|
if (exists) throw new InvalidOperationException("Email already registered");
|
|
}
|
|
|
|
var user = new User
|
|
{
|
|
TenantId = tenant.Id,
|
|
Email = request.Email?.ToLowerInvariant(),
|
|
PhoneNumber = request.PhoneNumber,
|
|
PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password),
|
|
PasswordSetAt = DateTime.UtcNow,
|
|
FullName = request.FullName,
|
|
RegisterMode = string.IsNullOrEmpty(request.PhoneNumber) ? RegisterMode.Email : RegisterMode.Mobile,
|
|
RegisterDate = DateTime.UtcNow,
|
|
};
|
|
|
|
db.Users.Add(user);
|
|
await db.SaveChangesAsync();
|
|
|
|
// Create email verification token
|
|
bool verificationRequired = false;
|
|
if (!string.IsNullOrEmpty(user.Email))
|
|
{
|
|
verificationRequired = true;
|
|
await CreateConfirmationTokenAsync(user.Id, tenant.Id, TokenPurpose.EmailVerification, user.Email, ipAddress);
|
|
}
|
|
|
|
return new RegisterResponse(user.Id, verificationRequired);
|
|
}
|
|
|
|
public async Task<AuthTokensResponse> LoginAsync(LoginRequest request, string? ipAddress)
|
|
{
|
|
var tenant = await db.Tenants.FirstOrDefaultAsync(t => t.Slug == request.TenantSlug && t.DeletedAt == null)
|
|
?? throw new UnauthorizedAccessException("Tenant not found");
|
|
|
|
User? user = null;
|
|
if (!string.IsNullOrEmpty(request.Email))
|
|
user = await db.Users.FirstOrDefaultAsync(u => u.TenantId == tenant.Id && u.Email == request.Email && u.DeletedAt == null);
|
|
else if (!string.IsNullOrEmpty(request.PhoneNumber))
|
|
user = await db.Users.FirstOrDefaultAsync(u => u.TenantId == tenant.Id && u.PhoneNumber == request.PhoneNumber && u.DeletedAt == null);
|
|
|
|
if (user == null || string.IsNullOrEmpty(user.PasswordHash))
|
|
throw new UnauthorizedAccessException("Invalid credentials");
|
|
|
|
if (!BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash))
|
|
throw new UnauthorizedAccessException("Invalid credentials");
|
|
|
|
if (user.BanAccount && (user.UnblockDate == null || user.UnblockDate > DateTime.UtcNow))
|
|
throw new UnauthorizedAccessException("Account is banned");
|
|
|
|
// Check MFA
|
|
var mfaFactor = await db.MfaFactors.FirstOrDefaultAsync(m => m.UserId == user.Id && m.IsVerified && m.IsPrimary);
|
|
if (mfaFactor != null)
|
|
{
|
|
// Return mfa_token — caller must complete challenge
|
|
throw new MfaRequiredException(GenerateMfaToken(user.Id, tenant.Id), "MFA required");
|
|
}
|
|
|
|
user.LastLoginAt = DateTime.UtcNow;
|
|
user.LastLoginIp = ipAddress;
|
|
user.LastActiveDate = DateTime.UtcNow;
|
|
|
|
var tokens = await CreateSessionAsync(user, tenant, request.DeviceId, request.DeviceName, ipAddress);
|
|
return tokens;
|
|
}
|
|
|
|
public async Task<AuthTokensResponse> RefreshAsync(string refreshToken)
|
|
{
|
|
var tokenHash = tokenService.HashToken(refreshToken);
|
|
var session = await db.UserSessions
|
|
.Include(s => s.User)
|
|
.FirstOrDefaultAsync(s => s.RefreshTokenHash == tokenHash && s.RevokedAt == null && s.ExpiresAt > DateTime.UtcNow)
|
|
?? throw new UnauthorizedAccessException("Invalid or expired refresh token");
|
|
|
|
var tenant = await db.Tenants.FindAsync(session.TenantId)
|
|
?? throw new UnauthorizedAccessException("Tenant not found");
|
|
|
|
// Rotate refresh token
|
|
session.RevokedAt = DateTime.UtcNow;
|
|
var tokens = await CreateSessionAsync(session.User, tenant, session.DeviceId, session.DeviceName, null, session.Id);
|
|
|
|
await db.SaveChangesAsync();
|
|
return tokens;
|
|
}
|
|
|
|
public async Task LogoutAsync(Guid sessionId, Guid userId)
|
|
{
|
|
var session = await db.UserSessions.FirstOrDefaultAsync(s => s.Id == sessionId && s.UserId == userId);
|
|
if (session != null)
|
|
{
|
|
session.RevokedAt = DateTime.UtcNow;
|
|
await db.SaveChangesAsync();
|
|
}
|
|
}
|
|
|
|
public async Task<List<SessionResponse>> GetSessionsAsync(Guid userId)
|
|
{
|
|
var sessions = await db.UserSessions
|
|
.Where(s => s.UserId == userId && s.RevokedAt == null && s.ExpiresAt > DateTime.UtcNow)
|
|
.OrderByDescending(s => s.LastUsedAt ?? s.IssuedAt)
|
|
.ToListAsync();
|
|
|
|
return sessions.Select(s => new SessionResponse(
|
|
s.Id, s.DeviceName, s.UserAgent, s.IpAddress,
|
|
s.IssuedAt, s.LastUsedAt, false
|
|
)).ToList();
|
|
}
|
|
|
|
public async Task RevokeSessionAsync(Guid sessionId, Guid userId)
|
|
{
|
|
var session = await db.UserSessions.FirstOrDefaultAsync(s => s.Id == sessionId && s.UserId == userId)
|
|
?? throw new KeyNotFoundException("Session not found");
|
|
session.RevokedAt = DateTime.UtcNow;
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
public async Task<bool> VerifyEmailAsync(string tokenHash, string code)
|
|
{
|
|
var confirmation = await db.ConfirmationTokens
|
|
.FirstOrDefaultAsync(t =>
|
|
t.TokenHash == tokenHash &&
|
|
t.Purpose == TokenPurpose.EmailVerification &&
|
|
!t.IsConsumed &&
|
|
t.ExpiresAt > DateTime.UtcNow)
|
|
?? throw new InvalidOperationException("Invalid or expired token");
|
|
|
|
if (confirmation.TryCount >= confirmation.MaxTries)
|
|
throw new InvalidOperationException("Too many attempts");
|
|
|
|
confirmation.TryCount++;
|
|
|
|
if (confirmation.Code != code)
|
|
{
|
|
await db.SaveChangesAsync();
|
|
return false;
|
|
}
|
|
|
|
confirmation.IsConsumed = true;
|
|
confirmation.ConsumedAt = DateTime.UtcNow;
|
|
|
|
if (confirmation.UserId.HasValue)
|
|
{
|
|
var user = await db.Users.FindAsync(confirmation.UserId.Value);
|
|
if (user != null)
|
|
{
|
|
user.EmailVerified = true;
|
|
user.EmailVerifiedAt = DateTime.UtcNow;
|
|
}
|
|
}
|
|
|
|
await db.SaveChangesAsync();
|
|
return true;
|
|
}
|
|
|
|
public async Task<bool> VerifyPhoneAsync(string tokenHash, string code)
|
|
{
|
|
var confirmation = await db.ConfirmationTokens
|
|
.FirstOrDefaultAsync(t =>
|
|
t.TokenHash == tokenHash &&
|
|
t.Purpose == TokenPurpose.PhoneVerification &&
|
|
!t.IsConsumed &&
|
|
t.ExpiresAt > DateTime.UtcNow)
|
|
?? throw new InvalidOperationException("Invalid or expired token");
|
|
|
|
if (confirmation.TryCount >= confirmation.MaxTries)
|
|
throw new InvalidOperationException("Too many attempts");
|
|
|
|
confirmation.TryCount++;
|
|
|
|
if (confirmation.Code != code)
|
|
{
|
|
await db.SaveChangesAsync();
|
|
return false;
|
|
}
|
|
|
|
confirmation.IsConsumed = true;
|
|
confirmation.ConsumedAt = DateTime.UtcNow;
|
|
|
|
if (confirmation.UserId.HasValue)
|
|
{
|
|
var user = await db.Users.FindAsync(confirmation.UserId.Value);
|
|
if (user != null)
|
|
{
|
|
user.PhoneVerified = true;
|
|
user.PhoneVerifiedAt = DateTime.UtcNow;
|
|
}
|
|
}
|
|
|
|
await db.SaveChangesAsync();
|
|
return true;
|
|
}
|
|
|
|
public async Task RequestPasswordResetAsync(string tenantSlug, string? email, string? phone)
|
|
{
|
|
var tenant = await db.Tenants.FirstOrDefaultAsync(t => t.Slug == tenantSlug && t.DeletedAt == null);
|
|
if (tenant == null) return;
|
|
|
|
User? user = null;
|
|
if (!string.IsNullOrEmpty(email))
|
|
user = await db.Users.FirstOrDefaultAsync(u => u.TenantId == tenant.Id && u.Email == email && u.DeletedAt == null);
|
|
else if (!string.IsNullOrEmpty(phone))
|
|
user = await db.Users.FirstOrDefaultAsync(u => u.TenantId == tenant.Id && u.PhoneNumber == phone && u.DeletedAt == null);
|
|
|
|
if (user == null) return;
|
|
|
|
await CreateConfirmationTokenAsync(user.Id, tenant.Id, TokenPurpose.PasswordReset, email ?? phone!, null);
|
|
}
|
|
|
|
public async Task<bool> ConfirmPasswordResetAsync(string token, string newPassword)
|
|
{
|
|
var tokenHash = tokenService.HashToken(token);
|
|
var confirmation = await db.ConfirmationTokens
|
|
.FirstOrDefaultAsync(t =>
|
|
t.TokenHash == tokenHash &&
|
|
t.Purpose == TokenPurpose.PasswordReset &&
|
|
!t.IsConsumed &&
|
|
t.ExpiresAt > DateTime.UtcNow)
|
|
?? throw new InvalidOperationException("Invalid or expired token");
|
|
|
|
confirmation.IsConsumed = true;
|
|
confirmation.ConsumedAt = DateTime.UtcNow;
|
|
|
|
if (confirmation.UserId.HasValue)
|
|
{
|
|
var user = await db.Users.FindAsync(confirmation.UserId.Value);
|
|
if (user != null)
|
|
{
|
|
user.PasswordHash = BCrypt.Net.BCrypt.HashPassword(newPassword);
|
|
user.PasswordSetAt = DateTime.UtcNow;
|
|
user.LastPasswordResetDate = DateTime.UtcNow;
|
|
// Revoke all sessions
|
|
await db.UserSessions
|
|
.Where(s => s.UserId == user.Id && s.RevokedAt == null)
|
|
.ExecuteUpdateAsync(s => s.SetProperty(x => x.RevokedAt, DateTime.UtcNow));
|
|
}
|
|
}
|
|
|
|
await db.SaveChangesAsync();
|
|
return true;
|
|
}
|
|
|
|
public async Task ChangePasswordAsync(Guid userId, string currentPassword, string newPassword)
|
|
{
|
|
var user = await db.Users.FindAsync(userId)
|
|
?? throw new KeyNotFoundException("User not found");
|
|
|
|
if (string.IsNullOrEmpty(user.PasswordHash) || !BCrypt.Net.BCrypt.Verify(currentPassword, user.PasswordHash))
|
|
throw new UnauthorizedAccessException("Current password is incorrect");
|
|
|
|
user.PasswordHash = BCrypt.Net.BCrypt.HashPassword(newPassword);
|
|
user.PasswordSetAt = DateTime.UtcNow;
|
|
user.LastPasswordResetDate = DateTime.UtcNow;
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
public async Task<MfaSetupResponse> SetupMfaAsync(Guid userId, string factorType, string? label)
|
|
{
|
|
if (!Enum.TryParse<Domain.Enums.MfaFactorType>(factorType, true, out var type))
|
|
throw new ArgumentException("Invalid factor type");
|
|
|
|
if (type == Domain.Enums.MfaFactorType.TOTP)
|
|
{
|
|
var secret = KeyGeneration.GenerateRandomKey(20);
|
|
var base32Secret = Base32Encoding.ToString(secret);
|
|
|
|
var user = await db.Users.FindAsync(userId)!;
|
|
var factor = new MfaFactor
|
|
{
|
|
UserId = userId,
|
|
FactorType = type,
|
|
SecretEncrypted = base32Secret,
|
|
Label = label ?? user?.Email ?? "FlatRender",
|
|
};
|
|
db.MfaFactors.Add(factor);
|
|
await db.SaveChangesAsync();
|
|
|
|
var qrLabel = Uri.EscapeDataString($"FlatRender:{user?.Email ?? userId.ToString()}");
|
|
var qrSecret = Uri.EscapeDataString(base32Secret);
|
|
var qrUrl = $"otpauth://totp/{qrLabel}?secret={qrSecret}&issuer=FlatRender";
|
|
|
|
return new MfaSetupResponse(factor.Id, base32Secret, qrUrl, null);
|
|
}
|
|
|
|
throw new NotImplementedException($"Factor type {factorType} not yet supported");
|
|
}
|
|
|
|
public async Task<bool> VerifyMfaAsync(Guid userId, Guid factorId, string code)
|
|
{
|
|
var factor = await db.MfaFactors.FirstOrDefaultAsync(m => m.Id == factorId && m.UserId == userId)
|
|
?? throw new KeyNotFoundException("MFA factor not found");
|
|
|
|
if (!VerifyTotp(factor.SecretEncrypted!, code)) return false;
|
|
|
|
factor.IsVerified = true;
|
|
factor.IsPrimary = true;
|
|
factor.LastUsedAt = DateTime.UtcNow;
|
|
await db.SaveChangesAsync();
|
|
return true;
|
|
}
|
|
|
|
public async Task<AuthTokensResponse> ChallengeMfaAsync(string mfaToken, string code)
|
|
{
|
|
var (userId, tenantId) = ValidateMfaToken(mfaToken);
|
|
|
|
var factor = await db.MfaFactors.FirstOrDefaultAsync(m => m.UserId == userId && m.IsVerified && m.IsPrimary)
|
|
?? throw new UnauthorizedAccessException("No verified MFA factor");
|
|
|
|
if (!VerifyTotp(factor.SecretEncrypted!, code))
|
|
throw new UnauthorizedAccessException("Invalid MFA code");
|
|
|
|
factor.LastUsedAt = DateTime.UtcNow;
|
|
|
|
var user = await db.Users.FindAsync(userId)!
|
|
?? throw new UnauthorizedAccessException("User not found");
|
|
var tenant = await db.Tenants.FindAsync(tenantId)!
|
|
?? throw new UnauthorizedAccessException("Tenant not found");
|
|
|
|
return await CreateSessionAsync(user, tenant, null, null, null);
|
|
}
|
|
|
|
public async Task SubscribePushAsync(Guid userId, Guid tenantId, string endpoint, string p256dh, string auth, string? userAgent)
|
|
{
|
|
var existing = await db.PushSubscriptions.FirstOrDefaultAsync(p => p.UserId == userId && p.Endpoint == endpoint);
|
|
if (existing != null)
|
|
{
|
|
existing.IsActive = true;
|
|
existing.FailureCount = 0;
|
|
}
|
|
else
|
|
{
|
|
db.PushSubscriptions.Add(new PushSubscription
|
|
{
|
|
UserId = userId,
|
|
TenantId = tenantId,
|
|
Endpoint = endpoint,
|
|
P256dhKey = p256dh,
|
|
AuthKey = auth,
|
|
UserAgent = userAgent,
|
|
});
|
|
}
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
public async Task UnsubscribePushAsync(Guid userId, string? endpoint)
|
|
{
|
|
if (string.IsNullOrEmpty(endpoint))
|
|
{
|
|
await db.PushSubscriptions
|
|
.Where(p => p.UserId == userId)
|
|
.ExecuteUpdateAsync(p => p.SetProperty(x => x.IsActive, false));
|
|
}
|
|
else
|
|
{
|
|
await db.PushSubscriptions
|
|
.Where(p => p.UserId == userId && p.Endpoint == endpoint)
|
|
.ExecuteUpdateAsync(p => p.SetProperty(x => x.IsActive, false));
|
|
}
|
|
}
|
|
|
|
// ── Private helpers ──────────────────────────────────────────────────
|
|
|
|
private async Task<AuthTokensResponse> CreateSessionAsync(
|
|
User user, Tenant tenant,
|
|
string? deviceId, string? deviceName, string? ipAddress,
|
|
Guid? replacingSessionId = null)
|
|
{
|
|
var accessToken = tokenService.GenerateAccessToken(user, tenant);
|
|
var refreshToken = tokenService.GenerateRefreshToken();
|
|
var refreshHash = tokenService.HashToken(refreshToken);
|
|
|
|
var session = new UserSession
|
|
{
|
|
UserId = user.Id,
|
|
TenantId = tenant.Id,
|
|
RefreshTokenHash = refreshHash,
|
|
DeviceId = deviceId,
|
|
DeviceName = deviceName,
|
|
IpAddress = ipAddress,
|
|
ExpiresAt = DateTime.UtcNow.AddDays(_refreshTokenDays),
|
|
};
|
|
db.UserSessions.Add(session);
|
|
await db.SaveChangesAsync();
|
|
|
|
var userResponse = MapUserResponse(user);
|
|
var tenantResponse = MapTenantResponse(tenant);
|
|
|
|
return new AuthTokensResponse(accessToken, refreshToken, "Bearer", 15 * 60, userResponse, tenantResponse);
|
|
}
|
|
|
|
/// <summary>Issue a session for an externally-authenticated (OAuth/social) user.</summary>
|
|
public Task<AuthTokensResponse> IssueOAuthSessionAsync(User user, Tenant tenant, string? deviceName, string? ipAddress)
|
|
=> CreateSessionAsync(user, tenant, null, deviceName, ipAddress);
|
|
|
|
private async Task CreateConfirmationTokenAsync(
|
|
Guid userId, Guid tenantId, TokenPurpose purpose, string identifier, string? ipAddress)
|
|
{
|
|
var rawToken = tokenService.GenerateRefreshToken();
|
|
var tokenHash = tokenService.HashToken(rawToken);
|
|
var code = Random.Shared.Next(100000, 999999).ToString();
|
|
|
|
db.ConfirmationTokens.Add(new ConfirmationToken
|
|
{
|
|
UserId = userId,
|
|
TenantId = tenantId,
|
|
Purpose = purpose,
|
|
Identifier = identifier,
|
|
TokenHash = tokenHash,
|
|
Code = code,
|
|
RequestIp = ipAddress,
|
|
ExpiresAt = DateTime.UtcNow.AddHours(24),
|
|
});
|
|
await db.SaveChangesAsync();
|
|
// TODO: send code via email/SMS
|
|
}
|
|
|
|
private string GenerateMfaToken(Guid userId, Guid tenantId)
|
|
{
|
|
// Short-lived token encoding userId+tenantId for the MFA challenge flow
|
|
var payload = $"{userId}:{tenantId}:{DateTime.UtcNow.AddMinutes(10).Ticks}";
|
|
var bytes = System.Text.Encoding.UTF8.GetBytes(payload);
|
|
return Convert.ToBase64String(bytes);
|
|
}
|
|
|
|
private (Guid userId, Guid tenantId) ValidateMfaToken(string mfaToken)
|
|
{
|
|
try
|
|
{
|
|
var payload = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(mfaToken));
|
|
var parts = payload.Split(':');
|
|
if (parts.Length != 3) throw new UnauthorizedAccessException("Invalid MFA token");
|
|
|
|
var expiry = new DateTime(long.Parse(parts[2]));
|
|
if (expiry < DateTime.UtcNow) throw new UnauthorizedAccessException("MFA token expired");
|
|
|
|
return (Guid.Parse(parts[0]), Guid.Parse(parts[1]));
|
|
}
|
|
catch (Exception ex) when (ex is not UnauthorizedAccessException)
|
|
{
|
|
throw new UnauthorizedAccessException("Invalid MFA token");
|
|
}
|
|
}
|
|
|
|
private static bool VerifyTotp(string base32Secret, string code)
|
|
{
|
|
try
|
|
{
|
|
var secretBytes = Base32Encoding.ToBytes(base32Secret);
|
|
var totp = new Totp(secretBytes);
|
|
return totp.VerifyTotp(code, out _, new VerificationWindow(1, 1));
|
|
}
|
|
catch
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
internal static UserResponse MapUserResponse(User u) => new(
|
|
u.Id, u.TenantId, u.Email, u.EmailVerified,
|
|
u.PhoneNumber, u.PhoneVerified, u.FullName, u.AvatarUrl,
|
|
u.IsAdmin, u.IsTenantAdmin, u.RegisterMode.ToString(),
|
|
u.LastActiveDate, u.BalanceMinor, u.AffiliateBalanceMinor,
|
|
u.LoyaltyScore, u.DailyRemainRenderCount, u.MaxDailyRenderCount,
|
|
u.ParallelRenderingCeiling, u.UsedStorageBytes, u.RegisterDate,
|
|
u.Slogan, u.AboutMe, u.CompanyName, u.WebsiteName,
|
|
u.BirthDate, u.Gender?.ToString(), u.NationalCode, u.CountryCode
|
|
);
|
|
|
|
internal static TenantResponse MapTenantResponse(Tenant t) => new(
|
|
t.Id, t.Slug, t.Name, t.Kind.ToString(), t.Status.ToString(),
|
|
t.CustomDomain, t.DomainVerified, t.ContactEmail,
|
|
t.MaxUsers, t.MaxStorageGb, t.MonthlyRenderQty,
|
|
t.TrialEndsAt, t.CreatedAt
|
|
);
|
|
}
|
|
|
|
public class MfaRequiredException(string mfaToken, string message) : Exception(message)
|
|
{
|
|
public string MfaToken { get; } = mfaToken;
|
|
}
|