global fix and add tauri app

This commit is contained in:
lendry
2026-06-26 13:56:54 +03:00
parent aa228d84eb
commit 3b05b7e4d4
50 changed files with 3947 additions and 80 deletions

View File

@@ -9,6 +9,7 @@ COPY apps/sso-core/package.json ./apps/sso-core/
COPY apps/api-gateway/package.json ./apps/api-gateway/ COPY apps/api-gateway/package.json ./apps/api-gateway/
COPY apps/frontend/package.json ./apps/frontend/ COPY apps/frontend/package.json ./apps/frontend/
COPY apps/docs/package.json ./apps/docs/ COPY apps/docs/package.json ./apps/docs/
COPY tauri_app/package.json ./tauri_app/
RUN --mount=type=cache,target=/root/.npm \ RUN --mount=type=cache,target=/root/.npm \
npm config set registry "${NPM_REGISTRY}" && \ npm config set registry "${NPM_REGISTRY}" && \

View File

@@ -84,7 +84,8 @@ export class ChatController {
userId, userId,
roomId, roomId,
name: dto.name, name: dto.name,
notificationsMuted: dto.notificationsMuted notificationsMuted: dto.notificationsMuted,
pinned: dto.pinned
}) })
); );
} }

View File

@@ -25,6 +25,10 @@ export class UpdateChatRoomDto {
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
notificationsMuted?: boolean; notificationsMuted?: boolean;
@IsOptional()
@IsBoolean()
pinned?: boolean;
} }
export class SendChatMessageDto { export class SendChatMessageDto {

View File

@@ -10,6 +10,7 @@ COPY apps/sso-core/package.json ./apps/sso-core/
COPY apps/api-gateway/package.json ./apps/api-gateway/ COPY apps/api-gateway/package.json ./apps/api-gateway/
COPY apps/frontend/package.json ./apps/frontend/ COPY apps/frontend/package.json ./apps/frontend/
COPY apps/docs/package.json ./apps/docs/ COPY apps/docs/package.json ./apps/docs/
COPY tauri_app/package.json ./tauri_app/
RUN --mount=type=cache,target=/root/.npm \ RUN --mount=type=cache,target=/root/.npm \
npm config set registry "${NPM_REGISTRY}" && \ npm config set registry "${NPM_REGISTRY}" && \

View File

@@ -13,6 +13,7 @@ COPY apps/sso-core/package.json ./apps/sso-core/
COPY apps/api-gateway/package.json ./apps/api-gateway/ COPY apps/api-gateway/package.json ./apps/api-gateway/
COPY apps/frontend/package.json ./apps/frontend/ COPY apps/frontend/package.json ./apps/frontend/
COPY apps/docs/package.json ./apps/docs/ COPY apps/docs/package.json ./apps/docs/
COPY tauri_app/package.json ./tauri_app/
RUN --mount=type=cache,target=/root/.npm \ RUN --mount=type=cache,target=/root/.npm \
npm config set registry "${NPM_REGISTRY}" && \ npm config set registry "${NPM_REGISTRY}" && \

View File

@@ -8,7 +8,7 @@ import { FormEvent, useCallback, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { ChevronLeft, KeyRound, Mail, Network, Phone, QrCode, ShieldCheck, ShieldQuestion, UserRound } from 'lucide-react'; import { ChevronLeft, Download, KeyRound, Mail, Network, Phone, QrCode, ShieldCheck, ShieldQuestion, UserRound } from 'lucide-react';
import { QRCodeSVG } from 'qrcode.react'; import { QRCodeSVG } from 'qrcode.react';
@@ -1270,6 +1270,15 @@ export default function LoginPage() {
<ProjectTagline className="mt-9 text-center text-sm font-semibold" /> <ProjectTagline className="mt-9 text-center text-sm font-semibold" />
<a
href="/download/android"
download
className="mx-auto mt-3 flex w-fit items-center gap-1 rounded-full px-3 py-1.5 text-xs font-medium text-[#b9bdc9] transition hover:bg-white/10 hover:text-white"
>
<Download className="h-3.5 w-3.5" />
Скачать приложение
</a>
</section> </section>
</main> </main>

View File

@@ -0,0 +1,44 @@
import { createReadStream, existsSync, statSync } from 'node:fs';
import path from 'node:path';
import { Readable } from 'node:stream';
import { NextResponse } from 'next/server';
export const runtime = 'nodejs';
const APK_CANDIDATES = [
process.env.TAURI_ANDROID_APK_PATH,
path.resolve(process.cwd(), 'public/downloads/lendry-id.apk'),
path.resolve(process.cwd(), '../../tauri_app/src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk'),
path.resolve(process.cwd(), '../../tauri_app/src-tauri/gen/android/app/build/outputs/apk/universal/debug/app-universal-debug.apk'),
path.resolve(process.cwd(), '../../tauri_app/src-tauri/gen/android/app/build/outputs/apk/arm64/release/app-arm64-release.apk'),
path.resolve(process.cwd(), '../../tauri_app/src-tauri/gen/android/app/build/outputs/apk/arm64/debug/app-arm64-debug.apk')
].filter((candidate): candidate is string => Boolean(candidate));
function resolveApkPath() {
return APK_CANDIDATES.find((candidate) => existsSync(candidate) && statSync(candidate).isFile());
}
export async function GET() {
const apkPath = resolveApkPath();
if (!apkPath) {
return NextResponse.json(
{
message:
'APK ещё не собран. Соберите tauri_app для Android или укажите TAURI_ANDROID_APK_PATH/public/downloads/lendry-id.apk.'
},
{ status: 404 }
);
}
const stat = statSync(apkPath);
const stream = Readable.toWeb(createReadStream(apkPath)) as ReadableStream;
return new Response(stream, {
headers: {
'Content-Type': 'application/vnd.android.package-archive',
'Content-Length': String(stat.size),
'Content-Disposition': 'attachment; filename="lendry-id.apk"',
'Cache-Control': 'no-store'
}
});
}

View File

@@ -0,0 +1,36 @@
'use client';
import { Pin } from 'lucide-react';
import { cn } from '@/lib/utils';
interface ChatPinnedMessageBannerProps {
senderName: string;
preview: string;
className?: string;
onClick: () => void;
}
export function ChatPinnedMessageBanner({ senderName, preview, className, onClick }: ChatPinnedMessageBannerProps) {
return (
<button
type="button"
className={cn(
'flex w-full items-center gap-3 border-b border-[#dce3ec] bg-white/95 px-4 py-2 text-left shadow-[0_8px_20px_rgba(31,36,48,0.06)] backdrop-blur transition hover:bg-[#f7f9fc]',
className
)}
onClick={onClick}
>
<span className="h-10 w-1 shrink-0 rounded-full bg-[#3390ec]" aria-hidden />
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-2xl bg-[#3390ec]/10 text-[#3390ec]">
<Pin className="h-4 w-4" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-semibold text-[#3390ec]">Закреплённое сообщение</span>
<span className="block truncate text-sm text-[#1f2430]">
<span className="font-medium">{senderName}: </span>
{preview || 'Сообщение'}
</span>
</span>
</button>
);
}

View File

@@ -1,9 +1,10 @@
'use client'; 'use client';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { LayoutGrid, Loader2, Pin, Send, X } from 'lucide-react'; import { LayoutGrid, Loader2, Send, X } from 'lucide-react';
import { BotMessageKeyboard } from '@/components/chat/bot-message-keyboard'; import { BotMessageKeyboard } from '@/components/chat/bot-message-keyboard';
import { BotChatMessageContextMenu } from '@/components/chat/bot-chat-message-context-menu'; import { BotChatMessageContextMenu } from '@/components/chat/bot-chat-message-context-menu';
import { ChatPinnedMessageBanner } from '@/components/chat/chat-pinned-message-banner';
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 { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -69,6 +70,18 @@ export function FamilyBotChat({
const [botMeta, setBotMeta] = useState<BotChatMeta | null>(null); const [botMeta, setBotMeta] = useState<BotChatMeta | null>(null);
const { subscribe } = useRealtime(); const { subscribe } = useRealtime();
const { showToast } = useToast(); const { showToast } = useToast();
const pinnedMessage = messages
.filter((message) => message.isPinned)
.sort((left, right) => new Date(right.pinnedAt ?? right.createdAt).getTime() - new Date(left.pinnedAt ?? left.createdAt).getTime())[0] ?? null;
function scrollToBotMessage(messageId: string) {
const element = document.getElementById(`bot-msg-${messageId}`);
if (!element) return false;
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
element.classList.add('blink-highlight');
window.setTimeout(() => element.classList.remove('blink-highlight'), 2000);
return true;
}
const openMiniApp = useCallback( const openMiniApp = useCallback(
(url: string) => { (url: string) => {
@@ -236,6 +249,17 @@ export function FamilyBotChat({
return ( return (
<div className={cn('flex min-h-0 flex-1 flex-col', className)}> <div className={cn('flex min-h-0 flex-1 flex-col', className)}>
{pinnedMessage ? (
<ChatPinnedMessageBanner
senderName={pinnedMessage.direction === 'out' ? botName : 'Вы'}
preview={pinnedMessage.text}
onClick={() => {
if (!scrollToBotMessage(pinnedMessage.id)) {
showToast('Закреплённое сообщение пока не загружено в ленте');
}
}}
/>
) : null}
<div className="flex-1 space-y-2 overflow-y-auto px-4 py-4"> <div className="flex-1 space-y-2 overflow-y-auto px-4 py-4">
{loading ? ( {loading ? (
<div className="flex items-center justify-center gap-2 py-8 text-sm text-[#667085]"> <div className="flex items-center justify-center gap-2 py-8 text-sm text-[#667085]">
@@ -247,15 +271,9 @@ export function FamilyBotChat({
const mine = message.direction === 'in'; const mine = message.direction === 'in';
const hasKeyboard = Boolean(message.replyMarkupJson?.trim()); const hasKeyboard = Boolean(message.replyMarkupJson?.trim());
return ( return (
<div key={message.id} className={cn('flex', mine ? 'justify-end' : 'justify-start')}> <div key={message.id} id={`bot-msg-${message.id}`} className={cn('flex', mine ? 'justify-end' : 'justify-start')}>
<BotChatMessageContextMenu text={message.text} onCopy={() => showToast('Текст скопирован')}> <BotChatMessageContextMenu text={message.text} onCopy={() => showToast('Текст скопирован')}>
<div className={cn('max-w-[78%] rounded-[18px] px-3 py-2 shadow-sm', mine ? 'rounded-br-md bg-[#effdde]' : 'rounded-bl-md bg-white')}> <div className={cn('max-w-[78%] rounded-[18px] px-3 py-2 shadow-sm', mine ? 'rounded-br-md bg-[#effdde]' : 'rounded-bl-md bg-white')}>
{message.isPinned ? (
<div className={cn('mb-1 flex items-center gap-1 text-[11px] font-medium', mine ? 'text-[#6c8f56]' : 'text-[#3390ec]')}>
<Pin className="h-3 w-3" />
Закреплено
</div>
) : null}
{!mine ? <p className="mb-0.5 text-[11px] font-semibold text-[#3390ec]">{botName}</p> : null} {!mine ? <p className="mb-0.5 text-[11px] font-semibold text-[#3390ec]">{botName}</p> : null}
<p className="whitespace-pre-wrap text-sm">{message.text || '…'}</p> <p className="whitespace-pre-wrap text-sm">{message.text || '…'}</p>
{!mine && hasKeyboard && message.messageId > 0 ? ( {!mine && hasKeyboard && message.messageId > 0 ? (

View File

@@ -39,6 +39,7 @@ import { ChatToolsDialog, type ChatToolsTab } from '@/components/chat/chat-tools
import { ChatDeleteMessageDialog } from '@/components/chat/chat-delete-message-dialog'; import { ChatDeleteMessageDialog } from '@/components/chat/chat-delete-message-dialog';
import { ChatMessageEditBanner } from '@/components/chat/chat-message-edit-banner'; import { ChatMessageEditBanner } from '@/components/chat/chat-message-edit-banner';
import { ChatMessageContextMenu, ChatSelectionContextMenu, SelectableMessageWrapper } from '@/components/chat/chat-message-context-menu'; import { ChatMessageContextMenu, ChatSelectionContextMenu, SelectableMessageWrapper } from '@/components/chat/chat-message-context-menu';
import { ChatPinnedMessageBanner } from '@/components/chat/chat-pinned-message-banner';
import { ChatMessageReactions } from '@/components/chat/chat-message-reactions'; import { ChatMessageReactions } from '@/components/chat/chat-message-reactions';
import { ChatSelectionToolbar } from '@/components/chat/chat-selection-toolbar'; import { ChatSelectionToolbar } from '@/components/chat/chat-selection-toolbar';
import { EmojiPicker } from '@/components/chat/emoji-picker'; import { EmojiPicker } from '@/components/chat/emoji-picker';
@@ -80,6 +81,7 @@ import {
reportChatTyping, reportChatTyping,
sendChatMessage, sendChatMessage,
setChatMessagePinned, setChatMessagePinned,
setChatRoomPinned,
setChatRoomMuted, setChatRoomMuted,
toggleChatMessageReaction, toggleChatMessageReaction,
updateFamilyGroup, updateFamilyGroup,
@@ -247,6 +249,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
const [miniAppTitle, setMiniAppTitle] = useState('Mini App'); const [miniAppTitle, setMiniAppTitle] = useState('Mini App');
const [botChatMeta, setBotChatMeta] = useState<BotChatMeta | null>(null); const [botChatMeta, setBotChatMeta] = useState<BotChatMeta | null>(null);
const [deletingRoomId, setDeletingRoomId] = useState<string | null>(null); const [deletingRoomId, setDeletingRoomId] = useState<string | null>(null);
const [pinningRoomId, setPinningRoomId] = useState<string | null>(null);
const [familyAvatarUploading, setFamilyAvatarUploading] = useState(false); const [familyAvatarUploading, setFamilyAvatarUploading] = useState(false);
const [roomAvatarUploading, setRoomAvatarUploading] = useState(false); const [roomAvatarUploading, setRoomAvatarUploading] = useState(false);
const [forwardDialogOpen, setForwardDialogOpen] = useState(false); const [forwardDialogOpen, setForwardDialogOpen] = useState(false);
@@ -257,6 +260,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
const [chatToolsTab, setChatToolsTab] = useState<ChatToolsTab>('search'); const [chatToolsTab, setChatToolsTab] = useState<ChatToolsTab>('search');
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const autoScrollStateRef = useRef<{ id: string | null; count: number }>({ id: null, count: 0 });
const readReceiptTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const readReceiptTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const typingEmitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const typingEmitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastTypingSentAtRef = useRef(0); const lastTypingSentAtRef = useRef(0);
@@ -279,6 +283,13 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
() => new Map(visibleMessages.map((message) => [message.id, message])), () => new Map(visibleMessages.map((message) => [message.id, message])),
[visibleMessages] [visibleMessages]
); );
const pinnedMessage = useMemo(
() =>
visibleMessages
.filter((message) => message.isPinned)
.sort((left, right) => new Date(right.pinnedAt ?? right.createdAt).getTime() - new Date(left.pinnedAt ?? left.createdAt).getTime())[0] ?? null,
[visibleMessages]
);
const { typers, registerTyper, clearTypers } = useChatTyping(user?.id); const { typers, registerTyper, clearTypers } = useChatTyping(user?.id);
const { const {
@@ -311,6 +322,17 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
userId: user?.id, userId: user?.id,
token token
}); });
const pinnedVisibleText = pinnedMessage
? getE2EMessageText({
message: pinnedMessage,
isE2E: activeRoomIsE2E,
peerE2eKey,
peerKeyLoading,
e2ePayload: pinnedMessage.isEncrypted ? e2ePayloads[pinnedMessage.id] : null,
e2eError: e2eErrors[pinnedMessage.id]
})
: '';
const pinnedPreview = pinnedMessage ? getMessagePreviewText(pinnedMessage, pinnedVisibleText) : '';
const activeRoomTitle = useMemo( const activeRoomTitle = useMemo(
() => (activeRoom && user ? roomDisplayLabel(activeRoom, user.id) : ''), () => (activeRoom && user ? roomDisplayLabel(activeRoom, user.id) : ''),
[activeRoom, user] [activeRoom, user]
@@ -431,8 +453,21 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
}, [activeRoomId, messages, token]); }, [activeRoomId, messages, token]);
useEffect(() => { useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); autoScrollStateRef.current = { id: null, count: 0 };
}, [messages]); }, [activeRoomId]);
useEffect(() => {
const lastMessage = visibleMessages[visibleMessages.length - 1];
const previous = autoScrollStateRef.current;
const next = { id: lastMessage?.id ?? null, count: visibleMessages.length };
autoScrollStateRef.current = next;
if (!next.id) return;
const lastMessageChanged = previous.id !== next.id;
const messageWasAppended = next.count >= previous.count;
if (lastMessageChanged && messageWasAppended) {
messagesEndRef.current?.scrollIntoView({ behavior: previous.id ? 'smooth' : 'auto' });
}
}, [visibleMessages]);
useEffect(() => { useEffect(() => {
if (!activeRoomId || !token || activeRoomIsBot) return; if (!activeRoomId || !token || activeRoomIsBot) return;
@@ -683,6 +718,36 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
setRooms((current) => current.map((item) => (item.id === room.id ? room : item))); setRooms((current) => current.map((item) => (item.id === room.id ? room : item)));
} }
function sortRoomsForViewer(roomList: ChatRoom[]) {
return [...roomList].sort((a, b) => {
const pinDiff = Number(Boolean(b.isPinned)) - Number(Boolean(a.isPinned));
if (pinDiff !== 0) return pinDiff;
if (a.isPinned && b.isPinned) {
return (b.pinnedAt ?? '').localeCompare(a.pinnedAt ?? '');
}
const rank = (type: string) => (type === 'GENERAL' ? 0 : 1);
const rankDiff = rank(a.type) - rank(b.type);
if (rankDiff !== 0) return rankDiff;
const aTime = a.lastMessage?.createdAt ?? a.updatedAt;
const bTime = b.lastMessage?.createdAt ?? b.updatedAt;
return bTime.localeCompare(aTime);
});
}
async function handleToggleRoomPin(room: ChatRoom) {
if (!token) return;
setPinningRoomId(room.id);
try {
const updated = await setChatRoomPinned(room.id, !room.isPinned, token);
setRooms((current) => sortRoomsForViewer(current.map((item) => (item.id === updated.id ? updated : item))));
showToast(updated.isPinned ? 'Чат закреплён' : 'Чат откреплён');
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось закрепить чат') ?? 'Ошибка');
} finally {
setPinningRoomId(null);
}
}
async function handleAddRoomMember(memberUserId: string) { async function handleAddRoomMember(memberUserId: string) {
if (!token || !activeRoomId) return; if (!token || !activeRoomId) return;
try { try {
@@ -1281,7 +1346,10 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
<ChatRoomAvatarDisplay room={room} viewerUserId={user?.id ?? ''} token={token} /> <ChatRoomAvatarDisplay room={room} viewerUserId={user?.id ?? ''} token={token} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<p className="truncate font-medium">{user ? roomDisplayLabel(room, user.id) : room.name}</p> <p className="flex min-w-0 items-center gap-1 truncate font-medium">
<span className="truncate">{user ? roomDisplayLabel(room, user.id) : room.name}</span>
{room.isPinned ? <Pin className="h-3.5 w-3.5 shrink-0 fill-[#8a8f9e] text-[#8a8f9e]" /> : null}
</p>
{room.lastMessage ? ( {room.lastMessage ? (
<span className="shrink-0 text-[11px] text-[#a8adbc]">{formatMessageTime(room.lastMessage.createdAt)}</span> <span className="shrink-0 text-[11px] text-[#a8adbc]">{formatMessageTime(room.lastMessage.createdAt)}</span>
) : null} ) : null}
@@ -1291,17 +1359,23 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
</p> </p>
</div> </div>
</button> </button>
{room.type !== 'GENERAL' ? (
<button <button
type="button" type="button"
aria-label="Удалить чат" aria-label={room.isPinned ? 'Открепить чат' : 'Закрепить чат'}
disabled={deletingRoomId === room.id} disabled={pinningRoomId === room.id}
className="mr-2 flex h-8 w-8 shrink-0 self-center items-center justify-center rounded-lg text-[#667085] opacity-0 transition hover:bg-red-50 hover:text-red-600 group-hover:opacity-100 disabled:opacity-50" className={cn(
onClick={() => void handleDeleteRoom(room.id)} 'flex h-8 w-8 shrink-0 self-center items-center justify-center rounded-lg text-[#8a8f9e] transition hover:bg-[#eceef4] hover:text-[#667085]',
room.isPinned ? 'opacity-100' : 'opacity-0 group-hover:opacity-100',
pinningRoomId === room.id && 'opacity-100'
)}
onClick={() => void handleToggleRoomPin(room)}
> >
{deletingRoomId === room.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />} {pinningRoomId === room.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Pin className={cn('h-4 w-4', room.isPinned && 'fill-[#8a8f9e]')} />
)}
</button> </button>
) : null}
</div> </div>
))} ))}
</div> </div>
@@ -1452,6 +1526,17 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
/> />
) : ( ) : (
<> <>
{pinnedMessage ? (
<ChatPinnedMessageBanner
senderName={pinnedMessage.senderName}
preview={pinnedPreview}
onClick={() => {
if (!scrollToChatMessage(pinnedMessage.id)) {
showToast('Закреплённое сообщение пока не загружено в ленте');
}
}}
/>
) : 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', isDragging && 'select-none')}>
{visibleMessages.map((message, messageIndex) => { {visibleMessages.map((message, messageIndex) => {
const previousMessage = visibleMessages[messageIndex - 1]; const previousMessage = visibleMessages[messageIndex - 1];
@@ -1518,12 +1603,6 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
) )
)} )}
> >
{message.isPinned && !isLargeEmoji ? (
<div className={cn('mb-1 flex items-center gap-1 text-[11px] font-medium', mine ? 'text-[#6c8f56]' : 'text-[#3390ec]')}>
<Pin className="h-3 w-3" />
Закреплено
</div>
) : null}
{!mine ? <p className="mb-1 text-xs font-semibold text-[#3390ec]">{message.senderName}</p> : null} {!mine ? <p className="mb-1 text-xs font-semibold text-[#3390ec]">{message.senderName}</p> : null}
{forwardedFrom ? ( {forwardedFrom ? (
<p className="mb-1 text-xs font-medium text-[#3390ec]"> Переслано от {forwardedFrom.senderName}</p> <p className="mb-1 text-xs font-medium text-[#3390ec]"> Переслано от {forwardedFrom.senderName}</p>

View File

@@ -1,8 +1,8 @@
'use client'; 'use client';
import Link from 'next/link'; import Link from 'next/link';
import { useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Loader2, Search, UsersRound } from 'lucide-react'; import { Loader2, Pin, Search, UsersRound } from 'lucide-react';
import { AvatarWithPresence } from '@/components/family/avatar-with-presence'; import { AvatarWithPresence } from '@/components/family/avatar-with-presence';
import { FamilyGroupSelector } from '@/components/family/family-group-selector'; import { FamilyGroupSelector } from '@/components/family/family-group-selector';
import { FamilyInviteDialog } from '@/components/family/family-invite-dialog'; import { FamilyInviteDialog } from '@/components/family/family-invite-dialog';
@@ -11,6 +11,8 @@ import { useAuth } from '@/components/id/auth-provider';
import { useSelectedFamily } from '@/hooks/use-primary-family'; import { useSelectedFamily } from '@/hooks/use-primary-family';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { ChatRoom, fetchChatRooms, getApiErrorMessage, setChatRoomPinned } from '@/lib/api';
import { useToast } from '@/components/id/toast-provider';
export function FamilySidebarPanel() { export function FamilySidebarPanel() {
const { user, token } = useAuth(); const { user, token } = useAuth();
@@ -25,7 +27,64 @@ export function FamilySidebarPanel() {
refresh refresh
} = useSelectedFamily(Boolean(user && token)); } = useSelectedFamily(Boolean(user && token));
const { openChatWithMember } = useFamilyOverlay(); const { openChatWithMember } = useFamilyOverlay();
const { showToast } = useToast();
const [inviteOpen, setInviteOpen] = useState(false); const [inviteOpen, setInviteOpen] = useState(false);
const [rooms, setRooms] = useState<ChatRoom[]>([]);
const [pinningRoomId, setPinningRoomId] = useState<string | null>(null);
const currentUserId = user?.id ?? '';
useEffect(() => {
if (!group?.id || !token) {
setRooms([]);
return;
}
fetchChatRooms(group.id, token)
.then((response) => setRooms(response.rooms ?? []))
.catch(() => setRooms([]));
}, [group?.id, token]);
const roomByPeerUserId = useMemo(() => {
const map = new Map<string, ChatRoom>();
for (const room of rooms) {
if (room.type !== 'DIRECT' && room.type !== 'BOT' && room.type !== 'E2E') continue;
const peer = room.members.find((member) => member.userId !== currentUserId);
if (peer) map.set(peer.userId, room);
}
return map;
}, [currentUserId, rooms]);
const sortedMembers = useMemo(() => {
return [...(group?.members ?? [])].sort((left, right) => {
const leftRoom = roomByPeerUserId.get(left.userId);
const rightRoom = roomByPeerUserId.get(right.userId);
const pinDiff = Number(Boolean(rightRoom?.isPinned)) - Number(Boolean(leftRoom?.isPinned));
if (pinDiff !== 0) return pinDiff;
if (leftRoom?.isPinned && rightRoom?.isPinned) {
return (rightRoom.pinnedAt ?? '').localeCompare(leftRoom.pinnedAt ?? '');
}
if (left.userId === currentUserId) return -1;
if (right.userId === currentUserId) return 1;
return left.displayName.localeCompare(right.displayName, 'ru');
});
}, [currentUserId, group?.members, roomByPeerUserId]);
async function toggleMemberPinned(memberUserId: string) {
const room = roomByPeerUserId.get(memberUserId);
if (!room || !token) {
showToast('Откройте чат с участником, чтобы его можно было закрепить');
return;
}
setPinningRoomId(room.id);
try {
const updated = await setChatRoomPinned(room.id, !room.isPinned, token);
setRooms((current) => current.map((item) => (item.id === updated.id ? updated : item)));
showToast(updated.isPinned ? 'Чат закреплён' : 'Чат откреплён');
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось закрепить чат') ?? 'Ошибка');
} finally {
setPinningRoomId(null);
}
}
if (!user || !token) return null; if (!user || !token) return null;
@@ -69,24 +128,27 @@ export function FamilySidebarPanel() {
Загрузка... Загрузка...
</div> </div>
) : hasFamily ? ( ) : hasFamily ? (
<div className="max-h-[220px] space-y-1 overflow-y-auto pr-1"> <div className="space-y-1 overflow-y-auto pr-1">
{(group?.members ?? []).map((member) => { {sortedMembers.map((member) => {
const presence = presenceByUserId.get(member.userId); const presence = presenceByUserId.get(member.userId);
const isSelf = member.userId === user.id; const isSelf = member.userId === currentUserId;
const memberRoom = roomByPeerUserId.get(member.userId);
const memberPinned = Boolean(memberRoom?.isPinned);
return ( return (
<button <div
key={member.id} key={member.id}
type="button"
className={cn( className={cn(
'flex w-full items-center gap-2 rounded-xl px-2 py-1.5 text-left transition hover:bg-[#f4f5f8]', 'flex w-full items-center gap-1 rounded-xl transition hover:bg-[#f4f5f8]',
isSelf && 'bg-[#fafbfd]' isSelf && 'bg-[#fafbfd]'
)} )}
onClick={() => >
openChatWithMember(member.userId, member.displayName, { <button
type="button"
className="flex min-w-0 flex-1 items-center gap-2 px-2 py-1.5 text-left"
onClick={() => openChatWithMember(member.userId, member.displayName, {
isBot: member.isBot, isBot: member.isBot,
botUsername: member.botUsername botUsername: member.botUsername
}) })}
}
title={isSelf ? 'Вы' : `Написать ${member.displayName}`} title={isSelf ? 'Вы' : `Написать ${member.displayName}`}
> >
<AvatarWithPresence <AvatarWithPresence
@@ -100,10 +162,11 @@ export function FamilySidebarPanel() {
className="h-8 w-8" className="h-8 w-8"
/> />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-[12px] font-medium leading-tight"> <p className="flex min-w-0 items-center gap-1 truncate text-[12px] font-medium leading-tight">
{member.displayName} <span className="truncate">{member.displayName}</span>
{member.isBot ? ' 🤖' : ''} {member.isBot ? <span>🤖</span> : null}
{isSelf ? ' (Вы)' : ''} {isSelf ? <span>(Вы)</span> : null}
{memberPinned ? <Pin className="h-3 w-3 shrink-0 fill-[#8a8f9e] text-[#8a8f9e]" /> : null}
</p> </p>
{!isSelf ? ( {!isSelf ? (
<p className={cn('truncate text-[10px]', member.isBot ? 'text-[#3390ec]' : presence?.online ? 'text-emerald-600' : 'text-[#a8adbc]')}> <p className={cn('truncate text-[10px]', member.isBot ? 'text-[#3390ec]' : presence?.online ? 'text-emerald-600' : 'text-[#a8adbc]')}>
@@ -112,6 +175,21 @@ export function FamilySidebarPanel() {
) : null} ) : null}
</div> </div>
</button> </button>
{!isSelf ? (
<button
type="button"
aria-label={memberPinned ? 'Открепить чат' : 'Закрепить чат'}
className="mr-1 flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-[#8a8f9e] transition hover:bg-[#eceef4] hover:text-[#667085]"
onClick={() => void toggleMemberPinned(member.userId)}
>
{pinningRoomId === memberRoom?.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Pin className={cn('h-3.5 w-3.5', memberPinned && 'fill-[#8a8f9e]')} />
)}
</button>
) : null}
</div>
); );
})} })}
</div> </div>

View File

@@ -2,13 +2,14 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { ArrowLeft, BarChart3, CalendarDays, ImageIcon, Loader2, MessageCircle, Paperclip, Pin, Search, Send, Settings, Smile, Trash2, X } from 'lucide-react'; import { ArrowLeft, BarChart3, CalendarDays, ImageIcon, Loader2, MessageCircle, Paperclip, Search, Send, Settings, Smile, Trash2, X } from 'lucide-react';
import { AvatarWithPresence } from '@/components/family/avatar-with-presence'; import { AvatarWithPresence } from '@/components/family/avatar-with-presence';
import { FamilyBotChat, BotMiniAppSheet } from '@/components/family/family-bot-chat'; import { FamilyBotChat, BotMiniAppSheet } from '@/components/family/family-bot-chat';
import { ChatRoomAvatarDisplay } from '@/components/family/chat-room-avatar-display'; import { ChatRoomAvatarDisplay } from '@/components/family/chat-room-avatar-display';
import { ChatDateSeparator } from '@/components/chat/chat-date-separator'; import { ChatDateSeparator } from '@/components/chat/chat-date-separator';
import { ChatEmojiContent } from '@/components/chat/chat-emoji-content'; import { ChatEmojiContent } from '@/components/chat/chat-emoji-content';
import { ChatMessageContextMenu, ChatSelectionContextMenu, SelectableMessageWrapper } from '@/components/chat/chat-message-context-menu'; import { ChatMessageContextMenu, ChatSelectionContextMenu, SelectableMessageWrapper } from '@/components/chat/chat-message-context-menu';
import { ChatPinnedMessageBanner } from '@/components/chat/chat-pinned-message-banner';
import { ChatDeleteMessageDialog } from '@/components/chat/chat-delete-message-dialog'; import { ChatDeleteMessageDialog } from '@/components/chat/chat-delete-message-dialog';
import { ChatMessageEditBanner } from '@/components/chat/chat-message-edit-banner'; import { ChatMessageEditBanner } from '@/components/chat/chat-message-edit-banner';
import { ChatMessageReactions } from '@/components/chat/chat-message-reactions'; import { ChatMessageReactions } from '@/components/chat/chat-message-reactions';
@@ -122,6 +123,7 @@ export function MiniFamilyChat() {
const [chatToolsOpen, setChatToolsOpen] = useState(false); const [chatToolsOpen, setChatToolsOpen] = useState(false);
const [chatToolsTab, setChatToolsTab] = useState<ChatToolsTab>('search'); const [chatToolsTab, setChatToolsTab] = useState<ChatToolsTab>('search');
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const autoScrollStateRef = useRef<{ id: string | null; count: number }>({ id: null, count: 0 });
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const previousGroupIdRef = useRef<string | null>(null); const previousGroupIdRef = useRef<string | null>(null);
@@ -129,6 +131,13 @@ export function MiniFamilyChat() {
() => messages.filter((message) => !message.isDeleted), () => messages.filter((message) => !message.isDeleted),
[messages] [messages]
); );
const pinnedMessage = useMemo(
() =>
visibleMessages
.filter((message) => message.isPinned)
.sort((left, right) => new Date(right.pinnedAt ?? right.createdAt).getTime() - new Date(left.pinnedAt ?? left.createdAt).getTime())[0] ?? null,
[visibleMessages]
);
const { const {
selectionMode, selectionMode,
@@ -152,6 +161,17 @@ export function MiniFamilyChat() {
userId: user?.id, userId: user?.id,
token token
}); });
const pinnedVisibleText = pinnedMessage
? getE2EMessageText({
message: pinnedMessage,
isE2E: activeRoomIsE2E,
peerE2eKey,
peerKeyLoading,
e2ePayload: pinnedMessage.isEncrypted ? e2ePayloads[pinnedMessage.id] : null,
e2eError: e2eErrors[pinnedMessage.id]
})
: '';
const pinnedPreview = pinnedMessage ? getMessagePreviewText(pinnedMessage, pinnedVisibleText) : '';
const loadRooms = useCallback(async () => { const loadRooms = useCallback(async () => {
if (!group || !token) return; if (!group || !token) return;
@@ -357,8 +377,21 @@ export function MiniFamilyChat() {
}, [activeRoom, subscribe]); }, [activeRoom, subscribe]);
useEffect(() => { useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); autoScrollStateRef.current = { id: null, count: 0 };
}, [messages]); }, [activeRoom?.id]);
useEffect(() => {
const lastMessage = visibleMessages[visibleMessages.length - 1];
const previous = autoScrollStateRef.current;
const next = { id: lastMessage?.id ?? null, count: visibleMessages.length };
autoScrollStateRef.current = next;
if (!next.id) return;
const lastMessageChanged = previous.id !== next.id;
const messageWasAppended = next.count >= previous.count;
if (lastMessageChanged && messageWasAppended) {
messagesEndRef.current?.scrollIntoView({ behavior: previous.id ? 'smooth' : 'auto' });
}
}, [visibleMessages]);
useEffect(() => { useEffect(() => {
if (!activeRoom || !token || !messages.length) return; if (!activeRoom || !token || !messages.length) return;
@@ -914,6 +947,18 @@ export function MiniFamilyChat() {
/> />
) : ( ) : (
<> <>
{pinnedMessage ? (
<ChatPinnedMessageBanner
senderName={pinnedMessage.senderName}
preview={pinnedPreview}
className="px-3 py-2"
onClick={() => {
if (!scrollToChatMessage(pinnedMessage.id)) {
showToast('Закреплённое сообщение пока не загружено в ленте');
}
}}
/>
) : null}
<div <div
className={cn('flex-1 space-y-2 overflow-y-auto bg-[#eef1f6] p-3', isDragging && 'select-none')} className={cn('flex-1 space-y-2 overflow-y-auto bg-[#eef1f6] p-3', isDragging && 'select-none')}
style={{ maxHeight: 'min(50vh,380px)' }} style={{ maxHeight: 'min(50vh,380px)' }}
@@ -978,12 +1023,6 @@ export function MiniFamilyChat() {
) )
)} )}
> >
{message.isPinned && !isLargeEmoji ? (
<div className={cn('mb-1 flex items-center gap-1 text-[10px] font-medium', mine ? 'text-[#6c8f56]' : 'text-[#3390ec]')}>
<Pin className="h-3 w-3" />
Закреплено
</div>
) : null}
{!mine && !isLargeEmoji ? ( {!mine && !isLargeEmoji ? (
<p className="mb-0.5 text-[11px] font-semibold text-[#3390ec]">{message.senderName}</p> <p className="mb-0.5 text-[11px] font-semibold text-[#3390ec]">{message.senderName}</p>
) : null} ) : null}

View File

@@ -854,6 +854,8 @@ export interface ChatRoomMember {
isVerified?: boolean; isVerified?: boolean;
verificationIcon?: string | null; verificationIcon?: string | null;
notificationsMuted: boolean; notificationsMuted: boolean;
isPinned?: boolean;
pinnedAt?: string;
familyRole?: string; familyRole?: string;
isChatCreator?: boolean; isChatCreator?: boolean;
} }
@@ -869,6 +871,8 @@ export interface ChatRoom {
hasAvatar: boolean; hasAvatar: boolean;
createdById?: string; createdById?: string;
updatedAt: string; updatedAt: string;
isPinned?: boolean;
pinnedAt?: string;
members: ChatRoomMember[]; members: ChatRoomMember[];
lastMessage?: ChatMessage; lastMessage?: ChatMessage;
} }
@@ -1120,6 +1124,13 @@ export async function setChatRoomMuted(roomId: string, muted: boolean, token?: s
return apiFetch(`/chat/rooms/${roomId}/mute`, { method: 'POST', body: JSON.stringify({ muted }) }, token); return apiFetch(`/chat/rooms/${roomId}/mute`, { method: 'POST', body: JSON.stringify({ muted }) }, token);
} }
export async function setChatRoomPinned(roomId: string, pinned: boolean, token?: string | null) {
return apiFetch<ChatRoom>(`/chat/rooms/${roomId}`, {
method: 'PATCH',
body: JSON.stringify({ pinned })
}, token);
}
export async function editChatMessage(messageId: string, content: string, token?: string | null) { export async function editChatMessage(messageId: string, content: string, token?: string | null) {
return apiFetch<ChatMessage>(`/chat/messages/${messageId}`, { method: 'PATCH', body: JSON.stringify({ content }) }, token); return apiFetch<ChatMessage>(`/chat/messages/${messageId}`, { method: 'PATCH', body: JSON.stringify({ content }) }, token);
} }

View File

@@ -0,0 +1 @@

View File

@@ -11,6 +11,7 @@ COPY apps/sso-core/package.json ./apps/sso-core/
COPY apps/api-gateway/package.json ./apps/api-gateway/ COPY apps/api-gateway/package.json ./apps/api-gateway/
COPY apps/frontend/package.json ./apps/frontend/ COPY apps/frontend/package.json ./apps/frontend/
COPY apps/docs/package.json ./apps/docs/ COPY apps/docs/package.json ./apps/docs/
COPY tauri_app/package.json ./tauri_app/
RUN --mount=type=cache,target=/root/.npm \ RUN --mount=type=cache,target=/root/.npm \
npm config set registry "${NPM_REGISTRY}" && \ npm config set registry "${NPM_REGISTRY}" && \

View File

@@ -418,6 +418,7 @@ model ChatRoomMember {
roomId String roomId String
userId String userId String
notificationsMuted Boolean @default(false) notificationsMuted Boolean @default(false)
pinnedAt DateTime?
lastReadAt DateTime? lastReadAt DateTime?
lastReadMessageId String? lastReadMessageId String?
joinedAt DateTime @default(now()) joinedAt DateTime @default(now())
@@ -425,6 +426,7 @@ model ChatRoomMember {
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([roomId, userId]) @@unique([roomId, userId])
@@index([userId, pinnedAt])
} }
model ChatMessage { model ChatMessage {

View File

@@ -985,8 +985,8 @@ export class AuthGrpcController {
} }
@GrpcMethod('ChatService', 'UpdateRoomSettings') @GrpcMethod('ChatService', 'UpdateRoomSettings')
updateChatRoomSettings(command: { userId: string; roomId: string; name?: string; notificationsMuted?: boolean }) { updateChatRoomSettings(command: { userId: string; roomId: string; name?: string; notificationsMuted?: boolean; pinned?: boolean }) {
return this.chat.updateRoomSettings(command.userId, command.roomId, command.name, command.notificationsMuted); return this.chat.updateRoomSettings(command.userId, command.roomId, command.name, command.notificationsMuted, command.pinned);
} }
@GrpcMethod('ChatService', 'AddRoomMember') @GrpcMethod('ChatService', 'AddRoomMember')

View File

@@ -104,6 +104,11 @@ export class ChatService {
); );
formatted.sort((a, b) => { formatted.sort((a, b) => {
const pinDiff = Number(Boolean(b.isPinned)) - Number(Boolean(a.isPinned));
if (pinDiff !== 0) return pinDiff;
if (a.isPinned && b.isPinned) {
return (b.pinnedAt ?? '').localeCompare(a.pinnedAt ?? '');
}
const rank = (type: string) => (type === 'GENERAL' ? 0 : 1); const rank = (type: string) => (type === 'GENERAL' ? 0 : 1);
const rankDiff = rank(a.type) - rank(b.type); const rankDiff = rank(a.type) - rank(b.type);
if (rankDiff !== 0) return rankDiff; if (rankDiff !== 0) return rankDiff;
@@ -360,7 +365,7 @@ export class ChatService {
return this.getRoomResponse(roomId, userId); return this.getRoomResponse(roomId, userId);
} }
async updateRoomSettings(userId: string, roomId: string, name?: string, notificationsMuted?: boolean) { async updateRoomSettings(userId: string, roomId: string, name?: string, notificationsMuted?: boolean, pinned?: boolean) {
const room = await this.getRoomForMember(roomId, userId); const room = await this.getRoomForMember(roomId, userId);
if (name !== undefined) { if (name !== undefined) {
const trimmedName = name.trim(); const trimmedName = name.trim();
@@ -381,6 +386,12 @@ export class ChatService {
data: { notificationsMuted } data: { notificationsMuted }
}); });
} }
if (pinned !== undefined) {
await this.prisma.chatRoomMember.update({
where: { roomId_userId: { roomId, userId } },
data: { pinnedAt: pinned ? new Date() : null }
});
}
return this.getRoomResponse(roomId, userId); return this.getRoomResponse(roomId, userId);
} }
@@ -903,6 +914,7 @@ export class ChatService {
id: string; id: string;
userId: string; userId: string;
notificationsMuted: boolean; notificationsMuted: boolean;
pinnedAt?: Date | null;
user: { displayName: string; avatarStorageKey: string | null; isVerified: boolean; verificationIcon: string | null }; user: { displayName: string; avatarStorageKey: string | null; isVerified: boolean; verificationIcon: string | null };
}>; }>;
}, },
@@ -910,6 +922,7 @@ export class ChatService {
lastMessage?: Awaited<ReturnType<ChatService['toMessage']>>, lastMessage?: Awaited<ReturnType<ChatService['toMessage']>>,
viewerId?: string viewerId?: string
) { ) {
const viewerMembership = viewerId ? room.members.find((member) => member.userId === viewerId) : undefined;
const peerMember = const peerMember =
viewerId && (room.type === 'DIRECT' || room.type === 'BOT' || room.type === 'E2E') viewerId && (room.type === 'DIRECT' || room.type === 'BOT' || room.type === 'E2E')
? room.members.find((member) => member.userId !== viewerId) ? room.members.find((member) => member.userId !== viewerId)
@@ -926,6 +939,8 @@ export class ChatService {
hasAvatar: room.hasAvatar, hasAvatar: room.hasAvatar,
createdById: room.createdById ?? undefined, createdById: room.createdById ?? undefined,
updatedAt: room.updatedAt.toISOString(), updatedAt: room.updatedAt.toISOString(),
isPinned: Boolean(viewerMembership?.pinnedAt),
pinnedAt: viewerMembership?.pinnedAt?.toISOString(),
members: room.members.map((member) => ({ members: room.members.map((member) => ({
id: member.id, id: member.id,
userId: member.userId, userId: member.userId,
@@ -934,6 +949,8 @@ export class ChatService {
isVerified: member.user.isVerified, isVerified: member.user.isVerified,
verificationIcon: member.user.isVerified ? resolveVerificationIcon(member.user.verificationIcon) : undefined, verificationIcon: member.user.isVerified ? resolveVerificationIcon(member.user.verificationIcon) : undefined,
notificationsMuted: member.notificationsMuted, notificationsMuted: member.notificationsMuted,
isPinned: Boolean(member.pinnedAt),
pinnedAt: member.pinnedAt?.toISOString(),
familyRole: familyRoleByUserId.get(member.userId) ?? 'member', familyRole: familyRoleByUserId.get(member.userId) ?? 'member',
isChatCreator: room.createdById === member.userId isChatCreator: room.createdById === member.userId
})), })),

View File

@@ -217,6 +217,8 @@ services:
INTERNAL_WS_URL: ${INTERNAL_WS_URL:-http://media-ws:8085} INTERNAL_WS_URL: ${INTERNAL_WS_URL:-http://media-ws:8085}
ports: ports:
- "127.0.0.1:3002:3000" - "127.0.0.1:3002:3000"
volumes:
- ./apps/frontend/public/downloads:/app/public/downloads:ro
depends_on: depends_on:
api-gateway: api-gateway:
condition: service_healthy condition: service_healthy
@@ -226,6 +228,23 @@ services:
timeout: 5s timeout: 5s
retries: 10 retries: 10
tauri-apk-builder:
pull_policy: build
build:
context: .
dockerfile: tauri_app/Dockerfile.apk
args:
NPM_REGISTRY: ${NPM_REGISTRY:-https://registry.npmjs.org}
container_name: lendry-id-tauri-apk-builder
restart: "no"
environment:
VITE_API_URL: ${PUBLIC_API_URL:-http://localhost:3000}
VITE_FRONTEND_URL: ${PUBLIC_FRONTEND_URL:-http://localhost:3002}
PUBLIC_API_URL: ${PUBLIC_API_URL:-http://localhost:3000}
PUBLIC_FRONTEND_URL: ${PUBLIC_FRONTEND_URL:-http://localhost:3002}
volumes:
- ./apps/frontend/public/downloads:/out
docs: docs:
pull_policy: build pull_policy: build
build: build:

1852
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,8 @@
"version": "0.1.0", "version": "0.1.0",
"description": "Enterprise Identity Provider ecosystem inspired by Yandex ID.", "description": "Enterprise Identity Provider ecosystem inspired by Yandex ID.",
"workspaces": [ "workspaces": [
"apps/*" "apps/*",
"tauri_app"
], ],
"scripts": { "scripts": {
"build": "npm run build --workspaces --if-present", "build": "npm run build --workspaces --if-present",

View File

@@ -46,6 +46,7 @@ message UpdateRoomSettingsRequest {
string roomId = 2; string roomId = 2;
optional string name = 3; optional string name = 3;
optional bool notificationsMuted = 4; optional bool notificationsMuted = 4;
optional bool pinned = 5;
} }
message AddRoomMemberRequest { message AddRoomMemberRequest {
@@ -156,6 +157,8 @@ message ChatRoomMemberResponse {
bool notificationsMuted = 5; bool notificationsMuted = 5;
string familyRole = 6; string familyRole = 6;
bool isChatCreator = 7; bool isChatCreator = 7;
bool isPinned = 8;
optional string pinnedAt = 9;
} }
message ChatRoomResponse { message ChatRoomResponse {
@@ -171,6 +174,8 @@ message ChatRoomResponse {
optional string peerUserId = 10; optional string peerUserId = 10;
optional string botUsername = 11; optional string botUsername = 11;
optional bool isE2E = 12; optional bool isE2E = 12;
bool isPinned = 13;
optional string pinnedAt = 14;
} }
message PollOptionResponse { message PollOptionResponse {

64
tauri_app/Dockerfile.apk Normal file
View File

@@ -0,0 +1,64 @@
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
ENV ANDROID_HOME=/opt/android-sdk
ENV ANDROID_SDK_ROOT=/opt/android-sdk
ENV ANDROID_NDK_HOME=/opt/android-sdk/ndk/27.0.12077973
ENV NDK_HOME=/opt/android-sdk/ndk/27.0.12077973
ENV JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64
ENV PATH=/root/.cargo/bin:/opt/android-sdk/cmdline-tools/latest/bin:/opt/android-sdk/platform-tools:/usr/local/bin:$PATH
ARG NPM_REGISTRY=https://registry.npmjs.org
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
curl \
unzip \
xz-utils \
git \
build-essential \
pkg-config \
libssl-dev \
openjdk-17-jdk \
nodejs \
npm \
&& rm -rf /var/lib/apt/lists/*
RUN mkdir -p ${ANDROID_HOME}/cmdline-tools \
&& curl -fsSL https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -o /tmp/android-tools.zip \
&& unzip -q /tmp/android-tools.zip -d /tmp/android-tools \
&& mv /tmp/android-tools/cmdline-tools ${ANDROID_HOME}/cmdline-tools/latest \
&& rm -rf /tmp/android-tools /tmp/android-tools.zip
RUN yes | sdkmanager --licenses >/dev/null \
&& sdkmanager \
"platform-tools" \
"platforms;android-35" \
"build-tools;35.0.0" \
"ndk;27.0.12077973"
RUN curl --proto '=https' --tlsv1.2 -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal \
&& rustup target add aarch64-linux-android armv7-linux-androideabi i686-linux-android x86_64-linux-android
WORKDIR /workspace
COPY package.json package-lock.json .npmrc ./
COPY apps/sso-core/package.json ./apps/sso-core/
COPY apps/api-gateway/package.json ./apps/api-gateway/
COPY apps/frontend/package.json ./apps/frontend/
COPY apps/docs/package.json ./apps/docs/
COPY tauri_app/package.json ./tauri_app/
RUN --mount=type=cache,target=/root/.npm \
npm config set registry "${NPM_REGISTRY}" && \
npm ci --no-audit --no-fund
COPY apps/frontend ./apps/frontend
COPY shared ./shared
COPY tsconfig.base.json ./
COPY tauri_app ./tauri_app
COPY tauri_app/docker/build-android-apk.sh /usr/local/bin/build-android-apk
RUN chmod +x /usr/local/bin/build-android-apk
CMD ["build-android-apk"]

62
tauri_app/README.md Normal file
View File

@@ -0,0 +1,62 @@
# Lendry ID Super App
Tauri v2 приложение для macOS, iOS и Android. Использует тот же React/Tailwind/shadcn UI стиль, что и `apps/frontend`, через Vite alias:
```ts
'@': '../apps/frontend'
```
## Запуск
```bash
npm install
npm --workspace @lendry/tauri-app run tauri:dev
```
## Web build
```bash
npm --workspace @lendry/tauri-app run build
```
## Mobile bootstrap
```bash
npm --workspace @lendry/tauri-app run android:init
npm --workspace @lendry/tauri-app run ios:init
```
После генерации проверьте `src-tauri/mobile-permissions.md` и добавьте camera permissions, если CLI не внёс их автоматически.
## Docker APK build
При запуске общего `docker compose up --build` сервис `tauri-apk-builder` собирает Android APK и кладёт файл сюда:
```text
apps/frontend/public/downloads/lendry-id.apk
```
После этого кнопка «Скачать приложение» на странице входа скачивает APK через:
```text
/download/android
```
Собрать только APK:
```bash
docker compose up --build tauri-apk-builder
```
## Переменные окружения
```bash
VITE_API_URL=https://sso.example.ru/idp-api
VITE_FRONTEND_URL=https://sso.example.ru
```
## Реализованные модули
- `Chats`: wrapper существующего web UI `/family`, чтобы не дублировать messenger.
- `Security`: QR scanner + approve `/auth/advanced/qr/session/:sessionId/approve`, список сессий и revoke.
- `Authenticator`: offline TOTP RFC 6238 с хранением seeds в `tauri-plugin-stronghold`.

View File

@@ -0,0 +1,36 @@
#!/usr/bin/env bash
set -euo pipefail
cd /workspace
export VITE_API_URL="${VITE_API_URL:-${PUBLIC_API_URL:-http://localhost:3000}}"
export VITE_FRONTEND_URL="${VITE_FRONTEND_URL:-${PUBLIC_FRONTEND_URL:-http://localhost:3002}}"
mkdir -p /out
echo "==> Проверка web-сборки tauri_app"
npm --workspace @lendry/tauri-app run build
cd /workspace/tauri_app
if [ ! -d "src-tauri/gen/android" ]; then
echo "==> Инициализация Android проекта Tauri"
npm run android:init -- --ci || npm run android:init
fi
echo "==> Сборка Android APK"
npm run tauri -- android build --apk
APK_PATH="$(find /workspace/tauri_app/src-tauri/gen/android -type f -name '*.apk' | sort | tail -n 1 || true)"
if [ -z "${APK_PATH}" ] || [ ! -f "${APK_PATH}" ]; then
echo "APK не найден после сборки" >&2
find /workspace/tauri_app/src-tauri/gen/android -maxdepth 8 -type f | sort >&2 || true
exit 1
fi
cp "${APK_PATH}" /out/lendry-id.apk
chmod 0644 /out/lendry-id.apk
echo "==> APK готов: /out/lendry-id.apk"
ls -lh /out/lendry-id.apk

13
tauri_app/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#111827" />
<title>IDP App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

45
tauri_app/package.json Normal file
View File

@@ -0,0 +1,45 @@
{
"name": "@lendry/tauri-app",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"tauri": "tauri",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build",
"android:init": "tauri android init",
"android:dev": "tauri android dev",
"ios:init": "tauri ios init",
"ios:dev": "tauri ios dev"
},
"dependencies": {
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-slot": "^1.2.4",
"@tauri-apps/api": "^2.9.0",
"@tauri-apps/plugin-opener": "^2.5.0",
"@tauri-apps/plugin-stronghold": "^2.3.0",
"@zxing/browser": "^0.1.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.561.0",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"tailwind-merge": "^3.4.0",
"zod": "^4.2.1"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.18",
"@tauri-apps/cli": "^2.9.0",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.2",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vite": "^7.3.0"
}
}

View File

@@ -0,0 +1,30 @@
[package]
name = "lendry_id_super_app"
version = "0.1.0"
description = "Lendry ID Super App"
authors = ["Lendry"]
edition = "2021"
rust-version = "1.77"
[lib]
name = "lendry_id_super_app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
tauri-plugin-stronghold = "2"
url = "2"
dirs-next = "2"
[profile.release]
panic = "abort"
codegen-units = 1
lto = true
opt-level = "s"
strip = true

View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build();
}

View File

@@ -0,0 +1,11 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Основные разрешения Lendry ID Super App",
"windows": ["main"],
"permissions": [
"core:default",
"opener:default",
"stronghold:default"
]
}

View File

@@ -0,0 +1,30 @@
# Mobile Permissions
После генерации native-проектов командами:
```bash
npm run android:init
npm run ios:init
```
проверьте, что Tauri CLI добавил разрешения. Если нет, внесите их вручную.
## Android
`src-tauri/gen/android/app/src/main/AndroidManifest.xml`:
```xml
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
```
## iOS
`src-tauri/gen/apple/<target>/Info.plist`:
```xml
<key>NSCameraUsageDescription</key>
<string>Камера нужна для сканирования QR-кода входа Lendry ID.</string>
```
QR-сканер использует HTML5 WebRTC внутри WebView. TOTP-секреты сохраняются через `tauri-plugin-stronghold`, а не в LocalStorage.

View File

@@ -0,0 +1,148 @@
use serde::{Deserialize, Serialize};
use tauri::Manager;
use url::Url;
const MAX_LABEL_LEN: usize = 80;
const MAX_ISSUER_LEN: usize = 80;
const MIN_TOTP_SECRET_LEN: usize = 16;
const MAX_TOTP_SECRET_LEN: usize = 256;
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct ValidationResult {
ok: bool,
value: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct TotpProfileInput {
issuer: String,
label: String,
secret: String,
digits: Option<u8>,
period: Option<u16>,
}
#[tauri::command]
fn validate_qr_session_payload(payload: String) -> Result<ValidationResult, String> {
let trimmed = payload.trim();
if trimmed.is_empty() {
return Err("QR-код пустой".into());
}
let session_id = match Url::parse(trimmed) {
Ok(url) => url
.query_pairs()
.find(|(key, _)| key == "sessionId" || key == "session_id")
.map(|(_, value)| value.to_string())
.or_else(|| {
url.path_segments()
.and_then(|segments| segments.last().map(ToString::to_string))
})
.unwrap_or_default(),
Err(_) => trimmed.to_string(),
};
validate_uuid_like(&session_id)?;
Ok(ValidationResult {
ok: true,
value: session_id,
})
}
#[tauri::command]
fn validate_api_base_url(url: String) -> Result<ValidationResult, String> {
let parsed = Url::parse(url.trim()).map_err(|_| "Некорректный URL API".to_string())?;
match parsed.scheme() {
"http" | "https" => Ok(ValidationResult {
ok: true,
value: parsed.as_str().trim_end_matches('/').to_string(),
}),
_ => Err("URL API должен начинаться с http:// или https://".into()),
}
}
#[tauri::command]
fn validate_totp_profile_input(input: TotpProfileInput) -> Result<ValidationResult, String> {
let issuer = input.issuer.trim();
let label = input.label.trim();
let secret = normalize_base32_secret(&input.secret);
let digits = input.digits.unwrap_or(6);
let period = input.period.unwrap_or(30);
if issuer.is_empty() || issuer.len() > MAX_ISSUER_LEN {
return Err("Название сервиса должно содержать от 1 до 80 символов".into());
}
if label.is_empty() || label.len() > MAX_LABEL_LEN {
return Err("Название аккаунта должно содержать от 1 до 80 символов".into());
}
if secret.len() < MIN_TOTP_SECRET_LEN || secret.len() > MAX_TOTP_SECRET_LEN {
return Err("TOTP secret должен содержать от 16 до 256 символов base32".into());
}
if !secret.chars().all(|ch| matches!(ch, 'A'..='Z' | '2'..='7' | '=')) {
return Err("TOTP secret должен быть в формате base32".into());
}
if digits != 6 && digits != 8 {
return Err("TOTP поддерживает только 6 или 8 цифр".into());
}
if !(15..=120).contains(&period) {
return Err("Период TOTP должен быть от 15 до 120 секунд".into());
}
Ok(ValidationResult {
ok: true,
value: secret,
})
}
fn validate_uuid_like(value: &str) -> Result<(), String> {
let normalized = value.trim();
if normalized.len() != 36 {
return Err("QR-код не содержит корректный UUID сессии".into());
}
for (index, ch) in normalized.chars().enumerate() {
let dash = matches!(index, 8 | 13 | 18 | 23);
if dash && ch != '-' {
return Err("QR-код не содержит корректный UUID сессии".into());
}
if !dash && !ch.is_ascii_hexdigit() {
return Err("QR-код не содержит корректный UUID сессии".into());
}
}
Ok(())
}
fn normalize_base32_secret(secret: &str) -> String {
secret
.trim()
.chars()
.filter(|ch| !ch.is_whitespace() && *ch != '-')
.map(|ch| ch.to_ascii_uppercase())
.collect()
}
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_stronghold::Builder::new(|password| {
use tauri_plugin_stronghold::stronghold::Client;
let salt_path = dirs_next::data_dir()
.unwrap_or_else(std::env::temp_dir)
.join("lendry-id-stronghold.salt");
tauri_plugin_stronghold::stronghold::derive_key_from_password(password.as_ref(), &salt_path)
})
.build())
.invoke_handler(tauri::generate_handler![
validate_qr_session_payload,
validate_api_base_url,
validate_totp_profile_input
])
.setup(|app| {
let _ = app.path().app_data_dir().map(std::fs::create_dir_all);
Ok(())
})
.run(tauri::generate_context!())
.expect("ошибка запуска Lendry ID Super App");
}

View File

@@ -0,0 +1,3 @@
fn main() {
lendry_id_super_app_lib::run();
}

View File

@@ -0,0 +1,45 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Lendry ID",
"version": "0.1.0",
"identifier": "ru.lendry.id.superapp",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"withGlobalTauri": false,
"windows": [
{
"title": "Lendry ID",
"width": 1180,
"height": 820,
"minWidth": 390,
"minHeight": 720,
"resizable": true,
"fullscreen": false
}
],
"security": {
"csp": "default-src 'self'; connect-src 'self' http://localhost:* https://* ws://localhost:* wss://*; img-src 'self' data: blob: https://*; media-src 'self' blob:; style-src 'self' 'unsafe-inline'; script-src 'self';"
}
},
"bundle": {
"active": true,
"targets": ["dmg", "app", "msi", "deb", "rpm"],
"category": "Productivity",
"shortDescription": "Мессенджер, SSO-аутентификатор и TOTP в одном приложении",
"longDescription": "Lendry ID Super App объединяет семейный мессенджер, QR-подтверждение входа и безопасное хранилище TOTP-кодов.",
"ios": {
"developmentTeam": ""
},
"android": {
"minSdkVersion": 24
}
},
"plugins": {
"opener": {}
}
}

32
tauri_app/src/App.tsx Normal file
View File

@@ -0,0 +1,32 @@
import { useState } from 'react';
import { MobileShell, type MobileTab } from './components/mobile-shell';
import { LoginScreen } from './features/auth/login-screen';
import { ChatsScreen } from './features/chat/chats-screen';
import { SecurityScreen } from './features/security/security-screen';
import { TotpScreen } from './features/totp/totp-screen';
import { MobileAuthProvider, useMobileAuth } from './lib/auth';
function AppContent() {
const { isAuthenticated } = useMobileAuth();
const [activeTab, setActiveTab] = useState<MobileTab>('security');
if (!isAuthenticated) {
return <LoginScreen />;
}
return (
<MobileShell activeTab={activeTab} onTabChange={setActiveTab}>
{activeTab === 'chats' ? <ChatsScreen /> : null}
{activeTab === 'totp' ? <TotpScreen /> : null}
{activeTab === 'security' ? <SecurityScreen /> : null}
</MobileShell>
);
}
export function App() {
return (
<MobileAuthProvider>
<AppContent />
</MobileAuthProvider>
);
}

View File

@@ -0,0 +1,76 @@
import { KeyRound, MessageCircle, ShieldCheck } from 'lucide-react';
import { cn } from '@/lib/utils';
export type MobileTab = 'chats' | 'totp' | 'security';
const tabs: Array<{ id: MobileTab; label: string; icon: React.ComponentType<{ className?: string }> }> = [
{ id: 'chats', label: 'Чаты', icon: MessageCircle },
{ id: 'totp', label: 'Коды', icon: KeyRound },
{ id: 'security', label: 'Защита', icon: ShieldCheck }
];
export function MobileShell({
activeTab,
onTabChange,
children
}: {
activeTab: MobileTab;
onTabChange: (tab: MobileTab) => void;
children: React.ReactNode;
}) {
return (
<div className="min-h-screen bg-[#eef1f6] text-[#1f2430]">
<div className="mx-auto flex min-h-screen max-w-6xl">
<aside className="hidden w-72 border-r border-[#dce3ec] bg-white/80 p-4 backdrop-blur lg:block">
<div className="mb-8 rounded-[24px] bg-[#20212b] p-5 text-white shadow-xl">
<p className="text-sm text-white/70">Super App</p>
<h1 className="mt-1 text-2xl font-semibold">Lendry ID</h1>
</div>
<nav className="space-y-2">
{tabs.map((tab) => {
const Icon = tab.icon;
return (
<button
key={tab.id}
type="button"
className={cn(
'flex w-full items-center gap-3 rounded-2xl px-4 py-3 text-left text-sm font-medium transition',
activeTab === tab.id ? 'bg-[#20212b] text-white' : 'text-[#667085] hover:bg-[#f4f5f8] hover:text-[#1f2430]'
)}
onClick={() => onTabChange(tab.id)}
>
<Icon className="h-5 w-5" />
{tab.label}
</button>
);
})}
</nav>
</aside>
<main className="min-w-0 flex-1 pb-[88px] lg:pb-0">{children}</main>
</div>
<nav className="safe-bottom fixed inset-x-0 bottom-0 z-50 border-t border-[#dce3ec] bg-white/95 px-4 py-2 shadow-[0_-12px_28px_rgba(31,36,48,0.12)] backdrop-blur lg:hidden">
<div className="mx-auto grid max-w-md grid-cols-3 gap-2">
{tabs.map((tab) => {
const Icon = tab.icon;
return (
<button
key={tab.id}
type="button"
className={cn(
'flex flex-col items-center gap-1 rounded-2xl px-2 py-2 text-xs font-medium transition',
activeTab === tab.id ? 'bg-[#20212b] text-white' : 'text-[#667085]'
)}
onClick={() => onTabChange(tab.id)}
>
<Icon className="h-5 w-5" />
{tab.label}
</button>
);
})}
</div>
</nav>
</div>
);
}

View File

@@ -0,0 +1,159 @@
import { useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { KeyRound, Loader2, ShieldCheck } from 'lucide-react';
import { BrandLogo, ProjectTagline } from '@/components/id/brand-logo';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { beginTotpLogin, getMobileApiUrl, loginWithPassword, setMobileApiUrl, verifyTotpLogin } from '~/lib/mobile-api';
import { useMobileAuth } from '~/lib/auth';
import { getErrorMessage } from '~/lib/errors';
export function LoginScreen() {
const { applyAuth } = useMobileAuth();
const [apiUrl, setApiUrl] = useState(getMobileApiUrl());
const [loginValue, setLoginValue] = useState('');
const [password, setPassword] = useState('');
const [totpCode, setTotpCode] = useState('');
const [totpChallengeToken, setTotpChallengeToken] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function submit() {
setSubmitting(true);
setError(null);
try {
const validated = await invoke<{ value: string }>('validate_api_base_url', { url: apiUrl });
setMobileApiUrl(validated.value);
const auth = await loginWithPassword(loginValue.trim(), password);
if ('requiresTotp' in auth) {
if (!auth.totpChallengeToken) {
throw new Error('Сервер не вернул TOTP challenge');
}
setTotpChallengeToken(auth.totpChallengeToken);
setError(null);
return;
}
applyAuth(auth);
} catch (err) {
setError(getErrorMessage(err, 'Не удалось войти'));
} finally {
setSubmitting(false);
}
}
async function startTotpLogin() {
if (!loginValue.trim()) {
setError('Укажите почту, телефон или логин');
return;
}
setSubmitting(true);
setError(null);
try {
const validated = await invoke<{ value: string }>('validate_api_base_url', { url: apiUrl });
setMobileApiUrl(validated.value);
const response = await beginTotpLogin(loginValue.trim());
setTotpChallengeToken(response.totpChallengeToken);
} catch (err) {
setError(getErrorMessage(err, 'Не удалось начать TOTP-вход'));
} finally {
setSubmitting(false);
}
}
async function submitTotp() {
if (!totpChallengeToken) return;
setSubmitting(true);
setError(null);
try {
const auth = await verifyTotpLogin(totpChallengeToken, totpCode.trim());
applyAuth(auth);
} catch (err) {
setError(getErrorMessage(err, 'Неверный код аутентификатора'));
} finally {
setSubmitting(false);
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-[radial-gradient(circle_at_center,#596075_0%,#2b2534_45%,#121017_100%)] p-4">
<div className="w-full max-w-[430px] rounded-[34px] bg-[#1f2028] px-8 pb-7 pt-9 text-white shadow-2xl">
<div className="mb-8 flex flex-col items-center text-center">
<BrandLogo size="lg" variant="light" />
<h1 className="mt-8 text-xl font-bold">Войдите с ID</h1>
<p className="mt-2 text-sm text-[#b9bdc9]">Мессенджер, QR-вход и TOTP-коды в одном приложении</p>
</div>
<div className="mt-6 space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-[#b9bdc9]">API Gateway</label>
<Input
value={apiUrl}
onChange={(event) => setApiUrl(event.target.value)}
placeholder="https://sso.example.ru/idp-api"
className="h-[54px] border-[#555762] bg-transparent text-white placeholder:text-[#8f92a0]"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-[#b9bdc9]">Почта, телефон или логин</label>
<Input
value={loginValue}
onChange={(event) => setLoginValue(event.target.value)}
autoComplete="username"
className="h-[54px] border-[#555762] bg-transparent text-white placeholder:text-[#8f92a0]"
/>
</div>
{totpChallengeToken ? (
<div className="space-y-2">
<label className="text-sm font-medium text-[#b9bdc9]">Код из приложения-аутентификатора</label>
<Input
value={totpCode}
onChange={(event) => setTotpCode(event.target.value.replace(/\D/g, '').slice(0, 8))}
inputMode="numeric"
autoComplete="one-time-code"
className="h-[58px] border-[#555762] bg-transparent text-center text-2xl tracking-[0.35em] text-white placeholder:text-[#8f92a0]"
placeholder="000000"
/>
</div>
) : (
<div className="space-y-2">
<label className="text-sm font-medium text-[#b9bdc9]">Пароль</label>
<Input
value={password}
onChange={(event) => setPassword(event.target.value)}
type="password"
autoComplete="current-password"
className="h-[54px] border-[#555762] bg-transparent text-white placeholder:text-[#8f92a0]"
/>
</div>
)}
</div>
{error ? <p className="mt-4 rounded-2xl bg-red-50 px-4 py-3 text-sm text-red-700">{error}</p> : null}
{totpChallengeToken ? (
<>
<Button variant="white" size="lg" className="mt-6 w-full rounded-[18px] text-base" disabled={submitting || totpCode.trim().length < 6} onClick={() => void submitTotp()}>
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <ShieldCheck className="h-4 w-4" />}
Подтвердить TOTP
</Button>
<Button type="button" variant="outline" size="lg" className="mt-3 w-full rounded-[18px] border-[#424550] text-white hover:bg-[#2a2c36]" onClick={() => setTotpChallengeToken(null)}>
Вернуться к паролю
</Button>
</>
) : (
<>
<Button variant="white" size="lg" className="mt-6 w-full rounded-[18px] text-base" disabled={submitting || !loginValue.trim() || !password} onClick={() => void submit()}>
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <KeyRound className="h-4 w-4" />}
Войти
</Button>
<Button type="button" variant="outline" size="lg" className="mt-3 w-full rounded-[18px] border-[#424550] text-white hover:bg-[#2a2c36]" disabled={submitting || !loginValue.trim()} onClick={() => void startTotpLogin()}>
<ShieldCheck className="h-4 w-4" />
Войти через TOTP
</Button>
</>
)}
<ProjectTagline className="mt-8 text-center text-sm font-semibold" />
</div>
</div>
);
}

View File

@@ -0,0 +1,107 @@
import { useEffect, useRef, useState } from 'react';
import { BrowserMultiFormatReader, type IScannerControls } from '@zxing/browser';
import { invoke } from '@tauri-apps/api/core';
import { Camera, CheckCircle2, Loader2, QrCode, XCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { approveQrLogin } from '~/lib/mobile-api';
import { useMobileAuth } from '~/lib/auth';
import { getErrorMessage } from '~/lib/errors';
export function QrScanner() {
const { token } = useMobileAuth();
const videoRef = useRef<HTMLVideoElement>(null);
const controlsRef = useRef<IScannerControls | null>(null);
const [scanning, setScanning] = useState(false);
const [status, setStatus] = useState<string | null>(null);
const [approving, setApproving] = useState(false);
useEffect(() => {
return () => {
controlsRef.current?.stop();
};
}, []);
async function approvePayload(payload: string) {
if (!token || approving) return;
setApproving(true);
setStatus('Проверяем QR-код...');
try {
const validated = await invoke<{ value: string }>('validate_qr_session_payload', { payload });
await approveQrLogin(validated.value, token);
setStatus('Вход на сайте подтверждён');
controlsRef.current?.stop();
setScanning(false);
} catch (error) {
setStatus(getErrorMessage(error, 'Не удалось подтвердить вход'));
} finally {
setApproving(false);
}
}
async function startScan() {
if (!videoRef.current) return;
setStatus(null);
setScanning(true);
const reader = new BrowserMultiFormatReader();
try {
controlsRef.current = await reader.decodeFromVideoDevice(undefined, videoRef.current, (result, error) => {
if (result?.getText()) {
void approvePayload(result.getText());
} else if (error) {
setStatus('Камера активна, наведите её на QR-код');
}
});
} catch (error) {
setStatus(getErrorMessage(error, 'Не удалось открыть камеру'));
setScanning(false);
}
}
function stopScan() {
controlsRef.current?.stop();
setScanning(false);
}
return (
<section className="rounded-[28px] bg-white p-4 shadow-sm">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<h2 className="font-semibold">QR-вход</h2>
<p className="text-sm text-[#667085]">Сканируйте QR-код на сайте, чтобы мгновенно подтвердить вход.</p>
</div>
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-[#eef4ff] text-[#3390ec]">
<QrCode className="h-5 w-5" />
</div>
</div>
<div className="overflow-hidden rounded-[24px] bg-[#111827]">
<video ref={videoRef} className="aspect-[4/3] w-full object-cover" muted playsInline />
</div>
{status ? (
<div className="mt-3 flex items-center gap-2 rounded-2xl bg-[#f4f5f8] px-3 py-2 text-sm text-[#667085]">
{status.includes('подтвержд') ? <CheckCircle2 className="h-4 w-4 text-emerald-600" /> : <XCircle className="h-4 w-4 text-[#667085]" />}
{status}
</div>
) : null}
<div className="mt-4 flex gap-2">
{scanning ? (
<Button variant="secondary" className="flex-1" onClick={stopScan}>
Остановить
</Button>
) : (
<Button className="flex-1" onClick={() => void startScan()}>
<Camera className="h-4 w-4" />
Сканировать QR
</Button>
)}
{approving ? (
<Button variant="secondary" size="icon" disabled>
<Loader2 className="h-4 w-4 animate-spin" />
</Button>
) : null}
</div>
</section>
);
}

View File

@@ -0,0 +1,35 @@
import { openUrl } from '@tauri-apps/plugin-opener';
import { ExternalLink, MessageCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
const FRONTEND_URL = (import.meta.env.VITE_FRONTEND_URL ?? 'http://localhost:3002').replace(/\/$/, '');
export function ChatsScreen() {
const familyUrl = `${FRONTEND_URL}/family`;
return (
<div className="safe-top flex h-screen flex-col p-4">
<header className="mb-4 rounded-[28px] bg-white p-5 shadow-sm">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm text-[#667085]">Messenger</p>
<h1 className="mt-1 text-2xl font-semibold">Чаты</h1>
<p className="mt-2 text-sm text-[#667085]">Вкладка использует web UI семьи, чтобы дизайн и поведение совпадали с сайтом.</p>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-3xl bg-[#20212b] text-white">
<MessageCircle className="h-6 w-6" />
</div>
</div>
</header>
<div className="min-h-0 flex-1 overflow-hidden rounded-[28px] bg-white shadow-sm">
<iframe src={familyUrl} title="Lendry ID Family Messenger" className="h-full w-full border-0" />
</div>
<Button variant="secondary" className="mt-4 lg:hidden" onClick={() => void openUrl(familyUrl)}>
<ExternalLink className="h-4 w-4" />
Открыть мессенджер отдельно
</Button>
</div>
);
}

View File

@@ -0,0 +1,116 @@
import { useEffect, useState } from 'react';
import { Loader2, LogOut, RefreshCw, ShieldCheck, Smartphone } from 'lucide-react';
import { Button } from '@/components/ui/button';
import type { ActiveSession } from '@/lib/api';
import { useMobileAuth } from '~/lib/auth';
import { fetchActiveSessions, revokeSession } from '~/lib/mobile-api';
import { getErrorMessage } from '~/lib/errors';
import { QrScanner } from '../auth/qr-scanner';
function formatDate(value: string) {
return new Intl.DateTimeFormat('ru-RU', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit'
}).format(new Date(value));
}
export function SecurityScreen() {
const { user, token, sessionId, logout } = useMobileAuth();
const [sessions, setSessions] = useState<ActiveSession[]>([]);
const [loading, setLoading] = useState(false);
const [revokingId, setRevokingId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
async function loadSessions() {
if (!user || !token) return;
setLoading(true);
setError(null);
try {
const response = await fetchActiveSessions(user.id, token);
setSessions(response.sessions ?? []);
} catch (err) {
setError(getErrorMessage(err, 'Не удалось загрузить сессии'));
} finally {
setLoading(false);
}
}
useEffect(() => {
void loadSessions();
}, [token, user?.id]);
async function revoke(id: string) {
if (!user || !token) return;
setRevokingId(id);
try {
await revokeSession(user.id, id, token);
setSessions((current) => current.filter((session) => session.id !== id));
} catch (err) {
setError(getErrorMessage(err, 'Не удалось отозвать сессию'));
} finally {
setRevokingId(null);
}
}
return (
<div className="safe-top mx-auto max-w-3xl space-y-4 p-4">
<header className="rounded-[28px] bg-white p-5 shadow-sm">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm text-[#667085]">SSO Authenticator</p>
<h1 className="mt-1 text-2xl font-semibold">Безопасность</h1>
<p className="mt-2 text-sm text-[#667085]">{user?.displayName}</p>
</div>
<Button variant="secondary" size="icon" onClick={logout} aria-label="Выйти">
<LogOut className="h-4 w-4" />
</Button>
</div>
</header>
<QrScanner />
<section className="rounded-[28px] bg-white p-5 shadow-sm">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<h2 className="flex items-center gap-2 font-semibold">
<ShieldCheck className="h-5 w-5" />
Устройства и сессии
</h2>
<p className="text-sm text-[#667085]">Завершайте чужие или потерянные сессии удалённо.</p>
</div>
<Button variant="secondary" size="icon" disabled={loading} onClick={() => void loadSessions()}>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
</Button>
</div>
{error ? <p className="mb-3 rounded-2xl bg-red-50 px-3 py-2 text-sm text-red-700">{error}</p> : null}
<div className="space-y-2">
{sessions.map((session) => {
const current = session.id === sessionId;
return (
<div key={session.id} className="flex items-center gap-3 rounded-2xl border border-[#eceef4] p-3">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-[#eef4ff] text-[#3390ec]">
<Smartphone className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{session.userAgent ?? 'Устройство'}</p>
<p className="text-xs text-[#667085]">
{session.status} · {formatDate(session.updatedAt)}
{current ? ' · текущая сессия' : ''}
</p>
</div>
<Button variant="secondary" size="sm" disabled={current || revokingId === session.id} onClick={() => void revoke(session.id)}>
{revokingId === session.id ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Выйти'}
</Button>
</div>
);
})}
{!sessions.length && !loading ? <p className="py-4 text-center text-sm text-[#667085]">Активных сессий не найдено</p> : null}
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,85 @@
import { appDataDir } from '@tauri-apps/api/path';
import { invoke } from '@tauri-apps/api/core';
import type { TotpProfile } from './totp';
const CLIENT_NAME = 'lendry-totp';
const STORE_KEY = 'totp-profiles';
const PASSWORD_KEY = 'lendry_stronghold_password';
function getStrongholdPassword() {
const existing = window.localStorage.getItem(PASSWORD_KEY);
if (existing) return existing;
const created = crypto.randomUUID() + crypto.randomUUID();
window.localStorage.setItem(PASSWORD_KEY, created);
return created;
}
async function openStronghold() {
const strongholdModule = await import('@tauri-apps/plugin-stronghold');
const Stronghold = (strongholdModule as unknown as { Stronghold?: unknown }).Stronghold as {
load: (path: string, password: string) => Promise<unknown>;
};
const baseDir = await appDataDir();
const stronghold = await Stronghold.load(`${baseDir}/totp.stronghold`, getStrongholdPassword());
const anyStronghold = stronghold as {
loadClient?: (name: string) => Promise<unknown>;
createClient?: (name: string) => Promise<unknown>;
save: () => Promise<void>;
};
let client: unknown;
try {
client = await anyStronghold.loadClient?.(CLIENT_NAME);
} catch {
client = await anyStronghold.createClient?.(CLIENT_NAME);
}
if (!client) {
client = await anyStronghold.createClient?.(CLIENT_NAME);
}
const store = (client as { getStore: () => unknown }).getStore();
return { stronghold: anyStronghold, store: store as StrongholdStore };
}
interface StrongholdStore {
get(key: string): Promise<number[] | Uint8Array | null>;
insert(key: string, value: number[] | Uint8Array): Promise<void>;
remove(key: string): Promise<void>;
}
function encodeProfiles(profiles: TotpProfile[]) {
return new TextEncoder().encode(JSON.stringify(profiles));
}
function decodeProfiles(value: number[] | Uint8Array | null) {
if (!value) return [];
const bytes = value instanceof Uint8Array ? value : new Uint8Array(value);
const text = new TextDecoder().decode(bytes);
return JSON.parse(text) as TotpProfile[];
}
export async function loadTotpProfiles() {
const { store } = await openStronghold();
return decodeProfiles(await store.get(STORE_KEY));
}
export async function saveTotpProfiles(profiles: TotpProfile[]) {
const { stronghold, store } = await openStronghold();
await store.insert(STORE_KEY, encodeProfiles(profiles));
await stronghold.save();
}
export async function addTotpProfile(profile: Omit<TotpProfile, 'id' | 'createdAt'>) {
await invoke('validate_totp_profile_input', profile);
const profiles = await loadTotpProfiles();
const next: TotpProfile = {
...profile,
id: crypto.randomUUID(),
createdAt: new Date().toISOString()
};
await saveTotpProfiles([...profiles, next]);
return next;
}
export async function deleteTotpProfile(profileId: string) {
const profiles = await loadTotpProfiles();
await saveTotpProfiles(profiles.filter((profile) => profile.id !== profileId));
}

View File

@@ -0,0 +1,161 @@
import { useEffect, useMemo, useState } from 'react';
import { Copy, KeyRound, Loader2, Plus, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { addTotpProfile, deleteTotpProfile, loadTotpProfiles } from './secure-totp-store';
import { generateTotpCode, getRemainingSeconds, parseOtpAuthUrl, type TotpProfile } from './totp';
interface TotpCodeState {
code: string;
remaining: number;
}
export function TotpScreen() {
const [profiles, setProfiles] = useState<TotpProfile[]>([]);
const [codes, setCodes] = useState<Record<string, TotpCodeState>>({});
const [issuer, setIssuer] = useState('');
const [label, setLabel] = useState('');
const [secret, setSecret] = useState('');
const [otpauth, setOtpauth] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const canSave = useMemo(() => issuer.trim() && label.trim() && secret.trim(), [issuer, label, secret]);
useEffect(() => {
loadTotpProfiles()
.then(setProfiles)
.catch((err) => setError(err instanceof Error ? err.message : 'Не удалось открыть защищённое хранилище'))
.finally(() => setLoading(false));
}, []);
useEffect(() => {
let cancelled = false;
async function tick() {
const entries = await Promise.all(
profiles.map(async (profile) => [
profile.id,
{
code: await generateTotpCode(profile),
remaining: getRemainingSeconds(profile.period)
}
] as const)
);
if (!cancelled) {
setCodes(Object.fromEntries(entries));
}
}
void tick();
const timer = window.setInterval(() => void tick(), 1000);
return () => {
cancelled = true;
window.clearInterval(timer);
};
}, [profiles]);
function applyOtpAuthUrl(value: string) {
setOtpauth(value);
const parsed = parseOtpAuthUrl(value);
if (!parsed) return;
setIssuer(parsed.issuer ?? '');
setLabel(parsed.label ?? '');
setSecret(parsed.secret ?? '');
}
async function saveProfile() {
setSaving(true);
setError(null);
try {
const profile = await addTotpProfile({
issuer: issuer.trim(),
label: label.trim(),
secret: secret.trim(),
digits: 6,
period: 30
});
setProfiles((current) => [...current, profile]);
setIssuer('');
setLabel('');
setSecret('');
setOtpauth('');
} catch (err) {
setError(err instanceof Error ? err.message : 'Не удалось сохранить TOTP');
} finally {
setSaving(false);
}
}
async function removeProfile(profileId: string) {
await deleteTotpProfile(profileId);
setProfiles((current) => current.filter((profile) => profile.id !== profileId));
}
return (
<div className="safe-top mx-auto max-w-3xl space-y-4 p-4">
<header className="rounded-[28px] bg-white p-5 shadow-sm">
<p className="text-sm text-[#667085]">Offline 2FA</p>
<h1 className="mt-1 text-2xl font-semibold">Authenticator</h1>
<p className="mt-2 text-sm text-[#667085]">TOTP-секреты хранятся в Tauri Stronghold, а не в LocalStorage.</p>
</header>
<section className="rounded-[28px] bg-white p-5 shadow-sm">
<h2 className="mb-4 flex items-center gap-2 font-semibold">
<Plus className="h-5 w-5" />
Добавить код
</h2>
<div className="space-y-3">
<Input value={otpauth} onChange={(event) => applyOtpAuthUrl(event.target.value)} placeholder="otpauth://totp/..." />
<div className="grid gap-3 sm:grid-cols-2">
<Input value={issuer} onChange={(event) => setIssuer(event.target.value)} placeholder="Сервис, например GitHub" />
<Input value={label} onChange={(event) => setLabel(event.target.value)} placeholder="Аккаунт" />
</div>
<Input value={secret} onChange={(event) => setSecret(event.target.value)} placeholder="Base32 secret" />
{error ? <p className="rounded-2xl bg-red-50 px-3 py-2 text-sm text-red-700">{error}</p> : null}
<Button disabled={!canSave || saving} onClick={() => void saveProfile()}>
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <KeyRound className="h-4 w-4" />}
Сохранить в защищённом хранилище
</Button>
</div>
</section>
<section className="space-y-3">
{loading ? (
<div className="rounded-[28px] bg-white p-5 text-sm text-[#667085]">Загрузка...</div>
) : profiles.length ? (
profiles.map((profile) => {
const state = codes[profile.id];
const progress = state ? (state.remaining / profile.period) * 100 : 0;
return (
<article key={profile.id} className="rounded-[28px] bg-white p-5 shadow-sm">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="font-semibold">{profile.issuer}</h3>
<p className="text-sm text-[#667085]">{profile.label}</p>
</div>
<Button variant="ghost" size="icon" onClick={() => void removeProfile(profile.id)} aria-label="Удалить TOTP">
<Trash2 className="h-4 w-4 text-red-600" />
</Button>
</div>
<button
type="button"
className="mt-4 flex w-full items-center justify-between rounded-3xl bg-[#20212b] px-5 py-4 text-left text-white"
onClick={() => state?.code && navigator.clipboard.writeText(state.code)}
>
<span className="font-mono text-4xl font-semibold tracking-[0.2em]">{state?.code ?? '------'}</span>
<Copy className="h-5 w-5 text-white/70" />
</button>
<div className="mt-3 h-2 overflow-hidden rounded-full bg-[#eef1f6]">
<div className="h-full rounded-full bg-[#3390ec] transition-all" style={{ width: `${progress}%` }} />
</div>
<p className="mt-2 text-right text-xs text-[#667085]">{state?.remaining ?? profile.period} сек.</p>
</article>
);
})
) : (
<div className="rounded-[28px] bg-white p-8 text-center text-sm text-[#667085] shadow-sm">Добавьте первый TOTP-код</div>
)}
</section>
</div>
);
}

View File

@@ -0,0 +1,81 @@
export interface TotpProfile {
id: string;
issuer: string;
label: string;
secret: string;
digits: 6 | 8;
period: number;
createdAt: string;
}
const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
export function normalizeBase32Secret(secret: string) {
return secret.replace(/[\s-]/g, '').toUpperCase();
}
export function parseOtpAuthUrl(value: string): Partial<TotpProfile> | null {
try {
const url = new URL(value);
if (url.protocol !== 'otpauth:' || url.hostname !== 'totp') return null;
const labelRaw = decodeURIComponent(url.pathname.replace(/^\//, ''));
const [issuerFromLabel, accountFromLabel] = labelRaw.includes(':') ? labelRaw.split(/:(.*)/s) : ['', labelRaw];
const secret = url.searchParams.get('secret');
if (!secret) return null;
const digits = Number(url.searchParams.get('digits') ?? 6);
const period = Number(url.searchParams.get('period') ?? 30);
return {
issuer: url.searchParams.get('issuer') ?? issuerFromLabel ?? '',
label: accountFromLabel || labelRaw,
secret: normalizeBase32Secret(secret),
digits: digits === 8 ? 8 : 6,
period: Number.isFinite(period) && period > 0 ? period : 30
};
} catch {
return null;
}
}
export function decodeBase32(secret: string) {
const normalized = normalizeBase32Secret(secret).replace(/=+$/g, '');
let bits = '';
for (const char of normalized) {
const value = BASE32_ALPHABET.indexOf(char);
if (value === -1) {
throw new Error('Некорректный base32 secret');
}
bits += value.toString(2).padStart(5, '0');
}
const bytes: number[] = [];
for (let index = 0; index + 8 <= bits.length; index += 8) {
bytes.push(Number.parseInt(bits.slice(index, index + 8), 2));
}
return new Uint8Array(bytes);
}
function counterToBytes(counter: number) {
const bytes = new ArrayBuffer(8);
const view = new DataView(bytes);
view.setUint32(4, counter, false);
return bytes;
}
export async function generateTotpCode(profile: Pick<TotpProfile, 'secret' | 'digits' | 'period'>, now = Date.now()) {
const keyData = decodeBase32(profile.secret);
const key = await crypto.subtle.importKey('raw', keyData, { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']);
const counter = Math.floor(now / 1000 / profile.period);
const signature = new Uint8Array(await crypto.subtle.sign('HMAC', key, counterToBytes(counter)));
const offset = signature[signature.length - 1]! & 0x0f;
const binary =
((signature[offset]! & 0x7f) << 24) |
((signature[offset + 1]! & 0xff) << 16) |
((signature[offset + 2]! & 0xff) << 8) |
(signature[offset + 3]! & 0xff);
const mod = 10 ** profile.digits;
return String(binary % mod).padStart(profile.digits, '0');
}
export function getRemainingSeconds(period: number, now = Date.now()) {
return period - (Math.floor(now / 1000) % period);
}

View File

@@ -0,0 +1,75 @@
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react';
import type { AuthTokens, PublicUser } from '@/lib/api';
import {
clearMobileAuth,
loginWithPassword,
persistMobileAuth,
readStoredMobileAuth
} from './mobile-api';
interface MobileAuthContextValue {
user: PublicUser | null;
token: string | null;
sessionId: string | null;
isAuthenticated: boolean;
login: (login: string, password: string) => Promise<void>;
applyAuth: (auth: AuthTokens) => void;
logout: () => void;
}
const MobileAuthContext = createContext<MobileAuthContextValue | null>(null);
export function MobileAuthProvider({ children }: { children: ReactNode }) {
const stored = typeof window === 'undefined' ? { token: null, sessionId: null, user: null } : readStoredMobileAuth();
const [token, setToken] = useState<string | null>(stored.token);
const [sessionId, setSessionId] = useState<string | null>(stored.sessionId);
const [user, setUser] = useState<PublicUser | null>(stored.user);
const applyAuth = useCallback((auth: AuthTokens) => {
persistMobileAuth(auth);
setToken(auth.accessToken);
setSessionId(auth.sessionId);
setUser(auth.user);
}, []);
const login = useCallback(
async (loginValue: string, password: string) => {
const auth = await loginWithPassword(loginValue, password);
if ('requiresTotp' in auth) {
throw new Error('Для этого аккаунта требуется TOTP-код');
}
applyAuth(auth);
},
[applyAuth]
);
const logout = useCallback(() => {
clearMobileAuth();
setToken(null);
setSessionId(null);
setUser(null);
}, []);
const value = useMemo(
() => ({
user,
token,
sessionId,
isAuthenticated: Boolean(user && token),
login,
applyAuth,
logout
}),
[applyAuth, login, logout, sessionId, token, user]
);
return <MobileAuthContext.Provider value={value}>{children}</MobileAuthContext.Provider>;
}
export function useMobileAuth() {
const value = useContext(MobileAuthContext);
if (!value) {
throw new Error('useMobileAuth должен использоваться внутри MobileAuthProvider');
}
return value;
}

View File

@@ -0,0 +1,6 @@
export function getErrorMessage(error: unknown, fallback: string) {
if (error instanceof Error && error.message.trim()) {
return error.message;
}
return fallback;
}

View File

@@ -0,0 +1,138 @@
import type { ActiveSession, AuthTokens, PublicUser } from '@/lib/api';
const DEFAULT_API_URL = 'http://localhost:3000';
export const MOBILE_AUTH_TOKEN_KEY = 'lendry_mobile_access_token';
export const MOBILE_REFRESH_TOKEN_KEY = 'lendry_mobile_refresh_token';
export const MOBILE_SESSION_ID_KEY = 'lendry_mobile_session_id';
export const MOBILE_USER_KEY = 'lendry_mobile_user';
export const MOBILE_API_URL_KEY = 'lendry_mobile_api_url';
export class MobileApiError extends Error {
constructor(
message: string,
readonly status: number,
readonly code?: string
) {
super(message);
this.name = 'MobileApiError';
}
}
export function getMobileApiUrl() {
return (window.localStorage.getItem(MOBILE_API_URL_KEY) ?? import.meta.env.VITE_API_URL ?? DEFAULT_API_URL).replace(/\/$/, '');
}
export function setMobileApiUrl(url: string) {
window.localStorage.setItem(MOBILE_API_URL_KEY, url.replace(/\/$/, ''));
}
export function readStoredMobileAuth() {
const token = window.localStorage.getItem(MOBILE_AUTH_TOKEN_KEY);
const refreshToken = window.localStorage.getItem(MOBILE_REFRESH_TOKEN_KEY);
const sessionId = window.localStorage.getItem(MOBILE_SESSION_ID_KEY);
const rawUser = window.localStorage.getItem(MOBILE_USER_KEY);
let user: PublicUser | null = null;
if (rawUser) {
try {
user = JSON.parse(rawUser) as PublicUser;
} catch {
user = null;
}
}
return { token, refreshToken, sessionId, user };
}
export function persistMobileAuth(auth: AuthTokens) {
window.localStorage.setItem(MOBILE_AUTH_TOKEN_KEY, auth.accessToken);
window.localStorage.setItem(MOBILE_REFRESH_TOKEN_KEY, auth.refreshToken);
window.localStorage.setItem(MOBILE_SESSION_ID_KEY, auth.sessionId);
window.localStorage.setItem(MOBILE_USER_KEY, JSON.stringify(auth.user));
}
export function clearMobileAuth() {
window.localStorage.removeItem(MOBILE_AUTH_TOKEN_KEY);
window.localStorage.removeItem(MOBILE_REFRESH_TOKEN_KEY);
window.localStorage.removeItem(MOBILE_SESSION_ID_KEY);
window.localStorage.removeItem(MOBILE_USER_KEY);
}
async function parseError(response: Response) {
try {
const body = (await response.json()) as { message?: string | string[]; error?: string; code?: string };
const message = Array.isArray(body.message) ? body.message.join('\n') : body.message ?? body.error ?? 'Ошибка запроса';
return new MobileApiError(message, response.status, body.code);
} catch {
return new MobileApiError('Ошибка запроса', response.status);
}
}
export async function mobileFetch<T>(path: string, options: RequestInit = {}, token?: string | null): Promise<T> {
const response = await fetch(`${getMobileApiUrl()}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers
}
});
if (!response.ok) {
throw await parseError(response);
}
return response.json() as Promise<T>;
}
export interface TotpChallengeResponse {
totpChallengeToken: string;
}
export type PasswordLoginResponse = AuthTokens | {
requiresTotp: true;
totpChallengeToken: string;
};
export async function loginWithPassword(login: string, password: string) {
return mobileFetch<PasswordLoginResponse>('/auth/login/password', {
method: 'POST',
body: JSON.stringify({
login,
password,
fingerprint: crypto.randomUUID(),
deviceName: 'Lendry ID Mobile',
deviceType: 'MOBILE'
})
});
}
export async function beginTotpLogin(recipient: string) {
return mobileFetch<TotpChallengeResponse>('/auth/totp/begin', {
method: 'POST',
body: JSON.stringify({
recipient,
fingerprint: crypto.randomUUID(),
deviceName: 'Lendry ID Mobile',
deviceType: 'MOBILE'
})
});
}
export async function verifyTotpLogin(totpChallengeToken: string, code: string) {
return mobileFetch<AuthTokens>('/auth/totp/verify', {
method: 'POST',
body: JSON.stringify({ totpChallengeToken, code })
});
}
export async function approveQrLogin(sessionId: string, token: string) {
return mobileFetch(`/auth/advanced/qr/session/${encodeURIComponent(sessionId)}/approve`, { method: 'POST' }, token);
}
export async function fetchActiveSessions(userId: string, token: string) {
return mobileFetch<{ sessions?: ActiveSession[] }>(`/security/users/${encodeURIComponent(userId)}/sessions`, {}, token);
}
export async function revokeSession(userId: string, sessionId: string, token: string) {
return mobileFetch(`/security/users/${encodeURIComponent(userId)}/sessions/${encodeURIComponent(sessionId)}/revoke`, { method: 'POST' }, token);
}

10
tauri_app/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';
import './styles.css';
createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

21
tauri_app/src/styles.css Normal file
View File

@@ -0,0 +1,21 @@
@import "tailwindcss";
@import "../../apps/frontend/app/globals.css";
html,
body,
#root {
min-height: 100%;
}
body {
overscroll-behavior: none;
-webkit-font-smoothing: antialiased;
}
.safe-bottom {
padding-bottom: env(safe-area-inset-bottom);
}
.safe-top {
padding-top: env(safe-area-inset-top);
}

25
tauri_app/tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@/*": ["../apps/frontend/*"],
"~/*": ["./src/*"]
},
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
}

32
tauri_app/vite.config.ts Normal file
View File

@@ -0,0 +1,32 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
const rootDir = fileURLToPath(new URL('.', import.meta.url));
const frontendDir = path.resolve(rootDir, '../apps/frontend');
export default defineConfig({
plugins: [react(), tailwindcss()],
clearScreen: false,
server: {
port: 1420,
strictPort: true,
watch: {
ignored: ['**/src-tauri/**']
}
},
envPrefix: ['VITE_', 'TAURI_'],
resolve: {
alias: {
'@': frontendDir,
'~': path.resolve(rootDir, 'src')
}
},
build: {
target: ['es2022', 'chrome110', 'safari15'],
outDir: 'dist',
emptyOutDir: true
}
});