pnpm build

This commit is contained in:
parent 82cdcdac92
commit bfc93734ec
2 changed files with 243 additions and 211 deletions

View file

@ -0,0 +1,225 @@
'use client';
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { env } from "@/lib/env";
type Status = "loading" | "success" | "error";
interface AuthCallbackResponse {
provider: string;
provider_user_id: string;
access_token: string;
refresh_token?: string | null;
expires_in?: number | null;
application_token?: string | null;
raw?: Record<string, unknown>;
}
export function YandexCallbackContent() {
const searchParams = useSearchParams();
const [status, setStatus] = useState<Status>("loading");
const [message, setMessage] = useState<string>("Проверяем ответ от Yandex ID…");
const [payload, setPayload] = useState<AuthCallbackResponse | null>(null);
const queryEntries = useMemo(() => {
return Object.fromEntries(searchParams.entries());
}, [searchParams]);
useEffect(() => {
const error = searchParams.get("error");
if (error) {
setStatus("error");
setMessage(
`Яндекс вернул ошибку: ${error}${
searchParams.get("error_description")
? ` (${searchParams.get("error_description")})`
: ""
}`,
);
return;
}
const code = searchParams.get("code");
const state = searchParams.get("state");
if (!code) {
setStatus("error");
setMessage("В ответе отсутствует код авторизации (code).");
return;
}
if (!env.backendBaseUrl) {
setStatus("error");
setMessage("Не указан адрес бэкенда (NEXT_PUBLIC_API_BASE_URL).");
return;
}
const controller = new AbortController();
async function sendCallback() {
try {
setStatus("loading");
setMessage("Передаём код авторизации на бэкенд…");
const response = await fetch(
`${env.backendBaseUrl.replace(
/\/$/,
"",
)}/api/v1/auth/callback/yandex`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
credentials: "include",
signal: controller.signal,
body: JSON.stringify({
code,
state,
raw: queryEntries,
}),
},
);
if (!response.ok) {
const text = await response.text();
throw new Error(
`Бэкенд вернул ${response.status}. Тело ответа: ${text || "пусто"}`,
);
}
const data = (await response.json()) as AuthCallbackResponse;
setPayload(data);
setStatus("success");
setMessage("Авторизация завершена успешно.");
} catch (error) {
console.error("Ошибка при обработке ответа от Яндекс.", error);
setStatus("error");
setMessage(
error instanceof Error
? error.message
: "Не удалось обработать ответ от Яндекс.",
);
}
}
void sendCallback();
return () => controller.abort();
}, [queryEntries, searchParams]);
const renderContent = () => {
if (status === "loading") {
return (
<div className="space-y-4 text-center">
<div className="mx-auto h-10 w-10 animate-spin rounded-full border-2 border-white/20 border-t-white/80" />
<p className="text-white/70">{message}</p>
</div>
);
}
if (status === "error") {
return (
<div className="space-y-4 text-center">
<p className="text-lg font-semibold text-red-300">Ошибка авторизации</p>
<p className="text-sm text-red-200/80">{message}</p>
<Link
href="/"
className="inline-flex items-center justify-center rounded-xl bg-white/15 px-6 py-3 text-sm font-semibold text-white transition hover:bg-white/25"
>
Вернуться и попробовать снова
</Link>
</div>
);
}
return (
<div className="space-y-5 text-left">
<div>
<p className="text-sm uppercase tracking-[0.4em] text-white/60">
Готово
</p>
<h2 className="text-2xl font-semibold text-white">Вход выполнен</h2>
<p className="text-sm text-white/65">
Мы сохранили информацию пользователя и токены в бэкенде и вернули
application_token, который можно использовать для дальнейших
запросов.
</p>
</div>
{payload && (
<div className="space-y-3 rounded-2xl bg-black/30 p-5 shadow-inner shadow-white/5">
<Field label="Провайдер" value={payload.provider} />
<Field label="ID пользователя" value={payload.provider_user_id} />
{payload.application_token && (
<Field
label="Application token"
value={payload.application_token}
/>
)}
<Field label="Access token" value={payload.access_token} masked />
{payload.refresh_token && (
<Field
label="Refresh token"
value={payload.refresh_token}
masked
/>
)}
{typeof payload.expires_in === "number" && (
<Field
label="Истекает через (сек.)"
value={payload.expires_in.toString()}
/>
)}
</div>
)}
<div className="flex flex-col gap-3 text-sm text-white/60">
<p>
Храните токены в безопасном месте и используйте их для обращения к
API вашего сервиса. Для завершения сессии удалите токены на стороне
клиента и выполните логаут на сервере.
</p>
<Link
href="/"
className="inline-flex w-fit items-center justify-center rounded-xl bg-yellow-300 px-6 py-3 font-semibold text-stone-950 transition hover:bg-yellow-200"
>
Вернуться на главную
</Link>
</div>
</div>
);
};
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-[#090916] via-[#12122c] to-[#05050b] px-4 py-12 text-white">
<main className="w-full max-w-2xl rounded-[28px] border border-white/10 bg-white/5 p-10 shadow-[0_35px_120px_rgba(2,5,33,0.55)] backdrop-blur-2xl">
{renderContent()}
</main>
</div>
);
}
interface FieldProps {
label: string;
value: string;
masked?: boolean;
}
function Field({ label, value, masked }: FieldProps) {
const displayValue =
masked && value ? `${value.slice(0, 4)}••••${value.slice(-4)}` : value || "—";
return (
<div className="flex flex-col rounded-xl border border-white/10 bg-black/30 px-4 py-3">
<span className="text-xs uppercase tracking-[0.3em] text-white/50">
{label}
</span>
<span className="mt-1 font-mono text-sm text-white/85">{displayValue}</span>
</div>
);
}

