first commit

This commit is contained in:
lendry
2026-06-24 14:37:15 +03:00
commit 995adeedd4
188 changed files with 28810 additions and 0 deletions

40
apps/docs/Dockerfile Normal file
View File

@@ -0,0 +1,40 @@
# syntax=docker/dockerfile:1.4
FROM node:24-alpine
WORKDIR /app
ARG NPM_REGISTRY=https://registry.npmjs.org
ARG NEXT_PUBLIC_API_URL=http://localhost:3000
COPY package.json package-lock.json .npmrc ./
COPY apps/sso-core/package.json ./apps/sso-core/
COPY apps/api-gateway/package.json ./apps/api-gateway/
COPY apps/frontend/package.json ./apps/frontend/
COPY apps/docs/package.json ./apps/docs/
RUN --mount=type=cache,target=/root/.npm \
npm config set registry "${NPM_REGISTRY}" && \
for i in 1 2 3 4 5; do \
echo "npm ci: попытка $i/5" && \
npm ci --no-audit --no-fund && exit 0; \
echo "npm ci: ошибка сети, повтор через $((i * 15)) сек..."; \
sleep $((i * 15)); \
done; \
echo "npm ci: все попытки исчерпаны"; \
exit 1
COPY apps ./apps
COPY shared ./shared
COPY tsconfig.base.json ./
ENV NEXT_TELEMETRY_DISABLED="1"
ENV PORT="3000"
ENV HOSTNAME="0.0.0.0"
ENV NEXT_PUBLIC_API_URL="${NEXT_PUBLIC_API_URL}"
RUN npm --workspace @lendry/docs run build
EXPOSE 3000
CMD ["npm", "--workspace", "@lendry/docs", "run", "start"]

View File

@@ -0,0 +1,17 @@
import { fetchPublicSettings } from '@/lib/api';
export const dynamic = 'force-dynamic';
export async function GET() {
const settings = await fetchPublicSettings();
return Response.json(
{
settings: Object.entries(settings).map(([key, value]) => ({ key, value }))
},
{
headers: {
'Cache-Control': 'no-store, max-age=0'
}
}
);
}

View File

@@ -0,0 +1,45 @@
import { notFound } from 'next/navigation';
import { DocBlockRenderer } from '@/components/doc-block-renderer';
import { DocsToc } from '@/components/docs-shell';
import { getAllDocSlugs, getDocPage } from '@/lib/docs-pages';
export function generateStaticParams() {
return getAllDocSlugs().map((slug) => ({ slug }));
}
export default async function DocPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const page = getDocPage(slug);
if (!page) notFound();
return (
<>
<main className="min-w-0 max-w-3xl prose-docs">
<div className="mb-10 border-b border-zinc-200 pb-8 dark:border-zinc-800">
<p className="text-sm font-medium text-zinc-500">Документация</p>
<h1 className="mt-2 text-4xl font-bold tracking-tight text-zinc-950 dark:text-zinc-50">{page.title}</h1>
<p className="mt-4 text-lg leading-relaxed text-zinc-600 dark:text-zinc-400">{page.description}</p>
</div>
<article className="space-y-12 pb-16">
{page.sections.map((section) => (
<section id={section.id} key={section.id} className="scroll-mt-24">
<h2 className="mb-4 text-2xl font-semibold tracking-tight text-zinc-950 dark:text-zinc-50">{section.title}</h2>
<div className="space-y-4">
{section.blocks.map((block, index) => (
<DocBlockRenderer key={`${section.id}-${index}`} block={block} />
))}
</div>
</section>
))}
</article>
</main>
<aside className="hidden xl:block">
<div className="sticky top-20">
<DocsToc sections={page.sections.map((s) => ({ id: s.id, title: s.title }))} />
</div>
</aside>
</>
);
}

View File

@@ -0,0 +1,27 @@
import { docNavigation, groupDocNavigation } from '@/lib/navigation';
import { fetchPublicSettings } from '@/lib/api';
import { PublicSettingsProvider } from '@/providers/public-settings-provider';
import { DocsHeader, DocsSidebar } from '@/components/docs-shell';
export const dynamic = 'force-dynamic';
export default async function DocsLayout({ children }: { children: React.ReactNode }) {
const initialSettings = await fetchPublicSettings();
const groups = groupDocNavigation(docNavigation);
return (
<PublicSettingsProvider initialSettings={initialSettings}>
<div className="min-h-screen bg-white dark:bg-zinc-950">
<DocsHeader />
<div className="mx-auto grid max-w-screen-2xl grid-cols-1 gap-8 px-4 py-8 lg:grid-cols-[240px_minmax(0,1fr)] lg:px-8 xl:grid-cols-[240px_minmax(0,1fr)_200px]">
<aside className="hidden lg:block">
<div className="sticky top-20">
<DocsSidebar groups={groups} />
</div>
</aside>
{children}
</div>
</div>
</PublicSettingsProvider>
);
}

View File

@@ -0,0 +1,5 @@
import { DocsHomeContent } from '@/components/docs-home-content';
export default function DocsHomePage() {
return <DocsHomeContent />;
}

46
apps/docs/app/globals.css Normal file
View File

@@ -0,0 +1,46 @@
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
:root {
--background: #ffffff;
--foreground: #09090b;
--muted: #f4f4f5;
--border: #e4e4e7;
}
.dark {
--background: #09090b;
--foreground: #fafafa;
--muted: #18181b;
--border: #27272a;
}
@theme inline {
--font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
--font-mono: var(--font-geist-mono), ui-monospace, monospace;
}
body {
margin: 0;
background: var(--background);
color: var(--foreground);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
}
a {
text-decoration: none;
}
::selection {
background: rgb(24 24 27 / 0.15);
}
.dark ::selection {
background: rgb(250 250 250 / 0.15);
}
.prose-docs h2 {
scroll-margin-top: 5rem;
}

37
apps/docs/app/layout.tsx Normal file
View File

@@ -0,0 +1,37 @@
import type { Metadata } from 'next';
import { Geist, Geist_Mono } from 'next/font/google';
import { ThemeProvider } from '@/providers/theme-provider';
import './globals.css';
const geistSans = Geist({
variable: '--font-geist-sans',
subsets: ['latin', 'cyrillic']
});
const geistMono = Geist_Mono({
variable: '--font-geist-mono',
subsets: ['latin']
});
export const metadata: Metadata = {
title: {
default: 'Документация',
template: '%s — Docs'
},
description: 'Документация по интеграции, OAuth 2.0, развёртыванию и REST API Identity Provider.'
};
const themeScript = `(function(){try{var t=localStorage.getItem('docs-theme');var dark=t==='dark'||(t!=='light'&&window.matchMedia('(prefers-color-scheme: dark)').matches);document.documentElement.classList.toggle('dark',dark);}catch(e){}})();`;
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ru" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
</head>
<body className={`${geistSans.variable} ${geistMono.variable} min-h-screen antialiased`}>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
);
}

5
apps/docs/app/page.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function HomePage() {
redirect('/docs');
}

View File

