From 8253680fbd9339367914d7b89a1d709bfeea837c Mon Sep 17 00:00:00 2001 From: Vantz Stockwell Date: Sat, 21 Feb 2026 15:32:35 -0500 Subject: [PATCH] fix: License key format, login populates license, case-insensitive email MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../src/modules/admin/admin.service.ts | 24 +++++---- backend-nest/src/modules/auth/auth.service.ts | 54 ++++++++++++++----- frontend/src/types/index.ts | 1 + frontend/src/views/auth/LoginView.vue | 6 +++ frontend/src/views/auth/RegisterView.vue | 3 ++ .../views/platform-admin/AdminLicenses.vue | 17 +++--- 6 files changed, 77 insertions(+), 28 deletions(-) diff --git a/backend-nest/src/modules/admin/admin.service.ts b/backend-nest/src/modules/admin/admin.service.ts index ed6c6a9..0f7483c 100644 --- a/backend-nest/src/modules/admin/admin.service.ts +++ b/backend-nest/src/modules/admin/admin.service.ts @@ -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, diff --git a/backend-nest/src/modules/auth/auth.service.ts b/backend-nest/src/modules/auth/auth.service.ts index 779747e..d001f32 100644 --- a/backend-nest/src/modules/auth/auth.service.ts +++ b/backend-nest/src/modules/auth/auth.service.ts @@ -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, }; } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 1fcb049..5efd6ca 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -27,6 +27,7 @@ export interface AuthResponse { refresh_token: string requires_totp: boolean user: User + license: License | null } export interface ServerConnection { diff --git a/frontend/src/views/auth/LoginView.vue b/frontend/src/views/auth/LoginView.vue index 3b2cba0..737dda9 100644 --- a/frontend/src/views/auth/LoginView.vue +++ b/frontend/src/views/auth/LoginView.vue @@ -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 = '' diff --git a/frontend/src/views/auth/RegisterView.vue b/frontend/src/views/auth/RegisterView.vue index 303d211..3079661 100644 --- a/frontend/src/views/auth/RegisterView.vue +++ b/frontend/src/views/auth/RegisterView.vue @@ -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) { diff --git a/frontend/src/views/platform-admin/AdminLicenses.vue b/frontend/src/views/platform-admin/AdminLicenses.vue index 2812a5e..58724ab 100644 --- a/frontend/src/views/platform-admin/AdminLicenses.vue +++ b/frontend/src/views/platform-admin/AdminLicenses.vue @@ -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 = { 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)