fix and update

This commit is contained in:
lendry
2026-06-29 22:51:25 +03:00
parent 885b07d76b
commit 57cb58347b
30 changed files with 799 additions and 187 deletions

View File

@@ -0,0 +1,36 @@
import type { Request } from 'express';
export function resolveClientIp(req: Pick<Request, 'ip' | 'headers'>): string | undefined {
const forwarded = req.headers['x-forwarded-for'];
if (typeof forwarded === 'string') {
const first = forwarded.split(',')[0]?.trim();
if (first) return first;
}
if (Array.isArray(forwarded)) {
const first = forwarded[0]?.trim();
if (first) return first;
}
const realIp = req.headers['x-real-ip'];
if (typeof realIp === 'string' && realIp.trim()) {
return realIp.trim();
}
const ip = req.ip?.replace(/^::ffff:/, '').trim();
return ip || undefined;
}
export function resolveClientUserAgent(req: Pick<Request, 'headers'>): string | undefined {
const value = req.headers['user-agent'];
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
return trimmed || undefined;
}
export function enrichAuthClientMeta<T extends object>(req: Pick<Request, 'ip' | 'headers'>, dto: T) {
return {
...dto,
ipAddress: resolveClientIp(req),
userAgent: resolveClientUserAgent(req)
};
}

View File

