global fix and add tauri app
This commit is contained in:
@@ -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}" && \
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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}" && \
|
||||||
|
|||||||
@@ -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}" && \
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
44
apps/frontend/app/download/android/route.ts
Normal file
44
apps/frontend/app/download/android/route.ts
Normal 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'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
36
apps/frontend/components/chat/chat-pinned-message-banner.tsx
Normal file
36
apps/frontend/components/chat/chat-pinned-message-banner.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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={room.isPinned ? 'Открепить чат' : 'Закрепить чат'}
|
||||||
aria-label="Удалить чат"
|
disabled={pinningRoomId === room.id}
|
||||||
disabled={deletingRoomId === room.id}
|
className={cn(
|
||||||
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"
|
'flex h-8 w-8 shrink-0 self-center items-center justify-center rounded-lg text-[#8a8f9e] transition hover:bg-[#eceef4] hover:text-[#667085]',
|
||||||
onClick={() => void handleDeleteRoom(room.id)}
|
room.isPinned ? 'opacity-100' : 'opacity-0 group-hover:opacity-100',
|
||||||
>
|
pinningRoomId === room.id && 'opacity-100'
|
||||||
{deletingRoomId === room.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
|
)}
|
||||||
</button>
|
onClick={() => void handleToggleRoomPin(room)}
|
||||||
) : null}
|
>
|
||||||
|
{pinningRoomId === room.id ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Pin className={cn('h-4 w-4', room.isPinned && 'fill-[#8a8f9e]')} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
</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>
|
||||||
|
|||||||
@@ -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,49 +128,68 @@ 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
|
userId={member.userId}
|
||||||
userId={member.userId}
|
displayName={member.displayName}
|
||||||
displayName={member.displayName}
|
hasAvatar={member.hasAvatar}
|
||||||
hasAvatar={member.hasAvatar}
|
token={token}
|
||||||
token={token}
|
isVerified={member.isVerified}
|
||||||
isVerified={member.isVerified}
|
verificationIcon={member.verificationIcon}
|
||||||
verificationIcon={member.verificationIcon}
|
online={isSelf ? undefined : presence?.online}
|
||||||
online={isSelf ? undefined : presence?.online}
|
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="flex min-w-0 items-center gap-1 truncate text-[12px] font-medium leading-tight">
|
||||||
<p className="truncate text-[12px] font-medium leading-tight">
|
<span className="truncate">{member.displayName}</span>
|
||||||
{member.displayName}
|
{member.isBot ? <span>🤖</span> : null}
|
||||||
{member.isBot ? ' 🤖' : ''}
|
{isSelf ? <span>(Вы)</span> : null}
|
||||||
{isSelf ? ' (Вы)' : ''}
|
{memberPinned ? <Pin className="h-3 w-3 shrink-0 fill-[#8a8f9e] text-[#8a8f9e]" /> : null}
|
||||||
</p>
|
|
||||||
{!isSelf ? (
|
|
||||||
<p className={cn('truncate text-[10px]', member.isBot ? 'text-[#3390ec]' : presence?.online ? 'text-emerald-600' : 'text-[#a8adbc]')}>
|
|
||||||
{member.isBot ? 'Бот' : presence?.online ? 'В сети' : 'Не в сети'}
|
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
{!isSelf ? (
|
||||||
</div>
|
<p className={cn('truncate text-[10px]', member.isBot ? 'text-[#3390ec]' : presence?.online ? 'text-emerald-600' : 'text-[#a8adbc]')}>
|
||||||
</button>
|
{member.isBot ? 'Бот' : presence?.online ? 'В сети' : 'Не в сети'}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
1
apps/frontend/public/downloads/.gitkeep
Normal file
1
apps/frontend/public/downloads/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -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}" && \
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -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
1852
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
@@ -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
64
tauri_app/Dockerfile.apk
Normal 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
62
tauri_app/README.md
Normal 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`.
|
||||||
36
tauri_app/docker/build-android-apk.sh
Normal file
36
tauri_app/docker/build-android-apk.sh
Normal 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
13
tauri_app/index.html
Normal 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
45
tauri_app/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
30
tauri_app/src-tauri/Cargo.toml
Normal file
30
tauri_app/src-tauri/Cargo.toml
Normal 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
|
||||||
3
tauri_app/src-tauri/build.rs
Normal file
3
tauri_app/src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build();
|
||||||
|
}
|
||||||
11
tauri_app/src-tauri/capabilities/default.json
Normal file
11
tauri_app/src-tauri/capabilities/default.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
30
tauri_app/src-tauri/mobile-permissions.md
Normal file
30
tauri_app/src-tauri/mobile-permissions.md
Normal 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.
|
||||||
148
tauri_app/src-tauri/src/lib.rs
Normal file
148
tauri_app/src-tauri/src/lib.rs
Normal 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");
|
||||||
|
}
|
||||||
3
tauri_app/src-tauri/src/main.rs
Normal file
3
tauri_app/src-tauri/src/main.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
lendry_id_super_app_lib::run();
|
||||||
|
}
|
||||||
45
tauri_app/src-tauri/tauri.conf.json
Normal file
45
tauri_app/src-tauri/tauri.conf.json
Normal 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
32
tauri_app/src/App.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
tauri_app/src/components/mobile-shell.tsx
Normal file
76
tauri_app/src/components/mobile-shell.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
159
tauri_app/src/features/auth/login-screen.tsx
Normal file
159
tauri_app/src/features/auth/login-screen.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
tauri_app/src/features/auth/qr-scanner.tsx
Normal file
107
tauri_app/src/features/auth/qr-scanner.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
tauri_app/src/features/chat/chats-screen.tsx
Normal file
35
tauri_app/src/features/chat/chats-screen.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
tauri_app/src/features/security/security-screen.tsx
Normal file
116
tauri_app/src/features/security/security-screen.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
tauri_app/src/features/totp/secure-totp-store.ts
Normal file
85
tauri_app/src/features/totp/secure-totp-store.ts
Normal 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));
|
||||||
|
}
|
||||||
161
tauri_app/src/features/totp/totp-screen.tsx
Normal file
161
tauri_app/src/features/totp/totp-screen.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
tauri_app/src/features/totp/totp.ts
Normal file
81
tauri_app/src/features/totp/totp.ts
Normal 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);
|
||||||
|
}
|
||||||
75
tauri_app/src/lib/auth.tsx
Normal file
75
tauri_app/src/lib/auth.tsx
Normal 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;
|
||||||
|
}
|
||||||
6
tauri_app/src/lib/errors.ts
Normal file
6
tauri_app/src/lib/errors.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export function getErrorMessage(error: unknown, fallback: string) {
|
||||||
|
if (error instanceof Error && error.message.trim()) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
138
tauri_app/src/lib/mobile-api.ts
Normal file
138
tauri_app/src/lib/mobile-api.ts
Normal 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
10
tauri_app/src/main.tsx
Normal 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
21
tauri_app/src/styles.css
Normal 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
25
tauri_app/tsconfig.json
Normal 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
32
tauri_app/vite.config.ts
Normal 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
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user