diff --git a/apps/docs/components/one-tap-button-builder.tsx b/apps/docs/components/one-tap-button-builder.tsx index 9f5d64a..a37893a 100644 --- a/apps/docs/components/one-tap-button-builder.tsx +++ b/apps/docs/components/one-tap-button-builder.tsx @@ -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(DEFAULT_BUILDER_OPTIONS); const [hovered, setHovered] = useState(false); const [status, setStatus] = useState(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

Загрузка актуальных URL из настроек IdP…

; + } + + if (error || !urls) { + return ( +

+ {error ?? 'Не удалось загрузить настройки для конструктора кнопок.'} +

+ ); + } + function update(key: K, value: ButtonBuilderOptions[K]) { setOptions((prev) => ({ ...prev, [key]: value })); } diff --git a/apps/docs/components/one-tap-code-tabs.tsx b/apps/docs/components/one-tap-code-tabs.tsx index 1145694..9b466d3 100644 --- a/apps/docs/components/one-tap-code-tabs.tsx +++ b/apps/docs/components/one-tap-code-tabs.tsx @@ -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

Загрузка актуальных URL из настроек IdP…

; + } - const urls = useMemo(() => buildOneTapUrls(apiBase, frontendBase, projectName), [apiBase, frontendBase, projectName]); - const examples = useMemo(() => buildOneTapExamples(urls), [urls]); + if (error || !urls) { + return ( +

+ {error ?? 'Не удалось построить примеры интеграции.'} +

+ ); + } return (
@@ -38,5 +33,4 @@ export function OneTapCodeTabs() {
); - } diff --git a/apps/docs/lib/api-endpoints.ts b/apps/docs/lib/api-endpoints.ts index dff2eb4..e4a5c80 100644 --- a/apps/docs/lib/api-endpoints.ts +++ b/apps/docs/lib/api-endpoints.ts @@ -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).' } ] }, diff --git a/apps/docs/lib/docs-pages.ts b/apps/docs/lib/docs-pages.ts index 3b5d3a5..22af280 100644 --- a/apps/docs/lib/docs-pages.ts +++ b/apps/docs/lib/docs-pages.ts @@ -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' } + ] } ] }, diff --git a/apps/docs/lib/one-tap-examples.ts b/apps/docs/lib/one-tap-examples.ts index 302f615..397442b 100644 --- a/apps/docs/lib/one-tap-examples.ts +++ b/apps/docs/lib/one-tap-examples.ts @@ -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 diff --git a/apps/docs/lib/use-one-tap-urls.ts b/apps/docs/lib/use-one-tap-urls.ts new file mode 100644 index 0000000..c1310cb --- /dev/null +++ b/apps/docs/lib/use-one-tap-urls.ts @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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] + ); +} diff --git a/apps/frontend/app/auth/login/page.tsx b/apps/frontend/app/auth/login/page.tsx index 5a2c13d..a199b2e 100644 --- a/apps/frontend/app/auth/login/page.tsx +++ b/apps/frontend/app/auth/login/page.tsx @@ -404,8 +404,6 @@ export default function LoginPage() { setOtpBackupChannels(result.otpBackupChannels ?? []); - - if (result.isTotpEnabled) { const challengeToken = await beginTotpLogin(identifier); @@ -416,13 +414,17 @@ export default function LoginPage() { } - - const channels = result.otpChannels ?? []; 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'); @@ -430,6 +432,16 @@ export default function LoginPage() { } + if (hasPassword) { + + setPassword(''); + + setStep('password'); + + return; + + } + if (channels.length === 1) { await requestLoginOtp(channels[0].channel, identifier); @@ -438,8 +450,6 @@ export default function LoginPage() { } - // Нет основных каналов, но есть резервные — даём выбрать резервную доставку. - if (backupChannels.length > 1) { setStep('otpChannel'); @@ -456,14 +466,6 @@ export default function LoginPage() { } - if (result.hasPassword) { - - setStep('password'); - - return; - - } - showToast('Для этого аккаунта не настроены каналы для SMS или email-кода'); } 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; @@ -707,7 +717,7 @@ export default function LoginPage() {
- {includeOtpRequest && otpChannels.length ? ( + {showPrimaryOtp ? ( ) : null} - {methods.map((method) => { + {showPassword ? ( - const Icon = channelIcon[method.channel] ?? KeyRound; + - {method.kind === 'password' ? 'Войти с паролем' : `Код на ${method.masked}`} + ) : null} - - - - - ); - - })} - - {otpBackupChannels.length ? ( + {showBackupOtp ? ( <> @@ -1083,13 +1089,37 @@ export default function LoginPage() { })} - {otpBackupChannels.length ? ( + {methods.some((method) => method.kind === 'password') ? ( - <> + + + ) : null} + + {otpChannels.length === 0 && !methods.some((method) => method.kind === 'password') + + ? otpBackupChannels.map((channel) => { const Icon = channelIcon[channel.channel] ?? Mail; @@ -1121,45 +1151,14 @@ export default function LoginPage() { ); - })} + }) - - - ) : null} - - {methods.some((method) => method.kind === 'password') ? ( - - <> - -

или

- - - - - - ) : null} + : null}
+ {renderAlternativeMethods('select')} + {totpChallengeToken ? (