@@ -0,0 +1,45 @@
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { apiReference, type ApiEndpoint } from '@/lib/api-endpoints';
import { cn } from '@/lib/utils';
const methodColors: Record<ApiEndpoint['method'], string> = {
GET: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300',
POST: 'bg-blue-100 text-blue-800 dark:bg-blue-950 dark:text-blue-300',
PUT: 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300',
PATCH: 'bg-violet-100 text-violet-800 dark:bg-violet-950 dark:text-violet-300',
DELETE: 'bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300'
};
export function ApiEndpointCard({ endpoint }: { endpoint: ApiEndpoint }) {
return (
<Card className="overflow-hidden">
<CardContent className="flex flex-col gap-2 p-4 sm:flex-row sm:items-start sm:gap-4">
<Badge className={cn('w-fit shrink-0 font-mono', methodColors[endpoint.method])}>{endpoint.method}</Badge>
<div className="min-w-0 flex-1">
<code className="break-all text-sm font-medium">{endpoint.path}</code>
<p className="mt-1 text-sm font-medium">{endpoint.summary}</p>
{endpoint.description ? <p className="mt-1 text-sm text-zinc-500">{endpoint.description}</p> : null}
{endpoint.auth ? <p className="mt-2 text-xs text-zinc-400">Требуется Authorization: Bearer JWT</p> : null}
</div>
</CardContent>
</Card>
);
}
export function ApiReferenceSection() {
return (
<div className="space-y-8">
{apiReference.map((group) => (
<div key={group.tag}>
<h3 className="mb-4 text-lg font-semibold">{group.tag}</h3>
<div className="space-y-3">
{group.endpoints.map((endpoint) => (
<ApiEndpointCard key={`${endpoint.method}-${endpoint.path}`} endpoint={endpoint} />
))}
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,8 @@
'use client';
import { authLoginExamples } from '@/lib/auth-examples';
import { CodeExampleTabs } from '@/components/code-example-tabs';
export function AuthLoginCodeTabs() {
return <CodeExampleTabs examples={authLoginExamples} />;
}

View File

@@ -0,0 +1,8 @@
'use client';
import { authLdapExamples } from '@/lib/auth-examples';
import { CodeExampleTabs } from '@/components/code-example-tabs';
export function AuthLdapCodeTabs() {
return <CodeExampleTabs examples={authLdapExamples} />;
}

View File

@@ -0,0 +1,43 @@
'use client';
import { useState } from 'react';
import { Check, Copy } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
export function CodeBlock({ code, language, title }: { code: string; language?: string; title?: string }) {
const [copied, setCopied] = useState(false);
async function copy() {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<div className="group relative my-4 overflow-hidden rounded-xl border border-zinc-200 dark:border-zinc-800">
{(title || language) && (
<div className="flex items-center justify-between border-b border-zinc-200 bg-zinc-50 px-4 py-2 text-xs text-zinc-500 dark:border-zinc-800 dark:bg-zinc-900">
<span>{title ?? language}</span>
<Button variant="ghost" size="sm" className="h-7 px-2 opacity-0 group-hover:opacity-100" onClick={copy}>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
{copied ? 'Скопировано' : 'Копировать'}
</Button>
</div>
)}
<pre className={cn('overflow-x-auto bg-zinc-950 p-4 text-sm leading-relaxed text-zinc-50', !title && !language && 'rounded-xl')}>
<code>{code}</code>
</pre>
{!title && !language && (
<Button
variant="ghost"
size="sm"
className="absolute right-2 top-2 h-7 bg-zinc-900/80 px-2 text-zinc-300 opacity-0 group-hover:opacity-100"
onClick={copy}
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
</Button>
)}
</div>
);
}

View File

@@ -0,0 +1,26 @@
'use client';
import type { CodeExample } from '@/lib/code-example';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { CodeBlock } from '@/components/code-block';
export function CodeExampleTabs({ examples }: { examples: CodeExample[] }) {
if (examples.length === 0) return null;
return (
<Tabs defaultValue={examples[0].id} className="my-6">
<TabsList className="mb-2 flex h-auto max-w-full flex-wrap gap-1">
{examples.map((example) => (
<TabsTrigger key={example.id} value={example.id} className="text-xs sm:text-sm">
{example.label}
</TabsTrigger>
))}
</TabsList>
{examples.map((example) => (
<TabsContent key={example.id} value={example.id}>
<CodeBlock code={example.code} language={example.language} title={example.label} />
</TabsContent>
))}
</Tabs>
);
}

View File

@@ -0,0 +1,78 @@
import type { DocBlock } from '@/lib/docs-pages';
import { OAuthCodeTabs } from '@/components/oauth-code-tabs';
import { AuthLoginCodeTabs } from '@/components/auth-code-tabs';
import { AuthLdapCodeTabs } from '@/components/auth-ldap-code-tabs';
import { ApiReferenceSection } from '@/components/api-endpoint-card';
import { CodeBlock } from '@/components/code-block';
import { cn } from '@/lib/utils';
function Callout({ variant, title, text }: { variant: 'info' | 'warning' | 'tip'; title: string; text: string }) {
const styles = {
info: 'border-blue-200 bg-blue-50 dark:border-blue-900 dark:bg-blue-950/40',
warning: 'border-amber-200 bg-amber-50 dark:border-amber-900 dark:bg-amber-950/40',
tip: 'border-emerald-200 bg-emerald-50 dark:border-emerald-900 dark:bg-emerald-950/40'
};
return (
<div className={cn('my-4 rounded-xl border p-4', styles[variant])}>
<p className="font-semibold">{title}</p>
<p className="mt-1 text-sm leading-relaxed text-zinc-600 dark:text-zinc-300">{text}</p>
</div>
);
}
export function DocBlockRenderer({ block }: { block: DocBlock }) {
switch (block.type) {
case 'paragraph':
return <p className="leading-7 text-zinc-600 dark:text-zinc-300">{block.text}</p>;
case 'list':
return (
<ul className="my-4 list-disc space-y-2 pl-6 leading-7 text-zinc-600 dark:text-zinc-300">
{block.items.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
);
case 'code':
return <CodeBlock code={block.code} language={block.language} title={block.title} />;
case 'callout':
return <Callout variant={block.variant} title={block.title} text={block.text} />;
case 'table':
return (
<div className="my-4 overflow-x-auto rounded-xl border border-zinc-200 dark:border-zinc-800">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-200 bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-900">
{block.headers.map((header) => (
<th key={header} className="px-4 py-3 text-left font-semibold">
{header}
</th>
))}
</tr>
</thead>
<tbody>
{block.rows.map((row, index) => (
<tr key={index} className="border-b border-zinc-100 last:border-0 dark:border-zinc-800">
{row.map((cell, cellIndex) => (
<td key={cellIndex} className="px-4 py-3 align-top text-zinc-600 dark:text-zinc-300">
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
case 'oauth-examples':
return <OAuthCodeTabs />;
case 'auth-login-examples':
return <AuthLoginCodeTabs />;
case 'auth-ldap-examples':
return <AuthLdapCodeTabs />;
case 'api-reference':
return <ApiReferenceSection />;
default:
return null;
}
}

View File

@@ -0,0 +1,103 @@
'use client';
import Link from 'next/link';
import { ArrowRight, BookOpen, Code2, Rocket, Shield } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { docNavigation, groupDocNavigation } from '@/lib/navigation';
import { usePublicSettings } from '@/providers/public-settings-provider';
const highlights = [
{
icon: Rocket,
title: 'Быстрый старт',
description: 'Локальный запуск через Docker Compose за несколько минут.',
href: '/docs/getting-started'
},
{
icon: Code2,
title: 'OAuth 2.0',
description: 'Примеры интеграции на JavaScript, Python, PHP, Go и других языках.',
href: '/docs/oauth'
},
{
icon: Shield,
title: 'Безопасность',
description: 'PIN-код, сессии, LDAP/LDAPS и управление устройствами.',
href: '/docs/sessions'
},
{
icon: BookOpen,
title: 'Справочник API',
description: 'Полный список REST endpoints и ссылка на Swagger.',
href: '/docs/api-reference'
}
];
export function DocsHomeContent() {
const { projectName, projectTagline } = usePublicSettings();
const groups = groupDocNavigation(docNavigation);
return (
<main className="min-w-0 pb-16 xl:col-span-1">
<section className="relative overflow-hidden rounded-2xl border border-zinc-200 bg-zinc-50 px-6 py-12 dark:border-zinc-800 dark:bg-zinc-900/50 sm:px-10 sm:py-16">
<p className="text-sm font-medium text-zinc-500">Документация</p>
<h1 className="mt-3 max-w-2xl text-4xl font-bold tracking-tight text-zinc-950 dark:text-zinc-50 sm:text-5xl">{projectName}</h1>
<p className="mt-4 max-w-2xl text-lg leading-relaxed text-zinc-600 dark:text-zinc-400">{projectTagline}</p>
<p className="mt-4 max-w-2xl text-zinc-600 dark:text-zinc-400">
Enterprise Identity Provider: единый вход, OAuth 2.0, корпоративный LDAP, семейные группы, чат и админ-панель.
</p>
<div className="mt-8 flex flex-wrap gap-3">
<Button asChild size="lg">
<Link href="/docs/getting-started">
Начать
<ArrowRight className="h-4 w-4" />
</Link>
</Button>
<Button asChild variant="outline" size="lg">
<Link href="/docs/deployment">Развёртывание</Link>
</Button>
</div>
</section>
<section className="mt-10 grid gap-4 sm:grid-cols-2">
{highlights.map((item) => (
<Link key={item.href} href={item.href}>
<Card className="h-full transition-colors hover:border-zinc-300 dark:hover:border-zinc-700">
<CardHeader>
<div className="mb-2 flex h-10 w-10 items-center justify-center rounded-lg bg-zinc-100 dark:bg-zinc-800">
<item.icon className="h-5 w-5" />
</div>
<CardTitle>{item.title}</CardTitle>
<CardDescription>{item.description}</CardDescription>
</CardHeader>
<CardContent>
<span className="text-sm font-medium text-zinc-500">Читать </span>
</CardContent>
</Card>
</Link>
))}
</section>
<section className="mt-12">
<h2 className="text-xl font-semibold">Все разделы</h2>
<div className="mt-4 grid gap-6 sm:grid-cols-2">
{groups.map(([group, items]) => (
<div key={group}>
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-zinc-500">{group}</p>
<ul className="space-y-2">
{items.map((item) => (
<li key={item.slug}>
<Link href={`/docs/${item.slug}`} className="text-sm text-zinc-600 hover:text-zinc-950 dark:text-zinc-400 dark:hover:text-zinc-50">
{item.title}
</Link>
</li>
))}
</ul>
</div>
))}
</div>
</section>
</main>
);
}

View File

@@ -0,0 +1,109 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { BookOpen, ExternalLink, Moon, Sun } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { usePublicSettings } from '@/providers/public-settings-provider';
import { useTheme } from '@/providers/theme-provider';
import { getApiBaseUrl } from '@/lib/api';
import { cn } from '@/lib/utils';
export function DocsHeader() {
const { projectName } = usePublicSettings();
const { resolvedTheme, setTheme } = useTheme();
const apiUrl = getApiBaseUrl();
return (
<header className="sticky top-0 z-50 w-full border-b border-zinc-200 bg-white/80 backdrop-blur dark:border-zinc-800 dark:bg-zinc-950/80">
<div className="mx-auto flex h-14 max-w-screen-2xl items-center justify-between gap-4 px-4 lg:px-8">
<Link href="/docs" className="flex items-center gap-2 font-semibold tracking-tight">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-zinc-900 text-white dark:bg-zinc-50 dark:text-zinc-900">
<BookOpen className="h-4 w-4" />
</div>
<span className="hidden sm:inline">{projectName}</span>
<span className="text-zinc-400 font-normal hidden sm:inline">Docs</span>
</Link>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" asChild className="hidden md:inline-flex">
<a href={`${apiUrl}/api`} target="_blank" rel="noreferrer">
Swagger
<ExternalLink className="h-3.5 w-3.5" />
</a>
</Button>
<Button
variant="outline"
size="icon"
aria-label="Переключить тему"
onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}
>
{resolvedTheme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</Button>
</div>
</div>
</header>
);
}
export function DocsSidebar({ groups }: { groups: Array<[string, Array<{ slug: string; title: string }>]> }) {
const pathname = usePathname();
return (
<nav className="space-y-6">
<Link
href="/docs"
className={cn(
'block rounded-lg px-3 py-2 text-sm font-medium transition-colors',
pathname === '/docs'
? 'bg-zinc-100 text-zinc-900 dark:bg-zinc-800 dark:text-zinc-50'
: 'text-zinc-600 hover:bg-zinc-50 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-900 dark:hover:text-zinc-50'
)}
>
Главная
</Link>
{groups.map(([group, items]) => (
<div key={group}>
<p className="mb-2 px-2 text-xs font-semibold uppercase tracking-wider text-zinc-500">{group}</p>
<div className="space-y-1">
{items.map((item) => {
const href = `/docs/${item.slug}`;
const active = pathname === href;
return (
<Link
key={item.slug}
href={href}
className={cn(
'block rounded-lg px-3 py-2 text-sm transition-colors',
active
? 'bg-zinc-100 font-medium text-zinc-900 dark:bg-zinc-800 dark:text-zinc-50'
: 'text-zinc-600 hover:bg-zinc-50 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-900 dark:hover:text-zinc-50'
)}
>
{item.title}
</Link>
);
})}
</div>
</div>
))}
</nav>
);
}
export function DocsToc({ sections }: { sections: Array<{ id: string; title: string }> }) {
return (
<nav className="space-y-2 text-sm">
<p className="mb-3 font-semibold">На этой странице</p>
{sections.map((section) => (
<a
key={section.id}
href={`#${section.id}`}
className="block text-zinc-500 transition-colors hover:text-zinc-900 dark:hover:text-zinc-50"
>
{section.title}
</a>
))}
</nav>
);
}

View File

@@ -0,0 +1,8 @@
'use client';
import { oauthExamples } from '@/lib/oauth-examples';
import { CodeExampleTabs } from '@/components/code-example-tabs';
export function OAuthCodeTabs() {
return <CodeExampleTabs examples={oauthExamples} />;
}

View File

@@ -0,0 +1,14 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export function Badge({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
'inline-flex items-center rounded-md border border-zinc-200 px-2.5 py-0.5 text-xs font-semibold transition-colors dark:border-zinc-800',
className
)}
{...props}
/>
);
}

View File

@@ -0,0 +1,41 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-xl text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default:
'border border-transparent bg-zinc-900 text-zinc-50 shadow hover:bg-zinc-800 dark:border-zinc-700 dark:bg-zinc-100 dark:text-zinc-900 dark:shadow-none dark:hover:bg-zinc-200',
secondary:
'bg-zinc-100 text-zinc-900 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700',
outline:
'border border-zinc-300 bg-transparent text-zinc-900 hover:bg-zinc-100 dark:border-zinc-600 dark:bg-transparent dark:text-zinc-100 dark:hover:bg-zinc-800',
ghost: 'text-zinc-900 hover:bg-zinc-100 dark:text-zinc-100 dark:hover:bg-zinc-800',
link: 'text-zinc-900 underline-offset-4 hover:underline dark:text-zinc-100'
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-lg px-3 text-xs',
lg: 'h-11 rounded-xl px-8 text-base',
icon: 'h-9 w-9'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
}
);
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
export function Button({ className, variant, size, asChild = false, ...props }: ButtonProps) {
const Comp = asChild ? Slot : 'button';
return <Comp className={cn(buttonVariants({ variant, size, className }))} {...props} />;
}

View File

@@ -0,0 +1,22 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn('rounded-xl border border-zinc-200 bg-white text-zinc-950 shadow-sm dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-50', className)} {...props} />;
}
export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />;
}
export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
return <h3 className={cn('text-lg font-semibold leading-none tracking-tight', className)} {...props} />;
}
export function CardDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
return <p className={cn('text-sm text-zinc-500 dark:text-zinc-400', className)} {...props} />;
}
export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn('p-6 pt-0', className)} {...props} />;
}

View File

@@ -0,0 +1,31 @@
'use client';
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
import { cn } from '@/lib/utils';
export function ScrollArea({ className, children, ...props }: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root className={cn('relative overflow-hidden', className)} {...props}>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">{children}</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);
}
function ScrollBar({ className, orientation = 'vertical', ...props }: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
orientation={orientation}
className={cn(
'flex touch-none select-none transition-colors',
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-[1px]',
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent p-[1px]',
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-zinc-200 dark:bg-zinc-800" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
);
}

View File

@@ -0,0 +1,31 @@
'use client';
import * as TabsPrimitive from '@radix-ui/react-tabs';
import { cn } from '@/lib/utils';
export const Tabs = TabsPrimitive.Root;
export function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
className={cn('inline-flex h-10 items-center justify-center rounded-lg bg-zinc-100 p-1 text-zinc-500 dark:bg-zinc-900', className)}
{...props}
/>
);
}
export function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
className={cn(
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium ring-offset-white transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-zinc-950 data-[state=active]:shadow dark:ring-offset-zinc-950 dark:data-[state=active]:bg-zinc-800 dark:data-[state=active]:text-zinc-50',
className
)}
{...props}
/>
);
}
export function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
return <TabsPrimitive.Content className={cn('mt-4 focus-visible:outline-none', className)} {...props} />;
}

View File

@@ -0,0 +1,116 @@
export interface ApiEndpoint {
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
path: string;
summary: string;
description?: string;
auth?: boolean;
}
export interface ApiTagGroup {
tag: string;
endpoints: ApiEndpoint[];
}
export const apiReference: ApiTagGroup[] = [
{
tag: 'Аутентификация',
endpoints: [
{ method: 'POST', path: '/auth/register', summary: 'Регистрация пользователя', description: 'Первый пользователь получает isSuperAdmin.' },
{ method: 'POST', path: '/auth/login', summary: 'Вход по почте, телефону или логину' },
{ method: 'POST', path: '/auth/identify', summary: 'Проверить способ входа (identifier-first)' },
{ method: 'POST', path: '/auth/otp/send', summary: 'Отправить OTP для passwordless-входа' },
{ method: 'POST', path: '/auth/otp/verify', summary: 'Проверить OTP' },
{ method: 'POST', path: '/auth/login/password', summary: 'Войти по паролю' },
{ method: 'POST', path: '/auth/ldap/login', summary: 'Войти через LDAP/LDAPS' },
{ method: 'POST', path: '/auth/pin/verify', summary: 'Подтвердить PIN-код' },
{ method: 'POST', path: '/auth/refresh', summary: 'Обновить access token' },
{ method: 'GET', path: '/auth/session', summary: 'Состояние текущей сессии', auth: true },
{ method: 'GET', path: '/auth/me', summary: 'Текущий пользователь', auth: true }
]
},
{
tag: 'OAuth 2.0',
endpoints: [
{ method: 'GET', path: '/oauth/authorize', summary: 'Создать authorization code' },
{ method: 'POST', path: '/oauth/token', summary: 'Выдать OAuth токены (code / refresh_token)' },
{ method: 'GET', path: '/oauth/userinfo', summary: 'Профиль по OAuth access token', auth: true }
]
},
{
tag: 'Профиль и биометрия',
endpoints: [
{ method: 'GET', path: '/profile/users/{userId}', summary: 'Получить профиль', auth: true },
{ method: 'PATCH', path: '/profile/users/{userId}', summary: 'Обновить профиль', auth: true },
{ method: 'PATCH', path: '/profile/users/{userId}/avatar', summary: 'Обновить аватар', auth: true },
{ method: 'PATCH', path: '/profile/users/{userId}/contacts', summary: 'Обновить контакты', auth: true },
{ method: 'POST', path: '/profile/users/{userId}/password', summary: 'Установить пароль', auth: true },
{ method: 'POST', path: '/profile/users/{userId}/self-delete', summary: 'Удалить свой профиль', auth: true }
]
},
{
tag: 'Безопасность',
endpoints: [
{ method: 'GET', path: '/security/users/{userId}/devices', summary: 'Активные устройства', auth: true },
{ method: 'GET', path: '/security/users/{userId}/sessions', summary: 'Активные сессии', auth: true },
{ method: 'POST', path: '/security/users/{userId}/pin/setup', summary: 'Настроить PIN', auth: true },
{ method: 'POST', path: '/security/users/{userId}/revoke-all-sessions', summary: 'Выйти везде', auth: true }
]
},
{
tag: 'Администрирование',
endpoints: [
{ method: 'GET', path: '/admin/users', summary: 'Список пользователей', auth: true },
{ method: 'PATCH', path: '/admin/users/{userId}', summary: 'Обновить пользователя', auth: true },
{ method: 'POST', path: '/admin/users/{userId}/reset-password', summary: 'Сбросить пароль', auth: true },
{ method: 'PATCH', path: '/admin/users/{userId}/super-admin', summary: 'Права супер-администратора', auth: true }
]
},
{
tag: 'RBAC и OAuth',
endpoints: [
{ method: 'GET', path: '/admin/rbac/roles', summary: 'Список ролей', auth: true },
{ method: 'POST', path: '/admin/rbac/oauth-clients', summary: 'Создать OAuth-приложение', auth: true },
{ method: 'GET', path: '/admin/rbac/oauth-scopes', summary: 'OAuth scopes', auth: true }
]
},
{
tag: 'Глобальные настройки',
endpoints: [
{ method: 'GET', path: '/admin/settings', summary: 'Список системных настроек', auth: true },
{ method: 'PUT', path: '/admin/settings', summary: 'Создать или обновить настройку', auth: true },
{ method: 'GET', path: '/settings/public', summary: 'Публичные настройки (без авторизации)' }
]
},
{
tag: 'Семья',
endpoints: [
{ method: 'POST', path: '/family/groups', summary: 'Создать семейную группу', auth: true },
{ method: 'GET', path: '/family/users/{userId}/groups', summary: 'Список семей пользователя', auth: true },
{ method: 'POST', path: '/family/groups/{groupId}/invites', summary: 'Пригласить участника', auth: true }
]
},
{
tag: 'Чат',
endpoints: [
{ method: 'GET', path: '/chat/groups/{groupId}/rooms', summary: 'Список чатов семьи', auth: true },
{ method: 'POST', path: '/chat/rooms/{roomId}/messages', summary: 'Отправить сообщение', auth: true },
{ method: 'GET', path: '/chat/rooms/{roomId}/messages', summary: 'Сообщения чата', auth: true }
]
},
{
tag: 'Медиа',
endpoints: [
{ method: 'POST', path: '/media/avatars/upload-url', summary: 'URL для загрузки аватара', auth: true },
{ method: 'GET', path: '/media/stream/{token}', summary: 'Потоковая выдача медиа' },
{ method: 'POST', path: '/media/chat/{roomId}/media/upload-url', summary: 'URL для медиа чата', auth: true }
]
},
{
tag: 'Уведомления',
endpoints: [
{ method: 'GET', path: '/notifications', summary: 'Список уведомлений', auth: true },
{ method: 'PATCH', path: '/notifications/{notificationId}/read', summary: 'Удалить просмотренное', auth: true },
{ method: 'DELETE', path: '/notifications', summary: 'Удалить все уведомления', auth: true }
]
}
];

59
apps/docs/lib/api.ts Normal file
View File

@@ -0,0 +1,59 @@
const clientApiUrl = (process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000').replace(/\/$/, '');
function getServerApiUrl() {
return (process.env.INTERNAL_API_URL ?? process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000').replace(/\/$/, '');
}
export interface PublicSetting {
key: string;
value: string;
}
async function fetchWithTimeout(url: string, init: RequestInit & { timeoutMs?: number } = {}) {
const { timeoutMs = 3000, ...rest } = init;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, { ...rest, signal: controller.signal });
} finally {
clearTimeout(timer);
}
}
function mapSettings(payload: { settings?: PublicSetting[] }) {
return Object.fromEntries((payload.settings ?? []).map((item) => [item.key, item.value]));
}
export async function fetchPublicSettings(): Promise<Record<string, string>> {
try {
const response = await fetchWithTimeout(`${getServerApiUrl()}/settings/public`, {
cache: 'no-store'
});
if (!response.ok) {
return {};
}
return mapSettings((await response.json()) as { settings?: PublicSetting[] });
} catch {
return {};
}
}
export async function fetchPublicSettingsClient(): Promise<Record<string, string>> {
const response = await fetchWithTimeout('/api/public-settings', { cache: 'no-store' });
if (!response.ok) {
throw new Error('Не удалось загрузить публичные настройки');
}
return mapSettings((await response.json()) as { settings?: PublicSetting[] });
}
export function getApiBaseUrl() {
return clientApiUrl;
}
export function getDefaultProjectName() {
return 'Lendry ID';
}
export function getDefaultProjectTagline() {
return 'Единый аккаунт для сервисов Lendry';
}

View File

@@ -0,0 +1,494 @@
import type { CodeExample } from '@/lib/code-example';
const API_BASE = 'https://id.lendry.ru';
const loginBody = `{
"login": "user@example.com",
"password": "SecurePass123",
"fingerprint": "device-fp-uuid",
"deviceName": "Chrome на Windows",
"deviceType": "WEB"
}`;
export const authLoginExamples: CodeExample[] = [
{
id: 'curl',
label: 'cURL',
language: 'bash',
code: `curl -X POST ${API_BASE}/auth/login/password \\
-H "Content-Type: application/json" \\
-d '${loginBody.replace(/\n/g, '\n ')}'`
},
{
id: 'javascript',
label: 'JavaScript',
language: 'javascript',
code: `const response = await fetch('${API_BASE}/auth/login/password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
login: 'user@example.com',
password: 'SecurePass123',
fingerprint: crypto.randomUUID(),
deviceName: 'Chrome на Windows',
deviceType: 'WEB'
})
});
if (!response.ok) {
throw new Error(await response.text());
}
const session = await response.json();
// session.accessToken, session.refreshToken, session.requiresPin`
},
{
id: 'typescript',
label: 'TypeScript',
language: 'typescript',
code: `interface LoginPayload {
login: string;
password: string;
fingerprint: string;
deviceName: string;
deviceType: 'WEB' | 'MOBILE' | 'DESKTOP';
}
interface AuthTokens {
accessToken: string;
refreshToken: string;
requiresPin?: boolean;
}
export async function login(payload: LoginPayload): Promise<AuthTokens> {
const response = await fetch('${API_BASE}/auth/login/password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) throw new Error(await response.text());
return response.json();
}`
},
{
id: 'python',
label: 'Python',
language: 'python',
code: `import requests
response = requests.post(
'${API_BASE}/auth/login/password',
json={
'login': 'user@example.com',
'password': 'SecurePass123',
'fingerprint': 'device-fp-uuid',
'deviceName': 'Chrome на Windows',
'deviceType': 'WEB',
},
timeout=15,
)
response.raise_for_status()
session = response.json()
print(session['accessToken'])`
},
{
id: 'php',
label: 'PHP',
language: 'php',
code: `<?php
$payload = json_encode([
'login' => 'user@example.com',
'password' => 'SecurePass123',
'fingerprint' => bin2hex(random_bytes(16)),
'deviceName' => 'Chrome на Windows',
'deviceType' => 'WEB',
]);
$ch = curl_init('${API_BASE}/auth/login/password');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_POSTFIELDS => $payload,
CURLOPT_RETURNTRANSFER => true,
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status >= 400) {
throw new RuntimeException($response);
}
$session = json_decode($response, true);`
},
{
id: 'go',
label: 'Go',
language: 'go',
code: `package main
import (
"bytes"
"encoding/json"
"net/http"
)
type LoginRequest struct {
Login string \`json:"login"\`
Password string \`json:"password"\`
Fingerprint string \`json:"fingerprint"\`
DeviceName string \`json:"deviceName"\`
DeviceType string \`json:"deviceType"\`
}
func login() (map[string]any, error) {
body, _ := json.Marshal(LoginRequest{
Login: "user@example.com", Password: "SecurePass123",
Fingerprint: "device-fp-uuid", DeviceName: "Chrome на Windows", DeviceType: "WEB",
})
resp, err := http.Post("${API_BASE}/auth/login/password", "application/json", bytes.NewReader(body))
if err != nil {
return nil, err
}
defer resp.Body.Close()
var session map[string]any
return session, json.NewDecoder(resp.Body).Decode(&session)
}`
},
{
id: 'rust',
label: 'Rust',
language: 'rust',
code: `use reqwest::Client;
use serde_json::json;
#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
let client = Client::new();
let response = client
.post("${API_BASE}/auth/login/password")
.json(&json!({
"login": "user@example.com",
"password": "SecurePass123",
"fingerprint": "device-fp-uuid",
"deviceName": "Chrome на Windows",
"deviceType": "WEB"
}))
.send()
.await?
.error_for_status()?;
let session: serde_json::Value = response.json().await?;
println!("{}", session["accessToken"]);
Ok(())
}`
},
{
id: 'ruby',
label: 'Ruby',
language: 'ruby',
code: `require 'net/http'
require 'json'
require 'uri'
uri = URI('${API_BASE}/auth/login/password')
request = Net::HTTP::Post.new(uri)
request['Content-Type'] = 'application/json'
request.body = {
login: 'user@example.com',
password: 'SecurePass123',
fingerprint: SecureRandom.uuid,
deviceName: 'Chrome на Windows',
deviceType: 'WEB'
}.to_json
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
http.request(request)
end
raise response.body unless response.is_a?(Net::HTTPSuccess)
session = JSON.parse(response.body)`
},
{
id: 'java',
label: 'Java',
language: 'java',
code: `import java.net.URI;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
var body = """
{
"login": "user@example.com",
"password": "SecurePass123",
"fingerprint": "device-fp-uuid",
"deviceName": "Chrome на Windows",
"deviceType": "WEB"
}
""";
var request = HttpRequest.newBuilder()
.uri(URI.create("${API_BASE}/auth/login/password"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8))
.build();
var response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 400) {
throw new RuntimeException(response.body());
}`
},
{
id: 'csharp',
label: 'C#',
language: 'csharp',
code: `using System.Net.Http.Json;
using var http = new HttpClient();
var payload = new {
login = "user@example.com",
password = "SecurePass123",
fingerprint = Guid.NewGuid().ToString(),
deviceName = "Chrome на Windows",
deviceType = "WEB"
};
var response = await http.PostAsJsonAsync("${API_BASE}/auth/login/password", payload);
response.EnsureSuccessStatusCode();
var session = await response.Content.ReadFromJsonAsync<Dictionary<string, object>>();`
},
{
id: 'kotlin',
label: 'Kotlin',
language: 'kotlin',
code: `import io.ktor.client.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import kotlinx.serialization.Serializable
@Serializable
data class LoginRequest(
val login: String,
val password: String,
val fingerprint: String,
val deviceName: String,
val deviceType: String
)
suspend fun login(client: HttpClient): String {
val response = client.post("${API_BASE}/auth/login/password") {
contentType(ContentType.Application.Json)
setBody(LoginRequest(
login = "user@example.com",
password = "SecurePass123",
fingerprint = "device-fp-uuid",
deviceName = "Chrome на Windows",
deviceType = "WEB"
))
}
if (!response.status.isSuccess()) error(response.bodyAsText())
return response.bodyAsText()
}`
},
{
id: 'swift',
label: 'Swift',
language: 'swift',
code: `import Foundation
struct LoginRequest: Encodable {
let login: String
let password: String
let fingerprint: String
let deviceName: String
let deviceType: String
}
var request = URLRequest(url: URL(string: "${API_BASE}/auth/login/password")!)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode(LoginRequest(
login: "user@example.com",
password: "SecurePass123",
fingerprint: UUID().uuidString,
deviceName: "Chrome на Windows",
deviceType: "WEB"
))
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
throw URLError(.badServerResponse)
}`
},
{
id: 'dart',
label: 'Dart',
language: 'dart',
code: `import 'dart:convert';
import 'package:http/http.dart' as http;
Future<Map<String, dynamic>> login() async {
final response = await http.post(
Uri.parse('${API_BASE}/auth/login/password'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'login': 'user@example.com',
'password': 'SecurePass123',
'fingerprint': 'device-fp-uuid',
'deviceName': 'Chrome на Windows',
'deviceType': 'WEB',
}),
);
if (response.statusCode >= 400) {
throw Exception(response.body);
}
return jsonDecode(response.body) as Map<String, dynamic>;
}`
},
{
id: 'elixir',
label: 'Elixir',
language: 'elixir',
code: `payload = Jason.encode!(%{
login: "user@example.com",
password: "SecurePass123",
fingerprint: Ecto.UUID.generate(),
deviceName: "Chrome на Windows",
deviceType: "WEB"
})
{:ok, %{status: status, body: body}} =
Req.post("${API_BASE}/auth/login/password",
headers: [{"content-type", "application/json"}],
body: payload
)
if status >= 400, do: raise(body)
session = Jason.decode!(body)`
},
{
id: 'powershell',
label: 'PowerShell',
language: 'powershell',
code: [
'$body = @{',
" login = 'user@example.com'",
" password = 'SecurePass123'",
' fingerprint = [guid]::NewGuid().ToString()',
" deviceName = 'Chrome на Windows'",
" deviceType = 'WEB'",
'} | ConvertTo-Json',
'',
'$response = Invoke-RestMethod `',
' -Method Post `',
` -Uri '${API_BASE}/auth/login/password' \``,
" -ContentType 'application/json' `",
' -Body $body',
'',
'$response.accessToken'
].join('\n')
}
];
export const authLdapExamples: CodeExample[] = [
{
id: 'curl-ldap',
label: 'cURL',
language: 'bash',
code: `curl -X POST ${API_BASE}/auth/ldap/login \\
-H "Content-Type: application/json" \\
-d '{
"username": "ivan.petrov",
"password": "ldap-password",
"fingerprint": "device-fp-uuid",
"deviceName": "Chrome",
"deviceType": "WEB"
}'`
},
{
id: 'javascript-ldap',
label: 'JavaScript',
language: 'javascript',
code: `const response = await fetch('${API_BASE}/auth/ldap/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: 'ivan.petrov',
password: 'ldap-password',
fingerprint: crypto.randomUUID(),
deviceName: 'Chrome',
deviceType: 'WEB'
})
});
const session = await response.json();`
},
{
id: 'python-ldap',
label: 'Python',
language: 'python',
code: `import requests
session = requests.post('${API_BASE}/auth/ldap/login', json={
'username': 'ivan.petrov',
'password': 'ldap-password',
'fingerprint': 'device-fp-uuid',
'deviceName': 'Chrome',
'deviceType': 'WEB',
}, timeout=15).json()`
},
{
id: 'php-ldap',
label: 'PHP',
language: 'php',
code: `<?php
$ch = curl_init('${API_BASE}/auth/ldap/login');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode([
'username' => 'ivan.petrov',
'password' => 'ldap-password',
'fingerprint' => bin2hex(random_bytes(16)),
'deviceName' => 'Chrome',
'deviceType' => 'WEB',
]),
CURLOPT_RETURNTRANSFER => true,
]);
$session = json_decode(curl_exec($ch), true);
curl_close($ch);`
},
{
id: 'go-ldap',
label: 'Go',
language: 'go',
code: `body, _ := json.Marshal(map[string]string{
"username": "ivan.petrov", "password": "ldap-password",
"fingerprint": "device-fp-uuid", "deviceName": "Chrome", "deviceType": "WEB",
})
resp, _ := http.Post("${API_BASE}/auth/ldap/login", "application/json", bytes.NewReader(body))
defer resp.Body.Close()
json.NewDecoder(resp.Body).Decode(&session)`
},
{
id: 'rust-ldap',
label: 'Rust',
language: 'rust',
code: `let session = reqwest::Client::new()
.post("${API_BASE}/auth/ldap/login")
.json(&serde_json::json!({
"username": "ivan.petrov",
"password": "ldap-password",
"fingerprint": "device-fp-uuid",
"deviceName": "Chrome",
"deviceType": "WEB"
}))
.send().await?
.json::<serde_json::Value>().await?;`
}
];

