fix and update

This commit is contained in:
lendry
2026-07-01 17:00:55 +03:00
parent 607397fcf3
commit 0c3c6d6d82
8 changed files with 323 additions and 184 deletions

View File

@@ -1,9 +1,6 @@
'use client';
import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react';
import { fetchPublicSettingsClient } from '@/lib/api';
import { buildOneTapUrls, type OneTapUrls } from '@/lib/one-tap-examples';
import { resolveFrontendBase, resolveOAuthApiBase } from '@/lib/oauth-url';
import { CodeBlock } from '@/components/code-block';
import { cn } from '@/lib/utils';
import {
@@ -16,6 +13,7 @@ import {
VIEW_OPTIONS,
type ButtonBuilderOptions
} from '@/lib/one-tap-builder';
import { useOneTapUrls } from '@/lib/use-one-tap-urls';
type ColorKey = 'bg' | 'bgHover' | 'border' | 'borderHover' | 'text';
@@ -40,36 +38,33 @@ const inputClass =
'h-9 w-full rounded-lg border border-zinc-300 bg-white px-3 text-sm text-zinc-900 outline-none transition focus:border-zinc-400 focus:ring-2 focus:ring-zinc-200 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:focus:ring-zinc-700';
export function OneTapButtonBuilder() {
const [apiBase, setApiBase] = useState('http://localhost:3000');
const [frontendBase, setFrontendBase] = useState('http://localhost:3002');
const [projectName, setProjectName] = useState('MVK ID');
const { urls, loading, error, frontendBase, projectName } = useOneTapUrls();
const [options, setOptions] = useState<ButtonBuilderOptions>(DEFAULT_BUILDER_OPTIONS);
const [hovered, setHovered] = useState(false);
const [status, setStatus] = useState<string | null>(null);
useEffect(() => {
void fetchPublicSettingsClient()
.then((settings) => {
setApiBase(resolveOAuthApiBase(settings));
setFrontendBase(resolveFrontendBase(settings));
if (settings.PROJECT_NAME?.trim()) {
const name = settings.PROJECT_NAME.trim();
setProjectName(name);
setOptions((prev) =>
prev.providerName === DEFAULT_BUILDER_OPTIONS.providerName ? { ...prev, providerName: name } : prev
);
}
})
.catch(() => undefined);
}, []);
const urls: OneTapUrls = useMemo(
() => buildOneTapUrls(apiBase, frontendBase, projectName),
[apiBase, frontendBase, projectName]
);
const snippet = useMemo(() => buildButtonSnippet(options, urls), [options, urls]);
const snippet = useMemo(() => (urls ? buildButtonSnippet(options, urls) : ''), [options, urls]);
const style = useMemo(() => resolveButtonStyle(options), [options]);
useEffect(() => {
if (!urls?.projectName) return;
setOptions((prev) =>
prev.providerName === DEFAULT_BUILDER_OPTIONS.providerName ? { ...prev, providerName: urls.projectName } : prev
);
}, [urls?.projectName]);
if (loading) {
return <p className="text-sm text-zinc-500 dark:text-zinc-400">Загрузка актуальных URL из настроек IdP</p>;
}
if (error || !urls) {
return (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-200">
{error ?? 'Не удалось загрузить настройки для конструктора кнопок.'}
</p>
);
}
function update<K extends keyof ButtonBuilderOptions>(key: K, value: ButtonBuilderOptions[K]) {
setOptions((prev) => ({ ...prev, [key]: value }));
}

View File

@@ -1,30 +1,25 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { fetchPublicSettingsClient } from '@/lib/api';
import { buildOneTapExamples, buildOneTapUrls } from '@/lib/one-tap-examples';
import { resolveFrontendBase, resolveOAuthApiBase } from '@/lib/oauth-url';
import { useMemo } from 'react';
import { buildOneTapExamples } from '@/lib/one-tap-examples';
import { useOneTapUrls } from '@/lib/use-one-tap-urls';
import { CodeExampleTabs } from '@/components/code-example-tabs';
export function OneTapCodeTabs() {
const [apiBase, setApiBase] = useState('http://localhost:3000');
const [frontendBase, setFrontendBase] = useState('http://localhost:3002');
const [projectName, setProjectName] = useState('MVK ID');
const { urls, loading, error, apiBase, frontendBase } = useOneTapUrls();
const examples = useMemo(() => (urls ? buildOneTapExamples(urls) : []), [urls]);
useEffect(() => {
void fetchPublicSettingsClient()
.then((settings) => {
setApiBase(resolveOAuthApiBase(settings));
setFrontendBase(resolveFrontendBase(settings));
if (settings.PROJECT_NAME?.trim()) {
setProjectName(settings.PROJECT_NAME.trim());
}
})
.catch(() => undefined);
}, []);
if (loading) {
return <p className="text-sm text-zinc-500 dark:text-zinc-400">Загрузка актуальных URL из настроек IdP</p>;
}
const urls = useMemo(() => buildOneTapUrls(apiBase, frontendBase, projectName), [apiBase, frontendBase, projectName]);
const examples = useMemo(() => buildOneTapExamples(urls), [urls]);
if (error || !urls) {
return (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-200">
{error ?? 'Не удалось построить примеры интеграции.'}
</p>
);
}
return (
<div className="space-y-3">
@@ -38,5 +33,4 @@ export function OneTapCodeTabs() {
<CodeExampleTabs examples={examples} />
</div>
);
}

View File

@@ -79,19 +79,25 @@ export const apiReference: ApiTagGroup[] = [
method: 'GET',
path: '/fedcm/config.json',
summary: 'Конфигурация FedCM IdP',
description: 'accounts_endpoint, id_assertion_endpoint, login_url, branding.'
description: 'accounts_endpoint, id_assertion_endpoint, login_url (same-origin с config), branding.name = PROJECT_NAME, branding.icons.'
},
{
method: 'GET',
path: '/fedcm/discover.json',
summary: 'Discovery для виджета',
description: 'configUrl, apiBase, frontendUrl, projectName, suggestedFields для RP.'
},
{
method: 'GET',
path: '/fedcm/accounts',
summary: 'Список аккаунтов FedCM',
description: 'Credentialed GET по cookie lendry_fedcm_sess. CORS с credentials для RP.'
description: 'Credentialed GET по cookie lendry_fedcm_sess. Поля: name, email, picture, tel. CORS с credentials для RP.'
},
{
method: 'POST',
path: '/fedcm/id_assertion',
summary: 'Выдача id_token FedCM',
description: 'Тело: client_id, account_id. Возвращает { token } — OIDC id_token.'
description: 'Form POST: client_id, account_id, fields, disclosure_shown_for. Возвращает { token } — OIDC id_token.'
},
{
method: 'GET',
@@ -105,6 +111,12 @@ export const apiReference: ApiTagGroup[] = [
summary: 'Синхронизация FedCM cookie',
description: 'Bearer access token → установка HttpOnly cookie lendry_fedcm_sess.',
auth: true
},
{
method: 'GET',
path: '/fedcm/login-status',
summary: 'FedCM Login Status bridge',
description: 'HTML на API origin для navigator.login.setStatus (origin login_url).'
}
]
},

View File

@@ -964,7 +964,7 @@ $oidc->authenticate(); // client_id, redirect_uri, response_type=code — авт
blocks: [
{
type: 'paragraph',
text: 'Для входа в один клик на сторонних сайтах (FedCM + виджет sso-widget.js) см. отдельный раздел документации.'
text: 'Для входа в один клик на сторонних сайтах (FedCM + виджет sso-widget.js) см. раздел «One Tap Login»: split-domain, branding, fields API, popup fallback.'
},
{
type: 'list',
@@ -1004,49 +1004,111 @@ $oidc->authenticate(); // client_id, redirect_uri, response_type=code — авт
blocks: [
{
type: 'paragraph',
text: 'One Tap Login позволяет пользователям вашего сайта войти через Lendry ID без полного редиректа на страницу авторизации. Поддерживаются два режима: Federated Credential Management (FedCM) в современных браузерах и виджет sso-widget.js с popup-окном для остальных.'
text: 'One Tap Login позволяет пользователям вашего сайта войти через IdP без полного редиректа на страницу авторизации. Поддерживаются два режима: Federated Credential Management (FedCM) в Chrome/Edge и виджет sso-widget.js с popup-окном для остальных браузеров.'
},
{
type: 'callout',
variant: 'info',
title: 'Предварительные требования',
text: 'Создайте OAuth-приложение в админ-панели (RBAC → OAuth приложения): укажите redirect_uri вашего сайта, scopes openid profile email и сохраните client_id. Подробнее — раздел «OAuth 2.0 / OIDC».'
text: 'Создайте OAuth-приложение в админ-панели (RBAC → OAuth приложения): укажите redirect_uri вашего сайта, scopes openid profile email и сохраните client_id. В системных настройках задайте PUBLIC_API_URL, PUBLIC_FRONTEND_URL и PROJECT_NAME. Подробнее — раздел «OAuth 2.0 / OIDC».'
},
{
type: 'list',
items: [
'FedCM — браузер показывает нативный диалог «Войти как …», если пользователь уже залогинен на домене IdP',
'Fallback — скрипт sso-widget.js рисует плашку «Войти через …» в правом верхнем углу и открывает popup с OAuth',
'Оба режима возвращают OIDC id_token (FedCM) или authorization code / токены (popup) — проверяйте на backend'
'FedCM — браузер показывает нативный диалог «Войти в … через {PROJECT_NAME}», если пользователь уже залогинен на IdP',
'Fallback — sso-widget.js рисует плашку «Войти через …» и открывает popup OAuth (display=popup)',
'FedCM возвращает OIDC id_token; popup — authorization code или токены через postMessage. Проверяйте на backend'
]
}
]
},
{
id: 'fedcm-architecture',
title: 'Архитектура FedCM (split-domain)',
blocks: [
{
type: 'paragraph',
text: 'FedCM endpoints размещаются на PUBLIC_API_URL (issuer), например https://api.idpmvk.lpr. UI авторизации — на PUBLIC_FRONTEND_URL, например https://sso.idpmvk.lpr. Виджет sso-widget.js подключается с frontend-домена, а configURL указывает на API-домен.'
},
{
type: 'list',
items: [
'configURL для navigator.credentials.get() — всегда {PUBLIC_API_URL}/fedcm/config.json, не путь через frontend',
'login_url в config.json обязан быть same-origin с config.json (требование Chrome). У нас: {PUBLIC_API_URL}/auth/login — nginx проксирует UI с frontend',
'Cookie lendry_fedcm_sess в production: Domain=.{корневой-домен}, Secure, SameSite=None — чтобы FedCM работал между api.* и sso.*',
'Манифест /.well-known/web-identity может отдаваться с apex-домена; provider_urls указывает на config.json API-домена'
]
},
{
type: 'callout',
variant: 'warning',
title: 'Типичные ошибки',
text: 'NetworkError / invalid login_url — login_url на другом origin, чем config.json. 401 на accounts — нет cookie или пользователь не залогинен на IdP. Disclosure показывает только «имя» — RP не передал fields в navigator.credentials.get().'
}
]
},
{
id: 'fedcm-endpoints',
title: 'FedCM endpoints',
blocks: [
{
type: 'paragraph',
text: 'Endpoints размещены на PUBLIC_API_URL (issuer). Локально: http://localhost:3000. При same-origin деплое: https://ваш-домен/idp-api. CORS настроен с credentials: true для запросов с сайтов-клиентов.'
text: 'Все URL ниже — относительно PUBLIC_API_URL (issuer). CORS настроен с credentials: true для запросов FedCM с сайтов-клиентов. Запросы config/accounts/id_assertion должны содержать Sec-Fetch-Dest: webidentity (Chrome добавляет автоматически).'
},
{
type: 'table',
headers: ['Endpoint', 'Метод', 'Описание'],
rows: [
['{PUBLIC_API_URL}/.well-known/web-identity', 'GET', 'Манифест FedCM → provider_urls'],
['{PUBLIC_API_URL}/fedcm/config.json', 'GET', 'Конфигурация IdP (accounts, id_assertion, branding)'],
['{PUBLIC_API_URL}/.well-known/web-identity', 'GET', 'Манифест FedCM → provider_urls, accounts_endpoint, login_url'],
['{PUBLIC_API_URL}/fedcm/config.json', 'GET', 'Конфигурация IdP: endpoints, login_url, branding (name, icons, colors)'],
['{PUBLIC_API_URL}/fedcm/discover.json', 'GET', 'Публичный discovery для sso-widget.js (configUrl, suggestedFields, projectName)'],
['{PUBLIC_API_URL}/fedcm/accounts', 'GET', 'Список аккаунтов по cookie lendry_fedcm_sess'],
['{PUBLIC_API_URL}/fedcm/id_assertion', 'POST', 'Выдача id_token для client_id + account_id'],
['{PUBLIC_API_URL}/fedcm/client_metadata', 'GET', '?client_id=… — privacy/terms для UI FedCM'],
['{PUBLIC_API_URL}/fedcm/session/sync', 'POST', 'Установить FedCM cookie по Bearer access token']
['{PUBLIC_API_URL}/fedcm/session/sync', 'POST/GET', 'Установить FedCM cookie по Bearer access token'],
['{PUBLIC_API_URL}/fedcm/login-status', 'GET', 'HTML-мост Set-Login на API origin (origin login_url)']
]
},
{
type: 'callout',
variant: 'info',
title: 'Branding в config.json',
text: 'branding.name берётся из PROJECT_NAME (админка). Иконки: {PUBLIC_FRONTEND_URL}/icon.svg (40px) и favicon API-домена. В диалоге Chrome отображается название проекта вместо технического домена IdP.'
},
{
type: 'callout',
variant: 'warning',
title: 'FedCM cookie',
text: 'FedCM использует HttpOnly cookie lendry_fedcm_sess на домене IdP (SameSite=None; Secure в production). Cookie устанавливается при входе на IdP или через POST /fedcm/session/sync. localStorage JWT с сайта клиента для FedCM не подходит.'
text: 'HttpOnly cookie lendry_fedcm_sess на домене IdP. Устанавливается при входе на IdP или через /fedcm/session/sync. JWT из localStorage сайта-клиента для FedCM не подходит — нужна сессия на IdP.'
}
]
},
{
id: 'fedcm-accounts-fields',
title: 'Данные аккаунта и disclosure',
blocks: [
{
type: 'paragraph',
text: 'GET /fedcm/accounts возвращает массив accounts с полями id, name, given_name, email, picture, tel (если есть у пользователя). picture — публичный URL аватара (legacy avatarUrl или временная ссылка /media/stream/{token}).'
},
{
type: 'paragraph',
text: 'Текст disclosure («какие данные передаются сайту») контролирует сайт-клиент (RP), а не IdP. RP должен указать fields в navigator.credentials.get():'
},
{
type: 'code',
language: 'javascript',
title: 'Fields API (Chrome 132+, tel — Chrome 141+)',
code: `fields: ['name', 'email', 'picture', 'tel']`
},
{
type: 'list',
items: [
'sso-widget.js передаёт fields автоматически',
'discover.json содержит suggestedFields для интеграторов',
'Без fields Chrome по умолчанию показывает disclosure только для имени',
'id_token после входа содержит claims OAuth scopes (openid profile email)'
]
}
]
},
@@ -1056,16 +1118,16 @@ $oidc->authenticate(); // client_id, redirect_uri, response_type=code — авт
blocks: [
{
type: 'paragraph',
text: 'Скрипт размещён на frontend IdP по адресу {PUBLIC_FRONTEND_URL}/sso-widget.js. Достаточно одного тега script — инициализация выполняется автоматически при загрузке страницы.'
text: 'Скрипт размещён на frontend IdP: {PUBLIC_FRONTEND_URL}/sso-widget.js. Достаточно одного тега script — инициализация выполняется автоматически. Виджет сначала запрашивает /fedcm/discover.json на API-домене, затем вызывает FedCM или popup.'
},
{
type: 'table',
headers: ['Атрибут data-*', 'Обязательный', 'Описание'],
rows: [
['data-client-id', 'Да', 'client_id OAuth-приложения из админки'],
['data-idp-url', 'Нет', 'PUBLIC_API_URL; по умолчанию origin скрипта + /idp-api'],
['data-idp-frontend-url', 'Нет', 'URL frontend IdP для popup; по умолчанию origin скрипта'],
['data-provider-name', 'Нет', 'Название в плашке (по умолчанию MVK ID)'],
['data-idp-url', 'Да*', 'PUBLIC_API_URL (issuer). Обязателен, если скрипт не на том же домене'],
['data-idp-frontend-url', 'Нет', 'PUBLIC_FRONTEND_URL для popup; по умолчанию origin скрипта'],
['data-provider-name', 'Нет', 'Название в плашке fallback (по умолчанию PROJECT_NAME)'],
['data-redirect-uri', 'Нет', 'redirect_uri OAuth; по умолчанию origin + /auth/callback'],
['data-scope', 'Нет', 'Scopes OAuth (по умолчанию openid profile email)'],
['data-on-success', 'Нет', 'Имя глобальной функции-callback'],
@@ -1076,7 +1138,7 @@ $oidc->authenticate(); // client_id, redirect_uri, response_type=code — авт
type: 'callout',
variant: 'tip',
title: 'Логика выбора режима',
text: 'Если браузер поддерживает IdentityCredential — скрипт вызывает FedCM. Если нет — показывается плашка в правом верхнем углу; по клику открывается popup OAuth (параметр display=popup).'
text: 'Если браузер поддерживает IdentityCredential — виджет вызывает FedCM с fields: name, email, picture, tel. Иначе — плашка в правом верхнем углу; по клику popup OAuth.'
}
]
},
@@ -1086,14 +1148,14 @@ $oidc->authenticate(); // client_id, redirect_uri, response_type=code — авт
blocks: [
{
type: 'paragraph',
text: 'Интерактивный конструктор позволяет настроить кнопку входа под внешний вид вашего сайта. Выберите параметры — справа обновится живое превью, а слева сформируется готовый код виджета. Скопируйте его и вставьте на свою страницу: атрибут data-button-container указывает, в какой элемент встроить кнопку.'
text: 'Интерактивный конструктор позволяет настроить кнопку входа под внешний вид вашего сайта. URL в примерах подставляются из PUBLIC_API_URL и PUBLIC_FRONTEND_URL текущего IdP.'
},
{ type: 'one-tap-builder' },
{
type: 'callout',
variant: 'tip',
title: 'Как это работает',
text: 'Кнопка рендерится скриптом sso-widget.js внутри контейнера с указанным id. Параметры размера, темы, вида, радиуса, иконки и CSS-цветов передаются через data-button-* атрибуты. Клик по превью открывает реальный popup авторизации для проверки.'
text: 'Кнопка рендерится sso-widget.js внутри контейнера с указанным id. Клик по превью открывает реальный popup авторизации для проверки.'
}
]
},
@@ -1103,7 +1165,7 @@ $oidc->authenticate(); // client_id, redirect_uri, response_type=code — авт
blocks: [
{
type: 'paragraph',
text: 'Если вы не используете sso-widget.js, можно вызвать FedCM API напрямую. configURL должен указывать на /fedcm/config.json провайдера.'
text: 'Если вы не используете sso-widget.js, вызовите FedCM API напрямую. configURL — {PUBLIC_API_URL}/fedcm/config.json. Пользователь должен быть залогинен на IdP (cookie lendry_fedcm_sess).'
},
{
type: 'code',
@@ -1112,8 +1174,9 @@ $oidc->authenticate(); // client_id, redirect_uri, response_type=code — авт
code: `const credential = await navigator.credentials.get({
identity: {
providers: [{
configURL: 'https://id.lendry.ru/idp-api/fedcm/config.json',
clientId: 'YOUR_CLIENT_ID'
configURL: '{PUBLIC_API_URL}/fedcm/config.json',
clientId: 'YOUR_CLIENT_ID',
fields: ['name', 'email', 'picture', 'tel']
}]
},
mediation: 'optional'
@@ -1127,7 +1190,8 @@ const idToken = credential?.token;
items: [
'mediation: "optional" — показать диалог только при наличии сессии IdP',
'mediation: "required" — всегда показывать UI выбора аккаунта',
'mediation: "silent" — без UI; вернёт ошибку, если пользователь не залогинен'
'mediation: "silent" — без UI; ошибка, если пользователь не залогинен',
'data-idp-url в виджете и configURL в ручной интеграции — публичный API-домен, доступный из браузера пользователя'
]
}
]
@@ -1138,7 +1202,7 @@ const idToken = credential?.token;
blocks: [
{
type: 'paragraph',
text: 'При клике на виджет открывается popup с OAuth authorize. После успешного входа IdP отправляет результат родительскому окну через window.postMessage с типом lendry-sso-onetap.'
text: 'Если FedCM недоступен, виджет показывает плашку и по клику открывает popup с OAuth authorize на {PUBLIC_FRONTEND_URL}/auth/oauth/authorize?display=popup. После успешного входа IdP отправляет результат родительскому окну через window.postMessage.'
},
{
type: 'code',
@@ -1146,16 +1210,16 @@ const idToken = credential?.token;
title: 'Обработка на сайте клиента',
code: `window.addEventListener('message', (event) => {
if (event.data?.type !== 'lendry-sso-onetap') return;
// Проверяйте event.origin — домен вашего IdP
// Проверяйте event.origin — PUBLIC_FRONTEND_URL вашего IdP
const { token, idToken, accessToken, code, method } = event.data;
console.log('Вход через', method);
console.log('Вход через', method); // 'fedcm' | 'popup'
});`
},
{
type: 'callout',
variant: 'info',
title: 'redirect_uri',
text: 'redirect_uri должен быть зарегистрирован у OAuth-клиента. Для SPA часто используют https://your-app.com/auth/callback — на этой странице backend обменивает code на токены, либо popup передаёт токен через postMessage.'
text: 'redirect_uri должен быть зарегистрирован у OAuth-клиента. Для SPA часто используют https://your-app.com/auth/callback — backend обменивает code на токены, либо popup передаёт токен через postMessage.'
}
]
},
@@ -1165,7 +1229,7 @@ const idToken = credential?.token;
blocks: [
{
type: 'paragraph',
text: 'FedCM возвращает id_token (JWT). Проверьте подпись (HS256, секрет JWT_ACCESS_SECRET IdP), issuer = PUBLIC_API_URL и aud = ваш client_id. Альтернатива — обмен authorization code через POST /oauth/token и запрос GET /oauth/userinfo.'
text: 'FedCM возвращает id_token (JWT). Проверьте подпись (HS256, секрет JWT_ACCESS_SECRET IdP), issuer = PUBLIC_API_URL и aud = ваш client_id. Альтернатива — обмен authorization code через POST /oauth/token и GET /oauth/userinfo.'
},
{
type: 'list',
@@ -1178,30 +1242,37 @@ const idToken = credential?.token;
]
},
{
id: 'examples',
title: 'Примеры интеграции',
blocks: [{ type: 'one-tap-examples' }]
},
{
id: 'local-dev',
title: 'Локальная разработка',
id: 'testing',
title: 'Проверка интеграции',
blocks: [
{
type: 'table',
headers: ['Сервис', 'URL'],
rows: [
['API (FedCM endpoints)', 'http://localhost:3000'],
['Frontend (sso-widget.js)', 'http://localhost:3002/sso-widget.js'],
['Документация', 'http://localhost:3003/docs/one-tap-login']
type: 'list',
items: [
'Войдите на IdP ({PUBLIC_FRONTEND_URL}) в том же браузере, где тестируете сайт-клиент',
'Убедитесь, что PUBLIC_API_URL в настройках = домен, который видит браузер (не внутренний Docker-хост)',
'Проверьте login_url в config.json: curl -s {PUBLIC_API_URL}/fedcm/config.json -H "Sec-Fetch-Dest: webidentity" | jq .login_url',
'ONE_TAP_ENABLED=true в системных настройках (по умолчанию включено)',
'При смене FEDCM_COOKIE_DOMAIN пользователям может потребоваться перелогин на IdP'
]
},
{
type: 'callout',
variant: 'tip',
title: 'Тест FedCM локально',
text: 'FedCM cookie в dev использует SameSite=Lax (без Secure). Войдите на http://localhost:3002, затем откройте тестовую страницу клиента с подключённым виджетом. Chrome может требовать флаг chrome://flags/#identity-credentials-api или HTTPS для FedCM — в этом случае проверяйте popup fallback.'
title: 'Диагностика в админке',
text: 'В карточке OAuth-приложения (RBAC → OAuth приложения → One Tap Login) отображаются актуальные FedCM URL, примеры кода и чеклист проверки.'
}
]
},
{
id: 'examples',
title: 'Примеры интеграции',
blocks: [
{
type: 'paragraph',
text: 'Примеры ниже автоматически подставляют PUBLIC_API_URL, PUBLIC_FRONTEND_URL и PROJECT_NAME из настроек текущего IdP.'
},
{ type: 'one-tap-examples' }
]
}
]
},

View File

@@ -5,6 +5,7 @@ export interface OneTapUrls {
frontendBase: string;
widgetUrl: string;
fedcmConfigUrl: string;
fedcmDiscoverUrl: string;
webIdentityUrl: string;
projectName: string;
}
@@ -21,13 +22,14 @@ export function buildOneTapUrls(
frontendBase: front,
widgetUrl: `${front}/sso-widget.js`,
fedcmConfigUrl: `${base}/fedcm/config.json`,
fedcmDiscoverUrl: `${base}/fedcm/discover.json`,
webIdentityUrl: `${base}/.well-known/web-identity`,
projectName
};
}
export function buildOneTapExamples(urls: OneTapUrls, clientIdPlaceholder = 'YOUR_CLIENT_ID'): OAuthExample[] {
const { apiBase, frontendBase, widgetUrl, fedcmConfigUrl, projectName } = urls;
const { apiBase, frontendBase, widgetUrl, fedcmConfigUrl, fedcmDiscoverUrl, webIdentityUrl, projectName } = urls;
const redirectUri = 'https://app.example.com/auth/callback';
return [
@@ -59,8 +61,8 @@ export function buildOneTapExamples(urls: OneTapUrls, clientIdPlaceholder = 'YOU
id: 'fedcm-native',
label: 'FedCM (нативный API)',
language: 'javascript',
code: `// Работает в Chrome/Edge при поддержке IdentityCredential.
// Пользователь должен быть залогинен на ${frontendBase} (FedCM cookie).
code: `// Работает в Chrome/Edge 132+ (поля name/email/picture/tel — с Chrome 141 для tel).
// Пользователь должен быть залогинен на ${frontendBase} (cookie lendry_fedcm_sess на домене IdP).
async function loginWithFedCM() {
if (!('IdentityCredential' in window)) {
@@ -72,6 +74,7 @@ async function loginWithFedCM() {
providers: [{
configURL: '${fedcmConfigUrl}',
clientId: '${clientIdPlaceholder}',
// Chrome 132+: disclosure в диалоге FedCM (имя, email, аватар, телефон)
fields: ['name', 'email', 'picture', 'tel']
}]
},
@@ -145,14 +148,20 @@ async function fetchProfile(accessToken) {
id: 'curl-fedcm',
label: 'FedCM endpoints (curl)',
language: 'bash',
code: `# Манифест FedCM
curl -s ${apiBase}/.well-known/web-identity | jq
code: `# Манифест FedCM (заголовок Sec-Fetch-Dest обязателен для Chrome)
curl -s ${webIdentityUrl} \\
-H "Sec-Fetch-Dest: webidentity" | jq
# Конфигурация провайдера
curl -s ${apiBase}/fedcm/config.json | jq
# Конфигурация провайдера (branding.name = PROJECT_NAME из админки)
curl -s ${fedcmConfigUrl} \\
-H "Sec-Fetch-Dest: webidentity" | jq
# Список аккаунтов (нужна cookie lendry_fedcm_sess после входа на IdP)
# Discovery для виджета и диагностики
curl -s ${fedcmDiscoverUrl} | jq
# Список аккаунтов (cookie lendry_fedcm_sess после входа на IdP)
curl -s ${apiBase}/fedcm/accounts \\
-H "Sec-Fetch-Dest: webidentity" \\
-H "Cookie: lendry_fedcm_sess=..." \\
--include

View File

@@ -0,0 +1,57 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { fetchPublicSettingsClient } from '@/lib/api';
import { buildOneTapUrls, type OneTapUrls } from '@/lib/one-tap-examples';
import { resolveFrontendBase, resolveOAuthApiBase } from '@/lib/oauth-url';
export function useOneTapUrls() {
const [urls, setUrls] = useState<OneTapUrls | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
void fetchPublicSettingsClient()
.then((settings) => {
if (cancelled) return;
const apiBase = resolveOAuthApiBase(settings, '');
const frontendBase = resolveFrontendBase(settings, '');
if (!apiBase || !frontendBase) {
setError('Укажите PUBLIC_API_URL и PUBLIC_FRONTEND_URL в настройках IdP — примеры подставят актуальные URL автоматически.');
setUrls(null);
return;
}
setUrls(buildOneTapUrls(apiBase, frontendBase, settings.PROJECT_NAME?.trim() || 'MVK ID'));
setError(null);
})
.catch(() => {
if (cancelled) return;
setError('Не удалось загрузить публичные настройки IdP.');
setUrls(null);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
return useMemo(
() => ({
urls,
loading,
error,
apiBase: urls?.apiBase ?? '',
frontendBase: urls?.frontendBase ?? '',
projectName: urls?.projectName ?? 'MVK ID'
}),
[error, loading, urls]
);
}