add oauth app connected
This commit is contained in:
@@ -1,13 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Loader2, ShieldCheck } from 'lucide-react';
|
||||
import { CheckCircle2, Loader2, ShieldCheck } from 'lucide-react';
|
||||
import { BrandLogo } from '@/components/id/brand-logo';
|
||||
import { useAuth } from '@/components/id/auth-provider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { approveOAuthAuthorization, fetchAuthSession } from '@/lib/api';
|
||||
import {
|
||||
approveOAuthAuthorization,
|
||||
checkOAuthConsent,
|
||||
fetchAuthSession,
|
||||
type OAuthConsentCheckResponse
|
||||
} from '@/lib/api';
|
||||
|
||||
function buildOAuthQuery(searchParams: URLSearchParams) {
|
||||
const params = new URLSearchParams();
|
||||
@@ -22,8 +27,11 @@ function OAuthAuthorizeContent() {
|
||||
const router = useRouter();
|
||||
const { user, token, isPinLocked, isLoading } = useAuth();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [checkingConsent, setCheckingConsent] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [sessionChecked, setSessionChecked] = useState(false);
|
||||
const [consentInfo, setConsentInfo] = useState<OAuthConsentCheckResponse | null>(null);
|
||||
const autoApproveStartedRef = useRef(false);
|
||||
|
||||
const oauthQuery = useMemo(() => buildOAuthQuery(searchParams), [searchParams]);
|
||||
|
||||
@@ -34,6 +42,8 @@ function OAuthAuthorizeContent() {
|
||||
|
||||
const returnUrl = useMemo(() => `/auth/oauth/authorize?${oauthQuery.toString()}`, [oauthQuery]);
|
||||
|
||||
const clientLabel = consentInfo?.client?.name ?? clientId ?? 'Приложение';
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchParams.has('userId')) return;
|
||||
router.replace(returnUrl);
|
||||
@@ -82,11 +92,40 @@ function OAuthAuthorizeContent() {
|
||||
window.location.href = data.redirectUrl;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Ошибка OAuth авторизации');
|
||||
autoApproveStartedRef.current = false;
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [isPinLocked, oauthQuery, token, user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !clientId || !sessionChecked || isPinLocked) return;
|
||||
|
||||
let cancelled = false;
|
||||
setCheckingConsent(true);
|
||||
void checkOAuthConsent(oauthQuery, token)
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
setConsentInfo(result);
|
||||
if (result.granted && !autoApproveStartedRef.current) {
|
||||
autoApproveStartedRef.current = true;
|
||||
void approve();
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : 'Не удалось проверить согласие');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setCheckingConsent(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [approve, clientId, isPinLocked, oauthQuery, sessionChecked, token]);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
if (!redirectUri) {
|
||||
router.push('/');
|
||||
@@ -104,6 +143,7 @@ function OAuthAuthorizeContent() {
|
||||
}, [redirectUri, router, state]);
|
||||
|
||||
const authReady = Boolean(user && token && !isPinLocked && sessionChecked);
|
||||
const scopeItems = consentInfo?.requestedScopes ?? [];
|
||||
|
||||
if (isLoading || (clientId && redirectUri && !authReady && !isPinLocked)) {
|
||||
return (
|
||||
@@ -133,6 +173,15 @@ function OAuthAuthorizeContent() {
|
||||
);
|
||||
}
|
||||
|
||||
if (checkingConsent || (consentInfo?.granted && submitting)) {
|
||||
return (
|
||||
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-3 px-4 text-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-[#667085]" />
|
||||
<p className="text-sm text-[#667085]">Продолжаем вход в {clientLabel}…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[70vh] max-w-lg flex-col justify-center px-4 py-12">
|
||||
<div className="mb-8 flex justify-center">
|
||||
@@ -144,19 +193,34 @@ function OAuthAuthorizeContent() {
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold">Разрешить доступ?</h1>
|
||||
<p className="mt-2 text-sm leading-relaxed text-[#667085]">
|
||||
Приложение <span className="font-medium text-[#1f2430]">{clientId}</span> запрашивает доступ к вашему аккаунту Lendry ID.
|
||||
Приложение <span className="font-medium text-[#1f2430]">{clientLabel}</span> запрашивает доступ к данным вашего аккаунта Lendry ID.
|
||||
</p>
|
||||
<div className="mt-4 space-y-2 rounded-2xl bg-[#f4f5f8] p-4 text-sm">
|
||||
<div className="mt-4 space-y-3 rounded-2xl bg-[#f4f5f8] p-4 text-sm">
|
||||
<p>
|
||||
<span className="text-[#667085]">Пользователь:</span> {user?.displayName}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-[#667085]">Scopes:</span> {scope}
|
||||
</p>
|
||||
<p className="break-all">
|
||||
<span className="text-[#667085]">Redirect URI:</span> {redirectUri}
|
||||
</p>
|
||||
<div>
|
||||
<p className="text-[#667085]">Запрашиваемые данные:</p>
|
||||
<ul className="mt-2 space-y-2">
|
||||
{scopeItems.length > 0 ? (
|
||||
scopeItems.map((item) => (
|
||||
<li key={item.slug} className="flex items-start gap-2">
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-[#3390ec]" />
|
||||
<div>
|
||||
<div className="font-medium text-[#1f2430]">{item.name}</div>
|
||||
{item.description ? <div className="text-xs text-[#667085]">{item.description}</div> : null}
|
||||
</div>
|
||||
</li>
|
||||
))
|
||||
) : (
|
||||
<li className="text-[#667085]">{scope}</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-4 text-xs leading-relaxed text-[#667085]">
|
||||
После подтверждения доступ сохранится, и повторно спрашивать не будем. Отозвать его можно в разделе «Данные → Доступы к данным».
|
||||
</p>
|
||||
{error ? <p className="mt-4 text-sm text-red-600">{error}</p> : null}
|
||||
<div className="mt-6 flex flex-col gap-3 sm:flex-row">
|
||||
<Button className="flex-1 rounded-xl" disabled={submitting || !authReady} onClick={() => void approve()}>
|
||||
|
||||
171
apps/frontend/app/data/consents/page.tsx
Normal file
171
apps/frontend/app/data/consents/page.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Ban, CheckCircle2, ChevronRight, FileKey2, Loader2 } from 'lucide-react';
|
||||
import { useAuth } from '@/components/id/auth-provider';
|
||||
import { IdShell } from '@/components/id/shell';
|
||||
import { useToast } from '@/components/id/toast-provider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { useRequireAuth } from '@/hooks/use-require-auth';
|
||||
import {
|
||||
fetchUserOAuthConsents,
|
||||
getApiErrorMessage,
|
||||
revokeOAuthConsent,
|
||||
type OAuthUserConsent
|
||||
} from '@/lib/api';
|
||||
|
||||
function formatGrantedAt(value: string) {
|
||||
return new Intl.DateTimeFormat('ru-RU', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
export default function DataConsentsPage() {
|
||||
const router = useRouter();
|
||||
const { user, token } = useAuth();
|
||||
const { isReady, isPinLocked } = useRequireAuth();
|
||||
const { showToast } = useToast();
|
||||
const [consents, setConsents] = useState<OAuthUserConsent[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedConsent, setSelectedConsent] = useState<OAuthUserConsent | null>(null);
|
||||
const [revoking, setRevoking] = useState(false);
|
||||
|
||||
const loadConsents = useCallback(async () => {
|
||||
if (!user || !token || isPinLocked) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetchUserOAuthConsents(user.id, token);
|
||||
setConsents(response.consents ?? []);
|
||||
} catch (error) {
|
||||
showToast(getApiErrorMessage(error, 'Не удалось загрузить доступы') ?? 'Ошибка');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [isPinLocked, showToast, token, user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isReady && user && !isPinLocked) void loadConsents();
|
||||
}, [isPinLocked, isReady, loadConsents, user]);
|
||||
|
||||
async function handleRevoke() {
|
||||
if (!user || !token || !selectedConsent) return;
|
||||
setRevoking(true);
|
||||
try {
|
||||
await revokeOAuthConsent(user.id, selectedConsent.id, token);
|
||||
setConsents((current) => current.filter((item) => item.id !== selectedConsent.id));
|
||||
setSelectedConsent(null);
|
||||
showToast('Доступ отозван');
|
||||
} catch (error) {
|
||||
showToast(getApiErrorMessage(error, 'Не удалось отозвать доступ') ?? 'Ошибка');
|
||||
} finally {
|
||||
setRevoking(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<IdShell active="/data">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/data')}
|
||||
className="mb-6 text-sm text-[#3390ec] transition hover:underline"
|
||||
>
|
||||
← Назад к данным
|
||||
</button>
|
||||
|
||||
<h1 className="text-2xl font-medium">Доступы к данным</h1>
|
||||
<p className="mt-1 text-sm text-[#667085]">
|
||||
Настройте, к каким данным аккаунта есть доступ у сервисов. После отзыва при следующем входе доступ нужно будет подтвердить снова.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 overflow-hidden rounded-[24px] bg-[#f4f5f8]">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center gap-2 px-4 py-10 text-sm text-[#667085]">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Загружаем приложения...
|
||||
</div>
|
||||
) : consents.length === 0 ? (
|
||||
<p className="px-4 py-10 text-center text-sm text-[#667085]">Вы ещё не выдавали доступ ни одному приложению</p>
|
||||
) : (
|
||||
consents.map((consent) => (
|
||||
<button
|
||||
key={consent.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedConsent(consent)}
|
||||
className="flex w-full items-center gap-4 border-b border-[#eceef4] px-4 py-4 text-left transition last:border-b-0 hover:bg-[#fafbfd]"
|
||||
>
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-white">
|
||||
<FileKey2 className="h-5 w-5 text-[#667085]" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium">{consent.clientName}</div>
|
||||
<div className="truncate text-sm text-[#667085]">Выданы {formatGrantedAt(consent.grantedAt)}</div>
|
||||
</div>
|
||||
<ChevronRight className="h-5 w-5 shrink-0 text-[#a8adbc]" />
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={Boolean(selectedConsent)} onOpenChange={(open) => !open && setSelectedConsent(null)}>
|
||||
<DialogContent className="rounded-[28px] sm:max-w-[480px]">
|
||||
{selectedConsent ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-[#eef4ff] text-[#3390ec]">
|
||||
<CheckCircle2 className="h-6 w-6" />
|
||||
</div>
|
||||
<DialogTitle className="text-center text-xl">{selectedConsent.clientName}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-center text-sm text-[#667085]">
|
||||
Выданы {formatGrantedAt(selectedConsent.grantedAt)}
|
||||
</p>
|
||||
<div className="mt-4 rounded-2xl bg-[#f4f5f8] p-4">
|
||||
<p className="text-sm font-medium">API Lendry ID</p>
|
||||
<ul className="mt-3 space-y-2">
|
||||
{selectedConsent.scopes.map((scope) => (
|
||||
<li key={scope.slug} className="flex items-start gap-2 text-sm">
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-[#3390ec]" />
|
||||
<div>
|
||||
<div>{scope.description ?? scope.name}</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<p className="mt-4 text-center text-xs text-[#667085]">ID: {selectedConsent.id.slice(0, 8).toUpperCase()}</p>
|
||||
<div className="mt-6 space-y-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full rounded-xl"
|
||||
disabled={revoking}
|
||||
onClick={() => void handleRevoke()}
|
||||
>
|
||||
{revoking ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Отзываем...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Ban className="mr-2 h-4 w-4" />
|
||||
Отозвать доступы
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button className="w-full rounded-xl" variant="secondary" onClick={() => setSelectedConsent(null)}>
|
||||
Закрыть
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</IdShell>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Car, ChevronRight, FileText, Loader2, Mail, Phone, Trash2, UserRound } from 'lucide-react';
|
||||
import { Car, ChevronRight, FileKey2, FileText, Loader2, Mail, Phone, Trash2, UserRound } from 'lucide-react';
|
||||
import { AddressQuickSection } from '@/components/addresses/address-quick-section';
|
||||
import { DocumentFormDialog } from '@/components/documents/document-form-dialog';
|
||||
import { ActionTile } from '@/components/id/action-tile';
|
||||
@@ -337,6 +337,14 @@ export default function DataPage() {
|
||||
|
||||
<section className="mt-10">
|
||||
<h2 className="text-2xl font-medium">Управление данными</h2>
|
||||
<div className="mt-2 overflow-hidden rounded-[24px] bg-[#f4f5f8]">
|
||||
<Row
|
||||
icon={FileKey2}
|
||||
title="Доступы к данным"
|
||||
text="Приложения, которым вы разрешили доступ к аккаунту"
|
||||
onClick={() => router.push('/data/consents')}
|
||||
/>
|
||||
</div>
|
||||
{deletionStatus?.pending && deletionStatus.effectiveAt ? (
|
||||
<div className="mt-4 rounded-[24px] border border-amber-200 bg-amber-50/80 p-5">
|
||||
<p className="font-medium text-amber-900">Удаление аккаунта запланировано</p>
|
||||
|
||||
@@ -7,13 +7,16 @@ import { useToast } from '@/components/id/toast-provider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { createManagedBot, getApiErrorMessage } from '@/lib/api';
|
||||
import { useEmbeddedAuthFallback } from '@/lib/embedded-auth-bridge';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Step = 'name' | 'username' | 'done';
|
||||
|
||||
export function BotCreateMiniAppContent() {
|
||||
const { token, user, isPinLocked } = useAuth();
|
||||
const { token, isPinLocked, isLoading } = useAuth();
|
||||
const embeddedToken = useEmbeddedAuthFallback();
|
||||
const { showToast } = useToast();
|
||||
const effectiveToken = token ?? embeddedToken;
|
||||
|
||||
const [step, setStep] = useState<Step>('name');
|
||||
const [name, setName] = useState('');
|
||||
@@ -21,11 +24,11 @@ export function BotCreateMiniAppContent() {
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createdBot, setCreatedBot] = useState<{ username: string; token: string; botId: string } | null>(null);
|
||||
|
||||
const canUse = Boolean(token && user && !isPinLocked);
|
||||
const canUse = Boolean(effectiveToken && !isPinLocked);
|
||||
const usernamePreview = useMemo(() => `${username.replace(/_bot$/i, '').trim()}_bot`, [username]);
|
||||
|
||||
async function handleCreate() {
|
||||
if (!canUse) return;
|
||||
if (!canUse || !effectiveToken) return;
|
||||
const trimmedName = name.trim();
|
||||
const trimmedUsername = username.replace(/_bot$/i, '').trim();
|
||||
if (!trimmedName) {
|
||||
@@ -40,7 +43,7 @@ export function BotCreateMiniAppContent() {
|
||||
|
||||
setCreating(true);
|
||||
try {
|
||||
const response = await createManagedBot({ name: trimmedName, username: trimmedUsername }, token);
|
||||
const response = await createManagedBot({ name: trimmedName, username: trimmedUsername }, effectiveToken);
|
||||
setCreatedBot({
|
||||
username: response.bot?.username ?? usernamePreview,
|
||||
token: response.token ?? '',
|
||||
@@ -61,6 +64,15 @@ export function BotCreateMiniAppContent() {
|
||||
showToast('Токен скопирован');
|
||||
}
|
||||
|
||||
if (isLoading && !effectiveToken) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-6 text-center text-sm text-[#667085]">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Загрузка...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!canUse) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-6 text-center text-sm text-[#667085]">
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
updateManagedBot,
|
||||
updateManagedBotProfile
|
||||
} from '@/lib/api';
|
||||
import { useEmbeddedAuthFallback } from '@/lib/embedded-auth-bridge';
|
||||
import { parseComposerMenuButtonJson } from '@/lib/bot-menu-button';
|
||||
|
||||
function FieldTextarea({
|
||||
@@ -77,7 +78,9 @@ function SectionCard({
|
||||
export function BotManageMiniAppContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const botId = searchParams.get('botId') ?? '';
|
||||
const { token, user, isPinLocked } = useAuth();
|
||||
const { token, isPinLocked, isLoading } = useAuth();
|
||||
const embeddedToken = useEmbeddedAuthFallback();
|
||||
const effectiveToken = token ?? embeddedToken;
|
||||
const { showToast } = useToast();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -94,13 +97,13 @@ export function BotManageMiniAppContent() {
|
||||
const [tokenPrefix, setTokenPrefix] = useState('');
|
||||
const [newToken, setNewToken] = useState<string | null>(null);
|
||||
|
||||
const canUse = Boolean(token && user && !isPinLocked && botId);
|
||||
const canUse = Boolean(effectiveToken && !isPinLocked && botId);
|
||||
|
||||
const loadBot = useCallback(async () => {
|
||||
if (!canUse) return;
|
||||
if (!canUse || !effectiveToken) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const bot = await fetchBot(botId, token);
|
||||
const bot = await fetchBot(botId, effectiveToken);
|
||||
setName(bot.name);
|
||||
setUsername(bot.username);
|
||||
setDescription(bot.description ?? '');
|
||||
@@ -121,7 +124,7 @@ export function BotManageMiniAppContent() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [botId, canUse, showToast, token]);
|
||||
}, [botId, canUse, effectiveToken, showToast]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadBot();
|
||||
@@ -130,7 +133,7 @@ export function BotManageMiniAppContent() {
|
||||
const usernameWithoutSuffix = useMemo(() => username.replace(/_bot$/i, ''), [username]);
|
||||
|
||||
async function handleSaveBasic() {
|
||||
if (!canUse) return;
|
||||
if (!canUse || !effectiveToken) return;
|
||||
setSavingBasic(true);
|
||||
try {
|
||||
const bot = await updateManagedBot(
|
||||
@@ -139,7 +142,7 @@ export function BotManageMiniAppContent() {
|
||||
name: name.trim(),
|
||||
username: usernameWithoutSuffix.trim()
|
||||
},
|
||||
token
|
||||
effectiveToken
|
||||
);
|
||||
setName(bot.name);
|
||||
setUsername(bot.username);
|
||||
@@ -152,7 +155,7 @@ export function BotManageMiniAppContent() {
|
||||
}
|
||||
|
||||
async function handleSaveProfile() {
|
||||
if (!canUse) return;
|
||||
if (!canUse || !effectiveToken) return;
|
||||
setSavingProfile(true);
|
||||
try {
|
||||
const bot = await updateManagedBotProfile(
|
||||
@@ -164,7 +167,7 @@ export function BotManageMiniAppContent() {
|
||||
menuButtonUrl: menuButtonUrl.trim(),
|
||||
menuButtonText: menuButtonText.trim() || 'App'
|
||||
},
|
||||
token
|
||||
effectiveToken
|
||||
);
|
||||
setDescription(bot.description ?? '');
|
||||
setAboutText(bot.aboutText ?? '');
|
||||
@@ -183,10 +186,10 @@ export function BotManageMiniAppContent() {
|
||||
}
|
||||
|
||||
async function handleRevokeToken() {
|
||||
if (!canUse) return;
|
||||
if (!canUse || !effectiveToken) return;
|
||||
setRevoking(true);
|
||||
try {
|
||||
const response = await revokeManagedBotToken(botId, token);
|
||||
const response = await revokeManagedBotToken(botId, effectiveToken);
|
||||
setNewToken(response.token ?? null);
|
||||
setTokenPrefix(response.bot?.tokenPrefix ?? tokenPrefix);
|
||||
showToast('Токен перевыпущен');
|
||||
@@ -207,6 +210,15 @@ export function BotManageMiniAppContent() {
|
||||
return <div className="flex min-h-screen items-center justify-center p-6 text-sm text-[#667085]">Не указан botId</div>;
|
||||
}
|
||||
|
||||
if (isLoading && !effectiveToken) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-6 text-center text-sm text-[#667085]">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Загрузка...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!canUse) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-6 text-center text-sm text-[#667085]">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { LayoutGrid, Loader2, Send, X } from 'lucide-react';
|
||||
import { BotMessageKeyboard } from '@/components/chat/bot-message-keyboard';
|
||||
import { BotChatMessageContextMenu } from '@/components/chat/bot-chat-message-context-menu';
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
fetchBotChatMessages,
|
||||
sendBotMessage
|
||||
} from '@/lib/api';
|
||||
import { postEmbeddedAuthToIframe } from '@/lib/embedded-auth-bridge';
|
||||
import { extractMessageReplyMarkup, serializeInlineKeyboardMarkup } from '@/lib/bot-reply-markup';
|
||||
import { resolveComposerMenuButton, type ComposerMenuButton } from '@/lib/bot-menu-button';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -279,6 +280,7 @@ export function FamilyBotChat({
|
||||
<BotMiniAppSheet
|
||||
url={internalMiniAppUrl}
|
||||
title={composerMenuButton?.text ?? 'Mini App'}
|
||||
authToken={token}
|
||||
onClose={() => setInternalMiniAppUrl(null)}
|
||||
/>
|
||||
</div>
|
||||
@@ -288,10 +290,41 @@ export function FamilyBotChat({
|
||||
interface BotMiniAppSheetProps {
|
||||
url: string | null;
|
||||
title?: string;
|
||||
authToken?: string | null;
|
||||
authRefreshToken?: string | null;
|
||||
authSessionId?: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function BotMiniAppSheet({ url, title = 'Mini App', onClose }: BotMiniAppSheetProps) {
|
||||
export function BotMiniAppSheet({
|
||||
url,
|
||||
title = 'Mini App',
|
||||
authToken,
|
||||
authRefreshToken,
|
||||
authSessionId,
|
||||
onClose
|
||||
}: BotMiniAppSheetProps) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
const pushAuthToIframe = useCallback(() => {
|
||||
if (!iframeRef.current || !authToken) return;
|
||||
postEmbeddedAuthToIframe(iframeRef.current, {
|
||||
token: authToken,
|
||||
refreshToken: authRefreshToken,
|
||||
sessionId: authSessionId
|
||||
});
|
||||
}, [authRefreshToken, authSessionId, authToken]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleMessage(event: MessageEvent) {
|
||||
if (event.origin !== window.location.origin) return;
|
||||
if (event.data?.type !== 'lendry-id-embedded-auth-request') return;
|
||||
pushAuthToIframe();
|
||||
}
|
||||
window.addEventListener('message', handleMessage);
|
||||
return () => window.removeEventListener('message', handleMessage);
|
||||
}, [pushAuthToIframe]);
|
||||
|
||||
if (!url) return null;
|
||||
return (
|
||||
<div className="fixed inset-0 z-[70] flex items-end justify-center bg-black/40 p-4 sm:items-center">
|
||||
@@ -302,7 +335,14 @@ export function BotMiniAppSheet({ url, title = 'Mini App', onClose }: BotMiniApp
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<iframe src={url} title={title} className="min-h-0 flex-1 border-0" allow="clipboard-read; clipboard-write" />
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={url}
|
||||
title={title}
|
||||
className="min-h-0 flex-1 border-0"
|
||||
allow="clipboard-read; clipboard-write"
|
||||
onLoad={pushAuthToIframe}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -364,9 +364,11 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
|
||||
const loadMessages = useCallback(async (roomId: string) => {
|
||||
if (!token || isPinLocked) return;
|
||||
const room = rooms.find((item) => item.id === roomId);
|
||||
if (room?.type === 'BOT') return;
|
||||
const response = await fetchChatMessages(roomId, token);
|
||||
setMessages((response.messages ?? []).filter((message) => !message.isDeleted));
|
||||
}, [isPinLocked, token]);
|
||||
}, [isPinLocked, rooms, token]);
|
||||
|
||||
const loadPresence = useCallback(async () => {
|
||||
if (!token || isPinLocked) return;
|
||||
@@ -473,7 +475,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
if (event.type === 'chat_message') {
|
||||
if (payload?.message) {
|
||||
appendMessage(payload.message as ChatMessage);
|
||||
} else if (activeRoomId) {
|
||||
} else if (activeRoomId && !activeRoomIsBot) {
|
||||
void loadMessages(activeRoomId);
|
||||
}
|
||||
void loadGroup();
|
||||
@@ -489,7 +491,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
} else {
|
||||
setMessages((current) => current.map((item) => (item.id === updated.id ? updated : item)));
|
||||
}
|
||||
} else if (activeRoomId) {
|
||||
} else if (activeRoomId && !activeRoomIsBot) {
|
||||
void loadMessages(activeRoomId);
|
||||
}
|
||||
void loadGroup();
|
||||
@@ -504,14 +506,14 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
if (editingMessageId === deleted.id) {
|
||||
cancelEditMessage();
|
||||
}
|
||||
} else if (activeRoomId) {
|
||||
} else if (activeRoomId && !activeRoomIsBot) {
|
||||
void loadMessages(activeRoomId);
|
||||
}
|
||||
void loadGroup();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === 'chat_read_receipt' && activeRoomId) {
|
||||
if (event.type === 'chat_read_receipt' && activeRoomId && !activeRoomIsBot) {
|
||||
if (readReceiptTimerRef.current) clearTimeout(readReceiptTimerRef.current);
|
||||
readReceiptTimerRef.current = setTimeout(() => void loadMessages(activeRoomId), 400);
|
||||
return;
|
||||
@@ -1854,7 +1856,12 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
}}
|
||||
onConfirm={() => void confirmDeleteMessages()}
|
||||
/>
|
||||
<BotMiniAppSheet url={miniAppUrl} title={miniAppTitle} onClose={() => setMiniAppUrl(null)} />
|
||||
<BotMiniAppSheet
|
||||
url={miniAppUrl}
|
||||
title={miniAppTitle}
|
||||
authToken={token}
|
||||
onClose={() => setMiniAppUrl(null)}
|
||||
/>
|
||||
|
||||
<Dialog open={familyMembersOpen} onOpenChange={setFamilyMembersOpen}>
|
||||
<DialogContent className="rounded-[28px] sm:max-w-[480px]">
|
||||
|
||||
@@ -1252,7 +1252,12 @@ export function MiniFamilyChat() {
|
||||
event.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<BotMiniAppSheet url={miniAppUrl} title={miniAppTitle} onClose={() => setMiniAppUrl(null)} />
|
||||
<BotMiniAppSheet
|
||||
url={miniAppUrl}
|
||||
title={miniAppTitle}
|
||||
authToken={token}
|
||||
onClose={() => setMiniAppUrl(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,14 +47,6 @@ export function Sidebar({ active }: { active: string }) {
|
||||
<FamilySidebarPanel />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 text-[11px] text-[#667085]">
|
||||
{user ? <p className="truncate font-medium text-[#1f2430]">{user.displayName}</p> : null}
|
||||
<button type="button" onClick={logout} className="flex items-center gap-2 rounded-lg px-2 py-1 text-[#1f2430] hover:bg-[#f4f5f8]">
|
||||
<LogOut className="h-3.5 w-3.5" />
|
||||
Выйти
|
||||
</button>
|
||||
<p>© 2026</p>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -127,6 +127,30 @@ export interface OAuthScope {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface OAuthConsentScopeInfo {
|
||||
slug: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface OAuthConsentCheckResponse {
|
||||
granted: boolean;
|
||||
client?: {
|
||||
clientId: string;
|
||||
name: string;
|
||||
};
|
||||
requestedScopes?: OAuthConsentScopeInfo[];
|
||||
}
|
||||
|
||||
export interface OAuthUserConsent {
|
||||
id: string;
|
||||
clientId: string;
|
||||
clientName: string;
|
||||
scopes: OAuthConsentScopeInfo[];
|
||||
grantedAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface OAuthClient {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -411,15 +435,35 @@ export async function fetchAuthSession(token?: string | null): Promise<AuthSessi
|
||||
}
|
||||
|
||||
export async function approveOAuthAuthorization(params: URLSearchParams, token: string) {
|
||||
const body = {
|
||||
client_id: params.get('client_id') ?? params.get('clientId') ?? undefined,
|
||||
redirect_uri: params.get('redirect_uri') ?? params.get('redirectUri') ?? undefined,
|
||||
scope: params.get('scope') ?? 'openid profile',
|
||||
state: params.get('state') ?? undefined,
|
||||
nonce: params.get('nonce') ?? undefined
|
||||
};
|
||||
return apiFetch<{ redirectUrl?: string }>('/oauth/consent/approve', {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
}, token);
|
||||
}
|
||||
|
||||
export async function checkOAuthConsent(params: URLSearchParams, token: string) {
|
||||
const query = new URLSearchParams();
|
||||
params.forEach((value, key) => {
|
||||
if (key !== 'userId') query.set(key, value);
|
||||
});
|
||||
return apiFetch<{ redirectUrl?: string }>(
|
||||
`/oauth/authorize?${query.toString()}`,
|
||||
{ headers: { Accept: 'application/json' } },
|
||||
token
|
||||
);
|
||||
const clientId = params.get('client_id') ?? params.get('clientId');
|
||||
const scope = params.get('scope') ?? 'openid profile';
|
||||
if (clientId) query.set('client_id', clientId);
|
||||
query.set('scope', scope);
|
||||
return apiFetch<OAuthConsentCheckResponse>(`/oauth/consent/check?${query.toString()}`, {}, token);
|
||||
}
|
||||
|
||||
export async function fetchUserOAuthConsents(userId: string, token: string) {
|
||||
return apiFetch<{ consents?: OAuthUserConsent[] }>(`/oauth/consents/users/${userId}`, {}, token);
|
||||
}
|
||||
|
||||
export async function revokeOAuthConsent(userId: string, consentId: string, token: string) {
|
||||
return apiFetch(`/oauth/consents/users/${userId}/${consentId}`, { method: 'DELETE' }, token);
|
||||
}
|
||||
|
||||
export async function refreshAuthSession(): Promise<RefreshSessionResponse> {
|
||||
|
||||
@@ -36,6 +36,16 @@ export function getMessageCopyText(message: ChatMessage, visibleText?: string) {
|
||||
return '';
|
||||
}
|
||||
|
||||
function looksLikeEncryptedPayload(content?: string | null) {
|
||||
if (!content?.trim().startsWith('{')) return false;
|
||||
try {
|
||||
const parsed = JSON.parse(content) as { v?: unknown; ciphertext?: unknown; kind?: unknown };
|
||||
return typeof parsed === 'object' && parsed !== null && ('v' in parsed || 'ciphertext' in parsed || 'kind' in parsed);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getMessagePreviewText(message: ChatMessage, visibleText?: string) {
|
||||
if (message.type === 'SYSTEM') {
|
||||
return resolveNativeEmojiContent(message.content?.trim() ?? 'Системное сообщение');
|
||||
@@ -44,7 +54,7 @@ export function getMessagePreviewText(message: ChatMessage, visibleText?: string
|
||||
return `📊 Опрос: ${message.poll.question}`;
|
||||
}
|
||||
const text = getMessageCopyText(message, visibleText);
|
||||
if (text) return text;
|
||||
if (text && !looksLikeEncryptedPayload(text)) return text;
|
||||
if (message.type === 'IMAGE') return '📷 Фото';
|
||||
if (message.type === 'VOICE') return '🎤 Голосовое';
|
||||
if (message.type === 'AUDIO') return '🎵 Аудио';
|
||||
|
||||
55
apps/frontend/lib/embedded-auth-bridge.ts
Normal file
55
apps/frontend/lib/embedded-auth-bridge.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AUTH_REFRESH_KEY, AUTH_SESSION_KEY, AUTH_TOKEN_KEY } from '@/lib/api';
|
||||
|
||||
export const EMBEDDED_AUTH_MESSAGE = 'lendry-id-embedded-auth';
|
||||
|
||||
export interface EmbeddedAuthPayload {
|
||||
type: typeof EMBEDDED_AUTH_MESSAGE;
|
||||
token: string;
|
||||
refreshToken?: string;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
export function postEmbeddedAuthToIframe(iframe: HTMLIFrameElement, auth: { token: string; refreshToken?: string | null; sessionId?: string | null }) {
|
||||
const target = iframe.contentWindow;
|
||||
if (!target || !auth.token) return;
|
||||
const payload: EmbeddedAuthPayload = {
|
||||
type: EMBEDDED_AUTH_MESSAGE,
|
||||
token: auth.token,
|
||||
refreshToken: auth.refreshToken ?? undefined,
|
||||
sessionId: auth.sessionId ?? undefined
|
||||
};
|
||||
target.postMessage(payload, window.location.origin);
|
||||
}
|
||||
|
||||
export function useEmbeddedAuthFallback() {
|
||||
const [embeddedToken, setEmbeddedToken] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleMessage(event: MessageEvent) {
|
||||
if (event.origin !== window.location.origin) return;
|
||||
const data = event.data as Partial<EmbeddedAuthPayload> | null;
|
||||
if (!data || data.type !== EMBEDDED_AUTH_MESSAGE || !data.token?.trim()) return;
|
||||
|
||||
const token = data.token.trim();
|
||||
setEmbeddedToken(token);
|
||||
window.localStorage.setItem(AUTH_TOKEN_KEY, token);
|
||||
if (data.refreshToken) {
|
||||
window.localStorage.setItem(AUTH_REFRESH_KEY, data.refreshToken);
|
||||
}
|
||||
if (data.sessionId) {
|
||||
window.localStorage.setItem(AUTH_SESSION_KEY, data.sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
if (window.parent !== window) {
|
||||
window.parent.postMessage({ type: 'lendry-id-embedded-auth-request' }, window.location.origin);
|
||||
}
|
||||
return () => window.removeEventListener('message', handleMessage);
|
||||
}, []);
|
||||
|
||||
return embeddedToken;
|
||||
}
|
||||
Reference in New Issue
Block a user