151 lines
5.8 KiB
TypeScript
151 lines
5.8 KiB
TypeScript
'use client';
|
||
|
||
import { Suspense, useCallback, useEffect, useMemo, useState } from 'react';
|
||
import Link from 'next/link';
|
||
import { useRouter, useSearchParams } from 'next/navigation';
|
||
import { 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';
|
||
|
||
function OAuthAuthorizeContent() {
|
||
const searchParams = useSearchParams();
|
||
const router = useRouter();
|
||
const { user, token, isPinLocked, isLoading } = useAuth();
|
||
const [submitting, setSubmitting] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
const oauthQuery = useMemo(() => {
|
||
const params = new URLSearchParams();
|
||
searchParams.forEach((value, key) => params.set(key, value));
|
||
return params;
|
||
}, [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';
|
||
|
||
useEffect(() => {
|
||
if (isLoading) return;
|
||
if (!clientId || !redirectUri) return;
|
||
if (user && token && !isPinLocked) return;
|
||
const returnUrl = `/auth/oauth/authorize?${oauthQuery.toString()}`;
|
||
router.replace(`/auth/login?redirect=${encodeURIComponent(returnUrl)}`);
|
||
}, [clientId, isLoading, isPinLocked, oauthQuery, redirectUri, router, token, user]);
|
||
|
||
const approve = useCallback(async () => {
|
||
if (!token || !user) return;
|
||
setSubmitting(true);
|
||
setError(null);
|
||
try {
|
||
const apiBase = (process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000').replace(/\/$/, '');
|
||
const url = new URL(`${apiBase}/oauth/authorize`);
|
||
oauthQuery.forEach((value, key) => url.searchParams.set(key, value));
|
||
url.searchParams.set('userId', user.id);
|
||
|
||
const response = await fetch(url.toString(), {
|
||
headers: {
|
||
Authorization: `Bearer ${token}`,
|
||
Accept: 'application/json'
|
||
}
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const payload = (await response.json().catch(() => null)) as { message?: string | string[] } | null;
|
||
const message = Array.isArray(payload?.message) ? payload.message.join(', ') : payload?.message;
|
||
throw new Error(message || 'Не удалось подтвердить доступ');
|
||
}
|
||
|
||
const data = (await response.json()) as { redirectUrl?: string };
|
||
if (!data.redirectUrl) {
|
||
throw new Error('Сервер не вернул redirect URL');
|
||
}
|
||
window.location.href = data.redirectUrl;
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : 'Ошибка OAuth авторизации');
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
}, [oauthQuery, token, user]);
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<div className="flex min-h-[60vh] items-center justify-center">
|
||
<Loader2 className="h-6 w-6 animate-spin text-[#667085]" />
|
||
</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 (!user || !token || isPinLocked) {
|
||
return (
|
||
<div className="flex min-h-[60vh] items-center justify-center">
|
||
<Loader2 className="h-6 w-6 animate-spin text-[#667085]" />
|
||
</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]">{clientId}</span> запрашивает доступ к вашему аккаунту Lendry ID.
|
||
</p>
|
||
<div className="mt-4 space-y-2 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>
|
||
{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} 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={() => router.push('/')}>
|
||
Отмена
|
||
</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>
|
||
);
|
||
}
|