@@ -1,4 +1,6 @@
import { Body, Controller, Get, Headers, Ip, Param, Post } from '@nestjs/common'; import { Body, Controller, Get, Headers, Param, Post, Req } from '@nestjs/common';
import type { Request } from 'express';
import { enrichAuthClientMeta } from '../client-request.util';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
import { CoreGrpcService } from '../core-grpc.service'; import { CoreGrpcService } from '../core-grpc.service';
@@ -33,14 +35,14 @@ export class AdvancedAuthController {
@ApiOperation({ summary: 'Создать QR-сессию', description: 'Создает временную QR-сессию для входа с другого устройства.' }) @ApiOperation({ summary: 'Создать QR-сессию', description: 'Создает временную QR-сессию для входа с другого устройства.' })
@ApiBody({ type: QrSessionDto }) @ApiBody({ type: QrSessionDto })
@ApiResponse({ status: 201, description: 'QR-сессия создана' }) @ApiResponse({ status: 201, description: 'QR-сессия создана' })
createQr(@Body() dto: QrSessionDto, @Ip() ipAddress?: string, @Headers('user-agent') userAgent?: string) { createQr(@Body() dto: QrSessionDto, @Req() req: Request) {
return this.core.advancedAuth.CreateQrSession({ return this.core.advancedAuth.CreateQrSession(
deviceName: dto.deviceName, enrichAuthClientMeta(req, {
fingerprint: dto.fingerprint, deviceName: dto.deviceName,
deviceType: dto.deviceType ?? 'WEB', fingerprint: dto.fingerprint,
ipAddress, deviceType: dto.deviceType ?? 'WEB'
userAgent })
}); );
} }
@Get('qr/session/:sessionId') @Get('qr/session/:sessionId')

View File

@@ -1,8 +1,10 @@
import { Body, Controller, Get, Headers, Ip, Post, UseInterceptors } from '@nestjs/common'; import { Body, Controller, Get, Headers, Post, Req, UseInterceptors } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Request } from 'express';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import { CoreGrpcService } from '../core-grpc.service'; import { CoreGrpcService } from '../core-grpc.service';
import { enrichAuthClientMeta } from '../client-request.util';
import { BeginTotpLoginDto, IdentifyDto, LdapLoginDto, LoginDto, PasswordlessOtpDto, PasswordlessVerifyDto, PasswordLoginDto, RefreshSessionDto, RegisterDto, VerifyPinDto, VerifyTotpLoginDto } from '../dto/auth.dto'; import { BeginTotpLoginDto, IdentifyDto, LdapLoginDto, LoginDto, PasswordlessOtpDto, PasswordlessVerifyDto, PasswordLoginDto, RefreshSessionDto, RegisterDto, VerifyPinDto, VerifyTotpLoginDto } from '../dto/auth.dto';
import { resolveAuthorizedPayload, verifyAccessToken } from '../session-auth'; import { resolveAuthorizedPayload, verifyAccessToken } from '../session-auth';
import { FedcmCookieInterceptor } from '../interceptors/fedcm-cookie.interceptor'; import { FedcmCookieInterceptor } from '../interceptors/fedcm-cookie.interceptor';
@@ -26,8 +28,8 @@ export class AuthController {
@Post('login') @Post('login')
@ApiOperation({ summary: 'Вход по почте, телефону или логину', description: 'Возвращает JWT и refresh token. Если PIN включен, сессия создается в ограниченном режиме.' }) @ApiOperation({ summary: 'Вход по почте, телефону или логину', description: 'Возвращает JWT и refresh token. Если PIN включен, сессия создается в ограниченном режиме.' })
@ApiBody({ type: LoginDto }) @ApiBody({ type: LoginDto })
login(@Body() dto: LoginDto) { login(@Body() dto: LoginDto, @Req() req: Request) {
return this.core.auth.Login(dto); return this.core.auth.Login(enrichAuthClientMeta(req, dto));
} }
@Post('identify') @Post('identify')
@@ -40,29 +42,29 @@ export class AuthController {
@Post('otp/send') @Post('otp/send')
@ApiOperation({ summary: 'Отправить OTP для входа', description: 'Passwordless-first вход: пользователь вводит почту или телефон, сервер создает 6-значный код и пишет его в console.log.' }) @ApiOperation({ summary: 'Отправить OTP для входа', description: 'Passwordless-first вход: пользователь вводит почту или телефон, сервер создает 6-значный код и пишет его в console.log.' })
@ApiBody({ type: PasswordlessOtpDto }) @ApiBody({ type: PasswordlessOtpDto })
sendOtp(@Body() dto: PasswordlessOtpDto, @Ip() ipAddress?: string) { sendOtp(@Body() dto: PasswordlessOtpDto, @Req() req: Request) {
return this.core.auth.SendOtp({ recipient: dto.recipient, channel: dto.channel, ipAddress }); return this.core.auth.SendOtp({ recipient: dto.recipient, channel: dto.channel, ipAddress: enrichAuthClientMeta(req, {}).ipAddress });
} }
@Post('otp/verify') @Post('otp/verify')
@ApiOperation({ summary: 'Проверить OTP для входа', description: 'Если пользователь не существует, создает его. Если пароль не задан, сразу возвращает JWT. Если пароль задан, возвращает requiresPassword=true и tempAuthToken.' }) @ApiOperation({ summary: 'Проверить OTP для входа', description: 'Если пользователь не существует, создает его. Если пароль не задан, сразу возвращает JWT. Если пароль задан, возвращает requiresPassword=true и tempAuthToken.' })
@ApiBody({ type: PasswordlessVerifyDto }) @ApiBody({ type: PasswordlessVerifyDto })
verifyOtp(@Body() dto: PasswordlessVerifyDto) { verifyOtp(@Body() dto: PasswordlessVerifyDto, @Req() req: Request) {
return this.core.auth.VerifyOtp(dto); return this.core.auth.VerifyOtp(enrichAuthClientMeta(req, dto));
} }
@Post('login/password') @Post('login/password')
@ApiOperation({ summary: 'Войти по паролю', description: 'Identifier-first парольный шаг. Принимает login+password, либо tempAuthToken+password для совместимости, затем выдает JWT.' }) @ApiOperation({ summary: 'Войти по паролю', description: 'Identifier-first парольный шаг. Принимает login+password, либо tempAuthToken+password для совместимости, затем выдает JWT.' })
@ApiBody({ type: PasswordLoginDto }) @ApiBody({ type: PasswordLoginDto })
loginWithPassword(@Body() dto: PasswordLoginDto) { loginWithPassword(@Body() dto: PasswordLoginDto, @Req() req: Request) {
return this.core.auth.LoginWithPassword(dto); return this.core.auth.LoginWithPassword(enrichAuthClientMeta(req, dto));
} }
@Post('ldap/login') @Post('ldap/login')
@ApiOperation({ summary: 'Войти через LDAP/LDAPS', description: 'Аутентификация через корпоративный LDAP-сервер. Требует включённой настройки LDAP_ENABLED.' }) @ApiOperation({ summary: 'Войти через LDAP/LDAPS', description: 'Аутентификация через корпоративный LDAP-сервер. Требует включённой настройки LDAP_ENABLED.' })
@ApiBody({ type: LdapLoginDto }) @ApiBody({ type: LdapLoginDto })
loginWithLdap(@Body() dto: LdapLoginDto) { loginWithLdap(@Body() dto: LdapLoginDto, @Req() req: Request) {
return this.core.auth.LoginWithLdap(dto); return this.core.auth.LoginWithLdap(enrichAuthClientMeta(req, dto));
} }
@Post('pin/verify') @Post('pin/verify')
@@ -75,15 +77,15 @@ export class AuthController {
@Post('totp/begin') @Post('totp/begin')
@ApiOperation({ summary: 'Начать вход по TOTP', description: 'Создаёт challenge для входа через приложение-аутентификатор вместо SMS/email OTP.' }) @ApiOperation({ summary: 'Начать вход по TOTP', description: 'Создаёт challenge для входа через приложение-аутентификатор вместо SMS/email OTP.' })
@ApiBody({ type: BeginTotpLoginDto }) @ApiBody({ type: BeginTotpLoginDto })
beginTotpLogin(@Body() dto: BeginTotpLoginDto) { beginTotpLogin(@Body() dto: BeginTotpLoginDto, @Req() req: Request) {
return this.core.auth.BeginTotpLogin(dto); return this.core.auth.BeginTotpLogin(enrichAuthClientMeta(req, dto));
} }
@Post('totp/verify') @Post('totp/verify')
@ApiOperation({ summary: 'Подтвердить TOTP при входе', description: 'Завершает вход после проверки кода из Google Authenticator или аналога.' }) @ApiOperation({ summary: 'Подтвердить TOTP при входе', description: 'Завершает вход после проверки кода из Google Authenticator или аналога.' })
@ApiBody({ type: VerifyTotpLoginDto }) @ApiBody({ type: VerifyTotpLoginDto })
verifyTotpLogin(@Body() dto: VerifyTotpLoginDto) { verifyTotpLogin(@Body() dto: VerifyTotpLoginDto, @Req() req: Request) {
return this.core.auth.VerifyTotpLogin(dto); return this.core.auth.VerifyTotpLogin(enrichAuthClientMeta(req, dto));
} }
@Post('refresh') @Post('refresh')

View File

@@ -194,15 +194,9 @@ export class FedcmController {
); );
} }
@Post('session/sync') private async syncFedcmSessionFromAuthorization(
@HttpCode(200) authorization: string | undefined,
@ApiOperation({ res: Response
summary: 'Синхронизировать FedCM cookie',
description: 'Устанавливает HttpOnly cookie для FedCM по Bearer access token (для уже авторизованных пользователей IdP).'
})
async syncSession(
@Headers('authorization') authorization: string | undefined,
@Res({ passthrough: true }) res: Response
) { ) {
const payload = await verifyAccessToken(this.jwt, authorization); const payload = await verifyAccessToken(this.jwt, authorization);
if (!payload.sessionId) { if (!payload.sessionId) {
@@ -217,4 +211,30 @@ export class FedcmController {
applyFedcmLoginStatus(res, true); applyFedcmLoginStatus(res, true);
return { synced: true }; return { synced: true };
} }
@Post('session/sync')
@HttpCode(200)
@ApiOperation({
summary: 'Синхронизировать FedCM cookie',
description: 'Устанавливает HttpOnly cookie для FedCM по Bearer access token (для уже авторизованных пользователей IdP).'
})
syncSessionPost(
@Headers('authorization') authorization: string | undefined,
@Res({ passthrough: true }) res: Response
) {
return this.syncFedcmSessionFromAuthorization(authorization, res);
}
@Get('session/sync')
@HttpCode(200)
@ApiOperation({
summary: 'Синхронизировать FedCM cookie (GET)',
description: 'Тот же sync по Bearer token. GET нужен для совместимости с редиректами прокси и prefetch.'
})
syncSessionGet(
@Headers('authorization') authorization: string | undefined,
@Res({ passthrough: true }) res: Response
) {
return this.syncFedcmSessionFromAuthorization(authorization, res);
}
} }

View File

@@ -27,7 +27,8 @@ export class SecurityController {
@ApiOperation({ summary: 'Активные сессии', description: 'Возвращает ACTIVE и LOCKED сессии пользователя для управления устройствами.' }) @ApiOperation({ summary: 'Активные сессии', description: 'Возвращает ACTIVE и LOCKED сессии пользователя для управления устройствами.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' }) @ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiResponse({ status: 200, description: 'Список активных сессий получен' }) @ApiResponse({ status: 200, description: 'Список активных сессий получен' })
listSessions(@Param('userId') userId: string) { async listSessions(@Param('userId') userId: string, @Headers('authorization') authorization?: string) {
await resolveAuthorizedPayload(this.jwt, this.core, authorization);
return this.core.security.ListActiveSessions({ userId }); return this.core.security.ListActiveSessions({ userId });
} }
@@ -35,8 +36,10 @@ export class SecurityController {
@ApiOperation({ summary: 'История входов', description: 'Показывает последние попытки входа и причины отказов.' }) @ApiOperation({ summary: 'История входов', description: 'Показывает последние попытки входа и причины отказов.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' }) @ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiResponse({ status: 200, description: 'История входов получена' }) @ApiResponse({ status: 200, description: 'История входов получена' })
listHistory(@Param('userId') userId: string) { listHistory(@Param('userId') userId: string, @Headers('authorization') authorization?: string) {
return this.core.security.ListSignInHistory({ userId }); return resolveAuthorizedPayload(this.jwt, this.core, authorization).then(() =>
this.core.security.ListSignInHistory({ userId })
);
} }
@Get('users/:userId/totp/status') @Get('users/:userId/totp/status')

View File

@@ -268,9 +268,7 @@ export default function LoginPage() {
try { try {
const deviceName = navigator.userAgent.includes('Windows') ? 'Windows Browser' : 'Browser'; const session = await createQrLoginSession();
const session = await createQrLoginSession(deviceName);
setQrSessionId(session.sessionId); setQrSessionId(session.sessionId);

View File

@@ -9,13 +9,13 @@ import { useToast } from '@/components/id/toast-provider';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { useRequireAuth } from '@/hooks/use-require-auth'; import { useRequireAuth } from '@/hooks/use-require-auth';
import { apiFetch, FamilyGroup, fetchFamilyGroups, getApiErrorMessage } from '@/lib/api'; import { apiFetch, FamilyGroup, fetchFamilyGroups, getAccessToken, getApiErrorMessage } from '@/lib/api';
import { defaultFamilyGroupName } from '@/lib/family-defaults'; import { defaultFamilyGroupName } from '@/lib/family-defaults';
export default function FamilyPage() { export default function FamilyPage() {
const router = useRouter(); const router = useRouter();
const { user, token } = useAuth(); const { user, token, isLoading, isPinLocked } = useAuth();
const { isReady, isPinLocked } = useRequireAuth(); const { isReady } = useRequireAuth();
const { showToast } = useToast(); const { showToast } = useToast();
const [groups, setGroups] = useState<FamilyGroup[]>([]); const [groups, setGroups] = useState<FamilyGroup[]>([]);
const [name, setName] = useState(''); const [name, setName] = useState('');
@@ -28,23 +28,25 @@ export default function FamilyPage() {
}, [user?.displayName]); }, [user?.displayName]);
useEffect(() => { useEffect(() => {
if (!user || !token || isPinLocked) return; const accessToken = getAccessToken() ?? token?.trim() ?? null;
fetchFamilyGroups(user.id, token) if (!user || !accessToken || isPinLocked || isLoading) return;
fetchFamilyGroups(user.id, accessToken)
.then((response) => setGroups(response.groups ?? [])) .then((response) => setGroups(response.groups ?? []))
.catch((error) => { .catch((error) => {
const message = getApiErrorMessage(error, 'Не удалось загрузить семью'); const message = getApiErrorMessage(error, 'Не удалось загрузить семью');
if (message) showToast(message); if (message) showToast(message);
}); });
}, [isPinLocked, showToast, token, user]); }, [isLoading, isPinLocked, showToast, token, user]);
async function createGroup() { async function createGroup() {
if (!user || !token) return; const accessToken = getAccessToken() ?? token?.trim() ?? null;
if (!user || !accessToken) return;
setCreating(true); setCreating(true);
try { try {
const group = await apiFetch<FamilyGroup>('/family/groups', { const group = await apiFetch<FamilyGroup>('/family/groups', {
method: 'POST', method: 'POST',
body: JSON.stringify({ ownerId: user.id, name }) body: JSON.stringify({ ownerId: user.id, name })
}, token); }, accessToken);
router.push(`/family/${group.id}`); router.push(`/family/${group.id}`);
} catch (error) { } catch (error) {
const message = getApiErrorMessage(error, 'Не удалось создать семью'); const message = getApiErrorMessage(error, 'Не удалось создать семью');
@@ -54,7 +56,7 @@ export default function FamilyPage() {
} }
} }
if (!isReady) { if (!isReady || isLoading) {
return ( return (
<IdShell active="/family"> <IdShell active="/family">
<div className="py-20 text-center text-[#667085]">Загрузка...</div> <div className="py-20 text-center text-[#667085]">Загрузка...</div>

View File

@@ -24,7 +24,8 @@ import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { OtpInput } from '@/components/ui/otp-input'; import { OtpInput } from '@/components/ui/otp-input';
import { ActiveDevice, apiFetch, getApiErrorMessage, SignInEvent, TotpSetupResponse, TotpStatusResponse } from '@/lib/api'; import { ActiveDevice, ActiveSession, apiFetch, AUTH_SESSION_KEY, getApiErrorMessage, SignInEvent, TotpSetupResponse, TotpStatusResponse } from '@/lib/api';
import { formatUserAgentLabel } from '@/lib/device-client';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { QRCodeSVG } from 'qrcode.react'; import { QRCodeSVG } from 'qrcode.react';
import { useRequireAuth } from '@/hooks/use-require-auth'; import { useRequireAuth } from '@/hooks/use-require-auth';
@@ -67,7 +68,9 @@ export default function SecurityPage() {
const { isReady } = useRequireAuth(); const { isReady } = useRequireAuth();
const { showToast } = useToast(); const { showToast } = useToast();
const [devices, setDevices] = useState<ActiveDevice[]>([]); const [devices, setDevices] = useState<ActiveDevice[]>([]);
const [sessions, setSessions] = useState<ActiveSession[]>([]);
const [history, setHistory] = useState<SignInEvent[]>([]); const [history, setHistory] = useState<SignInEvent[]>([]);
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
const [isSecurityLoading, setIsSecurityLoading] = useState(true); const [isSecurityLoading, setIsSecurityLoading] = useState(true);
const [isRevoking, setIsRevoking] = useState(false); const [isRevoking, setIsRevoking] = useState(false);
const [dialogMode, setDialogMode] = useState<DialogMode>(null); const [dialogMode, setDialogMode] = useState<DialogMode>(null);
@@ -92,13 +95,16 @@ export default function SecurityPage() {
if (!user || !token) return; if (!user || !token) return;
setIsSecurityLoading(true); setIsSecurityLoading(true);
try { try {
const [devicesResponse, historyResponse, totpResponse] = await Promise.all([ const [devicesResponse, sessionsResponse, historyResponse, totpResponse] = await Promise.all([
apiFetch<{ devices: ActiveDevice[] }>(`/security/users/${user.id}/devices`, {}, token), apiFetch<{ devices: ActiveDevice[] }>(`/security/users/${user.id}/devices`, {}, token),
apiFetch<{ sessions: ActiveSession[] }>(`/security/users/${user.id}/sessions`, {}, token),
apiFetch<{ events: SignInEvent[] }>(`/security/users/${user.id}/sign-in-history`, {}, token), apiFetch<{ events: SignInEvent[] }>(`/security/users/${user.id}/sign-in-history`, {}, token),
apiFetch<TotpStatusResponse>(`/security/users/${user.id}/totp/status`, {}, token) apiFetch<TotpStatusResponse>(`/security/users/${user.id}/totp/status`, {}, token)
]); ]);
setDevices(devicesResponse.devices ?? []); setDevices(devicesResponse.devices ?? []);
setSessions(sessionsResponse.sessions ?? []);
setHistory(historyResponse.events ?? []); setHistory(historyResponse.events ?? []);
setCurrentSessionId(window.localStorage.getItem(AUTH_SESSION_KEY));
setTotpEnabled(Boolean(totpResponse.isEnabled)); setTotpEnabled(Boolean(totpResponse.isEnabled));
} catch (error) { } catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось загрузить безопасность'); showToast(error instanceof Error ? error.message : 'Не удалось загрузить безопасность');
@@ -112,6 +118,31 @@ export default function SecurityPage() {
void loadSecurity(); void loadSecurity();
}, [isReady, loadSecurity, user]); }, [isReady, loadSecurity, user]);
async function revokeSession(sessionId: string) {
if (!user || !token) return;
try {
await apiFetch(`/security/users/${user.id}/sessions/${sessionId}/revoke`, { method: 'POST' }, token);
setSessions((current) => current.filter((session) => session.id !== sessionId));
showToast('Сессия завершена');
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось завершить сессию');
}
}
function sessionDeviceLabel(session: ActiveSession) {
return session.deviceName ?? formatUserAgentLabel(session.userAgent) ?? 'Неизвестное устройство';
}
function sessionMetaLine(session: ActiveSession) {
const parts = [session.ipAddress ?? 'IP не сохранён'];
const agentLabel = formatUserAgentLabel(session.userAgent);
if (agentLabel && agentLabel !== session.deviceName) {
parts.push(agentLabel);
}
parts.push(formatDate(session.updatedAt));
return parts.join(' · ');
}
async function revokeDevice(deviceId: string) { async function revokeDevice(deviceId: string) {
if (!user || !token) return; if (!user || !token) return;
try { try {
@@ -335,10 +366,53 @@ export default function SecurityPage() {
return ( return (
<IdShell active="/security"> <IdShell active="/security">
<p className="text-sm text-[#667085]">Безопасность</p> <p className="text-sm text-[#667085]">Безопасность</p>
<h1 className="text-2xl font-medium tracking-tight sm:text-4xl">Устройства</h1> <h1 className="text-2xl font-medium tracking-tight sm:text-4xl">Устройства и сессии</h1>
<p className="mt-2 max-w-[460px] text-base">На них вы вошли в ID и получаете уведомления безопасности от сервисов</p> <p className="mt-2 max-w-[460px] text-base">На них вы вошли в ID и получаете уведомления безопасности от сервисов</p>
<div className="mt-8 space-y-2"> <div className="mt-8 space-y-2">
<h2 className="text-lg font-medium">Активные сессии</h2>
{isSecurityLoading ? (
<div className="rounded-[20px] bg-[#f4f5f8] px-4 py-5 text-[#667085]">Загружаем сессии...</div>
) : sessions.length ? (
sessions.map((session) => {
const Icon = deviceIcons[session.deviceType ?? 'OTHER'] ?? Laptop;
const isCurrent = session.id === currentSessionId;
return (
<div key={session.id} className="flex items-center gap-4 rounded-[20px] bg-[#f4f5f8] px-4 py-3">
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-white">
<Icon className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium">{sessionDeviceLabel(session)}</span>
{isCurrent ? (
<span className="rounded-full bg-[#e8f4ff] px-2 py-0.5 text-[11px] font-semibold text-[#3390ec]">
Текущая
</span>
) : null}
</div>
<span className="text-xs text-[#667085]">{sessionMetaLine(session)}</span>
</div>
{!isCurrent ? (
<Button
variant="ghost"
size="sm"
className="shrink-0 text-[#d14343] hover:bg-[#fbeaea]"
onClick={() => void revokeSession(session.id)}
>
Выйти
</Button>
) : null}
</div>
);
})
) : (
<div className="rounded-[20px] bg-[#f4f5f8] px-4 py-5 text-[#667085]">Активных сессий пока нет</div>
)}
</div>
<div className="mt-8 space-y-2">
<h2 className="text-lg font-medium">Другие устройства</h2>
{isSecurityLoading ? ( {isSecurityLoading ? (
<div className="rounded-[20px] bg-[#f4f5f8] px-4 py-5 text-[#667085]">Загружаем устройства...</div> <div className="rounded-[20px] bg-[#f4f5f8] px-4 py-5 text-[#667085]">Загружаем устройства...</div>
) : devices.length ? ( ) : devices.length ? (
@@ -443,10 +517,17 @@ export default function SecurityPage() {
<div className="text-[#667085]">Загружаем историю входов...</div> <div className="text-[#667085]">Загружаем историю входов...</div>
) : history.length ? ( ) : history.length ? (
history.map((item) => ( history.map((item) => (
<div key={item.id} className="flex items-center gap-4 border-b border-[#eceef4] pb-3"> <div key={item.id} className="flex items-start gap-4 border-b border-[#eceef4] pb-3">
<Clock3 className="h-5 w-5" /> <Clock3 className="mt-0.5 h-5 w-5 shrink-0" />
<span className="flex-1">{item.success ? 'Успешный вход' : item.reason ?? 'Неудачная попытка входа'}</span> <div className="min-w-0 flex-1">
<span className="text-sm text-[#667085]">{formatDate(item.createdAt)}</span> <p>{item.success ? 'Успешный вход' : item.reason ?? 'Неудачная попытка входа'}</p>
<p className="mt-1 text-sm text-[#667085]">
{[item.deviceName, item.ipAddress ?? 'IP не сохранён', formatUserAgentLabel(item.userAgent)]
.filter(Boolean)
.join(' · ')}
</p>
</div>
<span className="shrink-0 text-sm text-[#667085]">{formatDate(item.createdAt)}</span>
</div> </div>
)) ))
) : ( ) : (

View File

@@ -3,6 +3,7 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { ChevronLeft, ChevronRight, Download, X } from 'lucide-react'; import { ChevronLeft, ChevronRight, Download, X } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { MediaImageSkeleton } from '@/components/ui/media-image-skeleton';
import type { ChatMessage } from '@/lib/api'; import type { ChatMessage } from '@/lib/api';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -97,7 +98,7 @@ export function ChatImageLightbox({
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img src={media.blobUrl} alt="" className="max-h-[calc(100vh-140px)] max-w-full object-contain" /> <img src={media.blobUrl} alt="" className="max-h-[calc(100vh-140px)] max-w-full object-contain" />
) : ( ) : (
<div className="text-sm text-white/70">Загрузка изображения...</div> <MediaImageSkeleton className="h-[min(70vh,520px)] w-[min(92vw,720px)]" rounded="2xl" />
)} )}
</div> </div>
@@ -137,7 +138,9 @@ export function ChatImageLightbox({
{thumb?.blobUrl ? ( {thumb?.blobUrl ? (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img src={thumb.blobUrl} alt="" className="h-full w-full object-cover" /> <img src={thumb.blobUrl} alt="" className="h-full w-full object-cover" />
) : null} ) : (
<MediaImageSkeleton className="h-full w-full min-h-0" rounded="none" />
)}
</button> </button>
); );
})} })}

View File

@@ -1,12 +1,13 @@
'use client'; 'use client';
import type { ChatMessage } from '@/lib/api'; import type { ChatMessage } from '@/lib/api';
import { MediaImageSkeleton } from '@/components/ui/media-image-skeleton';
import { parseChatMediaMetadata } from '@/lib/chat-media-album'; import { parseChatMediaMetadata } from '@/lib/chat-media-album';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
interface ChatMediaAlbumGridProps { interface ChatMediaAlbumGridProps {
messages: ChatMessage[]; messages: ChatMessage[];
mediaUrls: Record<string, { blobUrl?: string } | undefined>; mediaUrls: Record<string, { blobUrl?: string; status?: string } | undefined>;
className?: string; className?: string;
onImageClick?: (message: ChatMessage, index: number) => void; onImageClick?: (message: ChatMessage, index: number) => void;
} }
@@ -58,6 +59,7 @@ export function ChatMediaAlbumGrid({ messages, mediaUrls, className, onImageClic
{imageMessages.map((message, index) => { {imageMessages.map((message, index) => {
const media = message.storageKey ? mediaUrls[message.storageKey] : undefined; const media = message.storageKey ? mediaUrls[message.storageKey] : undefined;
const hiddenCount = count > 6 && index === 5 ? count - 6 : 0; const hiddenCount = count > 6 && index === 5 ? count - 6 : 0;
const isLoading = !media?.blobUrl || media.status === 'loading';
return ( return (
<button <button
key={message.id} key={message.id}
@@ -65,11 +67,11 @@ export function ChatMediaAlbumGrid({ messages, mediaUrls, className, onImageClic
className={cn('relative overflow-hidden bg-[#dfe4ea]', tileClass(Math.min(count, 6), index))} className={cn('relative overflow-hidden bg-[#dfe4ea]', tileClass(Math.min(count, 6), index))}
onClick={() => onImageClick?.(message, index)} onClick={() => onImageClick?.(message, index)}
> >
{media?.blobUrl ? ( {isLoading ? (
<MediaImageSkeleton className="h-full min-h-[96px] w-full rounded-none" rounded="none" />
) : (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img src={media.blobUrl} alt="" className="h-full w-full object-cover" /> <img src={media.blobUrl} alt="" className="h-full w-full object-cover" />
) : (
<div className="flex h-full min-h-[96px] items-center justify-center text-xs text-[#667085]"></div>
)} )}
{hiddenCount > 0 ? ( {hiddenCount > 0 ? (
<div className="absolute inset-0 flex items-center justify-center bg-black/45 text-2xl font-semibold text-white"> <div className="absolute inset-0 flex items-center justify-center bg-black/45 text-2xl font-semibold text-white">

View File

@@ -4,8 +4,11 @@ import { Bot } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { UserAvatar } from '@/components/id/user-avatar'; import { UserAvatar } from '@/components/id/user-avatar';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';
import { useAuth } from '@/components/id/auth-provider';
import { ChatRoom, apiFetch } from '@/lib/api'; import { ChatRoom, apiFetch } from '@/lib/api';
import { CHAT_ROOM_AVATAR_UPDATED_EVENT } from '@/lib/avatar-events'; import { CHAT_ROOM_AVATAR_UPDATED_EVENT } from '@/lib/avatar-events';
import { useResilientBlobUrl } from '@/hooks/use-resilient-blob-url';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
interface ChatRoomAvatarDisplayProps { interface ChatRoomAvatarDisplayProps {
@@ -21,7 +24,9 @@ function sizeClass(size: 'sm' | 'md') {
} }
export function ChatRoomAvatarDisplay({ room, viewerUserId, token, className, size = 'md' }: ChatRoomAvatarDisplayProps) { export function ChatRoomAvatarDisplay({ room, viewerUserId, token, className, size = 'md' }: ChatRoomAvatarDisplayProps) {
const { isLoading: authLoading } = useAuth();
const [roomAvatarUrl, setRoomAvatarUrl] = useState<string | null>(null); const [roomAvatarUrl, setRoomAvatarUrl] = useState<string | null>(null);
const [resolvingRoomAvatar, setResolvingRoomAvatar] = useState(false);
const peerMember = useMemo( const peerMember = useMemo(
() => () =>
room.type === 'DIRECT' || room.type === 'E2E' || room.type === 'BOT' room.type === 'DIRECT' || room.type === 'E2E' || room.type === 'BOT'
@@ -31,34 +36,44 @@ export function ChatRoomAvatarDisplay({ room, viewerUserId, token, className, si
); );
useEffect(() => { useEffect(() => {
if (!token || !room.hasAvatar || room.type === 'DIRECT' || room.type === 'E2E' || room.type === 'BOT') { if (!token || authLoading || !room.hasAvatar || room.type === 'DIRECT' || room.type === 'E2E' || room.type === 'BOT') {
setRoomAvatarUrl(null); setRoomAvatarUrl(null);
setResolvingRoomAvatar(false);
return; return;
} }
let cancelled = false; let cancelled = false;
setResolvingRoomAvatar(true);
apiFetch<{ accessUrl: string }>(`/media/chat/${room.id}/avatar/url`, {}, token) apiFetch<{ accessUrl: string }>(`/media/chat/${room.id}/avatar/url`, {}, token)
.then((response) => { .then((response) => {
if (!cancelled) setRoomAvatarUrl(response.accessUrl); if (!cancelled) setRoomAvatarUrl(response.accessUrl);
}) })
.catch(() => { .catch(() => {
if (!cancelled) setRoomAvatarUrl(null); if (!cancelled) setRoomAvatarUrl(null);
})
.finally(() => {
if (!cancelled) setResolvingRoomAvatar(false);
}); });
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [room.hasAvatar, room.id, room.type, room.updatedAt, token]); }, [authLoading, room.hasAvatar, room.id, room.type, room.updatedAt, token]);
useEffect(() => { useEffect(() => {
function handleRoomAvatarUpdated(event: Event) { function handleRoomAvatarUpdated(event: Event) {
const detail = (event as CustomEvent<{ roomId?: string }>).detail; const detail = (event as CustomEvent<{ roomId?: string }>).detail;
if (detail?.roomId !== room.id || !room.hasAvatar || !token) return; if (detail?.roomId !== room.id || !room.hasAvatar || !token || authLoading) return;
apiFetch<{ accessUrl: string }>(`/media/chat/${room.id}/avatar/url`, {}, token) apiFetch<{ accessUrl: string }>(`/media/chat/${room.id}/avatar/url`, {}, token)
.then((response) => setRoomAvatarUrl(response.accessUrl)) .then((response) => setRoomAvatarUrl(response.accessUrl))
.catch(() => setRoomAvatarUrl(null)); .catch(() => setRoomAvatarUrl(null));
} }
window.addEventListener(CHAT_ROOM_AVATAR_UPDATED_EVENT, handleRoomAvatarUpdated); window.addEventListener(CHAT_ROOM_AVATAR_UPDATED_EVENT, handleRoomAvatarUpdated);
return () => window.removeEventListener(CHAT_ROOM_AVATAR_UPDATED_EVENT, handleRoomAvatarUpdated); return () => window.removeEventListener(CHAT_ROOM_AVATAR_UPDATED_EVENT, handleRoomAvatarUpdated);
}, [room.hasAvatar, room.id, token]); }, [authLoading, room.hasAvatar, room.id, token]);
const { blobUrl, isLoading: isLoadingRoomBlob } = useResilientBlobUrl(roomAvatarUrl, token, {
enabled: Boolean(room.hasAvatar && roomAvatarUrl && !authLoading),
label: `аватар чата ${room.name}`
});
if (peerMember && (room.type === 'DIRECT' || room.type === 'E2E')) { if (peerMember && (room.type === 'DIRECT' || room.type === 'E2E')) {
return ( return (
@@ -85,10 +100,22 @@ export function ChatRoomAvatarDisplay({ room, viewerUserId, token, className, si
); );
} }
if (room.hasAvatar && roomAvatarUrl) { const isRoomAvatarLoading = Boolean(room.hasAvatar && (resolvingRoomAvatar || isLoadingRoomBlob || (roomAvatarUrl && !blobUrl)));
if (room.hasAvatar && isRoomAvatarLoading) {
return ( return (
<Avatar className={cn(sizeClass(size), className)}> <Avatar className={cn(sizeClass(size), className)}>
<AvatarImage src={roomAvatarUrl} alt={room.name} /> <AvatarFallback className="bg-transparent p-0">
<Skeleton className="h-full w-full rounded-full" />
</AvatarFallback>
</Avatar>
);
}
if (room.hasAvatar && blobUrl) {
return (
<Avatar className={cn(sizeClass(size), className)}>
<AvatarImage src={blobUrl} alt={room.name} />
<AvatarFallback>{room.name.slice(0, 2).toUpperCase()}</AvatarFallback> <AvatarFallback>{room.name.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar> </Avatar>
); );

View File

@@ -65,6 +65,7 @@ import { useAuth } from '@/components/id/auth-provider';
import { useToast } from '@/components/id/toast-provider'; import { useToast } from '@/components/id/toast-provider';
import { useRealtime } from '@/components/notifications/realtime-provider'; import { useRealtime } from '@/components/notifications/realtime-provider';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { MediaImageSkeleton } from '@/components/ui/media-image-skeleton';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { useRequireAuth } from '@/hooks/use-require-auth'; import { useRequireAuth } from '@/hooks/use-require-auth';
@@ -88,6 +89,7 @@ import {
fetchFamilyPresence, fetchFamilyPresence,
forwardChatMessages, forwardChatMessages,
getApiErrorMessage, getApiErrorMessage,
getAccessToken,
leaveFamilyGroup, leaveFamilyGroup,
markChatRoomRead, markChatRoomRead,
removeChatRoomMember, removeChatRoomMember,
@@ -197,22 +199,24 @@ function FamilySidebarAvatar({
uploading?: boolean; uploading?: boolean;
onUpload: (file: File) => void; onUpload: (file: File) => void;
}) { }) {
const { isLoading: authLoading } = useAuth();
const [url, setUrl] = useState<string | null>(null); const [url, setUrl] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (!hasAvatar || !token) { if (!hasAvatar || !token || authLoading) {
setUrl(null); setUrl(null);
return; return;
} }
apiFetch<{ accessUrl: string }>(`/media/families/${groupId}/avatar/url`, {}, token) apiFetch<{ accessUrl: string }>(`/media/families/${groupId}/avatar/url`, {}, token)
.then((response) => setUrl(response.accessUrl)) .then((response) => setUrl(response.accessUrl))
.catch(() => setUrl(null)); .catch(() => setUrl(null));
}, [groupId, hasAvatar, token]); }, [authLoading, groupId, hasAvatar, token]);
return ( return (
<HoverUploadAvatar <HoverUploadAvatar
name={name} name={name}
imageUrl={url} imageUrl={url}
imageToken={token}
uploading={uploading} uploading={uploading}
onFileSelect={onUpload} onFileSelect={onUpload}
className="h-11 w-11 shrink-0" className="h-11 w-11 shrink-0"
@@ -230,6 +234,10 @@ function familyMemberRoleLabel(role: string) {
return role === 'owner' ? 'Создатель семьи' : 'Участник'; return role === 'owner' ? 'Создатель семьи' : 'Участник';
} }
/** Desktop: зона под fixed UserMenu + колокольчик (IdShell), на mobile не применяется */
const DESKTOP_CHAT_RIGHT_GUTTER = 'lg:pr-64';
const DESKTOP_CHAT_TOP_GUTTER = 'lg:pt-14';
function shouldSkipGroupedAlbumMessage(messages: ChatMessage[], index: number) { function shouldSkipGroupedAlbumMessage(messages: ChatMessage[], index: number) {
const message = messages[index]; const message = messages[index];
if (!message) return false; if (!message) return false;
@@ -257,7 +265,7 @@ function collectAlbumMessages(messages: ChatMessage[], startIndex: number) {
export function FamilyGroupView({ groupId }: { groupId: string }) { export function FamilyGroupView({ groupId }: { groupId: string }) {
const router = useRouter(); const router = useRouter();
const { user, token } = useAuth(); const { user, token, isLoading: authLoading } = useAuth();
const { isReady, isPinLocked } = useRequireAuth(); const { isReady, isPinLocked } = useRequireAuth();
const { showToast } = useToast(); const { showToast } = useToast();
const { subscribe, connected } = useRealtime(); const { subscribe, connected } = useRealtime();
@@ -469,9 +477,10 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
}, [isPinLocked, rooms, showToast, token]); }, [isPinLocked, rooms, showToast, token]);
const loadPresence = useCallback(async () => { const loadPresence = useCallback(async () => {
if (!token || isPinLocked) return; const accessToken = getAccessToken() ?? token?.trim() ?? null;
if (!accessToken || isPinLocked) return;
try { try {
const response = await fetchFamilyPresence(groupId, token); const response = await fetchFamilyPresence(groupId, accessToken);
setPresenceMembers(response.members ?? []); setPresenceMembers(response.members ?? []);
} catch { } catch {
// ignore background presence errors // ignore background presence errors
@@ -479,13 +488,14 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
}, [groupId, isPinLocked, token]); }, [groupId, isPinLocked, token]);
useEffect(() => { useEffect(() => {
if (!isReady || !token || isPinLocked) return; const accessToken = getAccessToken() ?? token?.trim() ?? null;
if (!isReady || !accessToken || isPinLocked || authLoading) return;
setLoading(true); setLoading(true);
loadGroup() loadGroup()
.catch((error) => showToast(getApiErrorMessage(error, 'Не удалось загрузить семью') ?? 'Ошибка')) .catch((error) => showToast(getApiErrorMessage(error, 'Не удалось загрузить семью') ?? 'Ошибка'))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
void loadPresence(); void loadPresence();
}, [isPinLocked, isReady, loadGroup, loadPresence, showToast, token]); }, [authLoading, isPinLocked, isReady, loadGroup, loadPresence, showToast, token]);
useEffect(() => { useEffect(() => {
function handleAvatarUpdated() { function handleAvatarUpdated() {
@@ -496,11 +506,12 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
}, [loadGroup]); }, [loadGroup]);
useEffect(() => { useEffect(() => {
if (!token || isPinLocked) return; const accessToken = getAccessToken() ?? token?.trim() ?? null;
if (!accessToken || isPinLocked || authLoading) return;
void loadPresence(); void loadPresence();
const timer = window.setInterval(() => void loadPresence(), 25_000); const timer = window.setInterval(() => void loadPresence(), 25_000);
return () => window.clearInterval(timer); return () => window.clearInterval(timer);
}, [connected, isPinLocked, loadPresence, token]); }, [authLoading, connected, isPinLocked, loadPresence, token]);
useEffect(() => { useEffect(() => {
if (!activeRoomId || activeRoomIsBot) return; if (!activeRoomId || activeRoomIsBot) return;
@@ -1699,7 +1710,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
> >
{activeRoom ? ( {activeRoom ? (
<> <>
<div className="flex items-center justify-between gap-2 border-b border-[#dce3ec] bg-white px-3 py-3 sm:px-4 lg:pr-48"> <div className={cn('flex items-center justify-between gap-2 border-b border-[#dce3ec] bg-white px-3 py-3 sm:px-4', DESKTOP_CHAT_RIGHT_GUTTER)}>
<div className="flex min-w-0 flex-1 items-center gap-2 sm:gap-3"> <div className="flex min-w-0 flex-1 items-center gap-2 sm:gap-3">
<button <button
type="button" type="button"
@@ -1903,6 +1914,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
<ChatPinnedMessageBanner <ChatPinnedMessageBanner
senderName={pinnedMessage.senderName} senderName={pinnedMessage.senderName}
preview={pinnedPreview} preview={pinnedPreview}
className={DESKTOP_CHAT_RIGHT_GUTTER}
onClick={() => { onClick={() => {
if (!scrollToChatMessage(pinnedMessage.id)) { if (!scrollToChatMessage(pinnedMessage.id)) {
showToast('Закреплённое сообщение пока не загружено в ленте'); showToast('Закреплённое сообщение пока не загружено в ленте');
@@ -1910,7 +1922,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
}} }}
/> />
) : null} ) : null}
<div className={cn('flex-1 space-y-2 overflow-y-auto px-4 py-4', isDragging && 'select-none')}> <div className={cn('flex-1 space-y-2 overflow-y-auto px-4 py-4', DESKTOP_CHAT_RIGHT_GUTTER, DESKTOP_CHAT_TOP_GUTTER, isDragging && 'select-none')}>
{visibleMessages.map((message, messageIndex) => { {visibleMessages.map((message, messageIndex) => {
if (shouldSkipGroupedAlbumMessage(visibleMessages, messageIndex)) { if (shouldSkipGroupedAlbumMessage(visibleMessages, messageIndex)) {
return null; return null;
@@ -2055,10 +2067,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
} }
if (!media?.blobUrl || media.status === 'loading') { if (!media?.blobUrl || media.status === 'loading') {
return ( return (
<div className="flex items-center gap-2 text-sm text-[#667085]"> <MediaImageSkeleton className="max-h-72 w-full max-w-[320px]" rounded="xl" />
<Loader2 className="h-4 w-4 animate-spin" />
Загрузка файла...
</div>
); );
} }
if (message.type === 'IMAGE') { if (message.type === 'IMAGE') {
@@ -2256,7 +2265,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
onDelete={() => void handleBulkDelete()} onDelete={() => void handleBulkDelete()}
/> />
) : ( ) : (
<div className="border-t border-[#dce3ec] bg-white px-2 py-2 pb-[max(0.5rem,env(safe-area-inset-bottom))] sm:px-3 sm:py-3"> <div className={cn('border-t border-[#dce3ec] bg-white px-2 py-2 pb-[max(0.5rem,env(safe-area-inset-bottom))] sm:px-3 sm:py-3', DESKTOP_CHAT_RIGHT_GUTTER)}>
{editingMessageId ? ( {editingMessageId ? (
<ChatMessageEditBanner <ChatMessageEditBanner
preview={draft.trim() || messages.find((item) => item.id === editingMessageId)?.content || undefined} preview={draft.trim() || messages.find((item) => item.id === editingMessageId)?.content || undefined}

View File

@@ -3,11 +3,14 @@
import { Camera, Loader2 } from 'lucide-react'; import { Camera, Loader2 } from 'lucide-react';
import { useId } from 'react'; import { useId } from 'react';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';
import { useResilientBlobUrl } from '@/hooks/use-resilient-blob-url';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
interface HoverUploadAvatarProps { interface HoverUploadAvatarProps {
name: string; name: string;
imageUrl?: string | null; imageUrl?: string | null;
imageToken?: string | null;
uploading?: boolean; uploading?: boolean;
onFileSelect: (file: File) => void; onFileSelect: (file: File) => void;
className?: string; className?: string;
@@ -17,12 +20,18 @@ interface HoverUploadAvatarProps {
export function HoverUploadAvatar({ export function HoverUploadAvatar({
name, name,
imageUrl, imageUrl,
imageToken = null,
uploading = false, uploading = false,
onFileSelect, onFileSelect,
className, className,
fallbackClassName fallbackClassName
}: HoverUploadAvatarProps) { }: HoverUploadAvatarProps) {
const inputId = useId(); const inputId = useId();
const { blobUrl, isLoading } = useResilientBlobUrl(imageUrl, imageToken, {
enabled: Boolean(imageUrl && imageToken),
label: `аватар ${name}`
});
const resolvedUrl = blobUrl ?? (imageToken ? null : imageUrl);
return ( return (
<label <label
@@ -34,8 +43,16 @@ export function HoverUploadAvatar({
)} )}
> >
<Avatar className="h-full w-full"> <Avatar className="h-full w-full">
{imageUrl ? <AvatarImage src={imageUrl} alt={name} /> : null} {isLoading && imageUrl ? (
<AvatarFallback className={fallbackClassName}>{name.slice(0, 2).toUpperCase()}</AvatarFallback> <AvatarFallback className="bg-transparent p-0">
<Skeleton className="h-full w-full rounded-full" />
</AvatarFallback>
) : (
<>
{resolvedUrl ? <AvatarImage src={resolvedUrl} alt={name} /> : null}
<AvatarFallback className={fallbackClassName}>{name.slice(0, 2).toUpperCase()}</AvatarFallback>
</>
)}
</Avatar> </Avatar>
<span <span
className={cn( className={cn(

View File

@@ -13,9 +13,10 @@ import {
AuthTokens, AuthTokens,
ensureApiGatewayReady, ensureApiGatewayReady,
fetchAuthSession, fetchAuthSession,
getDeviceFingerprint, buildAuthDevicePayload,
IdentifyResponse, IdentifyResponse,
getApiErrorMessage, getApiErrorMessage,
isApiGatewayReady,
isPinRequiredError, isPinRequiredError,
OtpSendResponse, OtpSendResponse,
PasswordlessAuthResponse, PasswordlessAuthResponse,
@@ -93,10 +94,9 @@ function applySessionState(
setters.activatePinLock(session.sessionId ?? ''); setters.activatePinLock(session.sessionId ?? '');
} else { } else {
setters.clearPinLock(); setters.clearPinLock();
const accessToken = window.localStorage.getItem(AUTH_TOKEN_KEY); void ensureApiGatewayReady().then(() => {
if (accessToken) { void syncFedcmSession();
void syncFedcmSession(accessToken); });
}
} }
} }
@@ -169,10 +169,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
void ensureApiGatewayReady().finally(() => { void ensureApiGatewayReady().finally(() => {
initialBootstrapDoneRef.current = true; initialBootstrapDoneRef.current = true;
setIsLoading(false); setIsLoading(false);
if (auth.pinVerified !== false) {
void syncFedcmSession(auth.accessToken);
}
}); });
if (auth.pinVerified !== false) {
void syncFedcmSession(auth.accessToken);
}
}, },
[clearPinLock] [clearPinLock]
); );
@@ -277,10 +277,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
window.localStorage.getItem(AUTH_TOKEN_KEY) || window.localStorage.getItem(AUTH_REFRESH_KEY) window.localStorage.getItem(AUTH_TOKEN_KEY) || window.localStorage.getItem(AUTH_REFRESH_KEY)
); );
if (hasSession && isInitialBootstrap) { if (hasSession && isInitialBootstrap) {
try { const bootstrapDeadline = Date.now() + 120_000;
await ensureApiGatewayReady(); while (!isApiGatewayReady() && Date.now() < bootstrapDeadline) {
} catch { await ensureApiGatewayReady(true);
// Продолжаем даже если gateway не ответил в срок прогрева. if (isApiGatewayReady()) break;
await new Promise((resolve) => setTimeout(resolve, 800));
} }
} }
if (isInitialBootstrap) { if (isInitialBootstrap) {
@@ -339,9 +340,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
body: JSON.stringify({ body: JSON.stringify({
login: loginValue, login: loginValue,
password, password,
fingerprint: getDeviceFingerprint(), ...buildAuthDevicePayload(),
deviceName: navigator.userAgent.includes('Windows') ? 'Windows Browser' : 'Browser',
deviceType: 'WEB'
}) })
}); });
if (auth.pinVerified) { if (auth.pinVerified) {
@@ -376,9 +375,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
body: JSON.stringify({ body: JSON.stringify({
recipient, recipient,
code, code,
fingerprint: getDeviceFingerprint(), ...buildAuthDevicePayload(),
deviceName: navigator.userAgent.includes('Windows') ? 'Windows Browser' : 'Browser',
deviceType: 'WEB'
}) })
}); });
if (response.auth?.pinVerified) { if (response.auth?.pinVerified) {
@@ -399,9 +396,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
body: JSON.stringify({ body: JSON.stringify({
login: loginValue, login: loginValue,
password: passwordValue, password: passwordValue,
fingerprint: getDeviceFingerprint(), ...buildAuthDevicePayload(),
deviceName: navigator.userAgent.includes('Windows') ? 'Windows Browser' : 'Browser',
deviceType: 'WEB'
}) })
}); });
if (auth.pinVerified) { if (auth.pinVerified) {
@@ -422,9 +417,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
body: JSON.stringify({ body: JSON.stringify({
username, username,
password: passwordValue, password: passwordValue,
fingerprint: getDeviceFingerprint(), ...buildAuthDevicePayload(),
deviceName: navigator.userAgent.includes('Windows') ? 'Windows Browser' : 'Browser',
deviceType: 'WEB'
}) })
}); });
if (auth.pinVerified) { if (auth.pinVerified) {
@@ -443,9 +436,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
recipient, recipient,
fingerprint: getDeviceFingerprint(), ...buildAuthDevicePayload(),
deviceName: navigator.userAgent.includes('Windows') ? 'Windows Browser' : 'Browser',
deviceType: 'WEB'
}) })
}); });
return response.totpChallengeToken; return response.totpChallengeToken;

View File

@@ -3,6 +3,7 @@
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { Camera, Loader2 } from 'lucide-react'; import { Camera, Loader2 } from 'lucide-react';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';
import { useToast } from '@/components/id/toast-provider'; import { useToast } from '@/components/id/toast-provider';
import { useAvatarUrl } from '@/hooks/use-avatar-url'; import { useAvatarUrl } from '@/hooks/use-avatar-url';
import { apiFetch, getApiErrorMessage, uploadMediaObject } from '@/lib/api'; import { apiFetch, getApiErrorMessage, uploadMediaObject } from '@/lib/api';
@@ -41,7 +42,7 @@ export function AvatarUpload({
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const { showToast } = useToast(); const { showToast } = useToast();
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const { avatarUrl, refreshAvatarUrl } = useAvatarUrl(userId, hasAvatar, token); const { avatarUrl, isAvatarLoading, refreshAvatarUrl } = useAvatarUrl(userId, hasAvatar, token);
const initials = (displayName ?? '').slice(0, 2).toUpperCase() || 'ID'; const initials = (displayName ?? '').slice(0, 2).toUpperCase() || 'ID';
async function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) { async function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
@@ -99,8 +100,16 @@ export function AvatarUpload({
onClick={openFilePicker} onClick={openFilePicker}
> >
<Avatar className={cn('border-4 border-white shadow-xl', className)}> <Avatar className={cn('border-4 border-white shadow-xl', className)}>
{avatarUrl ? <AvatarImage src={avatarUrl} alt={displayName ?? 'Аватар'} /> : null} {isAvatarLoading && hasAvatar ? (
<AvatarFallback className="bg-[linear-gradient(135deg,#6f58ff,#ffd2a8)] text-2xl">{initials}</AvatarFallback> <AvatarFallback className="bg-transparent p-0">
<Skeleton className="h-full w-full rounded-full" />
</AvatarFallback>
) : (
<>
{avatarUrl ? <AvatarImage src={avatarUrl} alt={displayName ?? 'Аватар'} /> : null}
<AvatarFallback className="bg-[linear-gradient(135deg,#6f58ff,#ffd2a8)] text-2xl">{initials}</AvatarFallback>
</>
)}
</Avatar> </Avatar>
<span <span
className={cn( className={cn(
@@ -146,14 +155,22 @@ export function AvatarDisplay({
verificationIcon?: string | null; verificationIcon?: string | null;
badgeSize?: 'xs' | 'sm' | 'md' | 'lg'; badgeSize?: 'xs' | 'sm' | 'md' | 'lg';
}) { }) {
const { avatarUrl } = useAvatarUrl(userId, hasAvatar, token); const { avatarUrl, isAvatarLoading } = useAvatarUrl(userId, hasAvatar, token);
const initials = (displayName ?? '').slice(0, 2).toUpperCase() || 'ID'; const initials = (displayName ?? '').slice(0, 2).toUpperCase() || 'ID';
return ( return (
<div className="relative inline-flex shrink-0"> <div className="relative inline-flex shrink-0">
<Avatar className={className}> <Avatar className={className}>
{avatarUrl ? <AvatarImage src={avatarUrl} alt={displayName ?? 'Аватар'} /> : null} {isAvatarLoading && hasAvatar ? (
<AvatarFallback className="bg-[linear-gradient(135deg,#6f58ff,#ffd2a8)]">{initials}</AvatarFallback> <AvatarFallback className="bg-transparent p-0">
<Skeleton className="h-full w-full rounded-full" />
</AvatarFallback>
) : (
<>
{avatarUrl ? <AvatarImage src={avatarUrl} alt={displayName ?? 'Аватар'} /> : null}
<AvatarFallback className="bg-[linear-gradient(135deg,#6f58ff,#ffd2a8)]">{initials}</AvatarFallback>
</>
)}
</Avatar> </Avatar>
{isVerified ? ( {isVerified ? (
<VerificationBadge <VerificationBadge

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';
import { VerificationBadge } from '@/components/id/verification-badge'; import { VerificationBadge } from '@/components/id/verification-badge';
import { useAvatarUrl } from '@/hooks/use-avatar-url'; import { useAvatarUrl } from '@/hooks/use-avatar-url';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -26,7 +27,7 @@ export function UserAvatar({
badgeSize?: 'xs' | 'sm' | 'md' | 'lg'; badgeSize?: 'xs' | 'sm' | 'md' | 'lg';
fallbackClassName?: string; fallbackClassName?: string;
}) { }) {
const { avatarUrl } = useAvatarUrl(userId, hasAvatar, token); const { avatarUrl, isAvatarLoading } = useAvatarUrl(userId, hasAvatar, token);
const initials = (displayName ?? '') const initials = (displayName ?? '')
.split(' ') .split(' ')
.map((part) => part[0]) .map((part) => part[0])
@@ -37,10 +38,18 @@ export function UserAvatar({
return ( return (
<div className="relative inline-flex shrink-0"> <div className="relative inline-flex shrink-0">
<Avatar className={className}> <Avatar className={className}>
{avatarUrl ? <AvatarImage src={avatarUrl} alt={displayName ?? 'Аватар'} /> : null} {isAvatarLoading && hasAvatar ? (
<AvatarFallback className={cn('bg-[linear-gradient(135deg,#6f58ff,#ffd2a8)] text-xs font-semibold', fallbackClassName)}> <AvatarFallback className="bg-transparent p-0">
{initials.slice(0, 2)} <Skeleton className="h-full w-full rounded-full" />
</AvatarFallback> </AvatarFallback>
) : (
<>
{avatarUrl ? <AvatarImage src={avatarUrl} alt={displayName ?? 'Аватар'} /> : null}
<AvatarFallback className={cn('bg-[linear-gradient(135deg,#6f58ff,#ffd2a8)] text-xs font-semibold', fallbackClassName)}>
{initials.slice(0, 2)}
</AvatarFallback>
</>
)}
</Avatar> </Avatar>
{isVerified ? ( {isVerified ? (
<VerificationBadge <VerificationBadge

View File

@@ -101,6 +101,7 @@ export function RealtimeProvider({ children }: { children: React.ReactNode }) {
data.type === 'family_invite' || data.type === 'family_invite' ||
data.type === 'role_assigned' || data.type === 'role_assigned' ||
data.type === 'role_removed' || data.type === 'role_removed' ||
data.type === 'login_success' ||
(data.type === 'chat_message' && data.payload?.notificationId) (data.type === 'chat_message' && data.payload?.notificationId)
) { ) {
setUnreadCount((count) => count + 1); setUnreadCount((count) => count + 1);

View File

@@ -0,0 +1,22 @@
'use client';
import { cn } from '@/lib/utils';
import { Skeleton } from '@/components/ui/skeleton';
export function MediaImageSkeleton({
className,
rounded = 'xl'
}: {
className?: string;
rounded?: 'xl' | '2xl' | 'none' | 'full';
}) {
const roundedClass =
rounded === '2xl' ? 'rounded-2xl' : rounded === 'full' ? 'rounded-full' : rounded === 'none' ? 'rounded-none' : 'rounded-xl';
return (
<Skeleton
className={cn('relative min-h-[120px] w-full overflow-hidden', roundedClass, className)}
aria-hidden
/>
);
}

View File

@@ -0,0 +1,12 @@
'use client';
import { cn } from '@/lib/utils';
export function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
className={cn('animate-pulse rounded-md bg-gradient-to-r from-[#e8ebf0] via-[#f4f6f9] to-[#e8ebf0] bg-[length:200%_100%]', className)}
{...props}
/>
);
}

View File

@@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from 'react';
import { useAuth } from '@/components/id/auth-provider'; import { useAuth } from '@/components/id/auth-provider';
import { apiFetch } from '@/lib/api'; import { apiFetch } from '@/lib/api';
import { AVATAR_UPDATED_EVENT } from '@/lib/avatar-events'; import { AVATAR_UPDATED_EVENT } from '@/lib/avatar-events';
import { useResilientBlobUrl } from '@/hooks/use-resilient-blob-url';
interface AvatarAccessResponse { interface AvatarAccessResponse {
accessUrl: string; accessUrl: string;
@@ -12,41 +13,61 @@ interface AvatarAccessResponse {
export function useAvatarUrl(userId: string | undefined, hasAvatar: boolean | undefined, token: string | null) { export function useAvatarUrl(userId: string | undefined, hasAvatar: boolean | undefined, token: string | null) {
const { isPinLocked, isLoading } = useAuth(); const { isPinLocked, isLoading } = useAuth();
const [avatarUrl, setAvatarUrl] = useState<string | null>(null); const [accessUrl, setAccessUrl] = useState<string | null>(null);
const [resolvingAccessUrl, setResolvingAccessUrl] = useState(false);
const refresh = useCallback(async () => { const refreshAccessUrl = useCallback(async () => {
if (!userId || !token || !hasAvatar || isPinLocked || isLoading) { if (!userId || !token || !hasAvatar || isPinLocked || isLoading) {
setAvatarUrl(null); setAccessUrl(null);
setResolvingAccessUrl(false);
return; return;
} }
setResolvingAccessUrl(true);
try { try {
const response = await apiFetch<AvatarAccessResponse>(`/media/avatars/${userId}/url`, {}, token); const response = await apiFetch<AvatarAccessResponse>(`/media/avatars/${userId}/url`, {}, token);
setAvatarUrl(response.accessUrl); setAccessUrl(response.accessUrl);
} catch { } catch {
setAvatarUrl(null); setAccessUrl(null);
} finally {
setResolvingAccessUrl(false);
} }
}, [hasAvatar, isLoading, isPinLocked, token, userId]); }, [hasAvatar, isLoading, isPinLocked, token, userId]);
useEffect(() => { useEffect(() => {
void refresh(); void refreshAccessUrl();
if (!hasAvatar) return; if (!hasAvatar) return;
const timer = window.setInterval(() => { const timer = window.setInterval(() => {
void refresh(); void refreshAccessUrl();
}, 14 * 60 * 1000); }, 14 * 60 * 1000);
return () => window.clearInterval(timer); return () => window.clearInterval(timer);
}, [hasAvatar, refresh]); }, [hasAvatar, refreshAccessUrl]);
useEffect(() => { useEffect(() => {
if (!userId || !hasAvatar) return; if (!userId || !hasAvatar) return;
function handleAvatarUpdated(event: Event) { function handleAvatarUpdated(event: Event) {
const detail = (event as CustomEvent<{ userId?: string }>).detail; const detail = (event as CustomEvent<{ userId?: string }>).detail;
if (detail?.userId === userId) { if (detail?.userId === userId) {
void refresh(); void refreshAccessUrl();
} }
} }
window.addEventListener(AVATAR_UPDATED_EVENT, handleAvatarUpdated); window.addEventListener(AVATAR_UPDATED_EVENT, handleAvatarUpdated);
return () => window.removeEventListener(AVATAR_UPDATED_EVENT, handleAvatarUpdated); return () => window.removeEventListener(AVATAR_UPDATED_EVENT, handleAvatarUpdated);
}, [hasAvatar, refresh, userId]); }, [hasAvatar, refreshAccessUrl, userId]);
return { avatarUrl, refreshAvatarUrl: refresh }; const {
blobUrl,
isLoading: isLoadingBlob,
hasError
} = useResilientBlobUrl(accessUrl, token, {
enabled: Boolean(hasAvatar && accessUrl && !isPinLocked && !isLoading),
label: `аватар ${userId ?? ''}`.trim()
});
const avatarUrl = blobUrl;
const isAvatarLoading = Boolean(hasAvatar && (resolvingAccessUrl || isLoadingBlob || (accessUrl && !blobUrl && !hasError)));
const refreshAvatarUrl = useCallback(async () => {
await refreshAccessUrl();
}, [refreshAccessUrl]);
return { avatarUrl, isAvatarLoading, hasAvatarError: hasError, refreshAvatarUrl };
} }

View File

@@ -8,7 +8,8 @@ import {
FamilyPresenceMember, FamilyPresenceMember,
fetchFamilyGroup, fetchFamilyGroup,
fetchFamilyGroups, fetchFamilyGroups,
fetchFamilyPresence fetchFamilyPresence,
getAccessToken
} from '@/lib/api'; } from '@/lib/api';
export function useSelectedFamily(enabled = true) { export function useSelectedFamily(enabled = true) {
@@ -20,7 +21,8 @@ export function useSelectedFamily(enabled = true) {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
if (!enabled || !user || !token || isPinLocked || isLoading) { const accessToken = getAccessToken() ?? token?.trim() ?? null;
if (!enabled || !user || !accessToken || isPinLocked || isLoading) {
setGroups([]); setGroups([]);
setGroup(null); setGroup(null);
setPresenceMembers([]); setPresenceMembers([]);
@@ -29,7 +31,7 @@ export function useSelectedFamily(enabled = true) {
} }
try { try {
const groupsResponse = await fetchFamilyGroups(user.id, token); const groupsResponse = await fetchFamilyGroups(user.id, accessToken);
const list = groupsResponse.groups ?? []; const list = groupsResponse.groups ?? [];
setGroups(list); setGroups(list);
@@ -48,8 +50,8 @@ export function useSelectedFamily(enabled = true) {
} }
const [groupResponse, presenceResponse] = await Promise.all([ const [groupResponse, presenceResponse] = await Promise.all([
fetchFamilyGroup(effectiveId, token), fetchFamilyGroup(effectiveId, accessToken),
fetchFamilyPresence(effectiveId, token) fetchFamilyPresence(effectiveId, accessToken)
]); ]);
setGroup(groupResponse); setGroup(groupResponse);
setPresenceMembers(presenceResponse.members ?? []); setPresenceMembers(presenceResponse.members ?? []);
@@ -68,7 +70,8 @@ export function useSelectedFamily(enabled = true) {
}, [refresh]); }, [refresh]);
useEffect(() => { useEffect(() => {
if (!enabled || !token || isPinLocked || isLoading || !group) return; const accessToken = getAccessToken() ?? token?.trim() ?? null;
if (!enabled || !accessToken || isPinLocked || isLoading || !group) return;
const timer = window.setInterval(() => void refresh(), 25_000); const timer = window.setInterval(() => void refresh(), 25_000);
return () => window.clearInterval(timer); return () => window.clearInterval(timer);
}, [enabled, group, isLoading, isPinLocked, refresh, token]); }, [enabled, group, isLoading, isPinLocked, refresh, token]);

View File

@@ -0,0 +1,60 @@
'use client';
import { useEffect, useState } from 'react';
import { createBlobObjectUrl, revokeBlobObjectUrl } from '@/lib/authenticated-media';
import { fetchMediaBlobWithRetry } from '@/lib/resilient-media-fetch';
export type ResilientBlobStatus = 'idle' | 'loading' | 'ready' | 'error';
export function useResilientBlobUrl(
remoteUrl: string | null | undefined,
token: string | null | undefined,
options?: { enabled?: boolean; label?: string; mimeType?: string }
) {
const [blobUrl, setBlobUrl] = useState<string | null>(null);
const [status, setStatus] = useState<ResilientBlobStatus>('idle');
useEffect(() => {
const enabled = options?.enabled !== false;
if (!remoteUrl || !token || !enabled) {
setBlobUrl(null);
setStatus('idle');
return;
}
let cancelled = false;
let objectUrl: string | null = null;
async function load() {
setStatus('loading');
try {
const blob = await fetchMediaBlobWithRetry(remoteUrl!, token!, {
mimeType: options?.mimeType,
label: options?.label
});
if (cancelled) return;
objectUrl = createBlobObjectUrl(blob);
setBlobUrl(objectUrl);
setStatus('ready');
} catch {
if (cancelled) return;
setBlobUrl(null);
setStatus('error');
}
}
void load();
return () => {
cancelled = true;
revokeBlobObjectUrl(objectUrl ?? undefined);
};
}, [options?.enabled, options?.label, options?.mimeType, remoteUrl, token]);
return {
blobUrl,
status,
isLoading: status === 'loading',
hasError: status === 'error'
};
}

View File

@@ -219,13 +219,14 @@ export interface QrLoginSession {
auth?: AuthTokens & { user?: PublicUser }; auth?: AuthTokens & { user?: PublicUser };
} }
export async function createQrLoginSession(deviceName: string) { export async function createQrLoginSession(deviceName?: string) {
const { buildAuthDevicePayload } = await import('@/lib/device-client');
const device = buildAuthDevicePayload();
return apiFetch<QrLoginSession>('/auth/advanced/qr/session', { return apiFetch<QrLoginSession>('/auth/advanced/qr/session', {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
deviceName, ...device,
fingerprint: getDeviceFingerprint(), deviceName: deviceName?.trim() || device.deviceName
deviceType: 'WEB'
}) })
}); });
} }
@@ -368,6 +369,19 @@ export const AUTH_REFRESH_KEY = 'lendry_refresh_token';
export const AUTH_SESSION_KEY = 'lendry_session_id'; export const AUTH_SESSION_KEY = 'lendry_session_id';
export const AUTH_USER_CACHE_KEY = 'lendry_cached_user'; export const AUTH_USER_CACHE_KEY = 'lendry_cached_user';
/** Актуальный access token из localStorage (не полагается на React state). */
export function getAccessToken(): string | null {
if (typeof window === 'undefined') return null;
const value = window.localStorage.getItem(AUTH_TOKEN_KEY)?.trim();
return value || null;
}
function resolveAccessToken(explicit?: string | null): string | null {
const trimmed = explicit?.trim();
if (trimmed) return trimmed;
return getAccessToken();
}
export class ApiError extends Error { export class ApiError extends Error {
status: number; status: number;
code?: string; code?: string;
@@ -441,6 +455,7 @@ export function isGatewayUnavailableError(error: unknown): boolean {
export function getApiErrorMessage(error: unknown, fallback: string): string | null { export function getApiErrorMessage(error: unknown, fallback: string): string | null {
if (isPinRequiredError(error)) return null; if (isPinRequiredError(error)) return null;
if (isGatewayUnavailableError(error)) return null; if (isGatewayUnavailableError(error)) return null;
if (error instanceof ApiError && error.code === 'NO_TOKEN') return null;
if (error instanceof ApiError && error.status === 403 && error.message.toLowerCase().includes('pin')) { if (error instanceof ApiError && error.status === 403 && error.message.toLowerCase().includes('pin')) {
return null; return null;
} }
@@ -526,15 +541,18 @@ export async function refreshAuthSession(): Promise<RefreshSessionResponse> {
return response.json() as Promise<RefreshSessionResponse>; return response.json() as Promise<RefreshSessionResponse>;
} }
export async function syncFedcmSession(accessToken: string) { export async function syncFedcmSession(accessToken?: string | null) {
if (typeof window === 'undefined' || !accessToken.trim()) return; const token = resolveAccessToken(accessToken);
if (typeof window === 'undefined' || !token) return;
try { try {
await ensureApiGatewayReady(); await apiFetch<{ synced: boolean }>(
await fetchWithGatewayRetry(`${getApiUrl()}/fedcm/session/sync`, { '/fedcm/session/sync',
method: 'POST', {
headers: { Authorization: `Bearer ${accessToken.trim()}` }, method: 'POST',
credentials: 'include' credentials: 'include'
}); },
token
);
} catch { } catch {
// фоновая синхронизация FedCM cookie // фоновая синхронизация FedCM cookie
} }
@@ -602,6 +620,7 @@ export interface SignInEvent {
reason?: string | null; reason?: string | null;
ipAddress?: string | null; ipAddress?: string | null;
userAgent?: string | null; userAgent?: string | null;
deviceName?: string | null;
createdAt: string; createdAt: string;
} }
@@ -609,6 +628,8 @@ export interface ActiveSession {
id: string; id: string;
userId: string; userId: string;
deviceId?: string | null; deviceId?: string | null;
deviceName?: string | null;
deviceType?: string | null;
pinVerified: boolean; pinVerified: boolean;
status: string; status: string;
ipAddress?: string | null; ipAddress?: string | null;
@@ -633,10 +654,11 @@ export function buildSystemSettingPayload(setting: Pick<SystemSetting, 'key' | '
} }
const GATEWAY_RETRY_STATUS = new Set([502, 503, 504]); const GATEWAY_RETRY_STATUS = new Set([502, 503, 504]);
const GATEWAY_RETRY_ATTEMPTS = 6; const GATEWAY_RETRY_ATTEMPTS = 8;
const GATEWAY_WARMUP_MAX_ATTEMPTS = 40; const GATEWAY_WARMUP_MAX_ATTEMPTS = 80;
const GATEWAY_WARMUP_DELAY_MS = 300; const GATEWAY_WARMUP_DELAY_MS = 400;
const GATEWAY_READY_CONSECUTIVE_OK = 2; const GATEWAY_READY_CONSECUTIVE_OK = 2;
const GATEWAY_WARMUP_MAX_DELAY_MS = 2500;
function sleep(ms: number): Promise<void> { function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
@@ -650,6 +672,15 @@ export function resetApiGatewayWarmup() {
gatewayReadyPromise = null; gatewayReadyPromise = null;
} }
export function invalidateApiGatewayReady() {
gatewayReadyResolved = false;
gatewayReadyPromise = null;
}
export function isApiGatewayReady() {
return gatewayReadyResolved;
}
export function markApiGatewayReady() { export function markApiGatewayReady() {
gatewayReadyResolved = true; gatewayReadyResolved = true;
} }
@@ -671,8 +702,12 @@ export async function ensureApiGatewayReady(force = false): Promise<void> {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
if (gatewayReadyResolved && !force) return; if (gatewayReadyResolved && !force) return;
if (gatewayReadyPromise && !force) return gatewayReadyPromise; if (gatewayReadyPromise && !force) return gatewayReadyPromise;
if (force) {
gatewayReadyResolved = false;
gatewayReadyPromise = null;
}
gatewayReadyPromise = (async () => { gatewayReadyPromise = (async () => {
await sleep(600);
let consecutiveOk = 0; let consecutiveOk = 0;
for (let attempt = 0; attempt < GATEWAY_WARMUP_MAX_ATTEMPTS; attempt++) { for (let attempt = 0; attempt < GATEWAY_WARMUP_MAX_ATTEMPTS; attempt++) {
if (await probeGatewayReady()) { if (await probeGatewayReady()) {
@@ -684,9 +719,10 @@ export async function ensureApiGatewayReady(force = false): Promise<void> {
} else { } else {
consecutiveOk = 0; consecutiveOk = 0;
} }
await sleep(GATEWAY_WARMUP_DELAY_MS); const delay = Math.min(GATEWAY_WARMUP_DELAY_MS * 1.35 ** attempt, GATEWAY_WARMUP_MAX_DELAY_MS);
await sleep(delay);
} }
gatewayReadyResolved = true; gatewayReadyPromise = null;
})(); })();
return gatewayReadyPromise; return gatewayReadyPromise;
@@ -699,6 +735,8 @@ async function fetchWithGatewayRetry(url: string, init: RequestInit): Promise<Re
try { try {
const response = await fetch(url, init); const response = await fetch(url, init);
if (GATEWAY_RETRY_STATUS.has(response.status) && attempt + 1 < GATEWAY_RETRY_ATTEMPTS) { if (GATEWAY_RETRY_STATUS.has(response.status) && attempt + 1 < GATEWAY_RETRY_ATTEMPTS) {
invalidateApiGatewayReady();
await ensureApiGatewayReady(true);
await sleep(400 * (attempt + 1)); await sleep(400 * (attempt + 1));
continue; continue;
} }
@@ -706,6 +744,8 @@ async function fetchWithGatewayRetry(url: string, init: RequestInit): Promise<Re
} catch (error) { } catch (error) {
lastNetworkError = error; lastNetworkError = error;
if (attempt + 1 < GATEWAY_RETRY_ATTEMPTS) { if (attempt + 1 < GATEWAY_RETRY_ATTEMPTS) {
invalidateApiGatewayReady();
await ensureApiGatewayReady(true);
await sleep(400 * (attempt + 1)); await sleep(400 * (attempt + 1));
continue; continue;
} }
@@ -716,11 +756,11 @@ async function fetchWithGatewayRetry(url: string, init: RequestInit): Promise<Re
} }
export async function apiFetch<T>(path: string, options: RequestInit = {}, token?: string | null, allowRetry = true): Promise<T> { export async function apiFetch<T>(path: string, options: RequestInit = {}, token?: string | null, allowRetry = true): Promise<T> {
if (typeof window !== 'undefined' && path !== '/health' && !path.startsWith('/health?')) { if (typeof window !== 'undefined' && !path.startsWith('/health')) {
await ensureApiGatewayReady(); await ensureApiGatewayReady();
} }
const resolvedToken = token ?? (typeof window !== 'undefined' ? window.localStorage.getItem(AUTH_TOKEN_KEY) : null); const resolvedToken = resolveAccessToken(token);
const method = (options.method ?? 'GET').toUpperCase(); const method = (options.method ?? 'GET').toUpperCase();
const hasBody = options.body !== undefined && options.body !== null; const hasBody = options.body !== undefined && options.body !== null;
const headers: Record<string, string> = { const headers: Record<string, string> = {
@@ -762,21 +802,19 @@ export async function apiFetch<T>(path: string, options: RequestInit = {}, token
} }
} }
if (allowRetry && isGatewayUnavailableError(error)) {
invalidateApiGatewayReady();
await ensureApiGatewayReady(true);
return apiFetch<T>(path, options, token, false);
}
throw error; throw error;
} }
return response.json() as Promise<T>; return response.json() as Promise<T>;
} }
export function getDeviceFingerprint(): string { export { getDeviceFingerprint, buildAuthDevicePayload, resolveDeviceLabel, formatUserAgentLabel } from '@/lib/device-client';
if (typeof window === 'undefined') return 'server';
const key = 'lendry_device_fingerprint';
const existing = window.localStorage.getItem(key);
if (existing) return existing;
const value = crypto.randomUUID();
window.localStorage.setItem(key, value);
return value;
}
export interface MediaAccessResponse { export interface MediaAccessResponse {
accessUrl: string; accessUrl: string;
@@ -1022,7 +1060,11 @@ export interface ChatRoom {
} }
export async function fetchFamilyGroups(userId: string, token?: string | null) { export async function fetchFamilyGroups(userId: string, token?: string | null) {
return apiFetch<{ groups?: FamilyGroup[] }>(`/family/users/${userId}/groups`, {}, token); const accessToken = resolveAccessToken(token);
if (!accessToken) {
throw new ApiError('Не авторизован', 401, 'NO_TOKEN');
}
return apiFetch<{ groups?: FamilyGroup[] }>(`/family/users/${userId}/groups`, {}, accessToken);
} }
export async function fetchFamilyGroup(groupId: string, token?: string | null) { export async function fetchFamilyGroup(groupId: string, token?: string | null) {
@@ -1339,7 +1381,11 @@ export async function reportChatTyping(roomId: string, token?: string | null) {
} }
export async function fetchFamilyPresence(groupId: string, token?: string | null) { export async function fetchFamilyPresence(groupId: string, token?: string | null) {
return apiFetch<FamilyPresence>(`/family/groups/${groupId}/presence`, {}, token); const accessToken = resolveAccessToken(token);
if (!accessToken) {
throw new ApiError('Не авторизован', 401, 'NO_TOKEN');
}
return apiFetch<FamilyPresence>(`/family/groups/${groupId}/presence`, {}, accessToken);
} }
/** @deprecated Используйте getWsUrl() — URL может переключаться на same-origin /idp-api/ws в браузере */ /** @deprecated Используйте getWsUrl() — URL может переключаться на same-origin /idp-api/ws в браузере */

View File

@@ -1,15 +1,7 @@
import { fetchMediaBlobWithRetry } from '@/lib/resilient-media-fetch';
export async function fetchAuthenticatedMediaBlob(accessUrl: string, token: string, mimeType?: string) { export async function fetchAuthenticatedMediaBlob(accessUrl: string, token: string, mimeType?: string) {
const response = await fetch(accessUrl, { return fetchMediaBlobWithRetry(accessUrl, token, { mimeType, label: 'файл чата' });
headers: { Authorization: `Bearer ${token}` }
});
if (!response.ok) {
throw new Error('Не удалось загрузить файл');
}
const raw = await response.blob();
if (mimeType && (!raw.type || raw.type === 'application/octet-stream')) {
return new Blob([await raw.arrayBuffer()], { type: mimeType });
}
return raw;
} }
export function createBlobObjectUrl(blob: Blob) { export function createBlobObjectUrl(blob: Blob) {

View File

@@ -0,0 +1,81 @@
export function getDeviceFingerprint(): string {
if (typeof window === 'undefined') return 'server';
const key = 'lendry_device_fingerprint';
const existing = window.localStorage.getItem(key);
if (existing) return existing;
const value = crypto.randomUUID();
window.localStorage.setItem(key, value);
return value;
}
export function resolveDeviceType(): string {
if (typeof navigator === 'undefined') return 'WEB';
const ua = navigator.userAgent;
if (/Android/i.test(ua)) return 'ANDROID';
if (/iPhone|iPad|iPod/i.test(ua)) return 'IOS';
if (/Windows/i.test(ua)) return 'DESKTOP';
if (/Mac OS X/i.test(ua)) return 'DESKTOP';
return 'WEB';
}
export function resolveDeviceLabel(): string {
if (typeof navigator === 'undefined') return 'Browser';
const ua = navigator.userAgent;
const browser = (() => {
if (/Edg\//i.test(ua)) return 'Microsoft Edge';
if (/YaBrowser/i.test(ua)) return 'Яндекс Браузер';
if (/Firefox\//i.test(ua)) return 'Firefox';
if (/CriOS/i.test(ua)) return 'Chrome';
if (/Chrome\//i.test(ua) && !/Edg\//i.test(ua)) return 'Chrome';
if (/Safari/i.test(ua) && !/Chrome/i.test(ua)) return 'Safari';
return 'Браузер';
})();
const os = (() => {
if (/Windows NT/i.test(ua)) return 'Windows';
if (/Android/i.test(ua)) return 'Android';
if (/iPhone/i.test(ua)) return 'iPhone';
if (/iPad/i.test(ua)) return 'iPad';
if (/Mac OS X/i.test(ua)) return 'macOS';
if (/Linux/i.test(ua)) return 'Linux';
return 'Устройство';
})();
return `${browser} · ${os}`;
}
export function buildAuthDevicePayload() {
return {
fingerprint: getDeviceFingerprint(),
deviceName: resolveDeviceLabel(),
deviceType: resolveDeviceType()
};
}
export function formatUserAgentLabel(userAgent?: string | null): string | null {
if (!userAgent?.trim()) return null;
const ua = userAgent.trim();
const browser = (() => {
if (/Edg\//i.test(ua)) return 'Microsoft Edge';
if (/YaBrowser/i.test(ua)) return 'Яндекс Браузер';
if (/Firefox\//i.test(ua)) return 'Firefox';
if (/Chrome\//i.test(ua) && !/Edg\//i.test(ua)) return 'Chrome';
if (/Safari/i.test(ua) && !/Chrome/i.test(ua)) return 'Safari';
return null;
})();
const os = (() => {
if (/Windows NT/i.test(ua)) return 'Windows';
if (/Android/i.test(ua)) return 'Android';
if (/iPhone/i.test(ua)) return 'iPhone';
if (/iPad/i.test(ua)) return 'iPad';
if (/Mac OS X/i.test(ua)) return 'macOS';
if (/Linux/i.test(ua)) return 'Linux';
return null;
})();
if (browser && os) return `${browser} · ${os}`;
return browser ?? os;
}

View File

@@ -0,0 +1,60 @@
import { ensureApiGatewayReady, invalidateApiGatewayReady } from '@/lib/api';
const GATEWAY_RETRY_STATUS = new Set([502, 503, 504]);
const MEDIA_FETCH_MAX_ATTEMPTS = 8;
const MEDIA_SLOW_LOAD_WARN_MS = 8_000;
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function warnSlowMediaLoad(label: string, startedAt: number, error: unknown) {
if (Date.now() - startedAt < MEDIA_SLOW_LOAD_WARN_MS) return;
const detail = error instanceof Error ? error.message : 'неизвестная ошибка';
console.warn(`[media] ${label}: не удалось загрузить за ${Math.round((Date.now() - startedAt) / 1000)} с (${detail})`);
}
export async function fetchMediaBlobWithRetry(
accessUrl: string,
token: string,
options?: { mimeType?: string; label?: string }
): Promise<Blob> {
const startedAt = Date.now();
const label = options?.label ?? 'изображение';
let lastError: unknown;
for (let attempt = 0; attempt < MEDIA_FETCH_MAX_ATTEMPTS; attempt += 1) {
try {
await ensureApiGatewayReady();
const response = await fetch(accessUrl, {
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store'
});
if (GATEWAY_RETRY_STATUS.has(response.status) && attempt + 1 < MEDIA_FETCH_MAX_ATTEMPTS) {
invalidateApiGatewayReady();
await sleep(350 * (attempt + 1));
continue;
}
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const raw = await response.blob();
const mimeType = options?.mimeType;
if (mimeType && (!raw.type || raw.type === 'application/octet-stream')) {
return new Blob([await raw.arrayBuffer()], { type: mimeType });
}
return raw;
} catch (error) {
lastError = error;
if (attempt + 1 < MEDIA_FETCH_MAX_ATTEMPTS) {
await sleep(350 * (attempt + 1));
}
}
}
warnSlowMediaLoad(label, startedAt, lastError);
throw lastError instanceof Error ? lastError : new Error('Не удалось загрузить файл');
}

View File

@@ -414,6 +414,7 @@ export class AuthGrpcController {
reason: event.reason, reason: event.reason,
ipAddress: event.ipAddress, ipAddress: event.ipAddress,
userAgent: event.userAgent, userAgent: event.userAgent,
deviceName: event.device?.name ?? null,
createdAt: event.createdAt.toISOString() createdAt: event.createdAt.toISOString()
})) }))
}; };
@@ -1235,7 +1236,19 @@ export class AuthGrpcController {
return this.botApi.listBotChatMessages(command.userId, command.botRef, command.roomId); return this.botApi.listBotChatMessages(command.userId, command.botRef, command.roomId);
} }
private toSessionResponse(session: { id: string; userId: string; deviceId: string | null; pinVerified: boolean; status: string; ipAddress: string | null; userAgent: string | null; expiresAt: Date; createdAt: Date; updatedAt: Date }) { private toSessionResponse(session: {
id: string;
userId: string;
deviceId: string | null;
pinVerified: boolean;
status: string;
ipAddress: string | null;
userAgent: string | null;
expiresAt: Date;
createdAt: Date;
updatedAt: Date;
device?: { name: string; type: string } | null;
}) {
return { return {
id: session.id, id: session.id,
userId: session.userId, userId: session.userId,
@@ -1244,6 +1257,8 @@ export class AuthGrpcController {
status: session.status, status: session.status,
ipAddress: session.ipAddress, ipAddress: session.ipAddress,
userAgent: session.userAgent, userAgent: session.userAgent,
deviceName: session.device?.name ?? null,
deviceType: session.device?.type ?? null,
expiresAt: session.expiresAt.toISOString(), expiresAt: session.expiresAt.toISOString(),
createdAt: session.createdAt.toISOString(), createdAt: session.createdAt.toISOString(),
updatedAt: session.updatedAt.toISOString() updatedAt: session.updatedAt.toISOString()

View File

@@ -14,6 +14,7 @@ import { SettingsService } from './settings.service';
import { MessagingService } from '../infra/messaging.service'; import { MessagingService } from '../infra/messaging.service';
import { NotificationsService } from './notifications.service'; import { NotificationsService } from './notifications.service';
import { canonicalPhoneE164, phoneLookupVariants } from '../infra/phone.util'; import { canonicalPhoneE164, phoneLookupVariants } from '../infra/phone.util';
import { resolveDeviceName } from '../infra/client-device.util';
import { TotpService } from './totp.service'; import { TotpService } from './totp.service';
import { RbacService } from './rbac.service'; import { RbacService } from './rbac.service';
import { DEFAULT_VERIFICATION_ICON, resolveVerificationIcon } from './verification.constants'; import { DEFAULT_VERIFICATION_ICON, resolveVerificationIcon } from './verification.constants';
@@ -289,6 +290,33 @@ export class AuthService {
); );
} }
private async publishLoginSuccessNotification(
userId: string,
deviceId: string,
ipAddress?: string | null,
userAgent?: string | null
) {
try {
const device = await this.prisma.device.findUnique({ where: { id: deviceId } });
const deviceLabel = device?.name ?? resolveDeviceName(undefined, userAgent);
const ipLabel = ipAddress?.trim() || 'неизвестный IP';
await this.notifications.create(
userId,
'login_success',
'Выполнен вход',
`Выполнен вход с устройства «${deviceLabel}» и IP ${ipLabel}`,
{
deviceId,
deviceName: deviceLabel,
ipAddress: ipAddress ?? null,
userAgent: userAgent ?? null
}
);
} catch {
// Уведомление о входе не должно блокировать авторизацию.
}
}
async verifyOtp(command: LoginCommand & { recipient: string; code: string }) { async verifyOtp(command: LoginCommand & { recipient: string; code: string }) {
const existingUser = await this.findUserByRecipient(command.recipient); const existingUser = await this.findUserByRecipient(command.recipient);
const targetVariants = this.recipientLookupTargets(command.recipient); const targetVariants = this.recipientLookupTargets(command.recipient);
@@ -431,7 +459,8 @@ export class AuthService {
fingerprint: command.fingerprint, fingerprint: command.fingerprint,
deviceName: command.deviceName, deviceName: command.deviceName,
deviceType: command.deviceType, deviceType: command.deviceType,
ipAddress: command.ipAddress ipAddress: command.ipAddress,
userAgent: command.userAgent
}); });
return this.finalizeAuthentication( return this.finalizeAuthentication(
user, user,
@@ -649,20 +678,21 @@ export class AuthService {
return `totp:login:attempts:${challengeId}`; return `totp:login:attempts:${challengeId}`;
} }
private async upsertDevice(userId: string, command: Pick<LoginCommand, 'fingerprint' | 'deviceName' | 'deviceType' | 'ipAddress'>) { private async upsertDevice(userId: string, command: Pick<LoginCommand, 'fingerprint' | 'deviceName' | 'deviceType' | 'ipAddress' | 'userAgent'>) {
const resolvedName = resolveDeviceName(command.deviceName, command.userAgent);
return this.prisma.device.upsert({ return this.prisma.device.upsert({
where: { userId_fingerprint: { userId, fingerprint: command.fingerprint } }, where: { userId_fingerprint: { userId, fingerprint: command.fingerprint } },
create: { create: {
userId, userId,
fingerprint: command.fingerprint, fingerprint: command.fingerprint,
name: command.deviceName ?? 'Новое устройство', name: resolvedName,
type: this.toDeviceType(command.deviceType), type: this.toDeviceType(command.deviceType),
lastIp: command.ipAddress lastIp: command.ipAddress
}, },
update: { update: {
lastSeenAt: new Date(), lastSeenAt: new Date(),
lastIp: command.ipAddress, lastIp: command.ipAddress,
name: command.deviceName ?? undefined, name: resolvedName,
type: this.toDeviceType(command.deviceType) type: this.toDeviceType(command.deviceType)
} }
}); });
@@ -688,6 +718,7 @@ export class AuthService {
}); });
await this.notifications.dismissOtpCodeNotifications(user.id); await this.notifications.dismissOtpCodeNotifications(user.id);
await this.publishLoginSuccessNotification(user.id, deviceId, command.ipAddress, command.userAgent);
return { return {
accessToken: await this.issueAccessToken(user, session.id, pinVerified), accessToken: await this.issueAccessToken(user, session.id, pinVerified),

View File

@@ -0,0 +1,48 @@
const GENERIC_DEVICE_NAMES = new Set(['browser', 'windows browser', 'новое устройство']);
export function resolveDeviceName(deviceName?: string | null, userAgent?: string | null): string {
const parsed = formatUserAgentLabel(userAgent);
if (parsed) return parsed;
const normalized = deviceName?.trim();
if (normalized && !GENERIC_DEVICE_NAMES.has(normalized.toLowerCase())) {
return normalized;
}
return normalized || 'Неизвестное устройство';
}
export function formatUserAgentLabel(userAgent?: string | null): string | null {
if (!userAgent?.trim()) return null;
const ua = userAgent.trim();
const browser = detectBrowser(ua);
const os = detectOs(ua);
if (browser && os) return `${browser} · ${os}`;
return browser ?? os;
}
function detectBrowser(ua: string): string | null {
if (/Edg\//i.test(ua)) return 'Microsoft Edge';
if (/OPR\//i.test(ua) || /Opera/i.test(ua)) return 'Opera';
if (/YaBrowser/i.test(ua)) return 'Яндекс Браузер';
if (/SamsungBrowser/i.test(ua)) return 'Samsung Internet';
if (/Firefox\//i.test(ua)) return 'Firefox';
if (/CriOS/i.test(ua)) return 'Chrome';
if (/Chrome\//i.test(ua) && !/Edg\//i.test(ua)) return 'Chrome';
if (/Safari/i.test(ua) && !/Chrome/i.test(ua)) return 'Safari';
return null;
}
function detectOs(ua: string): string | null {
if (/Windows NT 10/i.test(ua)) return 'Windows';
if (/Windows NT/i.test(ua)) return 'Windows';
if (/Android/i.test(ua)) return 'Android';
if (/iPhone/i.test(ua)) return 'iPhone';
if (/iPad/i.test(ua)) return 'iPad';
if (/Mac OS X/i.test(ua)) return 'macOS';
if (/CrOS/i.test(ua)) return 'ChromeOS';
if (/Linux/i.test(ua)) return 'Linux';
return null;
}

View File

@@ -1870,6 +1870,7 @@ nginx_proxy_headers=" proxy_set_header Host \$host;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme; proxy_set_header X-Forwarded-Proto \$scheme;
proxy_set_header X-Forwarded-Host \$host; proxy_set_header X-Forwarded-Host \$host;
proxy_set_header Authorization \$http_authorization;
proxy_read_timeout 300s; proxy_read_timeout 300s;
proxy_connect_timeout 75s; proxy_connect_timeout 75s;
proxy_next_upstream error timeout invalid_header http_502 http_503 http_504; proxy_next_upstream error timeout invalid_header http_502 http_503 http_504;