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'; 'use client';
import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react'; 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 { CodeBlock } from '@/components/code-block';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { import {
@@ -16,6 +13,7 @@ import {
VIEW_OPTIONS, VIEW_OPTIONS,
type ButtonBuilderOptions type ButtonBuilderOptions
} from '@/lib/one-tap-builder'; } from '@/lib/one-tap-builder';
import { useOneTapUrls } from '@/lib/use-one-tap-urls';
type ColorKey = 'bg' | 'bgHover' | 'border' | 'borderHover' | 'text'; type ColorKey = 'bg' | 'bgHover' | 'border' | 'borderHover' | 'text';
@@ -40,35 +38,32 @@ 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'; '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() { export function OneTapButtonBuilder() {
const [apiBase, setApiBase] = useState('http://localhost:3000'); const { urls, loading, error, frontendBase, projectName } = useOneTapUrls();
const [frontendBase, setFrontendBase] = useState('http://localhost:3002');
const [projectName, setProjectName] = useState('MVK ID');
const [options, setOptions] = useState<ButtonBuilderOptions>(DEFAULT_BUILDER_OPTIONS); const [options, setOptions] = useState<ButtonBuilderOptions>(DEFAULT_BUILDER_OPTIONS);
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
const [status, setStatus] = useState<string | null>(null); const [status, setStatus] = useState<string | null>(null);
const snippet = useMemo(() => (urls ? buildButtonSnippet(options, urls) : ''), [options, urls]);
const style = useMemo(() => resolveButtonStyle(options), [options]);
useEffect(() => { useEffect(() => {
void fetchPublicSettingsClient() if (!urls?.projectName) return;
.then((settings) => {
setApiBase(resolveOAuthApiBase(settings));
setFrontendBase(resolveFrontendBase(settings));
if (settings.PROJECT_NAME?.trim()) {
const name = settings.PROJECT_NAME.trim();
setProjectName(name);
setOptions((prev) => setOptions((prev) =>
prev.providerName === DEFAULT_BUILDER_OPTIONS.providerName ? { ...prev, providerName: name } : 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>
); );
} }
})
.catch(() => undefined);
}, []);
const urls: OneTapUrls = useMemo(
() => buildOneTapUrls(apiBase, frontendBase, projectName),
[apiBase, frontendBase, projectName]
);
const snippet = useMemo(() => buildButtonSnippet(options, urls), [options, urls]);
const style = useMemo(() => resolveButtonStyle(options), [options]);
function update<K extends keyof ButtonBuilderOptions>(key: K, value: ButtonBuilderOptions[K]) { function update<K extends keyof ButtonBuilderOptions>(key: K, value: ButtonBuilderOptions[K]) {
setOptions((prev) => ({ ...prev, [key]: value })); setOptions((prev) => ({ ...prev, [key]: value }));

View File

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

View File

@@ -79,19 +79,25 @@ export const apiReference: ApiTagGroup[] = [
method: 'GET', method: 'GET',
path: '/fedcm/config.json', path: '/fedcm/config.json',
summary: 'Конфигурация FedCM IdP', 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', method: 'GET',
path: '/fedcm/accounts', path: '/fedcm/accounts',
summary: 'Список аккаунтов FedCM', 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', method: 'POST',
path: '/fedcm/id_assertion', path: '/fedcm/id_assertion',
summary: 'Выдача id_token FedCM', 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', method: 'GET',
@@ -105,6 +111,12 @@ export const apiReference: ApiTagGroup[] = [
summary: 'Синхронизация FedCM cookie', summary: 'Синхронизация FedCM cookie',
description: 'Bearer access token → установка HttpOnly cookie lendry_fedcm_sess.', description: 'Bearer access token → установка HttpOnly cookie lendry_fedcm_sess.',
auth: true 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: [ blocks: [
{ {
type: 'paragraph', type: 'paragraph',
text: 'Для входа в один клик на сторонних сайтах (FedCM + виджет sso-widget.js) см. отдельный раздел документации.' text: 'Для входа в один клик на сторонних сайтах (FedCM + виджет sso-widget.js) см. раздел «One Tap Login»: split-domain, branding, fields API, popup fallback.'
}, },
{ {
type: 'list', type: 'list',
@@ -1004,49 +1004,111 @@ $oidc->authenticate(); // client_id, redirect_uri, response_type=code — авт
blocks: [ blocks: [
{ {
type: 'paragraph', 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', type: 'callout',
variant: 'info', variant: 'info',
title: 'Предварительные требования', 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', type: 'list',
items: [ items: [
'FedCM — браузер показывает нативный диалог «Войти как …», если пользователь уже залогинен на домене IdP', 'FedCM — браузер показывает нативный диалог «Войти в … через {PROJECT_NAME}», если пользователь уже залогинен на IdP',
'Fallback — скрипт sso-widget.js рисует плашку «Войти через …» в правом верхнем углу и открывает popup с OAuth', 'Fallback — sso-widget.js рисует плашку «Войти через …» и открывает popup OAuth (display=popup)',
'Оба режима возвращают OIDC id_token (FedCM) или authorization code / токены (popup) — проверяйте на backend' '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', id: 'fedcm-endpoints',
title: 'FedCM endpoints', title: 'FedCM endpoints',
blocks: [ blocks: [
{ {
type: 'paragraph', 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', type: 'table',
headers: ['Endpoint', 'Метод', 'Описание'], headers: ['Endpoint', 'Метод', 'Описание'],
rows: [ rows: [
['{PUBLIC_API_URL}/.well-known/web-identity', 'GET', 'Манифест FedCM → provider_urls'], ['{PUBLIC_API_URL}/.well-known/web-identity', 'GET', 'Манифест FedCM → provider_urls, accounts_endpoint, login_url'],
['{PUBLIC_API_URL}/fedcm/config.json', 'GET', 'Конфигурация IdP (accounts, id_assertion, branding)'], ['{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/accounts', 'GET', 'Список аккаунтов по cookie lendry_fedcm_sess'],
['{PUBLIC_API_URL}/fedcm/id_assertion', 'POST', 'Выдача id_token для client_id + account_id'], ['{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/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', type: 'callout',
variant: 'warning', variant: 'warning',
title: 'FedCM cookie', 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: [ blocks: [
{ {
type: 'paragraph', 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', type: 'table',
headers: ['Атрибут data-*', 'Обязательный', 'Описание'], headers: ['Атрибут data-*', 'Обязательный', 'Описание'],
rows: [ rows: [
['data-client-id', 'Да', 'client_id OAuth-приложения из админки'], ['data-client-id', 'Да', 'client_id OAuth-приложения из админки'],
['data-idp-url', 'Нет', 'PUBLIC_API_URL; по умолчанию origin скрипта + /idp-api'], ['data-idp-url', 'Да*', 'PUBLIC_API_URL (issuer). Обязателен, если скрипт не на том же домене'],
['data-idp-frontend-url', 'Нет', 'URL frontend IdP для popup; по умолчанию origin скрипта'], ['data-idp-frontend-url', 'Нет', 'PUBLIC_FRONTEND_URL для popup; по умолчанию origin скрипта'],
['data-provider-name', 'Нет', 'Название в плашке (по умолчанию MVK ID)'], ['data-provider-name', 'Нет', 'Название в плашке fallback (по умолчанию PROJECT_NAME)'],
['data-redirect-uri', 'Нет', 'redirect_uri OAuth; по умолчанию origin + /auth/callback'], ['data-redirect-uri', 'Нет', 'redirect_uri OAuth; по умолчанию origin + /auth/callback'],
['data-scope', 'Нет', 'Scopes OAuth (по умолчанию openid profile email)'], ['data-scope', 'Нет', 'Scopes OAuth (по умолчанию openid profile email)'],
['data-on-success', 'Нет', 'Имя глобальной функции-callback'], ['data-on-success', 'Нет', 'Имя глобальной функции-callback'],
@@ -1076,7 +1138,7 @@ $oidc->authenticate(); // client_id, redirect_uri, response_type=code — авт
type: 'callout', type: 'callout',
variant: 'tip', variant: 'tip',
title: 'Логика выбора режима', 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: [ blocks: [
{ {
type: 'paragraph', type: 'paragraph',
text: 'Интерактивный конструктор позволяет настроить кнопку входа под внешний вид вашего сайта. Выберите параметры — справа обновится живое превью, а слева сформируется готовый код виджета. Скопируйте его и вставьте на свою страницу: атрибут data-button-container указывает, в какой элемент встроить кнопку.' text: 'Интерактивный конструктор позволяет настроить кнопку входа под внешний вид вашего сайта. URL в примерах подставляются из PUBLIC_API_URL и PUBLIC_FRONTEND_URL текущего IdP.'
}, },
{ type: 'one-tap-builder' }, { type: 'one-tap-builder' },
{ {
type: 'callout', type: 'callout',
variant: 'tip', variant: 'tip',
title: 'Как это работает', 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: [ blocks: [
{ {
type: 'paragraph', 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', type: 'code',
@@ -1112,8 +1174,9 @@ $oidc->authenticate(); // client_id, redirect_uri, response_type=code — авт
code: `const credential = await navigator.credentials.get({ code: `const credential = await navigator.credentials.get({
identity: { identity: {
providers: [{ providers: [{
configURL: 'https://id.lendry.ru/idp-api/fedcm/config.json', configURL: '{PUBLIC_API_URL}/fedcm/config.json',
clientId: 'YOUR_CLIENT_ID' clientId: 'YOUR_CLIENT_ID',
fields: ['name', 'email', 'picture', 'tel']
}] }]
}, },
mediation: 'optional' mediation: 'optional'
@@ -1127,7 +1190,8 @@ const idToken = credential?.token;
items: [ items: [
'mediation: "optional" — показать диалог только при наличии сессии IdP', 'mediation: "optional" — показать диалог только при наличии сессии IdP',
'mediation: "required" — всегда показывать UI выбора аккаунта', 'mediation: "required" — всегда показывать UI выбора аккаунта',
'mediation: "silent" — без UI; вернёт ошибку, если пользователь не залогинен' 'mediation: "silent" — без UI; ошибка, если пользователь не залогинен',
'data-idp-url в виджете и configURL в ручной интеграции — публичный API-домен, доступный из браузера пользователя'
] ]
} }
] ]
@@ -1138,7 +1202,7 @@ const idToken = credential?.token;
blocks: [ blocks: [
{ {
type: 'paragraph', 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', type: 'code',
@@ -1146,16 +1210,16 @@ const idToken = credential?.token;
title: 'Обработка на сайте клиента', title: 'Обработка на сайте клиента',
code: `window.addEventListener('message', (event) => { code: `window.addEventListener('message', (event) => {
if (event.data?.type !== 'lendry-sso-onetap') return; 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; const { token, idToken, accessToken, code, method } = event.data;
console.log('Вход через', method); console.log('Вход через', method); // 'fedcm' | 'popup'
});` });`
}, },
{ {
type: 'callout', type: 'callout',
variant: 'info', variant: 'info',
title: 'redirect_uri', 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: [ blocks: [
{ {
type: 'paragraph', 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', type: 'list',
@@ -1178,30 +1242,37 @@ const idToken = credential?.token;
] ]
}, },
{ {
id: 'examples', id: 'testing',
title: 'Примеры интеграции', title: 'Проверка интеграции',
blocks: [{ type: 'one-tap-examples' }]
},
{
id: 'local-dev',
title: 'Локальная разработка',
blocks: [ blocks: [
{ {
type: 'table', type: 'list',
headers: ['Сервис', 'URL'], items: [
rows: [ 'Войдите на IdP ({PUBLIC_FRONTEND_URL}) в том же браузере, где тестируете сайт-клиент',
['API (FedCM endpoints)', 'http://localhost:3000'], 'Убедитесь, что PUBLIC_API_URL в настройках = домен, который видит браузер (не внутренний Docker-хост)',
['Frontend (sso-widget.js)', 'http://localhost:3002/sso-widget.js'], 'Проверьте login_url в config.json: curl -s {PUBLIC_API_URL}/fedcm/config.json -H "Sec-Fetch-Dest: webidentity" | jq .login_url',
['Документация', 'http://localhost:3003/docs/one-tap-login'] 'ONE_TAP_ENABLED=true в системных настройках (по умолчанию включено)',
'При смене FEDCM_COOKIE_DOMAIN пользователям может потребоваться перелогин на IdP'
] ]
}, },
{ {
type: 'callout', type: 'callout',
variant: 'tip', variant: 'tip',
title: 'Тест FedCM локально', title: 'Диагностика в админке',
text: 'FedCM cookie в dev использует SameSite=Lax (без Secure). Войдите на http://localhost:3002, затем откройте тестовую страницу клиента с подключённым виджетом. Chrome может требовать флаг chrome://flags/#identity-credentials-api или HTTPS для FedCM — в этом случае проверяйте popup fallback.' 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; frontendBase: string;
widgetUrl: string; widgetUrl: string;
fedcmConfigUrl: string; fedcmConfigUrl: string;
fedcmDiscoverUrl: string;
webIdentityUrl: string; webIdentityUrl: string;
projectName: string; projectName: string;
} }
@@ -21,13 +22,14 @@ export function buildOneTapUrls(
frontendBase: front, frontendBase: front,
widgetUrl: `${front}/sso-widget.js`, widgetUrl: `${front}/sso-widget.js`,
fedcmConfigUrl: `${base}/fedcm/config.json`, fedcmConfigUrl: `${base}/fedcm/config.json`,
fedcmDiscoverUrl: `${base}/fedcm/discover.json`,
webIdentityUrl: `${base}/.well-known/web-identity`, webIdentityUrl: `${base}/.well-known/web-identity`,
projectName projectName
}; };
} }
export function buildOneTapExamples(urls: OneTapUrls, clientIdPlaceholder = 'YOUR_CLIENT_ID'): OAuthExample[] { 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'; const redirectUri = 'https://app.example.com/auth/callback';
return [ return [
@@ -59,8 +61,8 @@ export function buildOneTapExamples(urls: OneTapUrls, clientIdPlaceholder = 'YOU
id: 'fedcm-native', id: 'fedcm-native',
label: 'FedCM (нативный API)', label: 'FedCM (нативный API)',
language: 'javascript', language: 'javascript',
code: `// Работает в Chrome/Edge при поддержке IdentityCredential. code: `// Работает в Chrome/Edge 132+ (поля name/email/picture/tel — с Chrome 141 для tel).
// Пользователь должен быть залогинен на ${frontendBase} (FedCM cookie). // Пользователь должен быть залогинен на ${frontendBase} (cookie lendry_fedcm_sess на домене IdP).
async function loginWithFedCM() { async function loginWithFedCM() {
if (!('IdentityCredential' in window)) { if (!('IdentityCredential' in window)) {
@@ -72,6 +74,7 @@ async function loginWithFedCM() {
providers: [{ providers: [{
configURL: '${fedcmConfigUrl}', configURL: '${fedcmConfigUrl}',
clientId: '${clientIdPlaceholder}', clientId: '${clientIdPlaceholder}',
// Chrome 132+: disclosure в диалоге FedCM (имя, email, аватар, телефон)
fields: ['name', 'email', 'picture', 'tel'] fields: ['name', 'email', 'picture', 'tel']
}] }]
}, },
@@ -145,14 +148,20 @@ async function fetchProfile(accessToken) {
id: 'curl-fedcm', id: 'curl-fedcm',
label: 'FedCM endpoints (curl)', label: 'FedCM endpoints (curl)',
language: 'bash', language: 'bash',
code: `# Манифест FedCM code: `# Манифест FedCM (заголовок Sec-Fetch-Dest обязателен для Chrome)
curl -s ${apiBase}/.well-known/web-identity | jq curl -s ${webIdentityUrl} \\
-H "Sec-Fetch-Dest: webidentity" | jq
# Конфигурация провайдера # Конфигурация провайдера (branding.name = PROJECT_NAME из админки)
curl -s ${apiBase}/fedcm/config.json | jq 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 \\ curl -s ${apiBase}/fedcm/accounts \\
-H "Sec-Fetch-Dest: webidentity" \\
-H "Cookie: lendry_fedcm_sess=..." \\ -H "Cookie: lendry_fedcm_sess=..." \\
--include --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]
);
}

View File

@@ -404,8 +404,6 @@ export default function LoginPage() {
setOtpBackupChannels(result.otpBackupChannels ?? []); setOtpBackupChannels(result.otpBackupChannels ?? []);
if (result.isTotpEnabled) { if (result.isTotpEnabled) {
const challengeToken = await beginTotpLogin(identifier); const challengeToken = await beginTotpLogin(identifier);
@@ -416,13 +414,17 @@ export default function LoginPage() {
} }
const channels = result.otpChannels ?? []; const channels = result.otpChannels ?? [];
const backupChannels = result.otpBackupChannels ?? []; const backupChannels = result.otpBackupChannels ?? [];
if (channels.length > 1) { const hasPassword = result.hasPassword;
const primaryChoiceCount = channels.length + (hasPassword ? 1 : 0);
if (primaryChoiceCount > 1) {
setPassword('');
setStep('otpChannel'); setStep('otpChannel');
@@ -430,6 +432,16 @@ export default function LoginPage() {
} }
if (hasPassword) {
setPassword('');
setStep('password');
return;
}
if (channels.length === 1) { if (channels.length === 1) {
await requestLoginOtp(channels[0].channel, identifier); await requestLoginOtp(channels[0].channel, identifier);
@@ -438,8 +450,6 @@ export default function LoginPage() {
} }
// Нет основных каналов, но есть резервные — даём выбрать резервную доставку.
if (backupChannels.length > 1) { if (backupChannels.length > 1) {
setStep('otpChannel'); setStep('otpChannel');
@@ -456,14 +466,6 @@ export default function LoginPage() {
} }
if (result.hasPassword) {
setStep('password');
return;
}
showToast('Для этого аккаунта не настроены каналы для SMS или email-кода'); showToast('Для этого аккаунта не настроены каналы для SMS или email-кода');
} catch (error) { } catch (error) {
@@ -683,9 +685,17 @@ export default function LoginPage() {
function renderAlternativeMethods(includeOtpRequest: boolean) { function renderAlternativeMethods(context: 'select' | 'password' | 'otp' | 'totp') {
const hasAlternatives = includeOtpRequest || methods.length > 0 || otpBackupChannels.length > 0; const passwordMethod = methods.find((method) => method.kind === 'password');
const showPassword = context !== 'password' && context !== 'select' && Boolean(passwordMethod);
const showPrimaryOtp = context !== 'otp' && context !== 'select' && otpChannels.length > 0;
const showBackupOtp = otpBackupChannels.length > 0;
const hasAlternatives = showPassword || showPrimaryOtp || showBackupOtp;
if (!hasAlternatives) return null; if (!hasAlternatives) return null;
@@ -707,7 +717,7 @@ export default function LoginPage() {
<div className="mt-3 space-y-2"> <div className="mt-3 space-y-2">
{includeOtpRequest && otpChannels.length ? ( {showPrimaryOtp ? (
<button <button
@@ -723,25 +733,27 @@ export default function LoginPage() {
<Mail className="h-5 w-5 text-[#b9bdc9]" /> <Mail className="h-5 w-5 text-[#b9bdc9]" />
<span className="text-sm">Получить код на {otpChannels.length > 1 ? 'почту или телефон' : otpChannels[0]?.masked}</span> <span className="text-sm">
Получить код на {otpChannels.length > 1 ? 'почту или телефон' : otpChannels[0]?.masked}
</span>
</button> </button>
) : null} ) : null}
{methods.map((method) => { {showPassword ? (
const Icon = channelIcon[method.channel] ?? KeyRound;
return (
<button <button
key={`${method.kind}-${method.channel}`}
type="button" type="button"
onClick={() => void chooseMethod(method)} onClick={() => {
if (passwordMethod) void chooseMethod(passwordMethod);
}}
disabled={isSubmitting} disabled={isSubmitting}
@@ -749,21 +761,15 @@ export default function LoginPage() {
> >
<Icon className="h-5 w-5 text-[#b9bdc9]" /> <KeyRound className="h-5 w-5 text-[#b9bdc9]" />
<span className="text-sm"> <span className="text-sm">Войти с паролем</span>
{method.kind === 'password' ? 'Войти с паролем' : `Код на ${method.masked}`}
</span>
</button> </button>
); ) : null}
})} {showBackupOtp ? (
{otpBackupChannels.length ? (
<> <>
@@ -1083,13 +1089,37 @@ export default function LoginPage() {
})} })}
{otpBackupChannels.length ? ( {methods.some((method) => method.kind === 'password') ? (
<> <button
<p className="px-1 pt-2 text-xs uppercase tracking-wide text-[#7d818d]">Резервные контакты</p> type="button"
{otpBackupChannels.map((channel) => { disabled={isSubmitting}
onClick={() => {
setPassword('');
setStep('password');
}}
className="flex w-full items-center gap-3 rounded-[16px] bg-[#2a2c36] p-3 text-left hover:bg-[#33353f] disabled:opacity-50"
>
<KeyRound className="h-5 w-5 text-[#b9bdc9]" />
<span className="text-sm">Пароль</span>
</button>
) : null}
{otpChannels.length === 0 && !methods.some((method) => method.kind === 'password')
? otpBackupChannels.map((channel) => {
const Icon = channelIcon[channel.channel] ?? Mail; const Icon = channelIcon[channel.channel] ?? Mail;
@@ -1121,45 +1151,14 @@ export default function LoginPage() {
); );
})} })
</> : null}
) : null}
{methods.some((method) => method.kind === 'password') ? (
<>
<p className="px-1 pt-2 text-xs uppercase tracking-wide text-[#7d818d]">или</p>
<button
type="button"
disabled={isSubmitting}
onClick={() => {
const passwordMethod = methods.find((method) => method.kind === 'password');
if (passwordMethod) void chooseMethod(passwordMethod);
}}
className="flex w-full items-center gap-3 rounded-[16px] bg-[#2a2c36] p-3 text-left hover:bg-[#33353f] disabled:opacity-50"
>
<KeyRound className="h-5 w-5 text-[#b9bdc9]" />
<span className="text-sm">Войти с паролем</span>
</button>
</>
) : null}
</div> </div>
{renderAlternativeMethods('select')}
{totpChallengeToken ? ( {totpChallengeToken ? (
<Button type="button" variant="outline" size="lg" className="mt-6 w-full rounded-[18px] border-[#424550] text-white hover:bg-[#2a2c36]" onClick={() => setStep('totp')}> <Button type="button" variant="outline" size="lg" className="mt-6 w-full rounded-[18px] border-[#424550] text-white hover:bg-[#2a2c36]" onClick={() => setStep('totp')}>
@@ -1218,7 +1217,7 @@ export default function LoginPage() {
)} )}
</div> </div>
{renderAlternativeMethods(false)} {renderAlternativeMethods('otp')}
{totpChallengeToken ? ( {totpChallengeToken ? (
@@ -1262,7 +1261,7 @@ export default function LoginPage() {
{isSubmitting ? <p className="mt-3 text-center text-sm text-[#b9bdc9]">Проверяем код...</p> : null} {isSubmitting ? <p className="mt-3 text-center text-sm text-[#b9bdc9]">Проверяем код...</p> : null}
{renderAlternativeMethods(true)} {renderAlternativeMethods('totp')}
</div> </div>
@@ -1324,6 +1323,8 @@ export default function LoginPage() {
) : null} ) : null}
{renderAlternativeMethods('password')}
</form> </form>
) : null} ) : null}

View File

@@ -4,7 +4,7 @@ import './globals.css';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'MVK ID', title: 'MVK ID',
description: 'Единый аккаунт для сервисов Lendry', description: 'Единый аккаунт для сервисов',
icons: { icons: {
icon: '/icon.svg', icon: '/icon.svg',
shortcut: '/icon.svg', shortcut: '/icon.svg',