View File

@@ -0,0 +1,6 @@
export interface CodeExample {
id: string;
label: string;
language: string;
code: string;
}

615
apps/docs/lib/docs-pages.ts Normal file
View File

@@ -0,0 +1,615 @@
export type DocBlock =
| { type: 'paragraph'; text: string }
| { type: 'list'; items: string[] }
| { type: 'code'; language: string; code: string; title?: string }
| { type: 'callout'; variant: 'info' | 'warning' | 'tip'; title: string; text: string }
| { type: 'table'; headers: string[]; rows: string[][] }
| { type: 'oauth-examples' }
| { type: 'auth-login-examples' }
| { type: 'auth-ldap-examples' }
| { type: 'api-reference' };
export interface DocSection {
id: string;
title: string;
blocks: DocBlock[];
}
export interface DocPage {
slug: string;
title: string;
description: string;
sections: DocSection[];
}
export const docPages: DocPage[] = [
{
slug: 'getting-started',
title: 'Начало работы',
description: 'Обзор экосистемы Identity Provider, порты сервисов и быстрый локальный запуск.',
sections: [
{
id: 'overview',
title: 'Обзор',
blocks: [
{
type: 'paragraph',
text: 'Lendry ID — enterprise Identity Provider в стиле Yandex ID: единый аккаунт, OAuth 2.0, LDAP/LDAPS, семейные группы, чат, PIN-блокировка сессий и админ-панель. Монорепозиторий состоит из NestJS-микросервисов, Go-сервисов и Next.js-приложений.'
},
{
type: 'callout',
variant: 'info',
title: 'Домен',
text: 'Production-домен: id.lendry.ru. Локально API доступен на порту 3000, frontend — 3002, документация — 3003.'
}
]
},
{
id: 'requirements',
title: 'Требования',
blocks: [
{
type: 'list',
items: [
'Node.js 20.19+ и npm 10+',
'Docker и Docker Compose',
'PostgreSQL 16, Redis 7, RabbitMQ 3.13, MinIO',
'Go 1.23+ (для локальной сборки media-ws и ldap-auth)'
]
}
]
},
{
id: 'quick-start',
title: 'Быстрый запуск',
blocks: [
{
type: 'code',
language: 'bash',
title: 'Локально через Docker',
code: `git clone <repository-url> lendry-id
cd lendry-id
docker compose up -d --build
# Проверка
curl http://localhost:3000/health
curl http://localhost:3003/docs/getting-started`
},
{
type: 'table',
headers: ['Сервис', 'Порт', 'Назначение'],
rows: [
['api-gateway', '3000', 'REST API, Swagger'],
['sso-core', '3001 / 50051', 'Бизнес-логика, gRPC'],
['frontend', '3002', 'UI входа и профиля'],
['docs', '3003', 'Документация'],
['media-ws', '8085', 'WebSocket уведомлений и чата'],
['ldap-auth', '8086', 'LDAP/LDAPS аутентификация'],
['MinIO', '9000 / 9001', 'Хранилище медиа'],
['RabbitMQ', '5672 / 15672', 'Очереди событий']
]
}
]
},
{
id: 'first-user',
title: 'Первый пользователь',
blocks: [
{
type: 'paragraph',
text: 'Первый зарегистрированный пользователь автоматически получает права супер-администратора (isSuperAdmin). Race condition защищена Redis-lock и Serializable-транзакцией PostgreSQL.'
},
{
type: 'code',
language: 'bash',
title: 'Регистрация через API',
code: `curl -X POST http://localhost:3000/auth/register \\
-H "Content-Type: application/json" \\
-d '{"displayName":"Администратор","email":"admin@example.com","password":"SecurePass123"}'`
}
]
}
]
},
{
slug: 'architecture',
title: 'Архитектура',
description: 'Схема микросервисов, потоки данных и технологический стек.',
sections: [
{
id: 'stack',
title: 'Технологический стек',
blocks: [
{
type: 'table',
headers: ['Компонент', 'Технологии'],
rows: [
['sso-core', 'NestJS, Prisma 7+, PostgreSQL, gRPC'],
['api-gateway', 'NestJS, REST, Swagger'],
['frontend / docs', 'Next.js App Router, React, Tailwind, shadcn/ui'],
['media-ws', 'Go, Gorilla WebSocket, Redis, RabbitMQ'],
['ldap-auth', 'Go, go-ldap'],
['Инфраструктура', 'Docker, Redis, RabbitMQ, MinIO']
]
}
]
},
{
id: 'flow',
title: 'Поток запросов',
blocks: [
{
type: 'paragraph',
text: 'Клиент (браузер или стороннее приложение) обращается к api-gateway по REST. Gateway проксирует вызовы в sso-core через gRPC. Медиафайлы загружаются в MinIO через presigned URL. Realtime-события (уведомления, чат) доставляются через media-ws по WebSocket с JWT-авторизацией.'
},
{
type: 'list',
items: [
'JWT access + refresh tokens для сессий',
'PIN-код: при включении сессия создаётся с pinVerified=false',
'SystemSetting — динамические бизнес-правила без хардкода во frontend',
'LinkedAccount — привязка LDAP, Google, Yandex и других провайдеров'
]
}
]
}
]
},
{
slug: 'deployment',
title: 'Развёртывание на сервере',
description: 'Пошаговый гайд по production-развёртыванию на Linux-сервере с Docker, Nginx и TLS.',
sections: [
{
id: 'server-requirements',
title: '1. Требования к серверу',
blocks: [
{
type: 'list',
items: [
'Ubuntu 22.04+ / Debian 12+ / аналогичный Linux',
'Минимум 4 GB RAM, 2 vCPU, 40 GB SSD',
'Домен с DNS A-записью (например id.lendry.ru)',
'Открытые порты 80 и 443 (остальное — только localhost / internal network)'
]
}
]
},
{
id: 'install-docker',
title: '2. Установка Docker',
blocks: [
{
type: 'code',
language: 'bash',
code: `sudo apt update && sudo apt install -y ca-certificates curl gnupg
curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker $USER
docker compose version`
}
]
},
{
id: 'clone-config',
title: '3. Клонирование и переменные окружения',
blocks: [
{
type: 'paragraph',
text: 'Создайте файл .env в корне проекта с production-секретами. Никогда не коммитьте его в git.'
},
{
type: 'code',
language: 'bash',
title: '.env (пример)',
code: `# Секреты JWT и шифрования — сгенерируйте: openssl rand -base64 48
JWT_ACCESS_SECRET=your-long-access-secret
JWT_REFRESH_SECRET=your-long-refresh-secret
DATA_ENCRYPTION_KEY=your-32-byte-encryption-key-change-me
# PostgreSQL (можно оставить docker internal)
POSTGRES_USER=lendry
POSTGRES_PASSWORD=strong-db-password
POSTGRES_DB=lendry_id
# RabbitMQ
RABBITMQ_DEFAULT_USER=lendry
RABBITMQ_DEFAULT_PASS=strong-rmq-password
# MinIO
MINIO_ROOT_USER=minioadmin
MINIO_ROOT_PASSWORD=strong-minio-password`
},
{
type: 'callout',
variant: 'warning',
title: 'PUBLIC_API_URL',
text: 'В production задайте PUBLIC_API_URL=https://id.lendry.ru для sso-core и api-gateway, а также MINIO_PUBLIC_ENDPOINT=id.lendry.ru (или CDN-домен для MinIO).'
}
]
},
{
id: 'docker-compose-prod',
title: '4. Production docker-compose',
blocks: [
{
type: 'paragraph',
text: 'Для production рекомендуется не публиковать PostgreSQL, Redis и RabbitMQ наружу — оставьте только api-gateway (3000), frontend (3002) и docs (3003) за reverse proxy.'
},
{
type: 'code',
language: 'bash',
code: `export DOCKER_BUILDKIT=1
docker compose up -d --build
# Проверка health всех сервисов
docker compose ps
docker compose logs -f sso-core api-gateway`
}
]
},
{
id: 'nginx',
title: '5. Nginx + TLS (Let\'s Encrypt)',
blocks: [
{
type: 'code',
language: 'nginx',
title: '/etc/nginx/sites-available/id.lendry.ru',
code: `server {
listen 443 ssl http2;
server_name id.lendry.ru;
ssl_certificate /etc/letsencrypt/live/id.lendry.ru/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/id.lendry.ru/privkey.pem;
# REST API + Swagger
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Frontend UI
location /app/ {
proxy_pass http://127.0.0.1:3002/;
proxy_set_header Host $host;
}
# WebSocket
location /ws {
proxy_pass http://127.0.0.1:8085;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
}
# Документация на docs.id.lendry.ru или /docs-proxy
server {
listen 443 ssl http2;
server_name docs.id.lendry.ru;
ssl_certificate /etc/letsencrypt/live/docs.id.lendry.ru/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/docs.id.lendry.ru/privkey.pem;
location / {
proxy_pass http://127.0.0.1:3003;
proxy_set_header Host $host;
}
}`
},
{
type: 'code',
language: 'bash',
code: `sudo certbot --nginx -d id.lendry.ru -d docs.id.lendry.ru
sudo nginx -t && sudo systemctl reload nginx`
}
]
},
{
id: 'env-services',
title: '6. Переменные окружения сервисов',
blocks: [
{
type: 'table',
headers: ['Сервис', 'Ключевые переменные'],
rows: [
['sso-core', 'DATABASE_URL, JWT_*_SECRET, DATA_ENCRYPTION_KEY, REDIS_URL, RABBITMQ_URL, MINIO_*, LDAP_AUTH_URL, PUBLIC_API_URL'],
['api-gateway', 'SSO_CORE_GRPC_URL, JWT_ACCESS_SECRET, PUBLIC_API_URL, MINIO_*'],
['frontend', 'NEXT_PUBLIC_API_URL, NEXT_PUBLIC_WS_URL'],
['docs', 'NEXT_PUBLIC_API_URL'],
['media-ws', 'REDIS_ADDR, RABBITMQ_URL, JWT_ACCESS_SECRET'],
['ldap-auth', 'PORT=8086']
]
}
]
},
{
id: 'migrations',
title: '7. Миграции и seed',
blocks: [
{
type: 'code',
language: 'bash',
code: `# Prisma migrate внутри контейнера sso-core
docker compose exec sso-core npx prisma migrate deploy
# Seed системных настроек (PROJECT_NAME, LDAP_*, PIN_*)
docker compose restart sso-core`
},
{
type: 'callout',
variant: 'tip',
title: 'SystemSetting',
text: 'Название проекта (PROJECT_NAME), tagline и LDAP-параметры настраиваются в админке: /admin/settings. Frontend и docs подтягивают PROJECT_NAME через GET /settings/public.'
}
]
},
{
id: 'backup',
title: '8. Резервное копирование',
blocks: [
{
type: 'list',
items: [
'PostgreSQL: pg_dump lendry_id ежедневно',
'MinIO: mc mirror или snapshot volume minio_data',
'Redis: RDB/AOF snapshots при необходимости сохранения сессий',
'Секреты .env — хранить в vault, не в репозитории'
]
},
{
type: 'code',
language: 'bash',
code: `docker compose exec postgres pg_dump -U lendry lendry_id > backup-$(date +%F).sql`
}
]
},
{
id: 'monitoring',
title: '9. Мониторинг и обновления',
blocks: [
{
type: 'list',
items: [
'Health: GET /health на api-gateway и sso-core',
'Логи: docker compose logs -f --tail=200',
'Обновление: git pull && docker compose up -d --build',
'Zero-downtime: blue-green или rolling update через orchestrator (K8s / Swarm)'
]
}
]
}
]
},
{
slug: 'authentication',
title: 'Аутентификация',
description: 'Способы входа: пароль, OTP, LDAP, PIN и refresh-сессии.',
sections: [
{
id: 'flows',
title: 'Сценарии входа',
blocks: [
{
type: 'table',
headers: ['Сценарий', 'Endpoint', 'Описание'],
rows: [
['Identifier-first', 'POST /auth/identify', 'Проверка существования пользователя и способа входа'],
['Passwordless OTP', 'POST /auth/otp/send + verify', '6-значный код на почту/телефон'],
['Пароль', 'POST /auth/login/password', 'Логин + пароль или tempAuthToken после OTP'],
['LDAP/LDAPS', 'POST /auth/ldap/login', 'Корпоративный вход (требует LDAP_ENABLED)'],
['PIN', 'POST /auth/pin/verify', 'Разблокировка сессии после входа с PIN']
]
}
]
},
{
id: 'login-example',
title: 'Примеры входа по паролю',
blocks: [
{
type: 'paragraph',
text: 'Endpoint POST /auth/login/password принимает login (почта, телефон или username), password и метаданные устройства. Ниже — готовые примеры на разных языках.'
},
{ type: 'auth-login-examples' },
{
type: 'callout',
variant: 'info',
title: 'PIN-блокировка',
text: 'Если у пользователя включён PIN, ответ содержит requiresPin=true и ограниченную сессию. Полный JWT выдаётся после POST /auth/pin/verify.'
}
]
}
]
},
{
slug: 'oauth',
title: 'OAuth 2.0',
description: 'Регистрация приложения, Authorization Code Flow, scopes и примеры на разных языках.',
sections: [
{
id: 'register-app',
title: 'Регистрация OAuth-приложения',
blocks: [
{
type: 'list',
items: [
'Войдите как супер-администратор в админ-панель',
'Перейдите в RBAC → OAuth приложения',
'Создайте клиент: name, redirectUris, scopes (openid, profile, email)',
'Confidential-клиент получит client_secret один раз — сохраните его'
]
}
]
},
{
id: 'flow',
title: 'Authorization Code Flow',
blocks: [
{
type: 'paragraph',
text: '1) Пользователь авторизуется в Lendry ID. 2) Ваше приложение перенаправляет на GET /oauth/authorize с clientId, redirectUri, scope, userId. 3) IdP возвращает redirectUrl с authorization code. 4) Backend обменивает code на токены через POST /oauth/token. 5) GET /oauth/userinfo возвращает профиль.'
},
{
type: 'callout',
variant: 'warning',
title: 'Безопасность',
text: 'client_secret никогда не храните во frontend. Обмен code → token выполняйте только на сервере.'
}
]
},
{
id: 'examples',
title: 'Примеры интеграции',
blocks: [{ type: 'oauth-examples' }]
},
{
id: 'scopes',
title: 'Scopes',
blocks: [
{
type: 'table',
headers: ['Scope', 'Доступ'],
rows: [
['openid', 'Идентификатор пользователя (sub)'],
['profile', 'displayName, avatar, bio'],
['email', 'Основная и резервная почта'],
['phone', 'Телефоны пользователя']
]
}
]
}
]
},
{
slug: 'ldap',
title: 'LDAP / LDAPS',
description: 'Корпоративный вход через Active Directory.',
sections: [
{
id: 'setup',
title: 'Настройка в админке',
blocks: [
{
type: 'table',
headers: ['Ключ SystemSetting', 'Описание'],
rows: [
['LDAP_ENABLED', 'Включить кнопку «Войти через LDAP»'],
['LDAP_USE_LDAPS', 'LDAPS на порту 636'],
['LDAP_HOST', 'DC-1.domain.local,DC-2.domain.local'],
['LDAP_BIND_USERNAME', 'Логин сервисной УЗ (mvkadmin)'],
['LDAP_BASE_DN', 'OU=Users,DC=domain,DC=local'],
['LDAP_BIND_PASSWORD', 'Пароль сервисной УЗ']
]
}
]
},
{
id: 'login',
title: 'Примеры LDAP-входа',
blocks: [
{
type: 'paragraph',
text: 'Endpoint POST /auth/ldap/login доступен при включённой настройке LDAP_ENABLED.'
},
{ type: 'auth-ldap-examples' }
]
}
]
},
{
slug: 'sessions',
title: 'Сессии и PIN',
description: 'Управление устройствами, PIN-блокировка и отзыв сессий.',
sections: [
{
id: 'pin',
title: 'PIN-код',
blocks: [
{
type: 'paragraph',
text: 'PIN хранится как bcrypt hash. Таймаут блокировки читается из SystemSetting PIN_LOCK_TIMEOUT_MINUTES — значение не захардкожено во frontend.'
}
]
},
{
id: 'devices',
title: 'Устройства и сессии',
blocks: [
{
type: 'list',
items: [
'GET /security/users/{userId}/devices — список устройств',
'POST /security/users/{userId}/sessions/{sessionId}/revoke — выход с устройства',
'POST /security/users/{userId}/revoke-all-sessions — выход везде'
]
}
]
}
]
},
{
slug: 'family-chat',
title: 'Семья и чат',
description: 'Семейные группы, приглашения, чат и realtime через WebSocket.',
sections: [
{
id: 'family',
title: 'Семейные группы',
blocks: [
{
type: 'paragraph',
text: 'Пользователь создаёт семью, приглашает участников по email/телефону/логину. Лимиты (max family members) берутся из SystemSetting.'
}
]
},
{
id: 'chat',
title: 'Чат и WebSocket',
blocks: [
{
type: 'list',
items: [
'REST: /chat/groups/{groupId}/rooms, /chat/rooms/{roomId}/messages',
'WebSocket: ws://localhost:8085/ws с JWT в query или заголовке',
'Медиа чата: presigned upload + защищённый stream с Authorization'
]
}
]
}
]
},
{
slug: 'api-reference',
title: 'Справочник API',
description: 'Полный список REST endpoints Lendry ID API. Swagger: /api на api-gateway.',
sections: [
{
id: 'endpoints',
title: 'Endpoints',
blocks: [{ type: 'api-reference' }]
},
{
id: 'swagger',
title: 'OpenAPI / Swagger',
blocks: [
{
type: 'paragraph',
text: 'Интерактивная документация доступна по адресу http://localhost:3000/api (Swagger UI). Все DTO и схемы генерируются автоматически из NestJS decorators.'
}
]
}
]
}
];
export function getDocPage(slug: string): DocPage | undefined {
return docPages.find((page) => page.slug === slug);
}
export function getAllDocSlugs() {
return docPages.map((page) => page.slug);
}

