first commit
This commit is contained in:
116
apps/docs/lib/api-endpoints.ts
Normal file
116
apps/docs/lib/api-endpoints.ts
Normal 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
59
apps/docs/lib/api.ts
Normal 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';
|
||||
}
|
||||
494
apps/docs/lib/auth-examples.ts
Normal file
494
apps/docs/lib/auth-examples.ts
Normal 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?;`
|
||||
}
|
||||
];
|
||||
6
apps/docs/lib/code-example.ts
Normal file
6
apps/docs/lib/code-example.ts
Normal 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
615
apps/docs/lib/docs-pages.ts
Normal 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);
|
||||
}
|
||||
31
apps/docs/lib/navigation.ts
Normal file
31
apps/docs/lib/navigation.ts
Normal 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());
|
||||
}
|
||||
273
apps/docs/lib/oauth-examples.ts
Normal file
273
apps/docs/lib/oauth-examples.ts
Normal 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"
|
||||
}'`
|
||||
}
|
||||
];
|
||||
32
apps/docs/lib/public-settings-cache.ts
Normal file
32
apps/docs/lib/public-settings-cache.ts
Normal 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
6
apps/docs/lib/utils.ts
Normal 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));
|
||||
}
|
||||
Reference in New Issue
Block a user