fix and update
This commit is contained in:
@@ -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 }));
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
@@ -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).'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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' }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
57
apps/docs/lib/use-one-tap-urls.ts
Normal file
57
apps/docs/lib/use-one-tap-urls.ts
Normal 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]
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
<div className="mt-3 space-y-2">
|
||||
|
||||
{includeOtpRequest && otpChannels.length ? (
|
||||
{showPrimaryOtp ? (
|
||||
|
||||
<button
|
||||
|
||||
@@ -723,47 +733,43 @@ export default function LoginPage() {
|
||||
|
||||
<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>
|
||||
|
||||
) : null}
|
||||
|
||||
{methods.map((method) => {
|
||||
{showPassword ? (
|
||||
|
||||
const Icon = channelIcon[method.channel] ?? KeyRound;
|
||||
<button
|
||||
|
||||
return (
|
||||
type="button"
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
|
||||
key={`${method.kind}-${method.channel}`}
|
||||
if (passwordMethod) void chooseMethod(passwordMethod);
|
||||
|
||||
type="button"
|
||||
}}
|
||||
|
||||
onClick={() => void chooseMethod(method)}
|
||||
disabled={isSubmitting}
|
||||
|
||||
disabled={isSubmitting}
|
||||
className="flex w-full items-center gap-3 rounded-[16px] bg-[#2a2c36] p-3 text-left hover:bg-[#33353f] disabled:opacity-50"
|
||||
|
||||
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]" />
|
||||
|
||||
<Icon className="h-5 w-5 text-[#b9bdc9]" />
|
||||
<span className="text-sm">Войти с паролем</span>
|
||||
|
||||
<span className="text-sm">
|
||||
</button>
|
||||
|
||||
{method.kind === 'password' ? 'Войти с паролем' : `Код на ${method.masked}`}
|
||||
) : null}
|
||||
|
||||
</span>
|
||||
|
||||
</button>
|
||||
|
||||
);
|
||||
|
||||
})}
|
||||
|
||||
{otpBackupChannels.length ? (
|
||||
{showBackupOtp ? (
|
||||
|
||||
<>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1121,45 +1151,14 @@ export default function LoginPage() {
|
||||
|
||||
);
|
||||
|
||||
})}
|
||||
})
|
||||
|
||||
</>
|
||||
|
||||
) : 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}
|
||||
: null}
|
||||
|
||||
</div>
|
||||
|
||||
{renderAlternativeMethods('select')}
|
||||
|
||||
{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')}>
|
||||
@@ -1218,7 +1217,7 @@ export default function LoginPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{renderAlternativeMethods(false)}
|
||||
{renderAlternativeMethods('otp')}
|
||||
|
||||
{totpChallengeToken ? (
|
||||
|
||||
@@ -1262,7 +1261,7 @@ export default function LoginPage() {
|
||||
|
||||
{isSubmitting ? <p className="mt-3 text-center text-sm text-[#b9bdc9]">Проверяем код...</p> : null}
|
||||
|
||||
{renderAlternativeMethods(true)}
|
||||
{renderAlternativeMethods('totp')}
|
||||
|
||||
</div>
|
||||
|
||||
@@ -1324,6 +1323,8 @@ export default function LoginPage() {
|
||||
|
||||
) : null}
|
||||
|
||||
{renderAlternativeMethods('password')}
|
||||
|
||||
</form>
|
||||
|
||||
) : null}
|
||||
|
||||
@@ -4,7 +4,7 @@ import './globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'MVK ID',
|
||||
description: 'Единый аккаунт для сервисов Lendry',
|
||||
description: 'Единый аккаунт для сервисов',
|
||||
icons: {
|
||||
icon: '/icon.svg',
|
||||
shortcut: '/icon.svg',
|
||||
|
||||
Reference in New Issue
Block a user