From b81c0cedbb1eeaeba75d10086b8e22d58bcec6a4 Mon Sep 17 00:00:00 2001 From: lendry Date: Fri, 26 Jun 2026 10:49:34 +0300 Subject: [PATCH] add oauth app connected --- .../src/controllers/oauth.controller.ts | 116 +++++++++++- apps/api-gateway/src/dto/oauth.dto.ts | 37 ++++ apps/api-gateway/src/lib/oauth-params.ts | 14 ++ .../app/auth/oauth/authorize/page.tsx | 86 +++++++-- apps/frontend/app/data/consents/page.tsx | 171 ++++++++++++++++++ apps/frontend/app/data/page.tsx | 10 +- .../app/mini-apps/bot-create/content.tsx | 20 +- .../app/mini-apps/bot-manage/content.tsx | 34 ++-- .../components/family/family-bot-chat.tsx | 46 ++++- .../components/family/family-group-view.tsx | 19 +- .../components/family/mini-family-chat.tsx | 7 +- apps/frontend/components/id/sidebar.tsx | 8 - apps/frontend/lib/api.ts | 60 +++++- apps/frontend/lib/chat-message-utils.ts | 12 +- apps/frontend/lib/embedded-auth-bridge.ts | 55 ++++++ .../src/domain/auth-grpc.controller.ts | 21 +++ .../sso-core/src/domain/oauth-core.service.ts | 125 +++++++++++++ shared/proto/identity.proto | 56 ++++++ 18 files changed, 838 insertions(+), 59 deletions(-) create mode 100644 apps/frontend/app/data/consents/page.tsx create mode 100644 apps/frontend/lib/embedded-auth-bridge.ts diff --git a/apps/api-gateway/src/controllers/oauth.controller.ts b/apps/api-gateway/src/controllers/oauth.controller.ts index 6e4f1e5..c96df00 100644 --- a/apps/api-gateway/src/controllers/oauth.controller.ts +++ b/apps/api-gateway/src/controllers/oauth.controller.ts @@ -1,11 +1,11 @@ -import { Body, Controller, Get, Headers, Post, Query, Res, UnauthorizedException, UsePipes, ValidationPipe } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Headers, Param, Post, Query, Res, UnauthorizedException, UsePipes, ValidationPipe } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { ApiBearerAuth, ApiBody, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger'; import { firstValueFrom } from 'rxjs'; import { CoreGrpcService } from '../core-grpc.service'; -import { OAuthAuthorizeIncomingDto, OAuthTokenIncomingDto } from '../dto/oauth.dto'; +import { OAuthAuthorizeIncomingDto, OAuthConsentActionDto, OAuthTokenIncomingDto } from '../dto/oauth.dto'; import { extractBearerToken } from '../auth-token'; -import { appendQueryParams, mapUserInfoToOidc, mergeTokenCredentials, mapTokenResponseToStandard, normalizeAuthorizeQuery, normalizeTokenBody } from '../lib/oauth-params'; +import { appendQueryParams, mapUserInfoToOidc, mergeTokenCredentials, mapTokenResponseToStandard, normalizeAuthorizeQuery, normalizeConsentQuery, normalizeTokenBody } from '../lib/oauth-params'; import { resolveFrontendUrl } from '../lib/oauth-issuer'; import { verifyAccessToken } from '../session-auth'; @@ -74,6 +74,36 @@ export class OAuthController { return; } + const consentCheck = (await firstValueFrom( + this.core.oauth.CheckOAuthConsent({ + userId, + clientId: normalized.clientId, + scope: normalized.scope + }) + )) as { granted?: boolean }; + + const accept = acceptHeader ?? ''; + const wantsJson = authorization?.startsWith('Bearer') || accept.includes('application/json'); + + if (!consentCheck.granted) { + if (wantsJson) { + return (await firstValueFrom( + this.core.oauth.CheckOAuthConsent({ + userId, + clientId: normalized.clientId, + scope: normalized.scope + }) + )) as Record; + } + const frontendUrl = await resolveFrontendUrl(this.core); + const consentQuery = { ...(query as Record) }; + delete consentQuery.userId; + delete consentQuery.user_id; + const consentUrl = appendQueryParams(`${frontendUrl}/auth/oauth/authorize`, consentQuery); + res.redirect(302, consentUrl); + return; + } + const result = (await firstValueFrom( this.core.oauth.Authorize({ userId, @@ -85,8 +115,6 @@ export class OAuthController { }) )) as { redirectUrl?: string; code?: string; state?: string }; - const accept = acceptHeader ?? ''; - const wantsJson = authorization?.startsWith('Bearer') || accept.includes('application/json'); if (wantsJson) { return result; } @@ -98,6 +126,84 @@ export class OAuthController { res.redirect(302, result.redirectUrl); } + @Get('consent/check') + @ApiBearerAuth() + @UsePipes(oauthValidationPipe) + @ApiOperation({ summary: 'Проверить сохранённое согласие OAuth', description: 'Возвращает статус согласия пользователя для указанного OAuth-приложения и scopes.' }) + @ApiQuery({ name: 'client_id', required: false }) + @ApiQuery({ name: 'clientId', required: false }) + @ApiQuery({ name: 'scope', required: false }) + async checkConsent( + @Query() query: OAuthConsentActionDto, + @Headers('authorization') authorization: string | undefined + ) { + const payload = await verifyAccessToken(this.jwt, authorization); + const normalized = normalizeConsentQuery(query as Record); + return firstValueFrom( + this.core.oauth.CheckOAuthConsent({ + userId: payload.sub, + clientId: normalized.clientId, + scope: normalized.scope + }) + ); + } + + @Post('consent/approve') + @ApiBearerAuth() + @UsePipes(oauthValidationPipe) + @ApiOperation({ + summary: 'Подтвердить OAuth-согласие', + description: 'Сохраняет согласие пользователя и выдаёт authorization code для redirect_uri.' + }) + @ApiBody({ type: OAuthConsentActionDto }) + async approveConsent( + @Body() body: OAuthConsentActionDto, + @Headers('authorization') authorization: string | undefined + ) { + const payload = await verifyAccessToken(this.jwt, authorization); + const normalized = normalizeAuthorizeQuery(body as Record); + return firstValueFrom( + this.core.oauth.Authorize({ + userId: payload.sub, + clientId: normalized.clientId, + redirectUri: normalized.redirectUri, + scope: normalized.scope, + state: normalized.state, + nonce: normalized.nonce, + grantConsent: true + }) + ); + } + + @Get('consents/users/:userId') + @ApiBearerAuth() + @ApiOperation({ summary: 'Список OAuth-согласий пользователя', description: 'Возвращает все приложения, которым пользователь выдал доступ к данным.' }) + async listUserConsents( + @Param('userId') userId: string, + @Headers('authorization') authorization: string | undefined + ) { + const payload = await verifyAccessToken(this.jwt, authorization); + if (payload.sub !== userId) { + throw new UnauthorizedException('Можно просматривать только свои согласия'); + } + return firstValueFrom(this.core.oauth.ListUserOAuthConsents({ userId })); + } + + @Delete('consents/users/:userId/:consentId') + @ApiBearerAuth() + @ApiOperation({ summary: 'Отозвать OAuth-согласие', description: 'Удаляет сохранённое согласие. При следующем входе приложение снова запросит доступ.' }) + async revokeConsent( + @Param('userId') userId: string, + @Param('consentId') consentId: string, + @Headers('authorization') authorization: string | undefined + ) { + const payload = await verifyAccessToken(this.jwt, authorization); + if (payload.sub !== userId) { + throw new UnauthorizedException('Можно отзывать только свои согласия'); + } + return firstValueFrom(this.core.oauth.RevokeOAuthConsent({ userId, consentId })); + } + @Post('token') @UsePipes(oauthValidationPipe) @ApiOperation({ diff --git a/apps/api-gateway/src/dto/oauth.dto.ts b/apps/api-gateway/src/dto/oauth.dto.ts index 91c6caa..eb3d779 100644 --- a/apps/api-gateway/src/dto/oauth.dto.ts +++ b/apps/api-gateway/src/dto/oauth.dto.ts @@ -131,3 +131,40 @@ export class OAuthTokenIncomingDto { @IsString() code_verifier?: string; } + +export class OAuthConsentActionDto { + @ApiPropertyOptional({ description: 'OAuth client_id (RFC 6749)' }) + @IsOptional() + @IsString() + clientId?: string; + + @ApiPropertyOptional({ description: 'OAuth client_id (legacy camelCase)' }) + @IsOptional() + @IsString() + client_id?: string; + + @ApiPropertyOptional({ description: 'Scopes через пробел', example: 'openid profile email' }) + @IsOptional() + @IsString() + scope?: string; + + @ApiPropertyOptional({ description: 'redirect_uri (RFC 6749)' }) + @IsOptional() + @IsString() + redirectUri?: string; + + @ApiPropertyOptional({ description: 'redirect_uri (legacy camelCase)' }) + @IsOptional() + @IsString() + redirect_uri?: string; + + @ApiPropertyOptional({ description: 'OAuth state' }) + @IsOptional() + @IsString() + state?: string; + + @ApiPropertyOptional({ description: 'OpenID Connect nonce' }) + @IsOptional() + @IsString() + nonce?: string; +} diff --git a/apps/api-gateway/src/lib/oauth-params.ts b/apps/api-gateway/src/lib/oauth-params.ts index 2415eb0..04a4444 100644 --- a/apps/api-gateway/src/lib/oauth-params.ts +++ b/apps/api-gateway/src/lib/oauth-params.ts @@ -62,6 +62,20 @@ export function normalizeAuthorizeQuery(query: Record): Normali }; } +export function normalizeConsentQuery(query: Record) { + const clientId = readString(query.clientId) ?? readString(query.client_id); + const scope = readString(query.scope) ?? 'openid profile'; + const redirectUri = readString(query.redirectUri) ?? readString(query.redirect_uri); + const state = readString(query.state); + const nonce = readString(query.nonce); + + if (!clientId) { + throw new BadRequestException('Укажите client_id'); + } + + return { clientId, scope, redirectUri, state, nonce }; +} + export function normalizeTokenBody(body: Record): NormalizedTokenBody { const grantType = readString(body.grantType) ?? readString(body.grant_type); const clientId = readString(body.clientId) ?? readString(body.client_id); diff --git a/apps/frontend/app/auth/oauth/authorize/page.tsx b/apps/frontend/app/auth/oauth/authorize/page.tsx index 4bc9597..6d93515 100644 --- a/apps/frontend/app/auth/oauth/authorize/page.tsx +++ b/apps/frontend/app/auth/oauth/authorize/page.tsx @@ -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(null); const [sessionChecked, setSessionChecked] = useState(false); + const [consentInfo, setConsentInfo] = useState(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 ( +
+ +

Продолжаем вход в {clientLabel}…

+
+ ); + } + return (
@@ -144,19 +193,34 @@ function OAuthAuthorizeContent() {

Разрешить доступ?

- Приложение {clientId} запрашивает доступ к вашему аккаунту Lendry ID. + Приложение {clientLabel} запрашивает доступ к данным вашего аккаунта Lendry ID.

-
+

Пользователь: {user?.displayName}

-

- Scopes: {scope} -

-

- Redirect URI: {redirectUri} -

+
+

Запрашиваемые данные:

+
    + {scopeItems.length > 0 ? ( + scopeItems.map((item) => ( +
  • + +
    +
    {item.name}
    + {item.description ?
    {item.description}
    : null} +
    +
  • + )) + ) : ( +
  • {scope}
  • + )} +
+
+

+ После подтверждения доступ сохранится, и повторно спрашивать не будем. Отозвать его можно в разделе «Данные → Доступы к данным». +

{error ?

{error}

: null}
+ +

Доступы к данным

+

+ Настройте, к каким данным аккаунта есть доступ у сервисов. После отзыва при следующем входе доступ нужно будет подтвердить снова. +

+ +
+ {loading ? ( +
+ + Загружаем приложения... +
+ ) : consents.length === 0 ? ( +

Вы ещё не выдавали доступ ни одному приложению

+ ) : ( + consents.map((consent) => ( + + )) + )} +
+ + !open && setSelectedConsent(null)}> + + {selectedConsent ? ( + <> + +
+ +
+ {selectedConsent.clientName} +
+

+ Выданы {formatGrantedAt(selectedConsent.grantedAt)} +

+
+

API Lendry ID

+
    + {selectedConsent.scopes.map((scope) => ( +
  • + +
    +
    {scope.description ?? scope.name}
    +
    +
  • + ))} +
+
+

ID: {selectedConsent.id.slice(0, 8).toUpperCase()}

+
+ + +
+ + ) : null} +
+
+ + ); +} diff --git a/apps/frontend/app/data/page.tsx b/apps/frontend/app/data/page.tsx index 3283b14..838548e 100644 --- a/apps/frontend/app/data/page.tsx +++ b/apps/frontend/app/data/page.tsx @@ -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() {

Управление данными

+
+ router.push('/data/consents')} + /> +
{deletionStatus?.pending && deletionStatus.effectiveAt ? (

Удаление аккаунта запланировано

diff --git a/apps/frontend/app/mini-apps/bot-create/content.tsx b/apps/frontend/app/mini-apps/bot-create/content.tsx index 164a934..a781419 100644 --- a/apps/frontend/app/mini-apps/bot-create/content.tsx +++ b/apps/frontend/app/mini-apps/bot-create/content.tsx @@ -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('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 ( +
+ + Загрузка... +
+ ); + } + if (!canUse) { return (
diff --git a/apps/frontend/app/mini-apps/bot-manage/content.tsx b/apps/frontend/app/mini-apps/bot-manage/content.tsx index d09626f..f9e83bd 100644 --- a/apps/frontend/app/mini-apps/bot-manage/content.tsx +++ b/apps/frontend/app/mini-apps/bot-manage/content.tsx @@ -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(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
Не указан botId
; } + if (isLoading && !effectiveToken) { + return ( +
+ + Загрузка... +
+ ); + } + if (!canUse) { return (
diff --git a/apps/frontend/components/family/family-bot-chat.tsx b/apps/frontend/components/family/family-bot-chat.tsx index 193ad11..74b7408 100644 --- a/apps/frontend/components/family/family-bot-chat.tsx +++ b/apps/frontend/components/family/family-bot-chat.tsx @@ -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({ setInternalMiniAppUrl(null)} />
@@ -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(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 (
@@ -302,7 +335,14 @@ export function BotMiniAppSheet({ url, title = 'Mini App', onClose }: BotMiniApp
-