Files
registry/ts/models/user.ts

231 lines
6.1 KiB
TypeScript

/**
* User model for Stack.Gallery Registry
*/
import * as plugins from '../plugins.ts';
import type { IUser, TUserStatus } from '../interfaces/auth.interfaces.ts';
import { db } from './db.ts';
@plugins.smartdata.Collection(() => db)
export class User extends plugins.smartdata.SmartDataDbDoc<User, User> implements IUser {
@plugins.smartdata.unI()
public id: string = '';
@plugins.smartdata.svDb()
@plugins.smartdata.searchable()
@plugins.smartdata.index({ unique: true })
public email: string = '';
@plugins.smartdata.svDb()
@plugins.smartdata.searchable()
@plugins.smartdata.index({ unique: true })
public username: string = '';
@plugins.smartdata.svDb()
public passwordHash: string = '';
@plugins.smartdata.svDb()
@plugins.smartdata.searchable()
public displayName: string = '';
@plugins.smartdata.svDb()
public avatarUrl?: string;
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public status: TUserStatus = 'pending_verification';
@plugins.smartdata.svDb()
public emailVerified: boolean = false;
@plugins.smartdata.svDb()
public mfaEnabled: boolean = false;
@plugins.smartdata.svDb()
public mfaSecret?: string;
@plugins.smartdata.svDb()
public lastLoginAt?: Date;
@plugins.smartdata.svDb()
public lastLoginIp?: string;
@plugins.smartdata.svDb()
public failedLoginAttempts: number = 0;
@plugins.smartdata.svDb()
public lockedUntil?: Date;
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public isPlatformAdmin: boolean = false;
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public createdAt: Date = new Date();
@plugins.smartdata.svDb()
public updatedAt: Date = new Date();
/**
* Create a new user instance
*/
public static async createUser(data: {
email: string;
username: string;
passwordHash: string;
displayName?: string;
}): Promise<User> {
const user = new User();
user.id = await User.getNewId();
user.email = data.email.toLowerCase();
user.username = data.username.toLowerCase();
user.passwordHash = data.passwordHash;
user.displayName = data.displayName || data.username;
user.status = 'pending_verification';
user.createdAt = new Date();
user.updatedAt = new Date();
await user.save();
return user;
}
/**
* Find user by email
*/
public static async findByEmail(email: string): Promise<User | null> {
return await User.getInstance({ email: email.toLowerCase() });
}
/**
* Find user by username
*/
public static async findByUsername(username: string): Promise<User | null> {
return await User.getInstance({ username: username.toLowerCase() });
}
/**
* Lifecycle hook: Update timestamps before save
*/
public async beforeSave(): Promise<void> {
this.updatedAt = new Date();
if (!this.id) {
this.id = await User.getNewId();
}
}
/**
* Check if user is active (status === 'active')
*/
public get isActive(): boolean {
return this.status === 'active';
}
/**
* Alias for isPlatformAdmin for backward compatibility
*/
public get isSystemAdmin(): boolean {
return this.isPlatformAdmin;
}
/**
* Find user by ID
*/
public static async findById(id: string): Promise<User | null> {
return await User.getInstance({ id });
}
/**
* Verify password against stored hash
*/
public async verifyPassword(password: string): Promise<boolean> {
if (!this.passwordHash) return false;
const [saltHex, expectedHash] = this.passwordHash.split(':');
if (!saltHex || !expectedHash) return false;
const encoder = new TextEncoder();
const data = encoder.encode(saltHex + password);
let hash = data;
for (let i = 0; i < 10000; i++) {
hash = new Uint8Array(await crypto.subtle.digest('SHA-256', hash));
}
const hashHex = Array.from(hash)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
return hashHex === expectedHash;
}
/**
* Hash a password for storage
*/
public static async hashPassword(password: string): Promise<string> {
const salt = crypto.getRandomValues(new Uint8Array(16));
const saltHex = Array.from(salt)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
const encoder = new TextEncoder();
const data = encoder.encode(saltHex + password);
let hash = data;
for (let i = 0; i < 10000; i++) {
hash = new Uint8Array(await crypto.subtle.digest('SHA-256', hash));
}
const hashHex = Array.from(hash)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
return `${saltHex}:${hashHex}`;
}
/**
* Create the default admin user if no admin exists
*/
public static async seedDefaultAdmin(): Promise<User | null> {
const adminEmail = Deno.env.get('ADMIN_EMAIL') || 'admin@stack.gallery';
const adminPassword = Deno.env.get('ADMIN_PASSWORD') || 'admin';
// Check if any platform admin exists
const existingAdmin = await User.getInstance({ isPlatformAdmin: true });
if (existingAdmin) {
console.log('[User] Platform admin already exists, skipping seed');
return null;
}
// Check if admin email already exists
const existingUser = await User.findByEmail(adminEmail);
if (existingUser) {
console.log('[User] User with admin email already exists, promoting to admin');
existingUser.isPlatformAdmin = true;
existingUser.status = 'active';
await existingUser.save();
return existingUser;
}
// Create new admin user
console.log('[User] Creating default admin user:', adminEmail);
const passwordHash = await User.hashPassword(adminPassword);
const admin = new User();
admin.id = await User.getNewId();
admin.email = adminEmail.toLowerCase();
admin.username = 'admin';
admin.passwordHash = passwordHash;
admin.displayName = 'System Administrator';
admin.status = 'active';
admin.emailVerified = true;
admin.isPlatformAdmin = true;
admin.createdAt = new Date();
admin.updatedAt = new Date();
await admin.save();
console.log('[User] Default admin user created successfully');
return admin;
}
}