fix and update
This commit is contained in:
36
apps/api-gateway/src/client-request.util.ts
Normal file
36
apps/api-gateway/src/client-request.util.ts
Normal 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)
|
||||
};
|
||||
}
|
||||
@@ -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 { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { CoreGrpcService } from '../core-grpc.service';
|
||||
@@ -33,14 +35,14 @@ export class AdvancedAuthController {
|
||||
@ApiOperation({ summary: 'Создать QR-сессию', description: 'Создает временную QR-сессию для входа с другого устройства.' })
|
||||
@ApiBody({ type: QrSessionDto })
|
||||
@ApiResponse({ status: 201, description: 'QR-сессия создана' })
|
||||
createQr(@Body() dto: QrSessionDto, @Ip() ipAddress?: string, @Headers('user-agent') userAgent?: string) {
|
||||
return this.core.advancedAuth.CreateQrSession({
|
||||
deviceName: dto.deviceName,
|
||||
fingerprint: dto.fingerprint,
|
||||
deviceType: dto.deviceType ?? 'WEB',
|
||||
ipAddress,
|
||||
userAgent
|
||||
});
|
||||
createQr(@Body() dto: QrSessionDto, @Req() req: Request) {
|
||||
return this.core.advancedAuth.CreateQrSession(
|
||||
enrichAuthClientMeta(req, {
|
||||
deviceName: dto.deviceName,
|
||||
fingerprint: dto.fingerprint,
|
||||
deviceType: dto.deviceType ?? 'WEB'
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@Get('qr/session/:sessionId')
|
||||
|
||||
@@ -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 { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import type { Request } from 'express';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
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 { resolveAuthorizedPayload, verifyAccessToken } from '../session-auth';
|
||||
import { FedcmCookieInterceptor } from '../interceptors/fedcm-cookie.interceptor';
|
||||
@@ -26,8 +28,8 @@ export class AuthController {
|
||||
@Post('login')
|
||||
@ApiOperation({ summary: 'Вход по почте, телефону или логину', description: 'Возвращает JWT и refresh token. Если PIN включен, сессия создается в ограниченном режиме.' })
|
||||
@ApiBody({ type: LoginDto })
|
||||
login(@Body() dto: LoginDto) {
|
||||
return this.core.auth.Login(dto);
|
||||
login(@Body() dto: LoginDto, @Req() req: Request) {
|
||||
return this.core.auth.Login(enrichAuthClientMeta(req, dto));
|
||||
}
|
||||
|
||||
@Post('identify')
|
||||
@@ -40,29 +42,29 @@ export class AuthController {
|
||||
@Post('otp/send')
|
||||
@ApiOperation({ summary: 'Отправить OTP для входа', description: 'Passwordless-first вход: пользователь вводит почту или телефон, сервер создает 6-значный код и пишет его в console.log.' })
|
||||
@ApiBody({ type: PasswordlessOtpDto })
|
||||
sendOtp(@Body() dto: PasswordlessOtpDto, @Ip() ipAddress?: string) {
|
||||
return this.core.auth.SendOtp({ recipient: dto.recipient, channel: dto.channel, ipAddress });
|
||||
sendOtp(@Body() dto: PasswordlessOtpDto, @Req() req: Request) {
|
||||
return this.core.auth.SendOtp({ recipient: dto.recipient, channel: dto.channel, ipAddress: enrichAuthClientMeta(req, {}).ipAddress });
|
||||
}
|
||||
|
||||
@Post('otp/verify')
|
||||
@ApiOperation({ summary: 'Проверить OTP для входа', description: 'Если пользователь не существует, создает его. Если пароль не задан, сразу возвращает JWT. Если пароль задан, возвращает requiresPassword=true и tempAuthToken.' })
|
||||
@ApiBody({ type: PasswordlessVerifyDto })
|
||||
verifyOtp(@Body() dto: PasswordlessVerifyDto) {
|
||||
return this.core.auth.VerifyOtp(dto);
|
||||
verifyOtp(@Body() dto: PasswordlessVerifyDto, @Req() req: Request) {
|
||||
return this.core.auth.VerifyOtp(enrichAuthClientMeta(req, dto));
|
||||
}
|
||||
|
||||
@Post('login/password')
|
||||
@ApiOperation({ summary: 'Войти по паролю', description: 'Identifier-first парольный шаг. Принимает login+password, либо tempAuthToken+password для совместимости, затем выдает JWT.' })
|
||||
@ApiBody({ type: PasswordLoginDto })
|
||||
loginWithPassword(@Body() dto: PasswordLoginDto) {
|
||||
return this.core.auth.LoginWithPassword(dto);
|
||||
loginWithPassword(@Body() dto: PasswordLoginDto, @Req() req: Request) {
|
||||
return this.core.auth.LoginWithPassword(enrichAuthClientMeta(req, dto));
|
||||
}
|
||||
|
||||
@Post('ldap/login')
|
||||
@ApiOperation({ summary: 'Войти через LDAP/LDAPS', description: 'Аутентификация через корпоративный LDAP-сервер. Требует включённой настройки LDAP_ENABLED.' })
|
||||
@ApiBody({ type: LdapLoginDto })
|
||||
loginWithLdap(@Body() dto: LdapLoginDto) {
|
||||
return this.core.auth.LoginWithLdap(dto);
|
||||
loginWithLdap(@Body() dto: LdapLoginDto, @Req() req: Request) {
|
||||
return this.core.auth.LoginWithLdap(enrichAuthClientMeta(req, dto));
|
||||
}
|
||||
|
||||
@Post('pin/verify')
|
||||
@@ -75,15 +77,15 @@ export class AuthController {
|
||||
@Post('totp/begin')
|
||||
@ApiOperation({ summary: 'Начать вход по TOTP', description: 'Создаёт challenge для входа через приложение-аутентификатор вместо SMS/email OTP.' })
|
||||
@ApiBody({ type: BeginTotpLoginDto })
|
||||
beginTotpLogin(@Body() dto: BeginTotpLoginDto) {
|
||||
return this.core.auth.BeginTotpLogin(dto);
|
||||
beginTotpLogin(@Body() dto: BeginTotpLoginDto, @Req() req: Request) {
|
||||
return this.core.auth.BeginTotpLogin(enrichAuthClientMeta(req, dto));
|
||||
}
|
||||
|
||||
@Post('totp/verify')
|
||||
@ApiOperation({ summary: 'Подтвердить TOTP при входе', description: 'Завершает вход после проверки кода из Google Authenticator или аналога.' })
|
||||
@ApiBody({ type: VerifyTotpLoginDto })
|
||||
verifyTotpLogin(@Body() dto: VerifyTotpLoginDto) {
|
||||
return this.core.auth.VerifyTotpLogin(dto);
|
||||
verifyTotpLogin(@Body() dto: VerifyTotpLoginDto, @Req() req: Request) {
|
||||
return this.core.auth.VerifyTotpLogin(enrichAuthClientMeta(req, dto));
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
|
||||
@@ -194,15 +194,9 @@ export class FedcmController {
|
||||
);
|
||||
}
|
||||
|
||||
@Post('session/sync')
|
||||
@HttpCode(200)
|
||||
@ApiOperation({
|
||||
summary: 'Синхронизировать FedCM cookie',
|
||||
description: 'Устанавливает HttpOnly cookie для FedCM по Bearer access token (для уже авторизованных пользователей IdP).'
|
||||
})
|
||||
async syncSession(
|
||||
@Headers('authorization') authorization: string | undefined,
|
||||
@Res({ passthrough: true }) res: Response
|
||||
private async syncFedcmSessionFromAuthorization(
|
||||
authorization: string | undefined,
|
||||
res: Response
|
||||
) {
|
||||
const payload = await verifyAccessToken(this.jwt, authorization);
|
||||
if (!payload.sessionId) {
|
||||
@@ -217,4 +211,30 @@ export class FedcmController {
|
||||
applyFedcmLoginStatus(res, 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,8 @@ export class SecurityController {
|
||||
@ApiOperation({ summary: 'Активные сессии', description: 'Возвращает ACTIVE и LOCKED сессии пользователя для управления устройствами.' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
@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 });
|
||||
}
|
||||
|
||||
@@ -35,8 +36,10 @@ export class SecurityController {
|
||||
@ApiOperation({ summary: 'История входов', description: 'Показывает последние попытки входа и причины отказов.' })
|
||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||
@ApiResponse({ status: 200, description: 'История входов получена' })
|
||||
listHistory(@Param('userId') userId: string) {
|
||||
return this.core.security.ListSignInHistory({ userId });
|
||||
listHistory(@Param('userId') userId: string, @Headers('authorization') authorization?: string) {
|
||||
return resolveAuthorizedPayload(this.jwt, this.core, authorization).then(() =>
|
||||
this.core.security.ListSignInHistory({ userId })
|
||||
);
|
||||
}
|
||||
|
||||
@Get('users/:userId/totp/status')
|
||||
|
||||
@@ -268,9 +268,7 @@ export default function LoginPage() {
|
||||
|
||||
try {
|
||||
|
||||
const deviceName = navigator.userAgent.includes('Windows') ? 'Windows Browser' : 'Browser';
|
||||
|
||||
const session = await createQrLoginSession(deviceName);
|
||||
const session = await createQrLoginSession();
|
||||
|
||||
setQrSessionId(session.sessionId);
|
||||
|
||||
|
||||
@@ -9,13 +9,13 @@ import { useToast } from '@/components/id/toast-provider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
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';
|
||||
|
||||
export default function FamilyPage() {
|
||||
const router = useRouter();
|
||||
const { user, token } = useAuth();
|
||||
const { isReady, isPinLocked } = useRequireAuth();
|
||||
const { user, token, isLoading, isPinLocked } = useAuth();
|
||||
const { isReady } = useRequireAuth();
|
||||
const { showToast } = useToast();
|
||||
const [groups, setGroups] = useState<FamilyGroup[]>([]);
|
||||
const [name, setName] = useState('');
|
||||
@@ -28,23 +28,25 @@ export default function FamilyPage() {
|
||||
}, [user?.displayName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || !token || isPinLocked) return;
|
||||
fetchFamilyGroups(user.id, token)
|
||||
const accessToken = getAccessToken() ?? token?.trim() ?? null;
|
||||
if (!user || !accessToken || isPinLocked || isLoading) return;
|
||||
fetchFamilyGroups(user.id, accessToken)
|
||||
.then((response) => setGroups(response.groups ?? []))
|
||||
.catch((error) => {
|
||||
const message = getApiErrorMessage(error, 'Не удалось загрузить семью');
|
||||
if (message) showToast(message);
|
||||
});
|
||||
}, [isPinLocked, showToast, token, user]);
|
||||
}, [isLoading, isPinLocked, showToast, token, user]);
|
||||
|
||||
async function createGroup() {
|
||||
if (!user || !token) return;
|
||||
const accessToken = getAccessToken() ?? token?.trim() ?? null;
|
||||
if (!user || !accessToken) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
const group = await apiFetch<FamilyGroup>('/family/groups', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ownerId: user.id, name })
|
||||
}, token);
|
||||
}, accessToken);
|
||||
router.push(`/family/${group.id}`);
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(error, 'Не удалось создать семью');
|
||||
@@ -54,7 +56,7 @@ export default function FamilyPage() {
|
||||
}
|
||||
}
|
||||
|
||||
if (!isReady) {
|
||||
if (!isReady || isLoading) {
|
||||
return (
|
||||
<IdShell active="/family">
|
||||
<div className="py-20 text-center text-[#667085]">Загрузка...</div>
|
||||
|
||||
@@ -24,7 +24,8 @@ import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/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 { QRCodeSVG } from 'qrcode.react';
|
||||
import { useRequireAuth } from '@/hooks/use-require-auth';
|
||||
@@ -67,7 +68,9 @@ export default function SecurityPage() {
|
||||
const { isReady } = useRequireAuth();
|
||||
const { showToast } = useToast();
|
||||
const [devices, setDevices] = useState<ActiveDevice[]>([]);
|
||||
const [sessions, setSessions] = useState<ActiveSession[]>([]);
|
||||
const [history, setHistory] = useState<SignInEvent[]>([]);
|
||||
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
|
||||
const [isSecurityLoading, setIsSecurityLoading] = useState(true);
|
||||
const [isRevoking, setIsRevoking] = useState(false);
|
||||
const [dialogMode, setDialogMode] = useState<DialogMode>(null);
|
||||
@@ -92,13 +95,16 @@ export default function SecurityPage() {
|
||||
if (!user || !token) return;
|
||||
setIsSecurityLoading(true);
|
||||
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<{ sessions: ActiveSession[] }>(`/security/users/${user.id}/sessions`, {}, token),
|
||||
apiFetch<{ events: SignInEvent[] }>(`/security/users/${user.id}/sign-in-history`, {}, token),
|
||||
apiFetch<TotpStatusResponse>(`/security/users/${user.id}/totp/status`, {}, token)
|
||||
]);
|
||||
setDevices(devicesResponse.devices ?? []);
|
||||
setSessions(sessionsResponse.sessions ?? []);
|
||||
setHistory(historyResponse.events ?? []);
|
||||
setCurrentSessionId(window.localStorage.getItem(AUTH_SESSION_KEY));
|
||||
setTotpEnabled(Boolean(totpResponse.isEnabled));
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось загрузить безопасность');
|
||||
@@ -112,6 +118,31 @@ export default function SecurityPage() {
|
||||
void loadSecurity();
|
||||
}, [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) {
|
||||
if (!user || !token) return;
|
||||
try {
|
||||
@@ -335,10 +366,53 @@ export default function SecurityPage() {
|
||||
return (
|
||||
<IdShell active="/security">
|
||||
<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>
|
||||
|
||||
<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 ? (
|
||||
<div className="rounded-[20px] bg-[#f4f5f8] px-4 py-5 text-[#667085]">Загружаем устройства...</div>
|
||||
) : devices.length ? (
|
||||
@@ -443,10 +517,17 @@ export default function SecurityPage() {
|
||||
<div className="text-[#667085]">Загружаем историю входов...</div>
|
||||
) : history.length ? (
|
||||
history.map((item) => (
|
||||
<div key={item.id} className="flex items-center gap-4 border-b border-[#eceef4] pb-3">
|
||||
<Clock3 className="h-5 w-5" />
|
||||
<span className="flex-1">{item.success ? 'Успешный вход' : item.reason ?? 'Неудачная попытка входа'}</span>
|
||||
<span className="text-sm text-[#667085]">{formatDate(item.createdAt)}</span>
|
||||
<div key={item.id} className="flex items-start gap-4 border-b border-[#eceef4] pb-3">
|
||||
<Clock3 className="mt-0.5 h-5 w-5 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<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>
|
||||
))
|
||||
) : (
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect } from 'react';
|
||||
import { ChevronLeft, ChevronRight, Download, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { MediaImageSkeleton } from '@/components/ui/media-image-skeleton';
|
||||
import type { ChatMessage } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -97,7 +98,7 @@ export function ChatImageLightbox({
|
||||
// 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" />
|
||||
) : (
|
||||
<div className="text-sm text-white/70">Загрузка изображения...</div>
|
||||
<MediaImageSkeleton className="h-[min(70vh,520px)] w-[min(92vw,720px)]" rounded="2xl" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -137,7 +138,9 @@ export function ChatImageLightbox({
|
||||
{thumb?.blobUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import type { ChatMessage } from '@/lib/api';
|
||||
import { MediaImageSkeleton } from '@/components/ui/media-image-skeleton';
|
||||
import { parseChatMediaMetadata } from '@/lib/chat-media-album';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ChatMediaAlbumGridProps {
|
||||
messages: ChatMessage[];
|
||||
mediaUrls: Record<string, { blobUrl?: string } | undefined>;
|
||||
mediaUrls: Record<string, { blobUrl?: string; status?: string } | undefined>;
|
||||
className?: string;
|
||||
onImageClick?: (message: ChatMessage, index: number) => void;
|
||||
}
|
||||
@@ -58,6 +59,7 @@ export function ChatMediaAlbumGrid({ messages, mediaUrls, className, onImageClic
|
||||
{imageMessages.map((message, index) => {
|
||||
const media = message.storageKey ? mediaUrls[message.storageKey] : undefined;
|
||||
const hiddenCount = count > 6 && index === 5 ? count - 6 : 0;
|
||||
const isLoading = !media?.blobUrl || media.status === 'loading';
|
||||
return (
|
||||
<button
|
||||
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))}
|
||||
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
|
||||
<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 ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/45 text-2xl font-semibold text-white">
|
||||
|
||||
@@ -4,8 +4,11 @@ import { Bot } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { UserAvatar } from '@/components/id/user-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 { CHAT_ROOM_AVATAR_UPDATED_EVENT } from '@/lib/avatar-events';
|
||||
import { useResilientBlobUrl } from '@/hooks/use-resilient-blob-url';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ChatRoomAvatarDisplayProps {
|
||||
@@ -21,7 +24,9 @@ function sizeClass(size: 'sm' | 'md') {
|
||||
}
|
||||
|
||||
export function ChatRoomAvatarDisplay({ room, viewerUserId, token, className, size = 'md' }: ChatRoomAvatarDisplayProps) {
|
||||
const { isLoading: authLoading } = useAuth();
|
||||
const [roomAvatarUrl, setRoomAvatarUrl] = useState<string | null>(null);
|
||||
const [resolvingRoomAvatar, setResolvingRoomAvatar] = useState(false);
|
||||
const peerMember = useMemo(
|
||||
() =>
|
||||
room.type === 'DIRECT' || room.type === 'E2E' || room.type === 'BOT'
|
||||
@@ -31,34 +36,44 @@ export function ChatRoomAvatarDisplay({ room, viewerUserId, token, className, si
|
||||
);
|
||||
|
||||
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);
|
||||
setResolvingRoomAvatar(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setResolvingRoomAvatar(true);
|
||||
apiFetch<{ accessUrl: string }>(`/media/chat/${room.id}/avatar/url`, {}, token)
|
||||
.then((response) => {
|
||||
if (!cancelled) setRoomAvatarUrl(response.accessUrl);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setRoomAvatarUrl(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setResolvingRoomAvatar(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [room.hasAvatar, room.id, room.type, room.updatedAt, token]);
|
||||
}, [authLoading, room.hasAvatar, room.id, room.type, room.updatedAt, token]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleRoomAvatarUpdated(event: Event) {
|
||||
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)
|
||||
.then((response) => setRoomAvatarUrl(response.accessUrl))
|
||||
.catch(() => setRoomAvatarUrl(null));
|
||||
}
|
||||
window.addEventListener(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')) {
|
||||
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 (
|
||||
<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>
|
||||
</Avatar>
|
||||
);
|
||||
|
||||
@@ -65,6 +65,7 @@ import { useAuth } from '@/components/id/auth-provider';
|
||||
import { useToast } from '@/components/id/toast-provider';
|
||||
import { useRealtime } from '@/components/notifications/realtime-provider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { MediaImageSkeleton } from '@/components/ui/media-image-skeleton';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useRequireAuth } from '@/hooks/use-require-auth';
|
||||
@@ -88,6 +89,7 @@ import {
|
||||
fetchFamilyPresence,
|
||||
forwardChatMessages,
|
||||
getApiErrorMessage,
|
||||
getAccessToken,
|
||||
leaveFamilyGroup,
|
||||
markChatRoomRead,
|
||||
removeChatRoomMember,
|
||||
@@ -197,22 +199,24 @@ function FamilySidebarAvatar({
|
||||
uploading?: boolean;
|
||||
onUpload: (file: File) => void;
|
||||
}) {
|
||||
const { isLoading: authLoading } = useAuth();
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasAvatar || !token) {
|
||||
if (!hasAvatar || !token || authLoading) {
|
||||
setUrl(null);
|
||||
return;
|
||||
}
|
||||
apiFetch<{ accessUrl: string }>(`/media/families/${groupId}/avatar/url`, {}, token)
|
||||
.then((response) => setUrl(response.accessUrl))
|
||||
.catch(() => setUrl(null));
|
||||
}, [groupId, hasAvatar, token]);
|
||||
}, [authLoading, groupId, hasAvatar, token]);
|
||||
|
||||
return (
|
||||
<HoverUploadAvatar
|
||||
name={name}
|
||||
imageUrl={url}
|
||||
imageToken={token}
|
||||
uploading={uploading}
|
||||
onFileSelect={onUpload}
|
||||
className="h-11 w-11 shrink-0"
|
||||
@@ -230,6 +234,10 @@ function familyMemberRoleLabel(role: string) {
|
||||
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) {
|
||||
const message = messages[index];
|
||||
if (!message) return false;
|
||||
@@ -257,7 +265,7 @@ function collectAlbumMessages(messages: ChatMessage[], startIndex: number) {
|
||||
|
||||
export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
const router = useRouter();
|
||||
const { user, token } = useAuth();
|
||||
const { user, token, isLoading: authLoading } = useAuth();
|
||||
const { isReady, isPinLocked } = useRequireAuth();
|
||||
const { showToast } = useToast();
|
||||
const { subscribe, connected } = useRealtime();
|
||||
@@ -469,9 +477,10 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
}, [isPinLocked, rooms, showToast, token]);
|
||||
|
||||
const loadPresence = useCallback(async () => {
|
||||
if (!token || isPinLocked) return;
|
||||
const accessToken = getAccessToken() ?? token?.trim() ?? null;
|
||||
if (!accessToken || isPinLocked) return;
|
||||
try {
|
||||
const response = await fetchFamilyPresence(groupId, token);
|
||||
const response = await fetchFamilyPresence(groupId, accessToken);
|
||||
setPresenceMembers(response.members ?? []);
|
||||
} catch {
|
||||
// ignore background presence errors
|
||||
@@ -479,13 +488,14 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
}, [groupId, isPinLocked, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isReady || !token || isPinLocked) return;
|
||||
const accessToken = getAccessToken() ?? token?.trim() ?? null;
|
||||
if (!isReady || !accessToken || isPinLocked || authLoading) return;
|
||||
setLoading(true);
|
||||
loadGroup()
|
||||
.catch((error) => showToast(getApiErrorMessage(error, 'Не удалось загрузить семью') ?? 'Ошибка'))
|
||||
.finally(() => setLoading(false));
|
||||
void loadPresence();
|
||||
}, [isPinLocked, isReady, loadGroup, loadPresence, showToast, token]);
|
||||
}, [authLoading, isPinLocked, isReady, loadGroup, loadPresence, showToast, token]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleAvatarUpdated() {
|
||||
@@ -496,11 +506,12 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
}, [loadGroup]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || isPinLocked) return;
|
||||
const accessToken = getAccessToken() ?? token?.trim() ?? null;
|
||||
if (!accessToken || isPinLocked || authLoading) return;
|
||||
void loadPresence();
|
||||
const timer = window.setInterval(() => void loadPresence(), 25_000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [connected, isPinLocked, loadPresence, token]);
|
||||
}, [authLoading, connected, isPinLocked, loadPresence, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeRoomId || activeRoomIsBot) return;
|
||||
@@ -1699,7 +1710,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
>
|
||||
{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">
|
||||
<button
|
||||
type="button"
|
||||
@@ -1903,6 +1914,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
<ChatPinnedMessageBanner
|
||||
senderName={pinnedMessage.senderName}
|
||||
preview={pinnedPreview}
|
||||
className={DESKTOP_CHAT_RIGHT_GUTTER}
|
||||
onClick={() => {
|
||||
if (!scrollToChatMessage(pinnedMessage.id)) {
|
||||
showToast('Закреплённое сообщение пока не загружено в ленте');
|
||||
@@ -1910,7 +1922,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
}}
|
||||
/>
|
||||
) : 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) => {
|
||||
if (shouldSkipGroupedAlbumMessage(visibleMessages, messageIndex)) {
|
||||
return null;
|
||||
@@ -2055,10 +2067,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
}
|
||||
if (!media?.blobUrl || media.status === 'loading') {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm text-[#667085]">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Загрузка файла...
|
||||
</div>
|
||||
<MediaImageSkeleton className="max-h-72 w-full max-w-[320px]" rounded="xl" />
|
||||
);
|
||||
}
|
||||
if (message.type === 'IMAGE') {
|
||||
@@ -2256,7 +2265,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
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 ? (
|
||||
<ChatMessageEditBanner
|
||||
preview={draft.trim() || messages.find((item) => item.id === editingMessageId)?.content || undefined}
|
||||
|
||||
@@ -3,11 +3,14 @@
|
||||
import { Camera, Loader2 } from 'lucide-react';
|
||||
import { useId } from 'react';
|
||||
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';
|
||||
|
||||
interface HoverUploadAvatarProps {
|
||||
name: string;
|
||||
imageUrl?: string | null;
|
||||
imageToken?: string | null;
|
||||
uploading?: boolean;
|
||||
onFileSelect: (file: File) => void;
|
||||
className?: string;
|
||||
@@ -17,12 +20,18 @@ interface HoverUploadAvatarProps {
|
||||
export function HoverUploadAvatar({
|
||||
name,
|
||||
imageUrl,
|
||||
imageToken = null,
|
||||
uploading = false,
|
||||
onFileSelect,
|
||||
className,
|
||||
fallbackClassName
|
||||
}: HoverUploadAvatarProps) {
|
||||
const inputId = useId();
|
||||
const { blobUrl, isLoading } = useResilientBlobUrl(imageUrl, imageToken, {
|
||||
enabled: Boolean(imageUrl && imageToken),
|
||||
label: `аватар ${name}`
|
||||
});
|
||||
const resolvedUrl = blobUrl ?? (imageToken ? null : imageUrl);
|
||||
|
||||
return (
|
||||
<label
|
||||
@@ -34,8 +43,16 @@ export function HoverUploadAvatar({
|
||||
)}
|
||||
>
|
||||
<Avatar className="h-full w-full">
|
||||
{imageUrl ? <AvatarImage src={imageUrl} alt={name} /> : null}
|
||||
<AvatarFallback className={fallbackClassName}>{name.slice(0, 2).toUpperCase()}</AvatarFallback>
|
||||
{isLoading && imageUrl ? (
|
||||
<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>
|
||||
<span
|
||||
className={cn(
|
||||
|
||||
@@ -13,9 +13,10 @@ import {
|
||||
AuthTokens,
|
||||
ensureApiGatewayReady,
|
||||
fetchAuthSession,
|
||||
getDeviceFingerprint,
|
||||
buildAuthDevicePayload,
|
||||
IdentifyResponse,
|
||||
getApiErrorMessage,
|
||||
isApiGatewayReady,
|
||||
isPinRequiredError,
|
||||
OtpSendResponse,
|
||||
PasswordlessAuthResponse,
|
||||
@@ -93,10 +94,9 @@ function applySessionState(
|
||||
setters.activatePinLock(session.sessionId ?? '');
|
||||
} else {
|
||||
setters.clearPinLock();
|
||||
const accessToken = window.localStorage.getItem(AUTH_TOKEN_KEY);
|
||||
if (accessToken) {
|
||||
void syncFedcmSession(accessToken);
|
||||
}
|
||||
void ensureApiGatewayReady().then(() => {
|
||||
void syncFedcmSession();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,10 +169,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
void ensureApiGatewayReady().finally(() => {
|
||||
initialBootstrapDoneRef.current = true;
|
||||
setIsLoading(false);
|
||||
if (auth.pinVerified !== false) {
|
||||
void syncFedcmSession(auth.accessToken);
|
||||
}
|
||||
});
|
||||
if (auth.pinVerified !== false) {
|
||||
void syncFedcmSession(auth.accessToken);
|
||||
}
|
||||
},
|
||||
[clearPinLock]
|
||||
);
|
||||
@@ -277,10 +277,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
window.localStorage.getItem(AUTH_TOKEN_KEY) || window.localStorage.getItem(AUTH_REFRESH_KEY)
|
||||
);
|
||||
if (hasSession && isInitialBootstrap) {
|
||||
try {
|
||||
await ensureApiGatewayReady();
|
||||
} catch {
|
||||
// Продолжаем даже если gateway не ответил в срок прогрева.
|
||||
const bootstrapDeadline = Date.now() + 120_000;
|
||||
while (!isApiGatewayReady() && Date.now() < bootstrapDeadline) {
|
||||
await ensureApiGatewayReady(true);
|
||||
if (isApiGatewayReady()) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||
}
|
||||
}
|
||||
if (isInitialBootstrap) {
|
||||
@@ -339,9 +340,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
body: JSON.stringify({
|
||||
login: loginValue,
|
||||
password,
|
||||
fingerprint: getDeviceFingerprint(),
|
||||
deviceName: navigator.userAgent.includes('Windows') ? 'Windows Browser' : 'Browser',
|
||||
deviceType: 'WEB'
|
||||
...buildAuthDevicePayload(),
|
||||
})
|
||||
});
|
||||
if (auth.pinVerified) {
|
||||
@@ -376,9 +375,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
body: JSON.stringify({
|
||||
recipient,
|
||||
code,
|
||||
fingerprint: getDeviceFingerprint(),
|
||||
deviceName: navigator.userAgent.includes('Windows') ? 'Windows Browser' : 'Browser',
|
||||
deviceType: 'WEB'
|
||||
...buildAuthDevicePayload(),
|
||||
})
|
||||
});
|
||||
if (response.auth?.pinVerified) {
|
||||
@@ -399,9 +396,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
body: JSON.stringify({
|
||||
login: loginValue,
|
||||
password: passwordValue,
|
||||
fingerprint: getDeviceFingerprint(),
|
||||
deviceName: navigator.userAgent.includes('Windows') ? 'Windows Browser' : 'Browser',
|
||||
deviceType: 'WEB'
|
||||
...buildAuthDevicePayload(),
|
||||
})
|
||||
});
|
||||
if (auth.pinVerified) {
|
||||
@@ -422,9 +417,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
password: passwordValue,
|
||||
fingerprint: getDeviceFingerprint(),
|
||||
deviceName: navigator.userAgent.includes('Windows') ? 'Windows Browser' : 'Browser',
|
||||
deviceType: 'WEB'
|
||||
...buildAuthDevicePayload(),
|
||||
})
|
||||
});
|
||||
if (auth.pinVerified) {
|
||||
@@ -443,9 +436,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
recipient,
|
||||
fingerprint: getDeviceFingerprint(),
|
||||
deviceName: navigator.userAgent.includes('Windows') ? 'Windows Browser' : 'Browser',
|
||||
deviceType: 'WEB'
|
||||
...buildAuthDevicePayload(),
|
||||
})
|
||||
});
|
||||
return response.totpChallengeToken;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { Camera, Loader2 } from 'lucide-react';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useToast } from '@/components/id/toast-provider';
|
||||
import { useAvatarUrl } from '@/hooks/use-avatar-url';
|
||||
import { apiFetch, getApiErrorMessage, uploadMediaObject } from '@/lib/api';
|
||||
@@ -41,7 +42,7 @@ export function AvatarUpload({
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const { showToast } = useToast();
|
||||
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';
|
||||
|
||||
async function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
@@ -99,8 +100,16 @@ export function AvatarUpload({
|
||||
onClick={openFilePicker}
|
||||
>
|
||||
<Avatar className={cn('border-4 border-white shadow-xl', className)}>
|
||||
{avatarUrl ? <AvatarImage src={avatarUrl} alt={displayName ?? 'Аватар'} /> : null}
|
||||
<AvatarFallback className="bg-[linear-gradient(135deg,#6f58ff,#ffd2a8)] text-2xl">{initials}</AvatarFallback>
|
||||
{isAvatarLoading && hasAvatar ? (
|
||||
<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>
|
||||
<span
|
||||
className={cn(
|
||||
@@ -146,14 +155,22 @@ export function AvatarDisplay({
|
||||
verificationIcon?: string | null;
|
||||
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';
|
||||
|
||||
return (
|
||||
<div className="relative inline-flex shrink-0">
|
||||
<Avatar className={className}>
|
||||
{avatarUrl ? <AvatarImage src={avatarUrl} alt={displayName ?? 'Аватар'} /> : null}
|
||||
<AvatarFallback className="bg-[linear-gradient(135deg,#6f58ff,#ffd2a8)]">{initials}</AvatarFallback>
|
||||
{isAvatarLoading && hasAvatar ? (
|
||||
<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>
|
||||
{isVerified ? (
|
||||
<VerificationBadge
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { VerificationBadge } from '@/components/id/verification-badge';
|
||||
import { useAvatarUrl } from '@/hooks/use-avatar-url';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -26,7 +27,7 @@ export function UserAvatar({
|
||||
badgeSize?: 'xs' | 'sm' | 'md' | 'lg';
|
||||
fallbackClassName?: string;
|
||||
}) {
|
||||
const { avatarUrl } = useAvatarUrl(userId, hasAvatar, token);
|
||||
const { avatarUrl, isAvatarLoading } = useAvatarUrl(userId, hasAvatar, token);
|
||||
const initials = (displayName ?? '')
|
||||
.split(' ')
|
||||
.map((part) => part[0])
|
||||
@@ -37,10 +38,18 @@ export function UserAvatar({
|
||||
return (
|
||||
<div className="relative inline-flex shrink-0">
|
||||
<Avatar className={className}>
|
||||
{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>
|
||||
{isAvatarLoading && hasAvatar ? (
|
||||
<AvatarFallback className="bg-transparent p-0">
|
||||
<Skeleton className="h-full w-full rounded-full" />
|
||||
</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>
|
||||
{isVerified ? (
|
||||
<VerificationBadge
|
||||
|
||||
@@ -101,6 +101,7 @@ export function RealtimeProvider({ children }: { children: React.ReactNode }) {
|
||||
data.type === 'family_invite' ||
|
||||
data.type === 'role_assigned' ||
|
||||
data.type === 'role_removed' ||
|
||||
data.type === 'login_success' ||
|
||||
(data.type === 'chat_message' && data.payload?.notificationId)
|
||||
) {
|
||||
setUnreadCount((count) => count + 1);
|
||||
|
||||
22
apps/frontend/components/ui/media-image-skeleton.tsx
Normal file
22
apps/frontend/components/ui/media-image-skeleton.tsx
Normal 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
|
||||
/>
|
||||
);
|
||||
}
|
||||
12
apps/frontend/components/ui/skeleton.tsx
Normal file
12
apps/frontend/components/ui/skeleton.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from 'react';
|
||||
import { useAuth } from '@/components/id/auth-provider';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { AVATAR_UPDATED_EVENT } from '@/lib/avatar-events';
|
||||
import { useResilientBlobUrl } from '@/hooks/use-resilient-blob-url';
|
||||
|
||||
interface AvatarAccessResponse {
|
||||
accessUrl: string;
|
||||
@@ -12,41 +13,61 @@ interface AvatarAccessResponse {
|
||||
|
||||
export function useAvatarUrl(userId: string | undefined, hasAvatar: boolean | undefined, token: string | null) {
|
||||
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) {
|
||||
setAvatarUrl(null);
|
||||
setAccessUrl(null);
|
||||
setResolvingAccessUrl(false);
|
||||
return;
|
||||
}
|
||||
setResolvingAccessUrl(true);
|
||||
try {
|
||||
const response = await apiFetch<AvatarAccessResponse>(`/media/avatars/${userId}/url`, {}, token);
|
||||
setAvatarUrl(response.accessUrl);
|
||||
setAccessUrl(response.accessUrl);
|
||||
} catch {
|
||||
setAvatarUrl(null);
|
||||
setAccessUrl(null);
|
||||
} finally {
|
||||
setResolvingAccessUrl(false);
|
||||
}
|
||||
}, [hasAvatar, isLoading, isPinLocked, token, userId]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
void refreshAccessUrl();
|
||||
if (!hasAvatar) return;
|
||||
const timer = window.setInterval(() => {
|
||||
void refresh();
|
||||
void refreshAccessUrl();
|
||||
}, 14 * 60 * 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [hasAvatar, refresh]);
|
||||
}, [hasAvatar, refreshAccessUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId || !hasAvatar) return;
|
||||
function handleAvatarUpdated(event: Event) {
|
||||
const detail = (event as CustomEvent<{ userId?: string }>).detail;
|
||||
if (detail?.userId === userId) {
|
||||
void refresh();
|
||||
void refreshAccessUrl();
|
||||
}
|
||||
}
|
||||
window.addEventListener(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 };
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
FamilyPresenceMember,
|
||||
fetchFamilyGroup,
|
||||
fetchFamilyGroups,
|
||||
fetchFamilyPresence
|
||||
fetchFamilyPresence,
|
||||
getAccessToken
|
||||
} from '@/lib/api';
|
||||
|
||||
export function useSelectedFamily(enabled = true) {
|
||||
@@ -20,7 +21,8 @@ export function useSelectedFamily(enabled = true) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!enabled || !user || !token || isPinLocked || isLoading) {
|
||||
const accessToken = getAccessToken() ?? token?.trim() ?? null;
|
||||
if (!enabled || !user || !accessToken || isPinLocked || isLoading) {
|
||||
setGroups([]);
|
||||
setGroup(null);
|
||||
setPresenceMembers([]);
|
||||
@@ -29,7 +31,7 @@ export function useSelectedFamily(enabled = true) {
|
||||
}
|
||||
|
||||
try {
|
||||
const groupsResponse = await fetchFamilyGroups(user.id, token);
|
||||
const groupsResponse = await fetchFamilyGroups(user.id, accessToken);
|
||||
const list = groupsResponse.groups ?? [];
|
||||
setGroups(list);
|
||||
|
||||
@@ -48,8 +50,8 @@ export function useSelectedFamily(enabled = true) {
|
||||
}
|
||||
|
||||
const [groupResponse, presenceResponse] = await Promise.all([
|
||||
fetchFamilyGroup(effectiveId, token),
|
||||
fetchFamilyPresence(effectiveId, token)
|
||||
fetchFamilyGroup(effectiveId, accessToken),
|
||||
fetchFamilyPresence(effectiveId, accessToken)
|
||||
]);
|
||||
setGroup(groupResponse);
|
||||
setPresenceMembers(presenceResponse.members ?? []);
|
||||
@@ -68,7 +70,8 @@ export function useSelectedFamily(enabled = true) {
|
||||
}, [refresh]);
|
||||
|
||||
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);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [enabled, group, isLoading, isPinLocked, refresh, token]);
|
||||
|
||||
60
apps/frontend/hooks/use-resilient-blob-url.ts
Normal file
60
apps/frontend/hooks/use-resilient-blob-url.ts
Normal 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'
|
||||
};
|
||||
}
|
||||
@@ -219,13 +219,14 @@ export interface QrLoginSession {
|
||||
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', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
deviceName,
|
||||
fingerprint: getDeviceFingerprint(),
|
||||
deviceType: 'WEB'
|
||||
...device,
|
||||
deviceName: deviceName?.trim() || device.deviceName
|
||||
})
|
||||
});
|
||||
}
|
||||
@@ -368,6 +369,19 @@ export const AUTH_REFRESH_KEY = 'lendry_refresh_token';
|
||||
export const AUTH_SESSION_KEY = 'lendry_session_id';
|
||||
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 {
|
||||
status: number;
|
||||
code?: string;
|
||||
@@ -441,6 +455,7 @@ export function isGatewayUnavailableError(error: unknown): boolean {
|
||||
export function getApiErrorMessage(error: unknown, fallback: string): string | null {
|
||||
if (isPinRequiredError(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')) {
|
||||
return null;
|
||||
}
|
||||
@@ -526,15 +541,18 @@ export async function refreshAuthSession(): Promise<RefreshSessionResponse> {
|
||||
return response.json() as Promise<RefreshSessionResponse>;
|
||||
}
|
||||
|
||||
export async function syncFedcmSession(accessToken: string) {
|
||||
if (typeof window === 'undefined' || !accessToken.trim()) return;
|
||||
export async function syncFedcmSession(accessToken?: string | null) {
|
||||
const token = resolveAccessToken(accessToken);
|
||||
if (typeof window === 'undefined' || !token) return;
|
||||
try {
|
||||
await ensureApiGatewayReady();
|
||||
await fetchWithGatewayRetry(`${getApiUrl()}/fedcm/session/sync`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${accessToken.trim()}` },
|
||||
credentials: 'include'
|
||||
});
|
||||
await apiFetch<{ synced: boolean }>(
|
||||
'/fedcm/session/sync',
|
||||
{
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
},
|
||||
token
|
||||
);
|
||||
} catch {
|
||||
// фоновая синхронизация FedCM cookie
|
||||
}
|
||||
@@ -602,6 +620,7 @@ export interface SignInEvent {
|
||||
reason?: string | null;
|
||||
ipAddress?: string | null;
|
||||
userAgent?: string | null;
|
||||
deviceName?: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -609,6 +628,8 @@ export interface ActiveSession {
|
||||
id: string;
|
||||
userId: string;
|
||||
deviceId?: string | null;
|
||||
deviceName?: string | null;
|
||||
deviceType?: string | null;
|
||||
pinVerified: boolean;
|
||||
status: string;
|
||||
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_ATTEMPTS = 6;
|
||||
const GATEWAY_WARMUP_MAX_ATTEMPTS = 40;
|
||||
const GATEWAY_WARMUP_DELAY_MS = 300;
|
||||
const GATEWAY_RETRY_ATTEMPTS = 8;
|
||||
const GATEWAY_WARMUP_MAX_ATTEMPTS = 80;
|
||||
const GATEWAY_WARMUP_DELAY_MS = 400;
|
||||
const GATEWAY_READY_CONSECUTIVE_OK = 2;
|
||||
const GATEWAY_WARMUP_MAX_DELAY_MS = 2500;
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
@@ -650,6 +672,15 @@ export function resetApiGatewayWarmup() {
|
||||
gatewayReadyPromise = null;
|
||||
}
|
||||
|
||||
export function invalidateApiGatewayReady() {
|
||||
gatewayReadyResolved = false;
|
||||
gatewayReadyPromise = null;
|
||||
}
|
||||
|
||||
export function isApiGatewayReady() {
|
||||
return gatewayReadyResolved;
|
||||
}
|
||||
|
||||
export function markApiGatewayReady() {
|
||||
gatewayReadyResolved = true;
|
||||
}
|
||||
@@ -671,8 +702,12 @@ export async function ensureApiGatewayReady(force = false): Promise<void> {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (gatewayReadyResolved && !force) return;
|
||||
if (gatewayReadyPromise && !force) return gatewayReadyPromise;
|
||||
|
||||
if (force) {
|
||||
gatewayReadyResolved = false;
|
||||
gatewayReadyPromise = null;
|
||||
}
|
||||
gatewayReadyPromise = (async () => {
|
||||
await sleep(600);
|
||||
let consecutiveOk = 0;
|
||||
for (let attempt = 0; attempt < GATEWAY_WARMUP_MAX_ATTEMPTS; attempt++) {
|
||||
if (await probeGatewayReady()) {
|
||||
@@ -684,9 +719,10 @@ export async function ensureApiGatewayReady(force = false): Promise<void> {
|
||||
} else {
|
||||
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;
|
||||
@@ -699,6 +735,8 @@ async function fetchWithGatewayRetry(url: string, init: RequestInit): Promise<Re
|
||||
try {
|
||||
const response = await fetch(url, init);
|
||||
if (GATEWAY_RETRY_STATUS.has(response.status) && attempt + 1 < GATEWAY_RETRY_ATTEMPTS) {
|
||||
invalidateApiGatewayReady();
|
||||
await ensureApiGatewayReady(true);
|
||||
await sleep(400 * (attempt + 1));
|
||||
continue;
|
||||
}
|
||||
@@ -706,6 +744,8 @@ async function fetchWithGatewayRetry(url: string, init: RequestInit): Promise<Re
|
||||
} catch (error) {
|
||||
lastNetworkError = error;
|
||||
if (attempt + 1 < GATEWAY_RETRY_ATTEMPTS) {
|
||||
invalidateApiGatewayReady();
|
||||
await ensureApiGatewayReady(true);
|
||||
await sleep(400 * (attempt + 1));
|
||||
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> {
|
||||
if (typeof window !== 'undefined' && path !== '/health' && !path.startsWith('/health?')) {
|
||||
if (typeof window !== 'undefined' && !path.startsWith('/health')) {
|
||||
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 hasBody = options.body !== undefined && options.body !== null;
|
||||
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;
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
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 { getDeviceFingerprint, buildAuthDevicePayload, resolveDeviceLabel, formatUserAgentLabel } from '@/lib/device-client';
|
||||
|
||||
export interface MediaAccessResponse {
|
||||
accessUrl: string;
|
||||
@@ -1022,7 +1060,11 @@ export interface ChatRoom {
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -1339,7 +1381,11 @@ export async function reportChatTyping(roomId: 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 в браузере */
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
import { fetchMediaBlobWithRetry } from '@/lib/resilient-media-fetch';
|
||||
|
||||
export async function fetchAuthenticatedMediaBlob(accessUrl: string, token: string, mimeType?: string) {
|
||||
const response = await fetch(accessUrl, {
|
||||
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;
|
||||
return fetchMediaBlobWithRetry(accessUrl, token, { mimeType, label: 'файл чата' });
|
||||
}
|
||||
|
||||
export function createBlobObjectUrl(blob: Blob) {
|
||||
|
||||
81
apps/frontend/lib/device-client.ts
Normal file
81
apps/frontend/lib/device-client.ts
Normal 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;
|
||||
}
|
||||
60
apps/frontend/lib/resilient-media-fetch.ts
Normal file
60
apps/frontend/lib/resilient-media-fetch.ts
Normal 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('Не удалось загрузить файл');
|
||||
}
|
||||
@@ -414,6 +414,7 @@ export class AuthGrpcController {
|
||||
reason: event.reason,
|
||||
ipAddress: event.ipAddress,
|
||||
userAgent: event.userAgent,
|
||||
deviceName: event.device?.name ?? null,
|
||||
createdAt: event.createdAt.toISOString()
|
||||
}))
|
||||
};
|
||||
@@ -1235,7 +1236,19 @@ export class AuthGrpcController {
|
||||
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 {
|
||||
id: session.id,
|
||||
userId: session.userId,
|
||||
@@ -1244,6 +1257,8 @@ export class AuthGrpcController {
|
||||
status: session.status,
|
||||
ipAddress: session.ipAddress,
|
||||
userAgent: session.userAgent,
|
||||
deviceName: session.device?.name ?? null,
|
||||
deviceType: session.device?.type ?? null,
|
||||
expiresAt: session.expiresAt.toISOString(),
|
||||
createdAt: session.createdAt.toISOString(),
|
||||
updatedAt: session.updatedAt.toISOString()
|
||||
|
||||
@@ -14,6 +14,7 @@ import { SettingsService } from './settings.service';
|
||||
import { MessagingService } from '../infra/messaging.service';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
import { canonicalPhoneE164, phoneLookupVariants } from '../infra/phone.util';
|
||||
import { resolveDeviceName } from '../infra/client-device.util';
|
||||
import { TotpService } from './totp.service';
|
||||
import { RbacService } from './rbac.service';
|
||||
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 }) {
|
||||
const existingUser = await this.findUserByRecipient(command.recipient);
|
||||
const targetVariants = this.recipientLookupTargets(command.recipient);
|
||||
@@ -431,7 +459,8 @@ export class AuthService {
|
||||
fingerprint: command.fingerprint,
|
||||
deviceName: command.deviceName,
|
||||
deviceType: command.deviceType,
|
||||
ipAddress: command.ipAddress
|
||||
ipAddress: command.ipAddress,
|
||||
userAgent: command.userAgent
|
||||
});
|
||||
return this.finalizeAuthentication(
|
||||
user,
|
||||
@@ -649,20 +678,21 @@ export class AuthService {
|
||||
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({
|
||||
where: { userId_fingerprint: { userId, fingerprint: command.fingerprint } },
|
||||
create: {
|
||||
userId,
|
||||
fingerprint: command.fingerprint,
|
||||
name: command.deviceName ?? 'Новое устройство',
|
||||
name: resolvedName,
|
||||
type: this.toDeviceType(command.deviceType),
|
||||
lastIp: command.ipAddress
|
||||
},
|
||||
update: {
|
||||
lastSeenAt: new Date(),
|
||||
lastIp: command.ipAddress,
|
||||
name: command.deviceName ?? undefined,
|
||||
name: resolvedName,
|
||||
type: this.toDeviceType(command.deviceType)
|
||||
}
|
||||
});
|
||||
@@ -688,6 +718,7 @@ export class AuthService {
|
||||
});
|
||||
|
||||
await this.notifications.dismissOtpCodeNotifications(user.id);
|
||||
await this.publishLoginSuccessNotification(user.id, deviceId, command.ipAddress, command.userAgent);
|
||||
|
||||
return {
|
||||
accessToken: await this.issueAccessToken(user, session.id, pinVerified),
|
||||
|
||||
48
apps/sso-core/src/infra/client-device.util.ts
Normal file
48
apps/sso-core/src/infra/client-device.util.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user