252 lines
9.3 KiB
TypeScript
252 lines
9.3 KiB
TypeScript
'use client';
|
||
|
||
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||
import Link from 'next/link';
|
||
import { useRouter, useSearchParams } from 'next/navigation';
|
||
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,
|
||
checkOAuthConsent,
|
||
fetchAuthSession,
|
||
type OAuthConsentCheckResponse
|
||
} from '@/lib/api';
|
||
|
||
function buildOAuthQuery(searchParams: URLSearchParams) {
|
||
const params = new URLSearchParams();
|
||
searchParams.forEach((value, key) => {
|
||
if (key !== 'userId') params.set(key, value);
|
||
});
|
||
return params;
|
||
}
|
||
|
||
function OAuthAuthorizeContent() {
|
||
const searchParams = useSearchParams();
|
||
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]);
|
||
|
||
const clientId = searchParams.get('client_id') ?? searchParams.get('clientId');
|
||
const redirectUri = searchParams.get('redirect_uri') ?? searchParams.get('redirectUri');
|
||
const scope = searchParams.get('scope') ?? 'openid profile';
|
||
const state = searchParams.get('state');
|
||
|
||
const returnUrl = useMemo(() => `/auth/oauth/authorize?${oauthQuery.toString()}`, [oauthQuery]);
|
||
|
||
const clientLabel = consentInfo?.client?.name ?? clientId ?? 'Приложение';
|
||
|
||
useEffect(() => {
|
||
if (!searchParams.has('userId')) return;
|
||
router.replace(returnUrl);
|
||
}, [returnUrl, router, searchParams]);
|
||
|
||
useEffect(() => {
|
||
if (isLoading) return;
|
||
if (!clientId || !redirectUri) return;
|
||
if (isPinLocked) return;
|
||
if (user && token) return;
|
||
router.replace(`/auth/login?redirect=${encodeURIComponent(returnUrl)}`);
|
||
}, [clientId, isLoading, isPinLocked, redirectUri, returnUrl, router, token, user]);
|
||
|
||
useEffect(() => {
|
||
if (isLoading || isPinLocked || !token || !user) {
|
||
setSessionChecked(false);
|
||
return;
|
||
}
|
||
|
||
let cancelled = false;
|
||
void fetchAuthSession(token)
|
||
.then(() => {
|
||
if (!cancelled) setSessionChecked(true);
|
||
})
|
||
.catch(() => {
|
||
if (!cancelled) {
|
||
setSessionChecked(false);
|
||
router.replace(`/auth/login?redirect=${encodeURIComponent(returnUrl)}`);
|
||
}
|
||
});
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [isLoading, isPinLocked, returnUrl, router, token, user]);
|
||
|
||
const approve = useCallback(async () => {
|
||
if (!token || !user || isPinLocked) return;
|
||
setSubmitting(true);
|
||
setError(null);
|
||
try {
|
||
const data = await approveOAuthAuthorization(oauthQuery, token);
|
||
if (!data.redirectUrl) {
|
||
throw new Error('Сервер не вернул redirect URL');
|
||
}
|
||
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('/');
|
||
return;
|
||
}
|
||
try {
|
||
const url = new URL(redirectUri);
|
||
url.searchParams.set('error', 'access_denied');
|
||
url.searchParams.set('error_description', 'Пользователь отклонил запрос');
|
||
if (state) url.searchParams.set('state', state);
|
||
window.location.href = url.toString();
|
||
} catch {
|
||
router.push('/');
|
||
}
|
||
}, [redirectUri, router, state]);
|
||
|
||
const authReady = Boolean(user && token && !isPinLocked && sessionChecked);
|
||
const scopeItems = consentInfo?.requestedScopes ?? [];
|
||
|
||
if (isLoading || (clientId && redirectUri && !authReady && !isPinLocked)) {
|
||
return (
|
||
<div className="flex min-h-[60vh] items-center justify-center">
|
||
<Loader2 className="h-6 w-6 animate-spin text-[#667085]" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (isPinLocked) {
|
||
return (
|
||
<div className="flex min-h-[60vh] items-center justify-center px-4 text-center">
|
||
<p className="text-sm text-[#667085]">Подтвердите PIN-код, чтобы продолжить авторизацию приложения.</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!clientId || !redirectUri) {
|
||
return (
|
||
<div className="mx-auto max-w-md px-4 py-16 text-center">
|
||
<h1 className="text-xl font-semibold">Некорректный OAuth запрос</h1>
|
||
<p className="mt-3 text-sm text-[#667085]">Отсутствуют обязательные параметры client_id и redirect_uri.</p>
|
||
<Link href="/" className="mt-6 inline-block text-[#3390ec] hover:underline">
|
||
На главную
|
||
</Link>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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">
|
||
<BrandLogo />
|
||
</div>
|
||
<div className="rounded-[24px] border border-[#eceef4] bg-white p-6 shadow-sm">
|
||
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-[#eef4ff] text-[#3390ec]">
|
||
<ShieldCheck className="h-6 w-6" />
|
||
</div>
|
||
<h1 className="text-2xl font-semibold">Разрешить доступ?</h1>
|
||
<p className="mt-2 text-sm leading-relaxed text-[#667085]">
|
||
Приложение <span className="font-medium text-[#1f2430]">{clientLabel}</span> запрашивает доступ к данным вашего аккаунта Lendry ID.
|
||
</p>
|
||
<div className="mt-4 space-y-3 rounded-2xl bg-[#f4f5f8] p-4 text-sm">
|
||
<p>
|
||
<span className="text-[#667085]">Пользователь:</span> {user?.displayName}
|
||
</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()}>
|
||
{submitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||
Разрешить
|
||
</Button>
|
||
<Button variant="outline" className="flex-1 rounded-xl" disabled={submitting} onClick={cancel}>
|
||
Отмена
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function OAuthAuthorizePage() {
|
||
return (
|
||
<Suspense
|
||
fallback={
|
||
<div className="flex min-h-[60vh] items-center justify-center">
|
||
<Loader2 className="h-6 w-6 animate-spin text-[#667085]" />
|
||
</div>
|
||
}
|
||
>
|
||
<OAuthAuthorizeContent />
|
||
</Suspense>
|
||
);
|
||
}
|