pnpm build
This commit is contained in:
parent
82cdcdac92
commit
bfc93734ec
2 changed files with 243 additions and 211 deletions
225
goauthsocialfrontendtest/app/auth/callback/client.tsx
Normal file
225
goauthsocialfrontendtest/app/auth/callback/client.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,217 +1,24 @@
|
||||||
'use client';
|
import { Suspense } from "react";
|
||||||
|
|
||||||
import Link from "next/link";
|
import { YandexCallbackContent } from "./client";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import { useSearchParams } from "next/navigation";
|
|
||||||
|
|
||||||
import { env } from "@/lib/env";
|
function Loading() {
|
||||||
|
return (
|
||||||
type Status = "loading" | "success" | "error";
|
<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">
|
||||||
interface AuthCallbackResponse {
|
<div className="mx-auto h-10 w-10 animate-spin rounded-full border-2 border-white/20 border-t-white/80" />
|
||||||
provider: string;
|
<p className="mt-4 text-sm text-white/70">
|
||||||
provider_user_id: string;
|
Загружаем данные авторизации Yandex ID…
|
||||||
access_token: string;
|
</p>
|
||||||
refresh_token?: string | null;
|
</main>
|
||||||
expires_in?: number | null;
|
</div>
|
||||||
application_token?: string | null;
|
);
|
||||||
raw?: Record<string, unknown>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function YandexCallbackPage() {
|
export default function YandexCallbackPage() {
|
||||||
const searchParams = useSearchParams();
|
return (
|
||||||
const [status, setStatus] = useState<Status>("loading");
|
<Suspense fallback={<Loading />}>
|
||||||
const [message, setMessage] = useState<string>("Проверяем ответ от Yandex ID…");
|
<YandexCallbackContent />
|
||||||
const [payload, setPayload] = useState<AuthCallbackResponse | null>(null);
|
</Suspense>
|
||||||
|
);
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue