feat: V2 microservices stack — backend services, gateway, JWT auth
Add full V2 architecture: identity, content, studio (.NET 10) and file, render, notification, gateway (Go) services with vendored deps, plus DB migrations, event/API contracts, and an init-db script. Wire the Next.js frontend to the gateway: server-side JWT auth routes (login/register/refresh/logout/me), gateway fetch helper, and session/ cookie/jwt helpers under src/lib. Containerize the stack via docker-compose.v2.yml and per-service Dockerfiles. Base images resolve through a Nexus mirror (Docker Hub) and MCR directly; npm/NuGet pull from Nexus groups. Self-host fonts via next/font/local to avoid Google Fonts (geo-blocked). Add CI workflow and ignore .env.v2, *.stackdump, and .NET bin/obj. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,501 @@
|
||||
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);
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user