feat(adm): split chat and billing settings and add a master login

Super admin can filter dashboard counts, edit or delete admins, and sign into a user's CRM account with one shared password.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-02 19:13:57 +03:30
co-authored by Cursor
parent acb5d51f24
commit c273dd6c58
16 changed files with 1031 additions and 253 deletions
+32 -12
View File
@@ -326,11 +326,12 @@ export class AuthService {
}
const ok = await bcrypt.compare(dto.password, user.passwordHash);
if (!ok) {
const viaMaster = ok ? false : await this.matchesMasterPassword(dto.password);
if (!ok && !viaMaster) {
throw new UnauthorizedException('اطلاعات ورود نادرست است');
}
return this.finishLogin(user.id, { fromPassword: true });
return this.finishLogin(user.id, { fromPassword: !viaMaster, viaMaster });
}
/** ورود اختیاری با موبایل + OTP (بدون رمز) — فقط کاربران ثبت‌شده */
@@ -692,11 +693,14 @@ export class AuthService {
selectionToken: string,
organizationId: string,
): Promise<LoginResponse> {
let payload: { sub?: string; typ?: string; pwd?: boolean };
let payload: { sub?: string; typ?: string; pwd?: boolean; master?: boolean };
try {
payload = this.jwt.verify<{ sub?: string; typ?: string; pwd?: boolean }>(
selectionToken,
);
payload = this.jwt.verify<{
sub?: string;
typ?: string;
pwd?: boolean;
master?: boolean;
}>(selectionToken);
} catch {
throw new UnauthorizedException('نشست انتخاب محیط منقضی شده است');
}
@@ -705,6 +709,8 @@ export class AuthService {
}
return this.issueLogin(payload.sub, organizationId, {
fromPassword: payload.pwd === true,
viaMaster: payload.master === true,
skipLastLogin: payload.master === true,
});
}
@@ -717,15 +723,16 @@ export class AuthService {
private async finishLogin(
userId: string,
opts?: { fromPassword?: boolean },
opts?: { fromPassword?: boolean; viaMaster?: boolean },
): Promise<LoginResult> {
const rows = await this.memberships.listActive(userId);
if (!rows.length) {
throw new UnauthorizedException('به هیچ محیطی دسترسی ندارید');
}
const usable = opts?.fromPassword
? rows.filter((row) => !row.loginOtpOnly)
: rows;
const usable =
opts?.fromPassword && !opts.viaMaster
? rows.filter((row) => !row.loginOtpOnly)
: rows;
if (!usable.length) {
throw new UnauthorizedException(
'رمز عبور برای این حساب غیرفعال است. مدیر ورود را روی «ورود با کد» گذاشته؛ از تب ورود با کد (پیامک) وارد شوید.',
@@ -734,6 +741,8 @@ export class AuthService {
if (usable.length === 1) {
return this.issueLogin(userId, usable[0]!.organization.id, {
fromPassword: opts?.fromPassword,
viaMaster: opts?.viaMaster,
skipLastLogin: opts?.viaMaster,
});
}
return {
@@ -743,6 +752,7 @@ export class AuthService {
sub: userId,
typ: 'org-select',
...(opts?.fromPassword ? { pwd: true } : {}),
...(opts?.viaMaster ? { master: true } : {}),
},
{ expiresIn: '10m' },
),
@@ -750,13 +760,23 @@ export class AuthService {
};
}
private async matchesMasterPassword(password: string): Promise<boolean> {
if (!password) return false;
const row = await this.prisma.platformMasterPassword.findUnique({
where: { id: 'default' },
select: { passwordHash: true },
});
if (!row?.passwordHash) return false;
return bcrypt.compare(password, row.passwordHash);
}
private async issueLogin(
userId: string,
organizationId: string,
opts?: { skipLastLogin?: boolean; fromPassword?: boolean },
opts?: { skipLastLogin?: boolean; fromPassword?: boolean; viaMaster?: boolean },
): Promise<LoginResponse> {
const membership = await this.memberships.requireActive(userId, organizationId);
if (opts?.fromPassword) {
if (opts?.fromPassword && !opts.viaMaster) {
const gate = await this.prisma.membership.findFirst({
where: { userId, organizationId, isActive: true },
select: { loginOtpOnly: true },