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