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();
return {
data: licenses,
pagination: {
page,
limit,
total,
total_pages: Math.ceil(total / limit),
},
data: licenses.map(l => ({
id: l.id,
license_key: l.license_key,
owner_email: l.owner?.email ?? '',
server_name: l.server_name,
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);
}
// Create license
const licenseKey = crypto.randomBytes(32).toString('hex');
// Create license (branded CORR-XXXX-XXXX-XXXX format)
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({
license_key: licenseKey,
owner_user_id: user.id,

View File

@@ -35,13 +35,20 @@ export class AuthService {
) {}
async register(dto: RegisterDto) {
// Normalize email to lowercase to prevent case-sensitive duplicates
const normalizedEmail = dto.email.toLowerCase();
// Check if user already exists
const existingUser = await this.userRepository.findOne({
where: [{ email: dto.email }, { username: dto.username }],
});
const existingUser = await this.userRepository
.createQueryBuilder('user')
.where('LOWER(user.email) = :email OR user.username = :username', {
email: normalizedEmail,
username: dto.username,
})
.getOne();
if (existingUser) {
if (existingUser.email === dto.email) {
if (existingUser.email.toLowerCase() === normalizedEmail) {
throw new ConflictException('Email already registered');
}
throw new ConflictException('Username already taken');
@@ -50,9 +57,9 @@ export class AuthService {
// Hash password
const password_hash = await argon2.hash(dto.password);
// Create user
// Create user (email stored lowercase)
const user = this.userRepository.create({
email: dto.email,
email: normalizedEmail,
username: dto.username,
password_hash,
email_verified: false,
@@ -85,16 +92,28 @@ export class AuthService {
username: user.username,
is_super_admin: user.is_super_admin,
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) {
// Find user by email
const user = await this.userRepository.findOne({
where: { email: dto.email },
});
// Find user by email (case-insensitive)
const user = await this.userRepository
.createQueryBuilder('user')
.where('LOWER(user.email) = :email', { email: dto.email.toLowerCase() })
.getOne();
if (!user) {
throw new UnauthorizedException('Invalid credentials');
@@ -142,8 +161,19 @@ export class AuthService {
username: user.username,
is_super_admin: user.is_super_admin,
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
requires_totp: boolean
user: User
license: License | null
}
export interface ServerConnection {

View File

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

View File

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

View File

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