View file

@ -1,217 +1,24 @@
'use client';
import { Suspense } from "react";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { YandexCallbackContent } from "./client";
import { env } from "@/lib/env";
type Status = "loading" | "success" | "error";
interface AuthCallbackResponse {
provider: string;
provider_user_id: string;
access_token: string;
refresh_token?: string | null;
expires_in?: number | null;
application_token?: string | null;
raw?: Record<string, unknown>;
function Loading() {
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-[#090916] via-[#12122c] to-[#05050b] px-4 py-12 text-white">
<main className="w-full max-w-2xl rounded-[28px] border border-white/10 bg-white/5 p-10 text-center shadow-[0_35px_120px_rgba(2,5,33,0.55)] backdrop-blur-2xl">
<div className="mx-auto h-10 w-10 animate-spin rounded-full border-2 border-white/20 border-t-white/80" />
<p className="mt-4 text-sm text-white/70">
Загружаем данные авторизации Yandex ID…
</p>
</main>
</div>
);
}
export default function YandexCallbackPage() {
const searchParams = useSearchParams();
const [status, setStatus] = useState<Status>("loading");
const [message, setMessage] = useState<string>("Проверяем ответ от Yandex ID…");
const [payload, setPayload] = useState<AuthCallbackResponse | null>(null);
const queryEntries = useMemo(() => {
return Object.fromEntries(searchParams.entries());
}, [searchParams]);
useEffect(() => {
const error = searchParams.get("error");
if (error) {
setStatus("error");
setMessage(
`Яндекс вернул ошибку: ${error}${
searchParams.get("error_description")
? ` (${searchParams.get("error_description")})`
: ""
}`,
);
return;
}
const code = searchParams.get("code");
const state = searchParams.get("state");
if (!code) {
setStatus("error");
setMessage("В ответе отсутствует код авторизации (code).");
return;
}
if (!env.backendBaseUrl) {
setStatus("error");
setMessage("Не указан адрес бэкенда (NEXT_PUBLIC_API_BASE_URL).");
return;
}
const controller = new AbortController();
async function sendCallback() {
try {
setStatus("loading");
setMessage("Передаём код авторизации на бэкенд…");
const response = await fetch(
`${env.backendBaseUrl.replace(/\/$/, "")}/api/v1/auth/callback/yandex`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
credentials: "include",
signal: controller.signal,
body: JSON.stringify({
code,
state,
raw: queryEntries,
}),
},
);
if (!response.ok) {
const text = await response.text();
throw new Error(
`Бэкенд вернул ${response.status}. Тело ответа: ${text || "пусто"}`,
);
}
const data = (await response.json()) as AuthCallbackResponse;
setPayload(data);
setStatus("success");
setMessage("Авторизация завершена успешно.");
} catch (error) {
console.error("Ошибка при обработке ответа от Яндекс.", error);
setStatus("error");
setMessage(
error instanceof Error
? error.message
: "Не удалось обработать ответ от Яндекс.",
);
}
}
void sendCallback();
return () => controller.abort();
}, [queryEntries, searchParams]);
const renderContent = () => {
if (status === "loading") {
return (
<div className="space-y-4 text-center">
<div className="mx-auto h-10 w-10 animate-spin rounded-full border-2 border-white/20 border-t-white/80" />
<p className="text-white/70">{message}</p>
</div>
);
}
if (status === "error") {
return (
<div className="space-y-4 text-center">
<p className="text-lg font-semibold text-red-300">Ошибка авторизации</p>
<p className="text-sm text-red-200/80">{message}</p>
<Link
href="/"
className="inline-flex items-center justify-center rounded-xl bg-white/15 px-6 py-3 text-sm font-semibold text-white transition hover:bg-white/25"
>
Вернуться и попробовать снова
</Link>
</div>
);
}
return (
<div className="space-y-5 text-left">
<div>
<p className="text-sm uppercase tracking-[0.4em] text-white/60">Готово</p>
<h2 className="text-2xl font-semibold text-white">Вход выполнен</h2>
<p className="text-sm text-white/65">
Мы сохранили информацию пользователя и токены в бэкенде и вернули
application_token, который можно использовать для дальнейших запросов.
</p>
</div>
{payload && (
<div className="space-y-3 rounded-2xl bg-black/30 p-5 shadow-inner shadow-white/5">
<Field label="Провайдер" value={payload.provider} />
<Field label="ID пользователя" value={payload.provider_user_id} />
{payload.application_token && (
<Field
label="Application token"
value={payload.application_token}
/>
)}
<Field label="Access token" value={payload.access_token} masked />
{payload.refresh_token && (
<Field label="Refresh token" value={payload.refresh_token} masked />
)}
{typeof payload.expires_in === "number" && (
<Field
label="Истекает через (сек.)"
value={payload.expires_in.toString()}
/>
)}
</div>
)}
<div className="flex flex-col gap-3 text-sm text-white/60">
<p>
Храните токены в безопасном месте и используйте их для обращения к
API вашего сервиса. Для завершения сессии удалите токены на стороне
клиента и выполните логаут на сервере.
</p>
<Link
href="/"
className="inline-flex w-fit items-center justify-center rounded-xl bg-yellow-300 px-6 py-3 font-semibold text-stone-950 transition hover:bg-yellow-200"
>
Вернуться на главную
</Link>
</div>
</div>
);
};
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-[#090916] via-[#12122c] to-[#05050b] px-4 py-12 text-white">
<main className="w-full max-w-2xl rounded-[28px] border border-white/10 bg-white/5 p-10 shadow-[0_35px_120px_rgba(2,5,33,0.55)] backdrop-blur-2xl">
{renderContent()}
</main>
</div>
);
}
interface FieldProps {
label: string;
value: string;
masked?: boolean;
}
function Field({ label, value, masked }: FieldProps) {
const displayValue =
masked && value
? `${value.slice(0, 4)}••••${value.slice(-4)}`
: value || "—";
return (
<div className="flex flex-col rounded-xl border border-white/10 bg-black/30 px-4 py-3">
<span className="text-xs uppercase tracking-[0.3em] text-white/50">
{label}
</span>
<span className="mt-1 font-mono text-sm text-white/85">{displayValue}</span>
</div>
);
return (
<Suspense fallback={<Loading />}>
<YandexCallbackContent />
</Suspense>
);
}