View File

@@ -0,0 +1,31 @@
export interface DocNavItem {
slug: string;
title: string;
group: string;
}
export const docNavigation: DocNavItem[] = [
{ slug: 'getting-started', title: 'Начало работы', group: 'Введение' },
{ slug: 'architecture', title: 'Архитектура', group: 'Введение' },
{ slug: 'deployment', title: 'Развёртывание на сервере', group: 'Введение' },
{ slug: 'authentication', title: 'Аутентификация', group: 'Интеграция' },
{ slug: 'oauth', title: 'OAuth 2.0', group: 'Интеграция' },
{ slug: 'ldap', title: 'LDAP / LDAPS', group: 'Интеграция' },
{ slug: 'sessions', title: 'Сессии и PIN', group: 'Безопасность' },
{ slug: 'family-chat', title: 'Семья и чат', group: 'Функции' },
{ slug: 'api-reference', title: 'Справочник API', group: 'Справочник' }
];
export function getDocNavItem(slug: string) {
return docNavigation.find((item) => item.slug === slug);
}
export function groupDocNavigation(items: DocNavItem[]) {
const groups = new Map<string, DocNavItem[]>();
for (const item of items) {
const list = groups.get(item.group) ?? [];
list.push(item);
groups.set(item.group, list);
}
return Array.from(groups.entries());
}

