fix and update
This commit is contained in:
@@ -44,6 +44,7 @@ export function AddressQuickSection({
|
|||||||
isReady?: boolean;
|
isReady?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { user, token } = useAuth();
|
const { user, token } = useAuth();
|
||||||
|
const userId = user?.id;
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [addresses, setAddresses] = useState<UserAddress[]>([]);
|
const [addresses, setAddresses] = useState<UserAddress[]>([]);
|
||||||
const [formOpen, setFormOpen] = useState(false);
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
@@ -51,19 +52,19 @@ export function AddressQuickSection({
|
|||||||
const [editingAddress, setEditingAddress] = useState<UserAddress | null>(null);
|
const [editingAddress, setEditingAddress] = useState<UserAddress | null>(null);
|
||||||
|
|
||||||
const loadAddresses = useCallback(async () => {
|
const loadAddresses = useCallback(async () => {
|
||||||
if (!user || !token || isPinLocked || !isReady) return;
|
if (!userId || !token || isPinLocked || !isReady) return;
|
||||||
try {
|
try {
|
||||||
const response = await fetchUserAddresses(user.id, token);
|
const response = await fetchUserAddresses(userId, token);
|
||||||
setAddresses(response.addresses ?? []);
|
setAddresses(response.addresses ?? []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = getApiErrorMessage(error, 'Не удалось загрузить адреса');
|
const message = getApiErrorMessage(error, 'Не удалось загрузить адреса');
|
||||||
if (message) showToast(message);
|
if (message) showToast(message);
|
||||||
}
|
}
|
||||||
}, [isPinLocked, isReady, showToast, token, user]);
|
}, [isPinLocked, isReady, showToast, token, userId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isReady && user && !isPinLocked) void loadAddresses();
|
if (isReady && userId && !isPinLocked) void loadAddresses();
|
||||||
}, [isPinLocked, isReady, loadAddresses, user]);
|
}, [isPinLocked, isReady, loadAddresses, userId]);
|
||||||
|
|
||||||
const homeAddress = useMemo(() => getPrimaryAddress(addresses, 'HOME') as UserAddress | null, [addresses]);
|
const homeAddress = useMemo(() => getPrimaryAddress(addresses, 'HOME') as UserAddress | null, [addresses]);
|
||||||
const workAddress = useMemo(() => getPrimaryAddress(addresses, 'WORK') as UserAddress | null, [addresses]);
|
const workAddress = useMemo(() => getPrimaryAddress(addresses, 'WORK') as UserAddress | null, [addresses]);
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import {
|
|||||||
deleteChatRoom,
|
deleteChatRoom,
|
||||||
editChatMessage,
|
editChatMessage,
|
||||||
fetchBotChatMessages,
|
fetchBotChatMessages,
|
||||||
|
fetchChatMediaUrl,
|
||||||
fetchChatMessages,
|
fetchChatMessages,
|
||||||
fetchChatRooms,
|
fetchChatRooms,
|
||||||
getApiErrorMessage,
|
getApiErrorMessage,
|
||||||
@@ -67,6 +68,7 @@ import {
|
|||||||
} from '@/lib/chat-message-utils';
|
} from '@/lib/chat-message-utils';
|
||||||
import { detectChatMessageType, parseMessageMetadata, resolveChatContentType } from '@/lib/chat-media';
|
import { detectChatMessageType, parseMessageMetadata, resolveChatContentType } from '@/lib/chat-media';
|
||||||
import { uploadChatMediaBatch } from '@/lib/chat-media-upload';
|
import { uploadChatMediaBatch } from '@/lib/chat-media-upload';
|
||||||
|
import { fetchMediaBlobWithRetry } from '@/lib/resilient-media-fetch';
|
||||||
import { getSingleEmojiNative, isSingleEmojiMessage } from '@/lib/chat-emoji-display';
|
import { getSingleEmojiNative, isSingleEmojiMessage } from '@/lib/chat-emoji-display';
|
||||||
import { shouldShowChatDateSeparator } from '@/lib/chat-date-utils';
|
import { shouldShowChatDateSeparator } from '@/lib/chat-date-utils';
|
||||||
import { encryptE2EBlob, e2eKindFromMessageType } from '@/lib/e2e-chat';
|
import { encryptE2EBlob, e2eKindFromMessageType } from '@/lib/e2e-chat';
|
||||||
@@ -85,6 +87,74 @@ function formatMessageTime(value: string) {
|
|||||||
return new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' }).format(new Date(value));
|
return new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' }).format(new Date(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MiniChatImageMessage({ message, token }: { message: ChatMessage; token: string }) {
|
||||||
|
const [src, setSrc] = useState<string | null>(null);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const metadata = parseMessageMetadata(message.metadataJson) as { fileName?: string; width?: number; height?: number };
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!message.storageKey) return;
|
||||||
|
let cancelled = false;
|
||||||
|
let objectUrl: string | null = null;
|
||||||
|
|
||||||
|
setSrc(null);
|
||||||
|
setFailed(false);
|
||||||
|
|
||||||
|
async function loadImage() {
|
||||||
|
try {
|
||||||
|
const { accessUrl } = await fetchChatMediaUrl(message.roomId, message.storageKey!, token, metadata.fileName);
|
||||||
|
const blob = await fetchMediaBlobWithRetry(accessUrl, token, {
|
||||||
|
mimeType: message.mimeType,
|
||||||
|
label: 'фото чата'
|
||||||
|
});
|
||||||
|
if (cancelled) return;
|
||||||
|
objectUrl = URL.createObjectURL(blob);
|
||||||
|
setSrc(objectUrl);
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setFailed(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadImage();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||||
|
};
|
||||||
|
}, [message.id, message.mimeType, message.roomId, message.storageKey, metadata.fileName, token]);
|
||||||
|
|
||||||
|
const ratio =
|
||||||
|
metadata.width && metadata.height
|
||||||
|
? Math.max(0.65, Math.min(1.35, metadata.height / metadata.width))
|
||||||
|
: 0.72;
|
||||||
|
|
||||||
|
if (failed) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[120px] w-[190px] flex-col items-center justify-center gap-2 rounded-2xl bg-white/55 text-xs text-[#667085]">
|
||||||
|
<ImageIcon className="h-5 w-5" />
|
||||||
|
<span>Фото недоступно</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!src) {
|
||||||
|
return (
|
||||||
|
<div className="flex w-[190px] items-center justify-center rounded-2xl bg-white/55" style={{ height: Math.round(190 * ratio) }}>
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-[#8a94a6]" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={message.content?.trim() || metadata.fileName || 'Фото в чате'}
|
||||||
|
className="max-h-[260px] w-[190px] rounded-2xl object-cover shadow-sm"
|
||||||
|
style={{ aspectRatio: metadata.width && metadata.height ? `${metadata.width} / ${metadata.height}` : undefined }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function MiniFamilyChat() {
|
export function MiniFamilyChat() {
|
||||||
const { user, token, isPinLocked, isContentReady } = useAuth();
|
const { user, token, isPinLocked, isContentReady } = useAuth();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
@@ -1103,6 +1173,15 @@ export function MiniFamilyChat() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
) : message.storageKey && message.type === 'IMAGE' && token ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<MiniChatImageMessage message={message} token={token} />
|
||||||
|
{visibleText?.trim() ? (
|
||||||
|
<div className="whitespace-pre-wrap break-words text-sm leading-relaxed">
|
||||||
|
<ChatEmojiContent content={visibleText} size={18} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
) : message.storageKey ? (
|
) : message.storageKey ? (
|
||||||
<p className="text-sm">{getMessagePreviewText(message, visibleText)}</p>
|
<p className="text-sm">{getMessagePreviewText(message, visibleText)}</p>
|
||||||
) : isLargeEmoji ? (
|
) : isLargeEmoji ? (
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const RealtimeContext = React.createContext<RealtimeContextValue | null>(null);
|
|||||||
|
|
||||||
export function RealtimeProvider({ children }: { children: React.ReactNode }) {
|
export function RealtimeProvider({ children }: { children: React.ReactNode }) {
|
||||||
const { user, token, isPinLocked, isLoading, isContentReady } = useAuth();
|
const { user, token, isPinLocked, isLoading, isContentReady } = useAuth();
|
||||||
|
const userId = user?.id;
|
||||||
const [unreadCount, setUnreadCount] = React.useState(0);
|
const [unreadCount, setUnreadCount] = React.useState(0);
|
||||||
const [connected, setConnected] = React.useState(false);
|
const [connected, setConnected] = React.useState(false);
|
||||||
const listenersRef = React.useRef(new Set<Listener>());
|
const listenersRef = React.useRef(new Set<Listener>());
|
||||||
@@ -52,7 +53,7 @@ export function RealtimeProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!user || !token || isPinLocked || isLoading || !isContentReady) {
|
if (!userId || !token || isPinLocked || isLoading || !isContentReady) {
|
||||||
setUnreadCount(0);
|
setUnreadCount(0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -68,10 +69,10 @@ export function RealtimeProvider({ children }: { children: React.ReactNode }) {
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
window.clearTimeout(timer);
|
window.clearTimeout(timer);
|
||||||
};
|
};
|
||||||
}, [isContentReady, isLoading, isPinLocked, token, user]);
|
}, [isContentReady, isLoading, isPinLocked, token, userId]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!user || !token || isPinLocked || isLoading || !isContentReady) {
|
if (!userId || !token || isPinLocked || isLoading || !isContentReady) {
|
||||||
socketRef.current?.close();
|
socketRef.current?.close();
|
||||||
socketRef.current = null;
|
socketRef.current = null;
|
||||||
setConnected(false);
|
setConnected(false);
|
||||||
@@ -149,7 +150,7 @@ export function RealtimeProvider({ children }: { children: React.ReactNode }) {
|
|||||||
socketRef.current = null;
|
socketRef.current = null;
|
||||||
setConnected(false);
|
setConnected(false);
|
||||||
};
|
};
|
||||||
}, [emit, isContentReady, isLoading, isPinLocked, token, user]);
|
}, [emit, isContentReady, isLoading, isPinLocked, token, userId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RealtimeContext.Provider value={{ unreadCount, setUnreadCount, subscribe, connected }}>
|
<RealtimeContext.Provider value={{ unreadCount, setUnreadCount, subscribe, connected }}>
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
type E2EMessagePayload
|
type E2EMessagePayload
|
||||||
} from '@/lib/e2e-chat';
|
} from '@/lib/e2e-chat';
|
||||||
|
|
||||||
|
const publishedE2EKeys = new Set<string>();
|
||||||
|
|
||||||
export function resolveChatPeerUserId(room: ChatRoom | null | undefined, userId: string | undefined) {
|
export function resolveChatPeerUserId(room: ChatRoom | null | undefined, userId: string | undefined) {
|
||||||
if (!room || !userId) return null;
|
if (!room || !userId) return null;
|
||||||
const fromMembers = room.members.find((member) => member.userId !== userId)?.userId;
|
const fromMembers = room.members.find((member) => member.userId !== userId)?.userId;
|
||||||
@@ -69,7 +71,10 @@ export function useE2EChat(options: {
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const { publicKey } = await ensureE2EKeyPair(options.userId!);
|
const { publicKey } = await ensureE2EKeyPair(options.userId!);
|
||||||
|
const cacheKey = `${options.userId}:${publicKey}`;
|
||||||
|
if (publishedE2EKeys.has(cacheKey)) return;
|
||||||
await setUserE2EPublicKey(options.userId!, publicKey, options.token);
|
await setUserE2EPublicKey(options.userId!, publicKey, options.token);
|
||||||
|
publishedE2EKeys.add(cacheKey);
|
||||||
} catch {
|
} catch {
|
||||||
// фоновая инициализация E2E
|
// фоновая инициализация E2E
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -925,6 +925,12 @@ export async function fetchDocumentPhotoUrl(userId: string, storageKey: string,
|
|||||||
return apiFetch<MediaAccessResponse>(`/media/users/${userId}/documents/photo-url?${query.toString()}`, {}, token);
|
return apiFetch<MediaAccessResponse>(`/media/users/${userId}/documents/photo-url?${query.toString()}`, {}, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchChatMediaUrl(roomId: string, storageKey: string, token?: string | null, fileName?: string) {
|
||||||
|
const query = new URLSearchParams({ storageKey });
|
||||||
|
if (fileName) query.set('fileName', fileName);
|
||||||
|
return apiFetch<MediaAccessResponse>(`/media/chat/${roomId}/media/url?${query.toString()}`, {}, token);
|
||||||
|
}
|
||||||
|
|
||||||
export interface AccountDeletionStatus {
|
export interface AccountDeletionStatus {
|
||||||
pending: boolean;
|
pending: boolean;
|
||||||
deletionRequestedAt?: string;
|
deletionRequestedAt?: string;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { BadRequestException, HttpException, HttpStatus, Injectable, UnauthorizedException } from '@nestjs/common';
|
import { BadRequestException, HttpException, HttpStatus, Injectable, Logger, OnModuleInit, UnauthorizedException } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import * as bcrypt from 'bcryptjs';
|
import * as bcrypt from 'bcryptjs';
|
||||||
@@ -30,7 +30,9 @@ const TOTP_LOGIN_CHALLENGE_TTL_SECONDS = 5 * 60;
|
|||||||
type DeviceCommand = Pick<LoginCommand, 'fingerprint' | 'deviceName' | 'deviceType' | 'ipAddress' | 'userAgent'>;
|
type DeviceCommand = Pick<LoginCommand, 'fingerprint' | 'deviceName' | 'deviceType' | 'ipAddress' | 'userAgent'>;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(AuthService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly redis: RedisService,
|
private readonly redis: RedisService,
|
||||||
@@ -45,6 +47,10 @@ export class AuthService {
|
|||||||
private readonly rbac: RbacService
|
private readonly rbac: RbacService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
await this.repairMissingFirstHumanSuperAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
async register(command: RegisterCommand): Promise<PublicUser> {
|
async register(command: RegisterCommand): Promise<PublicUser> {
|
||||||
if (!command.email && !command.phone) {
|
if (!command.email && !command.phone) {
|
||||||
throw new BadRequestException('Укажите почту или телефон');
|
throw new BadRequestException('Укажите почту или телефон');
|
||||||
@@ -62,7 +68,7 @@ export class AuthService {
|
|||||||
try {
|
try {
|
||||||
const user = await this.prisma.$transaction(
|
const user = await this.prisma.$transaction(
|
||||||
async (tx) => {
|
async (tx) => {
|
||||||
const usersCount = await tx.user.count({ where: { deletedAt: null } });
|
const grantSuperAdmin = await this.shouldGrantFirstHumanSuperAdmin(tx);
|
||||||
return tx.user.create({
|
return tx.user.create({
|
||||||
data: {
|
data: {
|
||||||
email: command.email,
|
email: command.email,
|
||||||
@@ -70,7 +76,7 @@ export class AuthService {
|
|||||||
passwordHash,
|
passwordHash,
|
||||||
displayName: command.displayName,
|
displayName: command.displayName,
|
||||||
username: command.username,
|
username: command.username,
|
||||||
isSuperAdmin: usersCount === 0
|
isSuperAdmin: grantSuperAdmin
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -89,6 +95,79 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async shouldGrantFirstHumanSuperAdmin(tx: Prisma.TransactionClient): Promise<boolean> {
|
||||||
|
const [humanUsersCount, superAdminsCount] = await Promise.all([
|
||||||
|
tx.user.count({
|
||||||
|
where: {
|
||||||
|
deletedAt: null,
|
||||||
|
...HUMAN_USER_WHERE
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
tx.user.count({
|
||||||
|
where: {
|
||||||
|
deletedAt: null,
|
||||||
|
isSuperAdmin: true,
|
||||||
|
...HUMAN_USER_WHERE
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
return humanUsersCount === 0 && superAdminsCount === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async repairMissingFirstHumanSuperAdmin(): Promise<void> {
|
||||||
|
let lockAcquired = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
lockAcquired = await this.redis.acquireLock(FIRST_ADMIN_LOCK, 10_000);
|
||||||
|
if (!lockAcquired) return;
|
||||||
|
|
||||||
|
const promoted = await this.prisma.$transaction(
|
||||||
|
async (tx) => {
|
||||||
|
const superAdminsCount = await tx.user.count({
|
||||||
|
where: {
|
||||||
|
deletedAt: null,
|
||||||
|
isSuperAdmin: true,
|
||||||
|
...HUMAN_USER_WHERE
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (superAdminsCount > 0) return null;
|
||||||
|
|
||||||
|
const firstHumanUser = await tx.user.findFirst({
|
||||||
|
where: {
|
||||||
|
deletedAt: null,
|
||||||
|
...HUMAN_USER_WHERE
|
||||||
|
},
|
||||||
|
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
|
||||||
|
select: { id: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!firstHumanUser) return null;
|
||||||
|
|
||||||
|
return tx.user.update({
|
||||||
|
where: { id: firstHumanUser.id },
|
||||||
|
data: { isSuperAdmin: true },
|
||||||
|
select: { id: true }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (promoted) {
|
||||||
|
this.logger.warn(`Восстановлены права супер-администратора для первого пользователя: ${promoted.id}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Не удалось проверить первого супер-администратора: ${error instanceof Error ? error.message : 'неизвестная ошибка'}`
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (lockAcquired) {
|
||||||
|
await this.redis.releaseLock(FIRST_ADMIN_LOCK);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async login(command: LoginCommand): Promise<AuthTokens> {
|
async login(command: LoginCommand): Promise<AuthTokens> {
|
||||||
const user = await this.prisma.user.findFirst({
|
const user = await this.prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
@@ -587,14 +666,14 @@ export class AuthService {
|
|||||||
try {
|
try {
|
||||||
const user = await this.prisma.$transaction(
|
const user = await this.prisma.$transaction(
|
||||||
async (tx) => {
|
async (tx) => {
|
||||||
const usersCount = await tx.user.count({ where: { deletedAt: null } });
|
const grantSuperAdmin = await this.shouldGrantFirstHumanSuperAdmin(tx);
|
||||||
return tx.user.create({
|
return tx.user.create({
|
||||||
data: {
|
data: {
|
||||||
email: ldapResult.email ?? undefined,
|
email: ldapResult.email ?? undefined,
|
||||||
username: ldapResult.username,
|
username: ldapResult.username,
|
||||||
displayName: ldapResult.displayName,
|
displayName: ldapResult.displayName,
|
||||||
passwordHash: null,
|
passwordHash: null,
|
||||||
isSuperAdmin: usersCount === 0,
|
isSuperAdmin: grantSuperAdmin,
|
||||||
linkedAccounts: {
|
linkedAccounts: {
|
||||||
create: {
|
create: {
|
||||||
providerName: 'ldap',
|
providerName: 'ldap',
|
||||||
@@ -789,14 +868,14 @@ export class AuthService {
|
|||||||
try {
|
try {
|
||||||
const user = await this.prisma.$transaction(
|
const user = await this.prisma.$transaction(
|
||||||
async (tx) => {
|
async (tx) => {
|
||||||
const usersCount = await tx.user.count({ where: { deletedAt: null } });
|
const grantSuperAdmin = await this.shouldGrantFirstHumanSuperAdmin(tx);
|
||||||
return tx.user.create({
|
return tx.user.create({
|
||||||
data: {
|
data: {
|
||||||
email: recipient.includes('@') ? recipient : undefined,
|
email: recipient.includes('@') ? recipient : undefined,
|
||||||
phone: recipient.includes('@') ? undefined : recipient,
|
phone: recipient.includes('@') ? undefined : recipient,
|
||||||
passwordHash: null,
|
passwordHash: null,
|
||||||
displayName: recipient,
|
displayName: recipient,
|
||||||
isSuperAdmin: usersCount === 0
|
isSuperAdmin: grantSuperAdmin
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user