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>
183 lines
5.3 KiB
TypeScript
183 lines
5.3 KiB
TypeScript
import { Injectable, BadRequestException } from '@nestjs/common';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import { Repository, Like } from 'typeorm';
|
|
import { User } from '../../entities/user.entity';
|
|
import { License } from '../../entities/license.entity';
|
|
import { ServerConnection } from '../../entities/server-connection.entity';
|
|
import { WebstoreSubscription } from '../../entities/webstore-subscription.entity';
|
|
import * as crypto from 'crypto';
|
|
import * as argon2 from 'argon2';
|
|
|
|
@Injectable()
|
|
export class AdminService {
|
|
constructor(
|
|
@InjectRepository(User)
|
|
private readonly userRepo: Repository<User>,
|
|
@InjectRepository(License)
|
|
private readonly licenseRepo: Repository<License>,
|
|
@InjectRepository(ServerConnection)
|
|
private readonly serverConnectionRepo: Repository<ServerConnection>,
|
|
@InjectRepository(WebstoreSubscription)
|
|
private readonly webstoreSubRepo: Repository<WebstoreSubscription>,
|
|
) {}
|
|
|
|
async getStats() {
|
|
const [totalUsers, totalLicenses, activeServers] = await Promise.all([
|
|
this.userRepo.count(),
|
|
this.licenseRepo.count(),
|
|
this.serverConnectionRepo.count({
|
|
where: { connection_status: 'connected' },
|
|
}),
|
|
]);
|
|
|
|
return {
|
|
total_users: totalUsers,
|
|
total_licenses: totalLicenses,
|
|
active_servers: activeServers,
|
|
};
|
|
}
|
|
|
|
async getLicenses(page: number = 1, limit: number = 25, search?: string) {
|
|
const skip = (page - 1) * limit;
|
|
|
|
const queryBuilder = this.licenseRepo
|
|
.createQueryBuilder('license')
|
|
.leftJoinAndSelect('license.owner', 'owner')
|
|
.orderBy('license.created_at', 'DESC')
|
|
.skip(skip)
|
|
.take(limit);
|
|
|
|
if (search) {
|
|
queryBuilder.where(
|
|
'(license.license_key ILIKE :search OR license.server_name ILIKE :search OR license.subdomain ILIKE :search OR owner.email ILIKE :search)',
|
|
{ search: `%${search}%` },
|
|
);
|
|
}
|
|
|
|
const [licenses, total] = await queryBuilder.getManyAndCount();
|
|
|
|
return {
|
|
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,
|
|
};
|
|
}
|
|
|
|
async getLicenseById(id: string) {
|
|
return this.licenseRepo.findOne({
|
|
where: { id },
|
|
relations: ['owner'],
|
|
});
|
|
}
|
|
|
|
async createLicense(email: string) {
|
|
// Find or create user
|
|
let user = await this.userRepo.findOne({ where: { email } });
|
|
|
|
if (!user) {
|
|
// Create new user with random password
|
|
const randomPassword = crypto.randomBytes(16).toString('hex');
|
|
const passwordHash = await argon2.hash(randomPassword);
|
|
const username = email.split('@')[0] + '_' + Math.random().toString(36).substr(2, 5);
|
|
|
|
user = this.userRepo.create({
|
|
email,
|
|
username,
|
|
password_hash: passwordHash,
|
|
});
|
|
await this.userRepo.save(user);
|
|
}
|
|
|
|
// 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,
|
|
status: 'active',
|
|
});
|
|
|
|
return this.licenseRepo.save(license);
|
|
}
|
|
|
|
async getUsers(page: number = 1, limit: number = 25) {
|
|
const skip = (page - 1) * limit;
|
|
|
|
const [users, total] = await this.userRepo.findAndCount({
|
|
order: { created_at: 'DESC' },
|
|
skip,
|
|
take: limit,
|
|
});
|
|
|
|
return {
|
|
data: users.map(u => ({
|
|
id: u.id,
|
|
email: u.email,
|
|
username: u.username,
|
|
is_super_admin: u.is_super_admin,
|
|
email_verified: u.email_verified,
|
|
totp_enabled: u.totp_enabled,
|
|
created_at: u.created_at,
|
|
last_login_at: u.last_login_at,
|
|
})),
|
|
pagination: {
|
|
page,
|
|
limit,
|
|
total,
|
|
total_pages: Math.ceil(total / limit),
|
|
},
|
|
};
|
|
}
|
|
|
|
async updateUser(userId: string, data: Partial<User>) {
|
|
const user = await this.userRepo.findOne({ where: { id: userId } });
|
|
|
|
if (!user) {
|
|
throw new BadRequestException('User not found');
|
|
}
|
|
|
|
// Only allow updating specific fields
|
|
if (typeof data.is_super_admin !== 'undefined') {
|
|
user.is_super_admin = data.is_super_admin;
|
|
}
|
|
if (typeof data.email_verified !== 'undefined') {
|
|
user.email_verified = data.email_verified;
|
|
}
|
|
|
|
return this.userRepo.save(user);
|
|
}
|
|
|
|
async getSubscriptions() {
|
|
const subs = await this.webstoreSubRepo.find({
|
|
relations: ['license', 'license.owner'],
|
|
order: { created_at: 'DESC' },
|
|
});
|
|
|
|
return {
|
|
subscriptions: subs.map(sub => ({
|
|
owner_email: sub.license?.owner?.email ?? 'Unknown',
|
|
module_name: sub.plan_id,
|
|
license_id: sub.license_id,
|
|
})),
|
|
};
|
|
}
|
|
|
|
async getServers() {
|
|
return this.serverConnectionRepo
|
|
.createQueryBuilder('conn')
|
|
.leftJoinAndSelect('conn.license', 'license')
|
|
.leftJoinAndSelect('license.owner', 'owner')
|
|
.orderBy('conn.created_at', 'DESC')
|
|
.getMany();
|
|
}
|
|
}
|