fix and update

This commit is contained in:
lendry
2026-06-25 16:01:41 +03:00
parent ce58e6f4c1
commit 3880c68d59
40 changed files with 1715 additions and 215 deletions

View File

@@ -49,6 +49,9 @@ model User {
birthDate DateTime?
gender String?
isSuperAdmin Boolean @default(false)
isVerified Boolean @default(false)
verificationIcon String?
verifiedAt DateTime?
status UserStatus @default(ACTIVE)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

View File

@@ -14,6 +14,7 @@ export interface UserAccessContext {
canViewUsers: boolean;
canManageSettings: boolean;
canViewUserDocuments: boolean;
canVerifyUsers: boolean;
}
function hasAnyPermission(permissions: string[], ...slugs: string[]) {
@@ -40,7 +41,8 @@ export class AccessService {
'users.manage.all',
'settings.manage',
'rbac.manage',
'documents.view_others'
'documents.view_others',
'users.verify'
],
canAccessAdmin: true,
canManageRoles: true,
@@ -51,7 +53,8 @@ export class AccessService {
canManageAllUsers: true,
canViewUsers: true,
canManageSettings: true,
canViewUserDocuments: true
canViewUserDocuments: true,
canVerifyUsers: true
};
}
@@ -98,7 +101,8 @@ export class AccessService {
canManageAllUsers,
canViewUsers,
canManageSettings: permissions.includes('settings.manage'),
canViewUserDocuments: permissions.includes('documents.view_others')
canViewUserDocuments: permissions.includes('documents.view_others'),
canVerifyUsers: permissions.includes('users.verify')
};
}
@@ -124,6 +128,18 @@ export class AccessService {
}
}
async assertCanVerifyUsers(actorUserId: string) {
const actor = await this.prisma.user.findUnique({ where: { id: actorUserId } });
if (!actor) {
throw new NotFoundException('Пользователь не найден');
}
if (actor.isSuperAdmin) return;
const access = await this.getUserAccess(actorUserId, false);
if (!access.canVerifyUsers) {
throw new ForbiddenException('Недостаточно прав для верификации пользователей');
}
}
async assertSuperAdmin(actorUserId: string) {
const actor = await this.prisma.user.findUnique({ where: { id: actorUserId } });
if (!actor?.isSuperAdmin) {

View File

@@ -13,6 +13,7 @@ const PERMISSIONS = [
{ slug: 'users.view.all', name: 'Просмотр всех пользователей', description: 'Просмотр полного списка пользователей системы' },
{ slug: 'users.manage', name: 'Управление пользователями', description: 'Редактирование, блокировка и сброс паролей' },
{ slug: 'users.manage.all', name: 'Полное управление пользователями', description: 'Расширенное управление всеми пользователями' },
{ slug: 'users.verify', name: 'Верификация пользователей', description: 'Выдача и снятие верификации, выбор значка' },
{ slug: 'users.read', name: 'Просмотр пользователей (устар.)', description: 'Устаревшее право, заменено на users.view' },
{ slug: 'settings.manage', name: 'Управление настройками', description: 'Изменение глобальных SystemSetting' },
{ slug: 'rbac.manage', name: 'Управление ролями и правами', description: 'Назначение ролей, прав пользователям и редактирование ролей' },
@@ -32,7 +33,7 @@ const ROLES = [
slug: 'admin',
name: 'Администратор',
description: 'Полный доступ к админ-панели',
permissions: ['admin.access', 'oauth.manage.all', 'oauth.view.all', 'users.manage.all', 'users.view.all', 'settings.manage', 'rbac.manage'],
permissions: ['admin.access', 'oauth.manage.all', 'oauth.view.all', 'users.manage.all', 'users.view.all', 'users.verify', 'settings.manage', 'rbac.manage'],
isSystem: false,
isDefault: false
},

View File

@@ -3,6 +3,7 @@ import * as bcrypt from 'bcryptjs';
import { UserStatus } from '../generated/prisma/client';
import { PrismaService } from '../infra/prisma.service';
import { AccessService } from './access.service';
import { DEFAULT_VERIFICATION_ICON, isAllowedVerificationIcon, VERIFICATION_ICONS } from './verification.constants';
@Injectable()
export class AdminService {
@@ -92,6 +93,43 @@ export class AdminService {
return this.toAdminUser(user);
}
async setUserVerification(actorUserId: string, userId: string, data: { isVerified: boolean; verificationIcon?: string }) {
await this.access.assertCanVerifyUsers(actorUserId);
await this.ensureUserExists(userId);
if (data.isVerified) {
const icon = data.verificationIcon ?? DEFAULT_VERIFICATION_ICON;
if (!isAllowedVerificationIcon(icon)) {
throw new BadRequestException('Выберите значок верификации из списка');
}
const user = await this.prisma.user.update({
where: { id: userId },
data: {
isVerified: true,
verificationIcon: icon,
verifiedAt: new Date()
},
include: { userRoles: { include: { role: true } }, userPermissions: { include: { permission: true } } }
});
return this.toAdminUser(user);
}
const user = await this.prisma.user.update({
where: { id: userId },
data: {
isVerified: false,
verificationIcon: null,
verifiedAt: null
},
include: { userRoles: { include: { role: true } }, userPermissions: { include: { permission: true } } }
});
return this.toAdminUser(user);
}
listVerificationIcons() {
return { icons: VERIFICATION_ICONS.map((icon) => ({ slug: icon.slug, name: icon.name })) };
}
async deleteUser(userId: string) {
await this.ensureUserExists(userId);
const user = await this.prisma.user.update({
@@ -111,6 +149,8 @@ export class AdminService {
displayName: string;
username: string | null;
isSuperAdmin: boolean;
isVerified: boolean;
verificationIcon: string | null;
status: UserStatus;
createdAt: Date;
userRoles?: { role: { slug: string } }[];
@@ -125,6 +165,8 @@ export class AdminService {
displayName: user.displayName,
username: user.username ?? undefined,
isSuperAdmin: user.isSuperAdmin,
isVerified: user.isVerified,
verificationIcon: user.isVerified ? (user.verificationIcon ?? DEFAULT_VERIFICATION_ICON) : undefined,
status: user.status,
createdAt: user.createdAt.toISOString(),
roles: user.userRoles?.map((link) => link.role.slug) ?? [],

View File

@@ -157,6 +157,19 @@ export class AuthGrpcController {
return this.admin.setSuperAdmin(command.actorUserId, command.userId, command.isSuperAdmin);
}
@GrpcMethod('AdminService', 'SetUserVerification')
setUserVerification(command: { actorUserId: string; userId: string; isVerified: boolean; verificationIcon?: string }) {
return this.admin.setUserVerification(command.actorUserId, command.userId, {
isVerified: command.isVerified,
verificationIcon: command.verificationIcon
});
}
@GrpcMethod('AdminService', 'ListVerificationIcons')
listVerificationIcons() {
return this.admin.listVerificationIcons();
}
@GrpcMethod('RbacService', 'ListRoles')
async listRoles() {
const roles = await this.rbac.listRoles();

View File

@@ -16,6 +16,7 @@ import { NotificationsService } from './notifications.service';
import { canonicalPhoneE164, phoneLookupVariants } from '../infra/phone.util';
import { TotpService } from './totp.service';
import { RbacService } from './rbac.service';
import { DEFAULT_VERIFICATION_ICON, resolveVerificationIcon } from './verification.constants';
const FIRST_ADMIN_LOCK = 'locks:first-super-admin';
const TOTP_LOGIN_CHALLENGE_TTL_SECONDS = 5 * 60;
@@ -765,7 +766,7 @@ export class AuthService {
return this.toPublicUser(user);
}
async toPublicUser(user: Pick<User, 'id' | 'email' | 'phone' | 'backupEmail' | 'backupPhone' | 'displayName' | 'username' | 'avatarUrl' | 'avatarStorageKey' | 'isSuperAdmin' | 'status' | 'passwordHash'>): Promise<PublicUser> {
async toPublicUser(user: Pick<User, 'id' | 'email' | 'phone' | 'backupEmail' | 'backupPhone' | 'displayName' | 'username' | 'avatarUrl' | 'avatarStorageKey' | 'isSuperAdmin' | 'isVerified' | 'verificationIcon' | 'status' | 'passwordHash'>): Promise<PublicUser> {
const access = await this.access.getUserAccess(user.id, user.isSuperAdmin);
return {
id: user.id,
@@ -778,6 +779,8 @@ export class AuthService {
avatarUrl: user.avatarUrl,
hasAvatar: Boolean(user.avatarStorageKey || user.avatarUrl),
isSuperAdmin: user.isSuperAdmin,
isVerified: user.isVerified,
verificationIcon: user.isVerified ? resolveVerificationIcon(user.verificationIcon) : undefined,
status: user.status,
hasPassword: Boolean(user.passwordHash),
roles: access.roles,
@@ -791,7 +794,8 @@ export class AuthService {
canManageAllUsers: access.canManageAllUsers,
canManageSettings: access.canManageSettings,
canViewUsers: access.canViewUsers,
canViewUserDocuments: access.canViewUserDocuments
canViewUserDocuments: access.canViewUserDocuments,
canVerifyUsers: access.canVerifyUsers
};
}

View File

@@ -1,5 +1,6 @@
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../infra/prisma.service';
import { resolveVerificationIcon } from './verification.constants';
import { FamilyService } from './family.service';
import { NotificationsService } from './notifications.service';
@@ -467,7 +468,7 @@ export class ChatService {
id: string;
userId: string;
notificationsMuted: boolean;
user: { displayName: string; avatarStorageKey: string | null };
user: { displayName: string; avatarStorageKey: string | null; isVerified: boolean; verificationIcon: string | null };
}>;
},
familyRoleByUserId: Map<string, string>,
@@ -486,6 +487,8 @@ export class ChatService {
userId: member.userId,
displayName: member.user.displayName,
hasAvatar: Boolean(member.user.avatarStorageKey),
isVerified: member.user.isVerified,
verificationIcon: member.user.isVerified ? resolveVerificationIcon(member.user.verificationIcon) : undefined,
notificationsMuted: member.notificationsMuted,
familyRole: familyRoleByUserId.get(member.userId) ?? 'member',
isChatCreator: room.createdById === member.userId
@@ -594,7 +597,7 @@ export class ChatService {
createdAt: Date;
editedAt?: Date | null;
deletedAt?: Date | null;
sender: { displayName: string; avatarStorageKey: string | null };
sender: { displayName: string; avatarStorageKey: string | null; isVerified: boolean; verificationIcon: string | null };
poll?: {
id: string;
question: string;
@@ -621,6 +624,8 @@ export class ChatService {
senderId: message.senderId,
senderName: message.sender.displayName,
senderHasAvatar: Boolean(message.sender.avatarStorageKey),
senderIsVerified: message.sender.isVerified,
senderVerificationIcon: message.sender.isVerified ? resolveVerificationIcon(message.sender.verificationIcon) : undefined,
type: message.type,
content: isDeleted ? undefined : message.content ?? undefined,
replyToId: message.replyToId ?? undefined,

View File

@@ -52,6 +52,9 @@ export interface PublicUser {
canViewUsers?: boolean;
canViewUserDocuments?: boolean;
hasPassword?: boolean;
isVerified?: boolean;
verificationIcon?: string;
canVerifyUsers?: boolean;
}
export interface VerifyPinCommand {

View File

@@ -1,6 +1,7 @@
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../infra/prisma.service';
import { resolveVerificationIcon } from './verification.constants';
import { SettingsService } from './settings.service';
@@ -496,7 +497,9 @@ export class FamilyService {
email: true,
phone: true,
username: true,
avatarStorageKey: true
avatarStorageKey: true,
isVerified: true,
verificationIcon: true
}
});
@@ -507,7 +510,9 @@ export class FamilyService {
email: user.email,
phone: user.phone,
username: user.username,
hasAvatar: Boolean(user.avatarStorageKey)
hasAvatar: Boolean(user.avatarStorageKey),
isVerified: user.isVerified,
verificationIcon: user.isVerified ? resolveVerificationIcon(user.verificationIcon) : undefined
}))
};
}
@@ -800,7 +805,7 @@ export class FamilyService {
createdAt: Date;
user?: { displayName: string; avatarStorageKey: string | null };
user?: { displayName: string; avatarStorageKey: string | null; isVerified: boolean; verificationIcon: string | null };
}>;
@@ -840,7 +845,7 @@ export class FamilyService {
createdAt: Date;
user?: { displayName: string; avatarStorageKey: string | null };
user?: { displayName: string; avatarStorageKey: string | null; isVerified: boolean; verificationIcon: string | null };
}) {
@@ -858,6 +863,10 @@ export class FamilyService {
hasAvatar: Boolean(member.user?.avatarStorageKey),
isVerified: member.user?.isVerified ?? false,
verificationIcon: member.user?.isVerified ? resolveVerificationIcon(member.user?.verificationIcon) : undefined,
createdAt: member.createdAt.toISOString()
};

View File

@@ -0,0 +1,25 @@
export const DEFAULT_VERIFICATION_ICON = 'badge-check';
export const VERIFICATION_ICONS = [
{ slug: 'badge-check', name: 'Галочка' },
{ slug: 'star', name: 'Звезда' },
{ slug: 'sparkles', name: 'Искры' },
{ slug: 'moon', name: 'Луна' },
{ slug: 'sun', name: 'Солнце' },
{ slug: 'crown', name: 'Корона' },
{ slug: 'gem', name: 'Драгоценность' },
{ slug: 'heart', name: 'Сердце' },
{ slug: 'award', name: 'Награда' },
{ slug: 'shield-check', name: 'Щит' }
] as const;
export type VerificationIconSlug = (typeof VERIFICATION_ICONS)[number]['slug'];
export function isAllowedVerificationIcon(slug: string | null | undefined): slug is VerificationIconSlug {
if (!slug) return false;
return VERIFICATION_ICONS.some((icon) => icon.slug === slug);
}
export function resolveVerificationIcon(slug: string | null | undefined): VerificationIconSlug {
return isAllowedVerificationIcon(slug) ? slug : DEFAULT_VERIFICATION_ICON;
}