View File

@@ -0,0 +1,273 @@
export interface OAuthExample {
id: string;
label: string;
language: string;
code: string;
}
const API_BASE = 'https://id.lendry.ru';
export const oauthExamples: OAuthExample[] = [
{
id: 'javascript',
label: 'JavaScript',
language: 'javascript',
code: `// Authorization Code Flow (Node.js / браузер)
const clientId = 'YOUR_CLIENT_ID';
const redirectUri = 'https://app.example.com/oauth/callback';
const scope = 'openid profile email';
const state = crypto.randomUUID();
// Шаг 1: перенаправить пользователя на IdP
const authorizeUrl = new URL('${API_BASE}/oauth/authorize');
authorizeUrl.searchParams.set('userId', 'USER_ID_AFTER_LOGIN');
authorizeUrl.searchParams.set('clientId', clientId);
authorizeUrl.searchParams.set('redirectUri', redirectUri);
authorizeUrl.searchParams.set('scope', scope);
authorizeUrl.searchParams.set('state', state);
window.location.href = authorizeUrl.toString();
// Шаг 2: обменять code на токены (на backend!)
const tokenResponse = await fetch('${API_BASE}/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grantType: 'authorization_code',
code: 'AUTHORIZATION_CODE',
clientId,
clientSecret: 'YOUR_CLIENT_SECRET',
redirectUri
})
});
const tokens = await tokenResponse.json();
// Шаг 3: получить профиль
const profile = await fetch('${API_BASE}/oauth/userinfo', {
headers: { Authorization: \`Bearer \${tokens.accessToken}\` }
}).then((r) => r.json());`
},
{
id: 'typescript-next',
label: 'Next.js',
language: 'typescript',
code: `// app/api/oauth/callback/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
const code = request.nextUrl.searchParams.get('code');
const state = request.nextUrl.searchParams.get('state');
if (!code) return NextResponse.redirect('/login?error=oauth');
const tokenRes = await fetch('${API_BASE}/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grantType: 'authorization_code',
code,
clientId: process.env.OAUTH_CLIENT_ID,
clientSecret: process.env.OAUTH_CLIENT_SECRET,
redirectUri: process.env.OAUTH_REDIRECT_URI
})
});
const tokens = await tokenRes.json();
const response = NextResponse.redirect('/dashboard');
response.cookies.set('access_token', tokens.accessToken, { httpOnly: true, secure: true });
return response;
}`
},
{
id: 'python',
label: 'Python',
language: 'python',
code: `import requests
from urllib.parse import urlencode
API_BASE = '${API_BASE}'
CLIENT_ID = 'YOUR_CLIENT_ID'
CLIENT_SECRET = 'YOUR_CLIENT_SECRET'
REDIRECT_URI = 'https://app.example.com/oauth/callback'
# Ссылка для входа пользователя
params = urlencode({
'userId': 'USER_ID',
'clientId': CLIENT_ID,
'redirectUri': REDIRECT_URI,
'scope': 'openid profile email',
'state': 'random-state'
})
authorize_url = f'{API_BASE}/oauth/authorize?{params}'
# Обмен authorization code на токены
token_response = requests.post(f'{API_BASE}/oauth/token', json={
'grantType': 'authorization_code',
'code': 'AUTHORIZATION_CODE',
'clientId': CLIENT_ID,
'clientSecret': CLIENT_SECRET,
'redirectUri': REDIRECT_URI
}, timeout=15)
tokens = token_response.json()
# UserInfo
profile = requests.get(
f'{API_BASE}/oauth/userinfo',
headers={'Authorization': f"Bearer {tokens['accessToken']}"},
timeout=15
).json()`
},
{
id: 'php',
label: 'PHP',
language: 'php',
code: `<?php
$apiBase = '${API_BASE}';
$clientId = getenv('OAUTH_CLIENT_ID');
$clientSecret = getenv('OAUTH_CLIENT_SECRET');
$redirectUri = 'https://app.example.com/oauth/callback';
// Redirect пользователя
$params = http_build_query([
'userId' => 'USER_ID',
'clientId' => $clientId,
'redirectUri' => $redirectUri,
'scope' => 'openid profile email',
'state' => bin2hex(random_bytes(16)),
]);
header('Location: ' . $apiBase . '/oauth/authorize?' . $params);
exit;
// Callback: обмен code -> token
$payload = json_encode([
'grantType' => 'authorization_code',
'code' => $_GET['code'],
'clientId' => $clientId,
'clientSecret' => $clientSecret,
'redirectUri' => $redirectUri,
]);
$ch = curl_init($apiBase . '/oauth/token');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_POSTFIELDS => $payload,
CURLOPT_RETURNTRANSFER => true,
]);
$tokens = json_decode(curl_exec($ch), true);
curl_close($ch);
// UserInfo
$ch = curl_init($apiBase . '/oauth/userinfo');
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $tokens['accessToken']],
CURLOPT_RETURNTRANSFER => true,
]);
$profile = json_decode(curl_exec($ch), true);
curl_close($ch);`
},
{
id: 'go',
label: 'Go',
language: 'go',
code: `package main
import (
"bytes"
"encoding/json"
"net/http"
"net/url"
)
const apiBase = "${API_BASE}"
func buildAuthorizeURL(userID, clientID, redirectURI, scope, state string) string {
q := url.Values{}
q.Set("userId", userID)
q.Set("clientId", clientID)
q.Set("redirectUri", redirectURI)
q.Set("scope", scope)
q.Set("state", state)
return apiBase + "/oauth/authorize?" + q.Encode()
}
func exchangeCode(code, clientID, clientSecret, redirectURI string) (map[string]any, error) {
body, _ := json.Marshal(map[string]string{
"grantType": "authorization_code",
"code": code,
"clientId": clientID,
"clientSecret": clientSecret,
"redirectUri": redirectURI,
})
resp, err := http.Post(apiBase+"/oauth/token", "application/json", bytes.NewReader(body))
if err != nil {
return nil, err
}
defer resp.Body.Close()
var tokens map[string]any
return tokens, json.NewDecoder(resp.Body).Decode(&tokens)
}`
},
{
id: 'csharp',
label: 'C#',
language: 'csharp',
code: `using System.Net.Http.Json;
var apiBase = "${API_BASE}";
var clientId = Environment.GetEnvironmentVariable("OAUTH_CLIENT_ID");
var clientSecret = Environment.GetEnvironmentVariable("OAUTH_CLIENT_SECRET");
var redirectUri = "https://app.example.com/oauth/callback";
// Authorization URL
var authorizeUrl =
$"{apiBase}/oauth/authorize?userId=USER_ID&clientId={clientId}" +
$"&redirectUri={Uri.EscapeDataString(redirectUri)}&scope=openid profile email&state=xyz";
using var http = new HttpClient();
// Token exchange
var tokenResponse = await http.PostAsJsonAsync($"{apiBase}/oauth/token", new {
grantType = "authorization_code",
code = "AUTHORIZATION_CODE",
clientId,
clientSecret,
redirectUri
});
var tokens = await tokenResponse.Content.ReadFromJsonAsync<Dictionary<string, object>>();
// UserInfo
http.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", tokens!["accessToken"].ToString());
var profile = await http.GetFromJsonAsync<object>($"{apiBase}/oauth/userinfo");`
},
{
id: 'curl',
label: 'cURL',
language: 'bash',
code: `# Authorization (браузер пользователя)
open "${API_BASE}/oauth/authorize?userId=USER_ID&clientId=CLIENT_ID&redirectUri=https%3A%2F%2Fapp.example.com%2Fcallback&scope=openid%20profile%20email&state=xyz"
# Обмен code на токены
curl -X POST ${API_BASE}/oauth/token \\
-H "Content-Type: application/json" \\
-d '{
"grantType": "authorization_code",
"code": "AUTHORIZATION_CODE",
"clientId": "CLIENT_ID",
"clientSecret": "CLIENT_SECRET",
"redirectUri": "https://app.example.com/callback"
}'
# UserInfo
curl ${API_BASE}/oauth/userinfo \\
-H "Authorization: Bearer ACCESS_TOKEN"
# Refresh token
curl -X POST ${API_BASE}/oauth/token \\
-H "Content-Type: application/json" \\
-d '{
"grantType": "refresh_token",
"refreshToken": "REFRESH_TOKEN",
"clientId": "CLIENT_ID"
}'`
}
];

