fix: License key format, login populates license, case-insensitive email
All checks were successful
Test Asgard Runner / test (push) Successful in 3s

- admin.service.ts: createLicense() now uses CORR-XXXX-XXXX-XXXX format
  instead of raw hex hash
- admin.service.ts: getLicenses() flattens owner_email in response to
  match frontend expected shape
- auth.service.ts: Login/register responses now include full license
  object so frontend can populate auth store
- auth.service.ts: Email lookups are case-insensitive (LOWER()) to
  prevent duplicate accounts from case variations
- LoginView/RegisterView: Call setLicense() after setAuth()
- AdminLicenses: Handle null expires_at (was showing Dec 31, 1969),
  fix nullable types, fix query param name (per_page → limit)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell
2026-02-21 15:32:35 -05:00
parent 14b099b075
commit 8253680fbd
6 changed files with 77 additions and 28 deletions

View File

@@ -57,13 +57,16 @@ export class AdminService {
const [licenses, total] = await queryBuilder.getManyAndCount(); const [licenses, total] = await queryBuilder.getManyAndCount();
return { return {
data: licenses, data: licenses.map(l => ({
pagination: { id: l.id,
page, license_key: l.license_key,
limit, owner_email: l.owner?.email ?? '',
total, server_name: l.server_name,
total_pages: Math.ceil(total / limit), status: l.status,
}, created_at: l.created_at,
expires_at: l.expires_at,
})),
total,
}; };
} }
@@ -92,8 +95,11 @@ export class AdminService {
await this.userRepo.save(user); await this.userRepo.save(user);
} }
// Create license // Create license (branded CORR-XXXX-XXXX-XXXX format)
const licenseKey = crypto.randomBytes(32).toString('hex'); const part1 = crypto.randomBytes(2).toString('hex').toUpperCase();
const part2 = crypto.randomBytes(2).toString('hex').toUpperCase();
const part3 = crypto.randomBytes(2).toString('hex').toUpperCase();
const licenseKey = `CORR-${part1}-${part2}-${part3}`;
const license = this.licenseRepo.create({ const license = this.licenseRepo.create({
license_key: licenseKey, license_key: licenseKey,
owner_user_id: user.id, owner_user_id: user.id,

View File

@@ -35,13 +35,20 @@ export class AuthService {
) {} ) {}
async register(dto: RegisterDto) { async register(dto: RegisterDto) {
// Normalize email to lowercase to prevent case-sensitive duplicates
const normalizedEmail = dto.email.toLowerCase();
// Check if user already exists // Check if user already exists
const existingUser = await this.userRepository.findOne({ const existingUser = await this.userRepository
where: [{ email: dto.email }, { username: dto.username }], .createQueryBuilder('user')
}); .where('LOWER(user.email) = :email OR user.username = :username', {
email: normalizedEmail,
username: dto.username,
})
.getOne();
if (existingUser) { if (existingUser) {
if (existingUser.email === dto.email) { if (existingUser.email.toLowerCase() === normalizedEmail) {
throw new ConflictException('Email already registered'); throw new ConflictException('Email already registered');
} }
throw new ConflictException('Username already taken'); throw new ConflictException('Username already taken');
@@ -50,9 +57,9 @@ export class AuthService {
// Hash password // Hash password
const password_hash = await argon2.hash(dto.password); const password_hash = await argon2.hash(dto.password);
// Create user // Create user (email stored lowercase)
const user = this.userRepository.create({ const user = this.userRepository.create({
email: dto.email, email: normalizedEmail,
username: dto.username, username: dto.username,
password_hash, password_hash,
email_verified: false, email_verified: false,
@@ -85,16 +92,28 @@ export class AuthService {
username: user.username, username: user.username,
is_super_admin: user.is_super_admin, is_super_admin: user.is_super_admin,
totp_enabled: user.totp_enabled, totp_enabled: user.totp_enabled,
license_key: licenseKey, },
license: {
id: license.id,
license_key: license.license_key,
status: license.status,
server_name: license.server_name ?? null,
subdomain: license.subdomain ?? null,
custom_domain: license.custom_domain ?? null,
modules_enabled: license.modules_enabled,
webstore_active: license.webstore_active,
created_at: license.created_at,
expires_at: license.expires_at ?? null,
}, },
}; };
} }
async login(dto: LoginDto) { async login(dto: LoginDto) {
// Find user by email // Find user by email (case-insensitive)
const user = await this.userRepository.findOne({ const user = await this.userRepository
where: { email: dto.email }, .createQueryBuilder('user')
}); .where('LOWER(user.email) = :email', { email: dto.email.toLowerCase() })
.getOne();
if (!user) { if (!user) {
throw new UnauthorizedException('Invalid credentials'); throw new UnauthorizedException('Invalid credentials');
@@ -142,8 +161,19 @@ export class AuthService {
username: user.username, username: user.username,
is_super_admin: user.is_super_admin, is_super_admin: user.is_super_admin,
totp_enabled: user.totp_enabled, totp_enabled: user.totp_enabled,
license_key: license?.license_key,
}, },
license: license ? {
id: license.id,
license_key: license.license_key,
status: license.status,
server_name: license.server_name,
subdomain: license.subdomain,
custom_domain: license.custom_domain,
modules_enabled: license.modules_enabled,
webstore_active: license.webstore_active,
created_at: license.created_at,
expires_at: license.expires_at,
} : null,
}; };
} }

View File

@@ -27,6 +27,7 @@ export interface AuthResponse {
refresh_token: string refresh_token: string
requires_totp: boolean requires_totp: boolean
user: User user: User
license: License | null
} }
export interface ServerConnection { export interface ServerConnection {

View File

@@ -39,6 +39,9 @@ async function handleLogin() {
} }
authStore.setAuth(response) authStore.setAuth(response)
if (response.license) {
authStore.setLicense(response.license)
}
router.push('/') router.push('/')
} catch (err: unknown) { } catch (err: unknown) {
if (err instanceof Error) { if (err instanceof Error) {
@@ -68,6 +71,9 @@ async function handleTotpVerify() {
}) })
authStore.setAuth(response) authStore.setAuth(response)
if (response.license) {
authStore.setLicense(response.license)
}
router.push('/') router.push('/')
} catch (err: unknown) { } catch (err: unknown) {
totpCode.value = '' totpCode.value = ''

View File

@@ -57,6 +57,9 @@ async function handleRegister() {
}) })
authStore.setAuth(response) authStore.setAuth(response)
if (response.license) {
authStore.setLicense(response.license)
}
router.push('/setup') router.push('/setup')
} catch (err: unknown) { } catch (err: unknown) {
if (err instanceof Error) { if (err instanceof Error) {

View File

@@ -9,20 +9,20 @@ interface License {
id: string id: string
license_key: string license_key: string
owner_email: string owner_email: string
server_name: string server_name: string | null
status: 'active' | 'suspended' | 'expired' | 'revoked' status: 'active' | 'suspended' | 'expired' | 'revoked'
created_at: string created_at: string
expires_at: string expires_at: string | null
} }
interface LicenseDetail { interface LicenseDetail {
id: string id: string
license_key: string license_key: string
owner_email: string owner_email: string
server_name: string server_name: string | null
status: string status: string
created_at: string created_at: string
expires_at: string expires_at: string | null
team_count: number team_count: number
wipe_count: number wipe_count: number
server_connection: { server_connection: {
@@ -67,8 +67,11 @@ const statusBadgeClass: Record<string, string> = {
revoked: 'bg-red-500/10 text-red-400', revoked: 'bg-red-500/10 text-red-400',
} }
function formatDate(iso: string): string { function formatDate(iso: string | null | undefined): string {
return new Date(iso).toLocaleDateString('en-US', { if (!iso) return '—'
const d = new Date(iso)
if (isNaN(d.getTime()) || d.getTime() === 0) return '—'
return d.toLocaleDateString('en-US', {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
year: 'numeric', year: 'numeric',
@@ -80,7 +83,7 @@ async function fetchLicenses() {
try { try {
const params = new URLSearchParams({ const params = new URLSearchParams({
page: page.value.toString(), page: page.value.toString(),
per_page: perPage.toString(), limit: perPage.toString(),
}) })
if (searchQuery.value.trim()) params.set('search', searchQuery.value.trim()) if (searchQuery.value.trim()) params.set('search', searchQuery.value.trim())
if (statusFilter.value !== 'all') params.set('status', statusFilter.value) if (statusFilter.value !== 'all') params.set('status', statusFilter.value)