fix: License key format, login populates license, case-insensitive email
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
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:
@@ -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 ?? '',
|
||||||
|
server_name: l.server_name,
|
||||||
|
status: l.status,
|
||||||
|
created_at: l.created_at,
|
||||||
|
expires_at: l.expires_at,
|
||||||
|
})),
|
||||||
total,
|
total,
|
||||||
total_pages: Math.ceil(total / limit),
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 = ''
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user