View File

@@ -0,0 +1,32 @@
const STORAGE_KEY = 'docs-public-settings';
export function readCachedPublicSettings(): Record<string, string> {
if (typeof window === 'undefined') {
return {};
}
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) {
return {};
}
const parsed = JSON.parse(raw) as Record<string, string>;
return parsed && typeof parsed === 'object' ? parsed : {};
} catch {
return {};
}
}
export function writeCachedPublicSettings(settings: Record<string, string>) {
if (typeof window === 'undefined') {
return;
}
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
} catch {
// ignore quota errors
}
}
export function mergePublicSettings(...sources: Array<Record<string, string>>): Record<string, string> {
return Object.assign({}, ...sources);
}

6
apps/docs/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

6
apps/docs/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

5
apps/docs/next.config.ts Normal file
View File

@@ -0,0 +1,5 @@
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {};
export default nextConfig;

31
apps/docs/package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "@lendry/docs",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "tsc --noEmit"
},
"dependencies": {
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.561.0",
"next": "^16.0.10",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"tailwind-merge": "^3.4.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.18",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3"
}
}

View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
'@tailwindcss/postcss': {}
}
};
export default config;

View File

@@ -0,0 +1,80 @@
'use client';
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { fetchPublicSettingsClient, getDefaultProjectName, getDefaultProjectTagline } from '@/lib/api';
import { mergePublicSettings, readCachedPublicSettings, writeCachedPublicSettings } from '@/lib/public-settings-cache';
interface PublicSettingsContextValue {
projectName: string;
projectTagline: string;
isLoading: boolean;
refreshPublicSettings: () => Promise<void>;
}
const PublicSettingsContext = createContext<PublicSettingsContextValue>({
projectName: getDefaultProjectName(),
projectTagline: getDefaultProjectTagline(),
isLoading: true,
refreshPublicSettings: async () => undefined
});
export function PublicSettingsProvider({
children,
initialSettings
}: {
children: React.ReactNode;
initialSettings: Record<string, string>;
}) {
const [settings, setSettings] = useState<Record<string, string>>(() =>
mergePublicSettings(readCachedPublicSettings(), initialSettings)
);
const [isLoading, setIsLoading] = useState(true);
const refreshPublicSettings = useCallback(async () => {
try {
const next = await fetchPublicSettingsClient();
setSettings((current) => mergePublicSettings(current, next));
writeCachedPublicSettings(next);
} catch {
// Оставляем последние известные значения (кэш или SSR).
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
const cached = readCachedPublicSettings();
if (Object.keys(cached).length > 0) {
setSettings((current) => mergePublicSettings(cached, initialSettings, current));
}
}, [initialSettings]);
useEffect(() => {
void refreshPublicSettings();
const onFocus = () => void refreshPublicSettings();
window.addEventListener('focus', onFocus);
return () => window.removeEventListener('focus', onFocus);
}, [refreshPublicSettings]);
useEffect(() => {
if (settings.PROJECT_NAME) {
document.title = `Документация — ${settings.PROJECT_NAME}`;
}
}, [settings.PROJECT_NAME]);
const value = useMemo(
() => ({
projectName: settings.PROJECT_NAME || getDefaultProjectName(),
projectTagline: settings.PROJECT_TAGLINE || getDefaultProjectTagline(),
isLoading,
refreshPublicSettings
}),
[isLoading, refreshPublicSettings, settings.PROJECT_NAME, settings.PROJECT_TAGLINE]
);
return <PublicSettingsContext.Provider value={value}>{children}</PublicSettingsContext.Provider>;
}
export function usePublicSettings() {
return useContext(PublicSettingsContext);
}

View File

@@ -0,0 +1,51 @@
'use client';
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
type Theme = 'light' | 'dark' | 'system';
interface ThemeContextValue {
theme: Theme;
resolvedTheme: 'light' | 'dark';
setTheme: (theme: Theme) => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setThemeState] = useState<Theme>('system');
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>('light');
useEffect(() => {
const stored = localStorage.getItem('docs-theme') as Theme | null;
if (stored) setThemeState(stored);
}, []);
useEffect(() => {
const root = document.documentElement;
const media = window.matchMedia('(prefers-color-scheme: dark)');
const apply = () => {
const next = theme === 'system' ? (media.matches ? 'dark' : 'light') : theme;
setResolvedTheme(next);
root.classList.toggle('dark', next === 'dark');
};
apply();
media.addEventListener('change', apply);
return () => media.removeEventListener('change', apply);
}, [theme]);
const setTheme = (next: Theme) => {
setThemeState(next);
localStorage.setItem('docs-theme', next);
};
const value = useMemo(() => ({ theme, resolvedTheme, setTheme }), [theme, resolvedTheme]);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
return ctx;
}

20
apps/docs/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"module": "esnext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"noEmit": true,
"isolatedModules": true,
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
},
"plugins": [{ "name": "next" }],
"incremental": true
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}