fix and update

This commit is contained in:
lendry
2026-06-29 16:23:40 +03:00
parent aebce54bd7
commit 10253fc76b
8 changed files with 797 additions and 106 deletions

View File

@@ -1,6 +1,7 @@
import type { DocBlock } from '@/lib/docs-pages';
import { OAuthCodeTabs } from '@/components/oauth-code-tabs';
import { OneTapCodeTabs } from '@/components/one-tap-code-tabs';
import { OneTapButtonBuilder } from '@/components/one-tap-button-builder';
import { AuthLoginCodeTabs } from '@/components/auth-code-tabs';
import { AuthLdapCodeTabs } from '@/components/auth-ldap-code-tabs';
import { BotCodeTabs } from '@/components/bot-code-tabs';
@@ -70,6 +71,8 @@ export function DocBlockRenderer({ block }: { block: DocBlock }) {
return <OAuthCodeTabs />;
case 'one-tap-examples':
return <OneTapCodeTabs />;
case 'one-tap-builder':
return <OneTapButtonBuilder />;
case 'auth-login-examples':
return <AuthLoginCodeTabs />;
case 'auth-ldap-examples':

View File

@@ -0,0 +1,297 @@
'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 {
buildButtonSnippet,
DEFAULT_BUILDER_OPTIONS,
ICON_OPTIONS,
resolveButtonStyle,
SIZE_OPTIONS,
THEME_OPTIONS,
VIEW_OPTIONS,
type ButtonBuilderOptions
} from '@/lib/one-tap-builder';
type ColorKey = 'bg' | 'bgHover' | 'border' | 'borderHover' | 'text';
const COLOR_FIELDS: Array<{ key: ColorKey; label: string }> = [
{ key: 'bg', label: 'Цвет фона' },
{ key: 'bgHover', label: 'Фон при наведении' },
{ key: 'border', label: 'Цвет обводки' },
{ key: 'borderHover', label: 'Обводка при наведении' },
{ key: 'text', label: 'Цвет текста' }
];
function Field({ label, children }: { label: string; children: ReactNode }) {
return (
<label className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-zinc-500 dark:text-zinc-400">{label}</span>
{children}
</label>
);
}
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 [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 style = useMemo(() => resolveButtonStyle(options), [options]);
function update<K extends keyof ButtonBuilderOptions>(key: K, value: ButtonBuilderOptions[K]) {
setOptions((prev) => ({ ...prev, [key]: value }));
}
function toggleColor(key: ColorKey, enabled: boolean) {
setOptions((prev) => ({
...prev,
[key]: enabled ? (prev[key] || style.palette[key] || '#ffffff') : ''
}));
}
const buttonStyle: CSSProperties = {
height: style.preset.height,
fontSize: style.preset.font,
gap: style.preset.gap,
padding: style.iconOnly ? 0 : `0 ${style.preset.padX}px`,
width: style.iconOnly ? style.preset.height : 'auto',
borderRadius: style.radius,
background: hovered ? style.bgHover : style.bg,
color: style.text,
border: `1px solid ${hovered ? style.borderHover : style.border}`,
boxShadow: '0 8px 24px rgba(31,36,48,.12)',
transition: 'all .18s ease'
};
const badgeStyle: CSSProperties = {
minWidth: style.preset.badge,
height: style.preset.badge,
padding: `0 ${Math.round(style.preset.badge / 4)}px`,
fontSize: Math.round(style.preset.font * 0.85),
background: style.palette.badgeBg,
color: style.palette.badgeColor
};
function tryLogin() {
if (typeof window === 'undefined') return;
const params = new URLSearchParams({
client_id: options.clientId || 'YOUR_CLIENT_ID',
redirect_uri: options.redirectUri,
response_type: 'code',
scope: 'openid profile email',
display: 'popup',
popup_origin: window.location.origin,
state: 'preview_' + Math.random().toString(36).slice(2)
});
const url = `${frontendBase}/auth/oauth/authorize?${params.toString()}`;
const popup = window.open(url, 'mvkid_preview', 'popup,width=480,height=640');
if (!popup) {
setStatus('Браузер заблокировал popup — разрешите всплывающие окна для теста.');
return;
}
setStatus('Открыт popup авторизации. После входа токен придёт через postMessage.');
function onMessage(event: MessageEvent) {
if (event.data?.type !== 'lendry-sso-onetap') return;
window.removeEventListener('message', onMessage);
setStatus(`Получен ответ: метод ${event.data.method ?? 'popup'}.`);
}
window.addEventListener('message', onMessage);
}
return (
<div className="my-4 grid gap-4 lg:grid-cols-[1.1fr_1fr]">
{/* Левая колонка: превью + код */}
<div className="space-y-3">
<div className="flex min-h-[140px] items-center justify-center rounded-xl border border-zinc-200 bg-[#e9edf3] p-6 dark:border-zinc-800 dark:bg-[#15171c]">
<button
type="button"
className="inline-flex cursor-pointer items-center justify-center font-semibold leading-none"
style={buttonStyle}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onClick={tryLogin}
aria-label={`Войти через ${options.providerName || projectName}`}
>
{style.showBadge ? (
<span className="inline-flex items-center justify-center rounded-full font-bold" style={badgeStyle}>
ID
</span>
) : null}
{!style.iconOnly ? <span>Войти через {options.providerName || projectName}</span> : null}
</button>
</div>
{status ? (
<p className="rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-700 dark:border-blue-900 dark:bg-blue-950/40 dark:text-blue-300">
{status}
</p>
) : null}
<CodeBlock code={snippet} language="html" title="Код для вставки" />
</div>
{/* Правая колонка: параметры */}
<div className="space-y-3 rounded-xl border border-zinc-200 p-4 dark:border-zinc-800">
<Field label="Client ID (из админки: RBAC → OAuth приложения)">
<input
className={inputClass}
value={options.clientId}
onChange={(event) => update('clientId', event.target.value)}
placeholder="YOUR_CLIENT_ID"
/>
</Field>
<Field label="Redirect URI">
<input
className={inputClass}
value={options.redirectUri}
onChange={(event) => update('redirectUri', event.target.value)}
placeholder="https://app.example.com/auth/callback"
/>
</Field>
<Field label="Название провайдера (текст на кнопке)">
<input
className={inputClass}
value={options.providerName}
onChange={(event) => update('providerName', event.target.value)}
placeholder={projectName}
/>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Размер">
<select
className={inputClass}
value={options.size}
onChange={(event) => update('size', event.target.value as ButtonBuilderOptions['size'])}
>
{SIZE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</Field>
<Field label="Тема">
<select
className={inputClass}
value={options.theme}
onChange={(event) => update('theme', event.target.value as ButtonBuilderOptions['theme'])}
>
{THEME_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</Field>
<Field label="Вид">
<select
className={inputClass}
value={options.view}
onChange={(event) => update('view', event.target.value as ButtonBuilderOptions['view'])}
>
{VIEW_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</Field>
<Field label="Тип иконки">
<select
className={inputClass}
value={options.icon}
onChange={(event) => update('icon', event.target.value as ButtonBuilderOptions['icon'])}
>
{ICON_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</Field>
</div>
<Field label={`Радиус скругления — ${options.radius}px`}>
<input
type="range"
min={0}
max={32}
value={options.radius}
onChange={(event) => update('radius', Number(event.target.value))}
className="w-full accent-zinc-900 dark:accent-zinc-100"
/>
</Field>
<div className="space-y-2 border-t border-zinc-200 pt-3 dark:border-zinc-800">
<p className="text-xs font-medium text-zinc-500 dark:text-zinc-400">
CSS-цвета (необязательно иначе берётся цвет темы)
</p>
{COLOR_FIELDS.map((field) => {
const enabled = Boolean(options[field.key]);
const fallback = style.palette[field.key] || '#ffffff';
const value = options[field.key] || fallback;
return (
<div key={field.key} className="flex items-center gap-3">
<input
type="checkbox"
checked={enabled}
onChange={(event) => toggleColor(field.key, event.target.checked)}
className="h-4 w-4 accent-zinc-900 dark:accent-zinc-100"
/>
<input
type="color"
value={value.startsWith('#') ? value : '#ffffff'}
disabled={!enabled}
onChange={(event) => update(field.key, event.target.value)}
className={cn('h-8 w-10 cursor-pointer rounded border border-zinc-300 bg-transparent dark:border-zinc-700', !enabled && 'opacity-40')}
/>
<span className="text-sm text-zinc-600 dark:text-zinc-300">{field.label}</span>
{enabled ? (
<code className="ml-auto rounded bg-zinc-100 px-1.5 py-0.5 text-xs dark:bg-zinc-800">{options[field.key]}</code>
) : null}
</div>
);
})}
</div>
</div>
</div>
);
}

View File

@@ -6,6 +6,7 @@ export type DocBlock =
| { type: 'table'; headers: string[]; rows: string[][] }
| { type: 'oauth-examples' }
| { type: 'one-tap-examples' }
| { type: 'one-tap-builder' }
| { type: 'auth-login-examples' }
| { type: 'auth-ldap-examples' }
| { type: 'bot-examples' }
@@ -1079,6 +1080,23 @@ $oidc->authenticate(); // client_id, redirect_uri, response_type=code — авт
}
]
},
{
id: 'button-builder',
title: 'Конструктор кнопок',
blocks: [
{
type: 'paragraph',
text: 'Интерактивный конструктор позволяет настроить кнопку входа под внешний вид вашего сайта. Выберите параметры — справа обновится живое превью, а слева сформируется готовый код виджета. Скопируйте его и вставьте на свою страницу: атрибут data-button-container указывает, в какой элемент встроить кнопку.'
},
{ type: 'one-tap-builder' },
{
type: 'callout',
variant: 'tip',
title: 'Как это работает',
text: 'Кнопка рендерится скриптом sso-widget.js внутри контейнера с указанным id. Параметры размера, темы, вида, радиуса, иконки и CSS-цветов передаются через data-button-* атрибуты. Клик по превью открывает реальный popup авторизации для проверки.'
}
]
},
{
id: 'fedcm-integration',
title: 'Интеграция FedCM вручную',

View File

@@ -0,0 +1,186 @@
import type { OneTapUrls } from '@/lib/one-tap-examples';
export type ButtonSize = 's' | 'm' | 'l' | 'xl';
export type ButtonTheme = 'light' | 'dark';
export type ButtonView = 'main' | 'icon';
export type ButtonIcon = 'id' | 'none';
export interface ButtonBuilderOptions {
clientId: string;
providerName: string;
redirectUri: string;
size: ButtonSize;
theme: ButtonTheme;
view: ButtonView;
radius: number;
icon: ButtonIcon;
// Пустая строка = использовать цвет темы по умолчанию.
bg: string;
bgHover: string;
border: string;
borderHover: string;
text: string;
}
export interface SizePreset {
height: number;
font: number;
padX: number;
gap: number;
badge: number;
radius: number;
}
// Должно совпадать с SIZE_PRESETS в apps/frontend/public/sso-widget.js.
export const SIZE_PRESETS: Record<ButtonSize, SizePreset> = {
s: { height: 32, font: 13, padX: 12, gap: 8, badge: 20, radius: 16 },
m: { height: 40, font: 14, padX: 16, gap: 10, badge: 24, radius: 20 },
l: { height: 44, font: 15, padX: 18, gap: 10, badge: 28, radius: 22 },
xl: { height: 52, font: 16, padX: 22, gap: 12, badge: 32, radius: 26 }
};
export interface ButtonPalette {
bg: string;
bgHover: string;
border: string;
borderHover: string;
text: string;
badgeBg: string;
badgeColor: string;
}
// Должно совпадать с themePalette в apps/frontend/public/sso-widget.js.
export function themePalette(theme: ButtonTheme): ButtonPalette {
if (theme === 'dark') {
return {
bg: '#1f2430',
bgHover: '#2a3040',
border: 'transparent',
borderHover: 'transparent',
text: '#ffffff',
badgeBg: '#ffffff',
badgeColor: '#1f2430'
};
}
return {
bg: '#ffffff',
bgHover: '#f6f8fb',
border: '#e4e8ef',
borderHover: '#d4dae6',
text: '#1f2430',
badgeBg: '#111111',
badgeColor: '#ffffff'
};
}
export const SIZE_OPTIONS: Array<{ value: ButtonSize; label: string }> = [
{ value: 's', label: 'S — 32px' },
{ value: 'm', label: 'M — 40px' },
{ value: 'l', label: 'L — 44px' },
{ value: 'xl', label: 'XL — 52px' }
];
export const THEME_OPTIONS: Array<{ value: ButtonTheme; label: string }> = [
{ value: 'light', label: 'Светлая' },
{ value: 'dark', label: 'Тёмная' }
];
export const VIEW_OPTIONS: Array<{ value: ButtonView; label: string }> = [
{ value: 'main', label: 'Кнопка с текстом' },
{ value: 'icon', label: 'Только иконка' }
];
export const ICON_OPTIONS: Array<{ value: ButtonIcon; label: string }> = [
{ value: 'id', label: 'Значок ID' },
{ value: 'none', label: 'Без значка' }
];
export const DEFAULT_BUILDER_OPTIONS: ButtonBuilderOptions = {
clientId: 'YOUR_CLIENT_ID',
providerName: 'MVK ID',
redirectUri: 'https://app.example.com/auth/callback',
size: 'xl',
theme: 'light',
view: 'main',
radius: 26,
icon: 'id',
bg: '',
bgHover: '',
border: '',
borderHover: '',
text: ''
};
export interface ResolvedButtonStyle {
preset: SizePreset;
palette: ButtonPalette;
bg: string;
bgHover: string;
border: string;
borderHover: string;
text: string;
radius: number;
iconOnly: boolean;
showBadge: boolean;
}
export function resolveButtonStyle(options: ButtonBuilderOptions): ResolvedButtonStyle {
const preset = SIZE_PRESETS[options.size] ?? SIZE_PRESETS.xl;
const palette = themePalette(options.theme === 'dark' ? 'dark' : 'light');
const radius = Number.isFinite(options.radius) ? options.radius : preset.radius;
return {
preset,
palette,
bg: options.bg || palette.bg,
bgHover: options.bgHover || palette.bgHover,
border: options.border || palette.border,
borderHover: options.borderHover || palette.borderHover,
text: options.text || palette.text,
radius,
iconOnly: options.view === 'icon',
showBadge: options.icon !== 'none'
};
}
export function buildButtonSnippet(options: ButtonBuilderOptions, urls: OneTapUrls): string {
const providerName = options.providerName || urls.projectName;
const lines: string[] = [
` src="${urls.widgetUrl}"`,
` data-client-id="${options.clientId || 'YOUR_CLIENT_ID'}"`,
` data-idp-url="${urls.apiBase}"`,
` data-idp-frontend-url="${urls.frontendBase}"`,
` data-provider-name="${providerName}"`,
` data-redirect-uri="${options.redirectUri}"`,
` data-button-container="mvkid-button"`,
` data-button-size="${options.size}"`,
` data-button-theme="${options.theme}"`,
` data-button-view="${options.view}"`,
` data-button-radius="${options.radius}"`,
` data-button-icon="${options.icon}"`
];
if (options.bg) lines.push(` data-button-bg="${options.bg}"`);
if (options.bgHover) lines.push(` data-button-bg-hover="${options.bgHover}"`);
if (options.border) lines.push(` data-button-border="${options.border}"`);
if (options.borderHover) lines.push(` data-button-border-hover="${options.borderHover}"`);
if (options.text) lines.push(` data-button-text="${options.text}"`);
lines.push(' data-on-success="onMvkIdLogin"');
return `<!-- 1) Контейнер, в котором появится кнопка -->
<div id="mvkid-button"></div>
<!-- 2) Подключение виджета ${providerName} -->
<script
${lines.join('\n')}
></script>
<script>
function onMvkIdLogin(payload) {
// payload.token — id_token (FedCM) или токен из popup
// payload.method — 'fedcm' | 'popup'
console.log('${providerName}: вход через', payload.method, payload.token);
// Отправьте токен на ваш backend для проверки и создания сессии
}
</script>`;
}