add oauth app connected

This commit is contained in:
lendry
2026-06-26 10:49:34 +03:00
parent d5e6b58955
commit b81c0cedbb
18 changed files with 838 additions and 59 deletions

View File

@@ -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<string, unknown>;
}
const frontendUrl = await resolveFrontendUrl(this.core);
const consentQuery = { ...(query as Record<string, unknown>) };
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<string, unknown>);
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<string, unknown>);
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({

View File

@@ -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;
}

View File

@@ -62,6 +62,20 @@ export function normalizeAuthorizeQuery(query: Record<string, unknown>): Normali
};
}
export function normalizeConsentQuery(query: Record<string, unknown>) {
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<string, unknown>): NormalizedTokenBody {
const grantType = readString(body.grantType) ?? readString(body.grant_type);
const clientId = readString(body.clientId) ?? readString(body.client_id);

View File

@@ -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()}>

View 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>
);
}

View File

@@ -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>

View File

@@ -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]">

View File

@@ -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]">

View File

@@ -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>
);

View File

@@ -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]">

View File

@@ -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)}
/>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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> {

View File

@@ -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 '🎵 Аудио';

View 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;
}

View File

@@ -791,6 +791,7 @@ export class AuthGrpcController {
scope: string;
state?: string;
nonce?: string;
grantConsent?: boolean;
}) {
return this.oauthCore.authorize(command);
}
@@ -805,6 +806,26 @@ export class AuthGrpcController {
return this.oauthCore.userInfo(command.accessToken);
}
@GrpcMethod('OAuthCoreService', 'CheckOAuthConsent')
checkOAuthConsent(command: { userId: string; clientId: string; scope: string }) {
return this.oauthCore.checkConsent(command.userId, command.clientId, command.scope);
}
@GrpcMethod('OAuthCoreService', 'GrantOAuthConsent')
grantOAuthConsent(command: { userId: string; clientId: string; scope: string }) {
return this.oauthCore.grantConsent(command.userId, command.clientId, command.scope);
}
@GrpcMethod('OAuthCoreService', 'ListUserOAuthConsents')
listUserOAuthConsents(command: { userId: string }) {
return this.oauthCore.listUserConsents(command.userId);
}
@GrpcMethod('OAuthCoreService', 'RevokeOAuthConsent')
revokeOAuthConsent(command: { userId: string; consentId: string }) {
return this.oauthCore.revokeConsent(command.userId, command.consentId);
}
@GrpcMethod('OtpService', 'SendOtp')
sendOtp(command: { target: string; channel: string; purpose: string; userId?: string }) {
return this.otp.send(command);

View File

@@ -44,6 +44,7 @@ export class OAuthCoreService {
scope: string;
state?: string;
nonce?: string;
grantConsent?: boolean;
}) {
const user = await this.prisma.user.findFirst({
where: { id: command.userId, deletedAt: null, status: 'ACTIVE', ...HUMAN_USER_WHERE }
@@ -58,6 +59,15 @@ export class OAuthCoreService {
throw new BadRequestException('OAuth приложение не найдено или redirect_uri запрещен');
}
if (command.grantConsent) {
await this.persistConsent(command.userId, client.id, command.scope);
} else {
const consent = await this.checkConsent(command.userId, command.clientId, command.scope);
if (!consent.granted) {
throw new BadRequestException('Согласие пользователя на доступ не получено');
}
}
const code = randomBytes(32).toString('base64url');
await this.prisma.oAuthAuthorizationCode.create({
data: {
@@ -230,4 +240,119 @@ export class OAuthCoreService {
return { accessToken, tokenType: 'Bearer', expiresIn: 900, refreshToken, idToken };
}
async checkConsent(userId: string, clientId: string, scope: string) {
const client = await this.prisma.oAuthClient.findUnique({ where: { clientId } });
if (!client?.isActive) {
throw new BadRequestException('OAuth приложение не найдено');
}
const requestedSlugs = this.parseScopeSlugs(scope);
const requestedScopes = await this.resolveScopeInfos(requestedSlugs);
const grantedSlugs = await this.loadGrantedScopeSlugs(userId, client.id);
const granted = requestedSlugs.every((slug) => grantedSlugs.has(slug));
return {
granted,
client: { clientId: client.clientId, name: client.name },
requestedScopes
};
}
async grantConsent(userId: string, clientId: string, scope: string) {
const client = await this.prisma.oAuthClient.findUnique({ where: { clientId } });
if (!client?.isActive) {
throw new BadRequestException('OAuth приложение не найдено');
}
await this.persistConsent(userId, client.id, scope);
return { granted: true };
}
async listUserConsents(userId: string) {
const consents = await this.prisma.oAuthConsent.findMany({
where: { userId },
include: {
client: true,
scopes: { include: { scope: true } }
},
orderBy: { updatedAt: 'desc' }
});
return {
consents: consents.map((consent) => ({
id: consent.id,
clientId: consent.client.clientId,
clientName: consent.client.name,
scopes: consent.scopes.map((item) => ({
slug: item.scope.slug,
name: item.scope.name,
description: item.scope.description ?? undefined
})),
grantedAt: consent.createdAt.toISOString(),
updatedAt: consent.updatedAt.toISOString()
}))
};
}
async revokeConsent(userId: string, consentId: string) {
const consent = await this.prisma.oAuthConsent.findFirst({
where: { id: consentId, userId }
});
if (!consent) {
throw new BadRequestException('Согласие не найдено');
}
await this.prisma.oAuthConsent.delete({ where: { id: consent.id } });
return { count: 1 };
}
private parseScopeSlugs(scope: string) {
return [...new Set(scope.split(/\s+/).map((item) => item.trim()).filter(Boolean))];
}
private async resolveScopeInfos(slugs: string[]) {
if (slugs.length === 0) {
return [{ slug: 'openid', name: 'OpenID', description: 'Базовая идентификация OpenID Connect' }];
}
const records = await this.prisma.oAuthScope.findMany({ where: { slug: { in: slugs } } });
const bySlug = new Map(records.map((item) => [item.slug, item]));
return slugs.map((slug) => {
const record = bySlug.get(slug);
return {
slug,
name: record?.name ?? slug,
description: record?.description ?? undefined
};
});
}
private async loadGrantedScopeSlugs(userId: string, clientInternalId: string) {
const consent = await this.prisma.oAuthConsent.findUnique({
where: { userId_clientId: { userId, clientId: clientInternalId } },
include: { scopes: { include: { scope: true } } }
});
return new Set(consent?.scopes.map((item) => item.scope.slug) ?? []);
}
private async persistConsent(userId: string, clientInternalId: string, scope: string) {
const slugs = this.parseScopeSlugs(scope);
const scopeRecords = await this.prisma.oAuthScope.findMany({ where: { slug: { in: slugs } } });
const consent = await this.prisma.oAuthConsent.upsert({
where: { userId_clientId: { userId, clientId: clientInternalId } },
create: { userId, clientId: clientInternalId },
update: { updatedAt: new Date() }
});
for (const scopeRecord of scopeRecords) {
await this.prisma.oAuthConsentScope.upsert({
where: { consentId_scopeId: { consentId: consent.id, scopeId: scopeRecord.id } },
create: { consentId: consent.id, scopeId: scopeRecord.id },
update: {}
});
}
}
}