Compare commits
161 Commits
3aba59ef84
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fc3ca7952 | ||
|
|
f00f3d411d | ||
|
|
a0966e7ba2 | ||
|
|
a4b4577c55 | ||
|
|
caf12e64f7 | ||
|
|
e152442440 | ||
|
|
f36a8d7456 | ||
|
|
b90017aad0 | ||
|
|
9a0cf54aa6 | ||
|
|
ca2e30af04 | ||
|
|
57925fb2c4 | ||
|
|
1bd95fa99e | ||
|
|
911e76f232 | ||
|
|
f1821c2edc | ||
|
|
12f46f572d | ||
|
|
bd6cd0d798 | ||
|
|
29306eb2ec | ||
|
|
881e5d764b | ||
|
|
cddb29fef6 | ||
|
|
9ca5071f1a | ||
|
|
28b04ada81 | ||
|
|
adbd32fea0 | ||
|
|
ef7f0c5380 | ||
|
|
2f76b28339 | ||
|
|
4306d0ce37 | ||
|
|
bcdfbc3861 | ||
|
|
2b88e028c6 | ||
|
|
cb82544905 | ||
|
|
2a488f2ab6 | ||
|
|
7e54cec361 | ||
|
|
f423f512f8 | ||
|
|
322f8d6552 | ||
|
|
06f1481787 | ||
|
|
0c3c6d6d82 | ||
|
|
607397fcf3 | ||
|
|
ee8aaf9889 | ||
|
|
f7c01a3963 | ||
|
|
de4310239c | ||
|
|
7d344fb82e | ||
|
|
0c9b8e2629 | ||
|
|
55deb5c152 | ||
|
|
6929fb41fc | ||
|
|
dce16af5a5 | ||
|
|
f8f25c8289 | ||
|
|
2c4b1fcc44 | ||
|
|
7f5bb9838b | ||
|
|
115fc140af | ||
|
|
a0c1722a8d | ||
|
|
deb213bd77 | ||
|
|
4b2ade9354 | ||
|
|
6ee3ffe0a5 | ||
|
|
d41c9d1121 | ||
|
|
2ea790d21d | ||
|
|
521de7ea00 | ||
|
|
ac9f405f43 | ||
|
|
209036c036 | ||
|
|
31251be877 | ||
|
|
2eeb928a72 | ||
|
|
69063c8fba | ||
|
|
879875508f | ||
|
|
2a88c87e94 | ||
|
|
6a4bbd05b8 | ||
|
|
0b6e00205a | ||
|
|
46adf60ab8 | ||
|
|
df4bbba133 | ||
|
|
c082b087c5 | ||
|
|
7f10b18336 | ||
|
|
a76997986a | ||
|
|
4e98f6bfab | ||
|
|
250976ca08 | ||
|
|
f1d6a5167f | ||
|
|
40057b64c8 | ||
|
|
40d388e0ed | ||
|
|
0d43f9943f | ||
|
|
57cb58347b | ||
|
|
885b07d76b | ||
|
|
4cd75cb0b1 | ||
|
|
8369abb023 | ||
|
|
7233e8b70a | ||
|
|
69e6fced48 | ||
|
|
8bbaf8b343 | ||
|
|
115dc4e829 | ||
|
|
d312e76abb | ||
|
|
3a1bfb0903 | ||
|
|
5a220917dc | ||
|
|
71dfeda873 | ||
|
|
2701c0e90b | ||
|
|
e3c418d921 | ||
|
|
3fd5509186 | ||
|
|
6a7f1c5edf | ||
|
|
10253fc76b | ||
|
|
aebce54bd7 | ||
|
|
e127df3d6d | ||
|
|
01e4917acf | ||
|
|
0df7240dc8 | ||
|
|
75ccbe5fc4 | ||
|
|
923a028cdd | ||
|
|
ee28a7b1db | ||
|
|
4b86c64cc4 | ||
|
|
1a30e7e21c | ||
|
|
886b7e9ade | ||
|
|
f1bba24faa | ||
|
|
73c292b3a5 | ||
|
|
0a020d6857 | ||
|
|
fd574b4972 | ||
|
|
ea204a4d38 | ||
|
|
07df6eacf1 | ||
|
|
8805ec327f | ||
|
|
65abf17421 | ||
|
|
95ef9a9862 | ||
|
|
3a5281cc58 | ||
|
|
4e853f8041 | ||
|
|
c23f35e732 | ||
|
|
3ab48d8537 | ||
|
|
f1068edc89 | ||
|
|
dd36818f80 | ||
|
|
ce8a326602 | ||
|
|
5385563f6e | ||
|
|
fcca318ea0 | ||
|
|
06d33b89d9 | ||
|
|
a8ad32c837 | ||
|
|
ef5262ac4a | ||
|
|
bede54cde6 | ||
|
|
3b05b7e4d4 | ||
|
|
aa228d84eb | ||
|
|
d3ea470d02 | ||
|
|
7ed7cbdd16 | ||
|
|
489b4d4a23 | ||
|
|
b81c0cedbb | ||
|
|
d5e6b58955 | ||
|
|
ead3155ad8 | ||
|
|
dd4323ba51 | ||
|
|
c3b2eb4a50 | ||
|
|
971d10abf6 | ||
|
|
a15be4365c | ||
|
|
838a5ad923 | ||
|
|
72d6dcc145 | ||
|
|
0f1b360684 | ||
|
|
4e78a81eb1 | ||
|
|
b0ea87e898 | ||
|
|
3880c68d59 | ||
|
|
ce58e6f4c1 | ||
|
|
f2108c7bdd | ||
|
|
1c55c871fc | ||
|
|
9671fe458b | ||
|
|
6c63343fc7 | ||
|
|
1796008a28 | ||
|
|
c3e06e03cf | ||
|
|
d8f97ee232 | ||
|
|
933f7fb9e1 | ||
|
|
71b270fcb3 | ||
|
|
f2366a69a0 | ||
|
|
9727cf3f35 | ||
|
|
dcab6557d3 | ||
|
|
21f2a1c227 | ||
|
|
b6987f4aea | ||
|
|
e60d55f6bd | ||
|
|
d16eccb4c2 | ||
|
|
36f30039ad | ||
|
|
34c1d8446a | ||
|
|
ecd95c5698 |
@@ -10,3 +10,11 @@ apps/sso-core/.env
|
|||||||
coverage
|
coverage
|
||||||
*.log
|
*.log
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
**/*.apk
|
||||||
|
tauri_app/src-tauri/gen
|
||||||
|
tauri_app/src-tauri/target
|
||||||
|
tauri_app/docker/agp-resolver/.gradle
|
||||||
|
tauri_app/docker/agp-resolver/build
|
||||||
|
tauri_app/docker/offline-maven/.agp-offline-complete
|
||||||
|
tauri_app/docker/offline-maven/.android-deps-offline-complete
|
||||||
|
tauri_app/docker/offline-gradle/*.zip
|
||||||
|
|||||||
41
.env.example
41
.env.example
@@ -4,26 +4,47 @@
|
|||||||
# Режим: local | intranet | production
|
# Режим: local | intranet | production
|
||||||
INSTALL_MODE=local
|
INSTALL_MODE=local
|
||||||
|
|
||||||
# Nginx: auto (Windows → Docker) | host (Linux) | docker
|
# Nginx: docker (контейнер lendry-id-nginx на 80/443) | host (только с --nginx-mode host)
|
||||||
NGINX_MODE=auto
|
NGINX_MODE=docker
|
||||||
|
|
||||||
# SSL: none (HTTP) | selfsigned (HTTPS локально) | letsencrypt (интернет)
|
# Порты Nginx-контейнера (если 80/443 заняты другим процессом — install.sh предложит 8080/8443)
|
||||||
|
NGINX_HTTP_PORT=80
|
||||||
|
NGINX_HTTPS_PORT=443
|
||||||
|
|
||||||
|
# SSL: none (HTTP) | selfsigned (HTTPS локально) | custom (свои файлы) | letsencrypt (интернет)
|
||||||
SSL_TYPE=none
|
SSL_TYPE=none
|
||||||
|
|
||||||
# Домены без протокола (install.sh подставит PUBLIC_* URL)
|
# Папка со своими сертификатами (для SSL_TYPE=custom); install.sh копирует файлы в nginx/certs/custom/
|
||||||
DOMAIN_API=
|
SSL_CERT_DIR=
|
||||||
DOMAIN_FRONTEND=
|
SSL_CERT_FULLCHAIN=
|
||||||
|
SSL_CERT_KEY=
|
||||||
|
|
||||||
|
# Доп. DNS-имена в self-signed сертификат (опционально, поверх авто из OAuth redirect_uri)
|
||||||
|
# EXTRA_SSL_SANS=legacy-app.lan
|
||||||
|
|
||||||
|
DOMAIN_API=api.idpmvk.lpr
|
||||||
|
DOMAIN_FRONTEND=sso.idpmvk.lpr
|
||||||
DOMAIN_DOCS=
|
DOMAIN_DOCS=
|
||||||
# Пусто = WebSocket на DOMAIN_API по пути /ws
|
# Пусто = WebSocket на DOMAIN_API по пути /ws
|
||||||
DOMAIN_WS=
|
DOMAIN_WS=
|
||||||
DOMAIN_MINIO=
|
DOMAIN_MINIO=
|
||||||
DOMAIN_MINIO_CONSOLE=
|
DOMAIN_MINIO_CONSOLE=
|
||||||
|
|
||||||
# Публичные URL (генерируются install.sh)
|
# Публичные URL (генерируются install.sh; split-domain: API и SSO отдельно)
|
||||||
PUBLIC_API_URL=http://localhost:3000
|
PUBLIC_API_URL=https://api.idpmvk.lpr
|
||||||
PUBLIC_FRONTEND_URL=http://localhost:3002
|
PUBLIC_FRONTEND_URL=https://sso.idpmvk.lpr
|
||||||
PUBLIC_DOCS_URL=http://localhost:3003
|
PUBLIC_DOCS_URL=http://localhost:3003
|
||||||
PUBLIC_WS_URL=ws://localhost:8085/ws
|
PUBLIC_WS_URL=ws://localhost:8085/ws
|
||||||
|
# Docker: http://api-gateway:3000 | локальный npm run dev: http://localhost:3000
|
||||||
|
INTERNAL_API_URL=http://api-gateway:3000
|
||||||
|
INTERNAL_WS_URL=http://media-ws:8085
|
||||||
|
|
||||||
|
# LDAP в Docker: host network (рекомендуется для AD) или корпоративный DNS
|
||||||
|
LDAP_USE_HOST_NETWORK=true
|
||||||
|
# IP контроллера домена (обязательно для AD, если имя не резолвится):
|
||||||
|
# LDAP_EXTRA_HOSTS=DC-1.mvkug.local:192.168.1.10
|
||||||
|
# LDAP_DNS_SERVERS=192.168.1.10
|
||||||
|
# LDAP_DNS_SEARCH=mvkug.local
|
||||||
|
|
||||||
# Nginx (USE_NGINX_SSL=true только при SSL_TYPE=letsencrypt|selfsigned)
|
# Nginx (USE_NGINX_SSL=true только при SSL_TYPE=letsencrypt|selfsigned)
|
||||||
USE_NGINX_SSL=false
|
USE_NGINX_SSL=false
|
||||||
@@ -53,4 +74,6 @@ DATA_ENCRYPTION_KEY=change-me-data-encryption-key-32-chars-min
|
|||||||
# NPM registry (опционально)
|
# NPM registry (опционально)
|
||||||
NPM_REGISTRY=https://registry.npmjs.org
|
NPM_REGISTRY=https://registry.npmjs.org
|
||||||
|
|
||||||
|
# Android APK (tauri_app): keystore создаётся автоматически в tauri_app/.secrets/ при ./install.sh --build-apk
|
||||||
|
|
||||||
COMPOSE_PROJECT_NAME=lendry-id
|
COMPOSE_PROJECT_NAME=lendry-id
|
||||||
|
|||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -8,6 +8,7 @@ dist
|
|||||||
!.env.example
|
!.env.example
|
||||||
docker-compose.override.yml
|
docker-compose.override.yml
|
||||||
.idp-install.json
|
.idp-install.json
|
||||||
|
.idp-infra.secrets
|
||||||
nginx/certs/*
|
nginx/certs/*
|
||||||
!nginx/certs/.gitkeep
|
!nginx/certs/.gitkeep
|
||||||
nginx/conf.d/*.conf
|
nginx/conf.d/*.conf
|
||||||
@@ -15,3 +16,8 @@ coverage
|
|||||||
generated
|
generated
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
apps/media-ws/media-ws.exe
|
apps/media-ws/media-ws.exe
|
||||||
|
|
||||||
|
tauri_app/docker/agp-resolver/.gradle/
|
||||||
|
tauri_app/docker/agp-resolver/build/
|
||||||
|
tauri_app/docker/offline-gradle/*.zip
|
||||||
|
tauri_app/.secrets/
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
# syntax=docker/dockerfile:1.4
|
|
||||||
|
|
||||||
FROM node:24-alpine
|
FROM node:24-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -20,11 +20,13 @@
|
|||||||
"@nestjs/swagger": "^11.2.3",
|
"@nestjs/swagger": "^11.2.3",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.3",
|
"class-validator": "^0.14.3",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.2"
|
"rxjs": "^7.8.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^11.0.14",
|
"@nestjs/cli": "^11.0.14",
|
||||||
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,13 +14,21 @@ import { ProfileController } from './controllers/profile.controller';
|
|||||||
import { DocumentsController } from './controllers/documents.controller';
|
import { DocumentsController } from './controllers/documents.controller';
|
||||||
import { AddressesController } from './controllers/addresses.controller';
|
import { AddressesController } from './controllers/addresses.controller';
|
||||||
import { OAuthController } from './controllers/oauth.controller';
|
import { OAuthController } from './controllers/oauth.controller';
|
||||||
|
import { FedcmController } from './controllers/fedcm.controller';
|
||||||
|
import { WellKnownController, OAuthAuthorizeDiscoveryController } from './controllers/well-known.controller';
|
||||||
import { AdvancedAuthController } from './controllers/advanced-auth.controller';
|
import { AdvancedAuthController } from './controllers/advanced-auth.controller';
|
||||||
import { FamilyController } from './controllers/family.controller';
|
import { FamilyController } from './controllers/family.controller';
|
||||||
import { ChatController } from './controllers/chat.controller';
|
import { ChatController } from './controllers/chat.controller';
|
||||||
import { NotificationsController } from './controllers/notifications.controller';
|
import { NotificationsController } from './controllers/notifications.controller';
|
||||||
import { MediaController } from './controllers/media.controller';
|
import { MediaController } from './controllers/media.controller';
|
||||||
|
import { BotController } from './controllers/bot.controller';
|
||||||
|
import { AdminBotController } from './controllers/admin-bot.controller';
|
||||||
|
import { AdminAppReleaseController } from './controllers/admin-app-release.controller';
|
||||||
|
import { AppReleaseController } from './controllers/app-release.controller';
|
||||||
|
import { TelegramBotApiController } from './controllers/telegram-bot-api.controller';
|
||||||
import { CoreGrpcService } from './core-grpc.service';
|
import { CoreGrpcService } from './core-grpc.service';
|
||||||
import { AdminGuard, SuperAdminGuard } from './guards/admin.guard';
|
import { AdminGuard, RbacManageGuard, SuperAdminGuard } from './guards/admin.guard';
|
||||||
|
import { FedcmCookieInterceptor } from './interceptors/fedcm-cookie.interceptor';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -34,7 +42,7 @@ import { AdminGuard, SuperAdminGuard } from './guards/admin.guard';
|
|||||||
useFactory: (config: ConfigService) => ({
|
useFactory: (config: ConfigService) => ({
|
||||||
transport: Transport.GRPC,
|
transport: Transport.GRPC,
|
||||||
options: {
|
options: {
|
||||||
package: ['auth', 'admin', 'rbac', 'security', 'profile', 'documents', 'addresses', 'identity', 'media', 'notifications', 'chat'],
|
package: ['auth', 'admin', 'rbac', 'security', 'profile', 'documents', 'addresses', 'identity', 'media', 'notifications', 'chat', 'bot', 'apprelease'],
|
||||||
protoPath: [
|
protoPath: [
|
||||||
join(__dirname, '../../../shared/proto/auth.proto'),
|
join(__dirname, '../../../shared/proto/auth.proto'),
|
||||||
join(__dirname, '../../../shared/proto/admin.proto'),
|
join(__dirname, '../../../shared/proto/admin.proto'),
|
||||||
@@ -46,15 +54,23 @@ import { AdminGuard, SuperAdminGuard } from './guards/admin.guard';
|
|||||||
join(__dirname, '../../../shared/proto/identity.proto'),
|
join(__dirname, '../../../shared/proto/identity.proto'),
|
||||||
join(__dirname, '../../../shared/proto/media.proto'),
|
join(__dirname, '../../../shared/proto/media.proto'),
|
||||||
join(__dirname, '../../../shared/proto/notifications.proto'),
|
join(__dirname, '../../../shared/proto/notifications.proto'),
|
||||||
join(__dirname, '../../../shared/proto/chat.proto')
|
join(__dirname, '../../../shared/proto/chat.proto'),
|
||||||
|
join(__dirname, '../../../shared/proto/bot.proto'),
|
||||||
|
join(__dirname, '../../../shared/proto/app-release.proto')
|
||||||
],
|
],
|
||||||
url: config.get<string>('SSO_CORE_GRPC_URL', 'localhost:50051')
|
url: config.get<string>('SSO_CORE_GRPC_URL', 'localhost:50051'),
|
||||||
|
channelOptions: {
|
||||||
|
'grpc.keepalive_time_ms': 30000,
|
||||||
|
'grpc.keepalive_timeout_ms': 10000,
|
||||||
|
'grpc.keepalive_permit_without_calls': 1,
|
||||||
|
'grpc.http2.max_pings_without_data': 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
],
|
],
|
||||||
controllers: [AuthController, AdminController, RbacController, SecurityController, SettingsController, PublicSettingsController, HealthController, ProfileController, DocumentsController, AddressesController, OAuthController, AdvancedAuthController, FamilyController, ChatController, NotificationsController, MediaController],
|
controllers: [AuthController, AdminController, RbacController, SecurityController, SettingsController, PublicSettingsController, HealthController, ProfileController, DocumentsController, AddressesController, OAuthController, FedcmController, WellKnownController, OAuthAuthorizeDiscoveryController, AdvancedAuthController, FamilyController, ChatController, NotificationsController, MediaController, BotController, AdminBotController, AdminAppReleaseController, AppReleaseController, TelegramBotApiController],
|
||||||
providers: [CoreGrpcService, AdminGuard, SuperAdminGuard]
|
providers: [CoreGrpcService, AdminGuard, RbacManageGuard, SuperAdminGuard, FedcmCookieInterceptor]
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
36
apps/api-gateway/src/client-request.util.ts
Normal file
36
apps/api-gateway/src/client-request.util.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import type { Request } from 'express';
|
||||||
|
|
||||||
|
export function resolveClientIp(req: Pick<Request, 'ip' | 'headers'>): string | undefined {
|
||||||
|
const forwarded = req.headers['x-forwarded-for'];
|
||||||
|
if (typeof forwarded === 'string') {
|
||||||
|
const first = forwarded.split(',')[0]?.trim();
|
||||||
|
if (first) return first;
|
||||||
|
}
|
||||||
|
if (Array.isArray(forwarded)) {
|
||||||
|
const first = forwarded[0]?.trim();
|
||||||
|
if (first) return first;
|
||||||
|
}
|
||||||
|
|
||||||
|
const realIp = req.headers['x-real-ip'];
|
||||||
|
if (typeof realIp === 'string' && realIp.trim()) {
|
||||||
|
return realIp.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const ip = req.ip?.replace(/^::ffff:/, '').trim();
|
||||||
|
return ip || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveClientUserAgent(req: Pick<Request, 'headers'>): string | undefined {
|
||||||
|
const value = req.headers['user-agent'];
|
||||||
|
if (typeof value !== 'string') return undefined;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enrichAuthClientMeta<T extends object>(req: Pick<Request, 'ip' | 'headers'>, dto: T) {
|
||||||
|
return {
|
||||||
|
...dto,
|
||||||
|
ipAddress: resolveClientIp(req),
|
||||||
|
userAgent: resolveClientUserAgent(req)
|
||||||
|
};
|
||||||
|
}
|
||||||
193
apps/api-gateway/src/controllers/admin-app-release.controller.ts
Normal file
193
apps/api-gateway/src/controllers/admin-app-release.controller.ts
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Res,
|
||||||
|
UploadedFile,
|
||||||
|
UseGuards,
|
||||||
|
UseInterceptors,
|
||||||
|
UsePipes,
|
||||||
|
ValidationPipe
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
|
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
|
||||||
|
import { createHash, randomUUID } from 'node:crypto';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
import { CoreGrpcService } from '../core-grpc.service';
|
||||||
|
import { UpdateAppReleaseDto } from '../dto/app-release.dto';
|
||||||
|
import { AdminGuard, AdminRequestUser, assertAdminPermission } from '../guards/admin.guard';
|
||||||
|
import { CurrentAdmin } from '../decorators/current-admin.decorator';
|
||||||
|
import { buildContentDisposition } from '../media-content-disposition';
|
||||||
|
|
||||||
|
type StreamResponse = {
|
||||||
|
setHeader: (key: string, value: string) => void;
|
||||||
|
status: (code: number) => { json: (body: unknown) => void };
|
||||||
|
} & NodeJS.WritableStream;
|
||||||
|
|
||||||
|
const releaseWritePipe = new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
transform: true,
|
||||||
|
forbidNonWhitelisted: false
|
||||||
|
});
|
||||||
|
|
||||||
|
@ApiTags('Релизы приложений (админ)')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(AdminGuard)
|
||||||
|
@Controller('admin/releases')
|
||||||
|
export class AdminAppReleaseController {
|
||||||
|
private s3Client: S3Client | null = null;
|
||||||
|
|
||||||
|
constructor(private readonly core: CoreGrpcService) {}
|
||||||
|
|
||||||
|
private getS3Client() {
|
||||||
|
if (!this.s3Client) {
|
||||||
|
const endpoint = process.env.MINIO_ENDPOINT ?? 'localhost:9000';
|
||||||
|
const useSsl = process.env.MINIO_USE_SSL === 'true';
|
||||||
|
this.s3Client = new S3Client({
|
||||||
|
endpoint: `${useSsl ? 'https' : 'http'}://${endpoint}`,
|
||||||
|
region: process.env.MINIO_REGION ?? 'us-east-1',
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: process.env.MINIO_ACCESS_KEY ?? 'minioadmin',
|
||||||
|
secretAccessKey: process.env.MINIO_SECRET_KEY ?? 'minioadmin'
|
||||||
|
},
|
||||||
|
forcePathStyle: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.s3Client;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'Список релизов приложений', description: 'Возвращает все загруженные версии Android и Windows.' })
|
||||||
|
list(@CurrentAdmin() admin: AdminRequestUser, @Query('platform') platform?: string) {
|
||||||
|
assertAdminPermission(admin, 'canManageSettings');
|
||||||
|
return firstValueFrom(this.core.appRelease.ListAppReleases({ platform }));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('upload')
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Загрузить новый релиз',
|
||||||
|
description: 'Загружает APK или EXE в MinIO и создаёт запись релиза с SHA-256 подписью.'
|
||||||
|
})
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
platform: { type: 'string', enum: ['ANDROID', 'WINDOWS'] },
|
||||||
|
version: { type: 'string', example: '1.2.0' },
|
||||||
|
versionCode: { type: 'integer', example: 120 },
|
||||||
|
variant: { type: 'string', example: 'arm64-v8a', description: 'Вариант сборки. Если пусто — определяется из имени файла.' },
|
||||||
|
releaseNotes: { type: 'string' },
|
||||||
|
file: { type: 'string', format: 'binary' }
|
||||||
|
},
|
||||||
|
required: ['platform', 'version', 'versionCode', 'file']
|
||||||
|
}
|
||||||
|
})
|
||||||
|
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 350 * 1024 * 1024 } }))
|
||||||
|
async upload(
|
||||||
|
@CurrentAdmin() admin: AdminRequestUser,
|
||||||
|
@UploadedFile() file: { buffer: Buffer; originalname: string; mimetype: string; size: number } | undefined,
|
||||||
|
@Body('platform') platform: string,
|
||||||
|
@Body('version') version: string,
|
||||||
|
@Body('versionCode') versionCodeRaw: string,
|
||||||
|
@Body('variant') variant: string | undefined,
|
||||||
|
@Body('releaseNotes') releaseNotes?: string
|
||||||
|
) {
|
||||||
|
assertAdminPermission(admin, 'canManageSettings');
|
||||||
|
|
||||||
|
if (!file?.buffer?.length) {
|
||||||
|
throw new BadRequestException('Файл релиза не передан');
|
||||||
|
}
|
||||||
|
if (!platform?.trim() || !version?.trim()) {
|
||||||
|
throw new BadRequestException('Укажите платформу и версию');
|
||||||
|
}
|
||||||
|
|
||||||
|
const versionCode = Number(versionCodeRaw);
|
||||||
|
if (!Number.isInteger(versionCode) || versionCode < 1) {
|
||||||
|
throw new BadRequestException('Код версии должен быть положительным целым числом');
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedPlatform = platform.trim().toUpperCase();
|
||||||
|
const fileName = file.originalname?.trim() || 'release.bin';
|
||||||
|
const lowerName = fileName.toLowerCase();
|
||||||
|
if (normalizedPlatform === 'ANDROID' && !lowerName.endsWith('.apk')) {
|
||||||
|
throw new BadRequestException('Для Android загрузите файл .apk');
|
||||||
|
}
|
||||||
|
if (normalizedPlatform === 'WINDOWS' && !lowerName.endsWith('.exe')) {
|
||||||
|
throw new BadRequestException('Для Windows загрузите файл .exe');
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeVersion = version.trim().replace(/[^a-zA-Z0-9._-]+/g, '_');
|
||||||
|
const safeName = fileName.replace(/[^a-zA-Z0-9._-]+/g, '_');
|
||||||
|
const normalizedVariant = (variant?.trim() || fileName.replace(/\.apk$/i, ''))
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/[^a-z0-9._-]/g, '')
|
||||||
|
.slice(0, 64) || 'universal';
|
||||||
|
const storageKey = `releases/${normalizedPlatform.toLowerCase()}/${safeVersion}/${normalizedVariant}/${randomUUID()}-${safeName}`;
|
||||||
|
const sha256 = createHash('sha256').update(file.buffer).digest('hex');
|
||||||
|
const contentType =
|
||||||
|
normalizedPlatform === 'ANDROID'
|
||||||
|
? 'application/vnd.android.package-archive'
|
||||||
|
: 'application/vnd.microsoft.portable-executable';
|
||||||
|
|
||||||
|
const bucket = process.env.MINIO_BUCKET ?? 'lendry-id';
|
||||||
|
await this.getS3Client().send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: bucket,
|
||||||
|
Key: storageKey,
|
||||||
|
Body: file.buffer,
|
||||||
|
ContentType: contentType
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return firstValueFrom(
|
||||||
|
this.core.appRelease.CreateAppRelease({
|
||||||
|
platform: normalizedPlatform,
|
||||||
|
version: version.trim(),
|
||||||
|
versionCode,
|
||||||
|
variant: variant?.trim() || undefined,
|
||||||
|
fileName,
|
||||||
|
storageKey,
|
||||||
|
fileSize: String(file.size),
|
||||||
|
sha256,
|
||||||
|
releaseNotes: releaseNotes?.trim() || undefined,
|
||||||
|
createdById: admin.id
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':releaseId')
|
||||||
|
@UsePipes(releaseWritePipe)
|
||||||
|
@ApiOperation({ summary: 'Обновить релиз', description: 'Публикация/снятие с публикации и заметки к релизу.' })
|
||||||
|
@ApiParam({ name: 'releaseId', description: 'ID релиза' })
|
||||||
|
update(
|
||||||
|
@CurrentAdmin() admin: AdminRequestUser,
|
||||||
|
@Param('releaseId') releaseId: string,
|
||||||
|
@Body() dto: UpdateAppReleaseDto
|
||||||
|
) {
|
||||||
|
assertAdminPermission(admin, 'canManageSettings');
|
||||||
|
return firstValueFrom(
|
||||||
|
this.core.appRelease.UpdateAppRelease({
|
||||||
|
releaseId,
|
||||||
|
isPublished: dto.isPublished,
|
||||||
|
releaseNotes: dto.releaseNotes
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':releaseId')
|
||||||
|
@ApiOperation({ summary: 'Удалить релиз', description: 'Удаляет запись релиза и файл из хранилища.' })
|
||||||
|
@ApiParam({ name: 'releaseId', description: 'ID релиза' })
|
||||||
|
remove(@CurrentAdmin() admin: AdminRequestUser, @Param('releaseId') releaseId: string) {
|
||||||
|
assertAdminPermission(admin, 'canManageSettings');
|
||||||
|
return firstValueFrom(this.core.appRelease.DeleteAppRelease({ releaseId }));
|
||||||
|
}
|
||||||
|
}
|
||||||
68
apps/api-gateway/src/controllers/admin-bot.controller.ts
Normal file
68
apps/api-gateway/src/controllers/admin-bot.controller.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { Body, Controller, ForbiddenException, Get, Param, Patch, Query, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
import { CoreGrpcService } from '../core-grpc.service';
|
||||||
|
import { CurrentAdmin } from '../decorators/current-admin.decorator';
|
||||||
|
import { ListAdminBotsQueryDto, SetBotActiveDto } from '../dto/bot.dto';
|
||||||
|
import { AdminGuard, AdminRequestUser } from '../guards/admin.guard';
|
||||||
|
|
||||||
|
function assertManageAllBots(admin: AdminRequestUser) {
|
||||||
|
if (admin.isSuperAdmin || admin.permissions.includes('bots.manage.all')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new ForbiddenException('Недостаточно прав для управления всеми ботами');
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiTags('Администрирование ботов')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(AdminGuard)
|
||||||
|
@Controller('admin/bots')
|
||||||
|
export class AdminBotController {
|
||||||
|
constructor(private readonly core: CoreGrpcService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'Список всех ботов', description: 'Административный список Telegram-ботов с поиском и пагинацией.' })
|
||||||
|
listAllBots(@Query() query: ListAdminBotsQueryDto, @CurrentAdmin() admin: AdminRequestUser) {
|
||||||
|
assertManageAllBots(admin);
|
||||||
|
return firstValueFrom(
|
||||||
|
this.core.bot.ListAllBots({
|
||||||
|
requesterId: admin.id,
|
||||||
|
isSuperAdmin: admin.isSuperAdmin,
|
||||||
|
search: query.search,
|
||||||
|
page: query.page,
|
||||||
|
limit: query.limit
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('metrics')
|
||||||
|
@ApiOperation({ summary: 'Метрики ботов', description: 'Сводная статистика по ботам системы.' })
|
||||||
|
getMetrics(@CurrentAdmin() admin: AdminRequestUser) {
|
||||||
|
assertManageAllBots(admin);
|
||||||
|
return firstValueFrom(this.core.bot.GetBotMetrics({ requesterId: admin.id, isSuperAdmin: admin.isSuperAdmin }));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':botId')
|
||||||
|
@ApiOperation({ summary: 'Получить бота (админ)', description: 'Возвращает любого бота по ID.' })
|
||||||
|
getBot(@Param('botId') botId: string, @CurrentAdmin() admin: AdminRequestUser) {
|
||||||
|
assertManageAllBots(admin);
|
||||||
|
return firstValueFrom(
|
||||||
|
this.core.bot.GetBot({ requesterId: admin.id, botId, isSuperAdmin: admin.isSuperAdmin })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':botId/active')
|
||||||
|
@ApiOperation({ summary: 'Заблокировать или разблокировать бота', description: 'Анти-abuse: отключает Bot API токен.' })
|
||||||
|
@ApiBody({ type: SetBotActiveDto })
|
||||||
|
setActive(@Param('botId') botId: string, @Body() dto: SetBotActiveDto, @CurrentAdmin() admin: AdminRequestUser) {
|
||||||
|
assertManageAllBots(admin);
|
||||||
|
return firstValueFrom(
|
||||||
|
this.core.bot.SetBotActive({
|
||||||
|
requesterId: admin.id,
|
||||||
|
botId,
|
||||||
|
isActive: dto.isActive,
|
||||||
|
isSuperAdmin: admin.isSuperAdmin
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Body, Controller, ForbiddenException, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
|
import { BadRequestException, Body, Controller, Delete, ForbiddenException, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
import { map } from 'rxjs';
|
import { map } from 'rxjs';
|
||||||
import { CoreGrpcService } from '../core-grpc.service';
|
import { CoreGrpcService } from '../core-grpc.service';
|
||||||
import { CurrentAdmin } from '../decorators/current-admin.decorator';
|
import { CurrentAdmin } from '../decorators/current-admin.decorator';
|
||||||
import { ListUsersQueryDto, ResetPasswordDto, SetSuperAdminDto, UpdateUserDto } from '../dto/admin.dto';
|
import { ListUsersQueryDto, ResetPasswordDto, SetSuperAdminDto, SetUserVerificationDto, UpdateUserDto, UserInsightsQueryDto } from '../dto/admin.dto';
|
||||||
import { AdminGuard, AdminRequestUser, assertAdminPermission, SuperAdminGuard } from '../guards/admin.guard';
|
import { AdminGuard, AdminRequestUser, assertAdminPermission, SuperAdminGuard } from '../guards/admin.guard';
|
||||||
|
|
||||||
@ApiTags('Администрирование')
|
@ApiTags('Администрирование')
|
||||||
@@ -32,6 +33,15 @@ export class AdminController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('verification-icons')
|
||||||
|
@ApiOperation({ summary: 'Список значков верификации', description: 'Доступные значки для выбора при верификации пользователя.' })
|
||||||
|
listVerificationIcons(@CurrentAdmin() admin: AdminRequestUser) {
|
||||||
|
if (!admin.canVerifyUsers) {
|
||||||
|
throw new ForbiddenException('Недостаточно прав для верификации пользователей');
|
||||||
|
}
|
||||||
|
return this.core.admin.ListVerificationIcons({});
|
||||||
|
}
|
||||||
|
|
||||||
@Patch(':userId')
|
@Patch(':userId')
|
||||||
@ApiOperation({ summary: 'Обновить профиль пользователя', description: 'Обновляет основные и резервные контакты пользователя.' })
|
@ApiOperation({ summary: 'Обновить профиль пользователя', description: 'Обновляет основные и резервные контакты пользователя.' })
|
||||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||||
@@ -66,4 +76,170 @@ export class AdminController {
|
|||||||
setSuperAdmin(@Param('userId') userId: string, @Body() dto: SetSuperAdminDto, @CurrentAdmin() admin: AdminRequestUser) {
|
setSuperAdmin(@Param('userId') userId: string, @Body() dto: SetSuperAdminDto, @CurrentAdmin() admin: AdminRequestUser) {
|
||||||
return this.core.admin.SetSuperAdmin({ actorUserId: admin.id, userId, isSuperAdmin: dto.isSuperAdmin });
|
return this.core.admin.SetSuperAdmin({ actorUserId: admin.id, userId, isSuperAdmin: dto.isSuperAdmin });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('bot-accounts')
|
||||||
|
@ApiOperation({ summary: 'Системные учётные записи ботов', description: 'Возвращает пользователей, связанных с Telegram-ботами (BotFather и боты пользователей).' })
|
||||||
|
listBotAccounts(@Query() query: ListUsersQueryDto, @CurrentAdmin() admin: AdminRequestUser) {
|
||||||
|
if (!admin.canViewUsers && !admin.canManageUsers && !admin.isSuperAdmin && !admin.permissions.includes('bots.manage.all')) {
|
||||||
|
throw new ForbiddenException('Недостаточно прав для просмотра ботов');
|
||||||
|
}
|
||||||
|
return this.core.admin.ListBotAccounts(query).pipe(
|
||||||
|
map((response) => {
|
||||||
|
const payload = response as { users?: Array<{ roles?: string[] }> };
|
||||||
|
return {
|
||||||
|
users: (payload.users ?? []).map((user) => ({
|
||||||
|
...user,
|
||||||
|
roles: user.roles ?? []
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':userId/totp/admin-disable')
|
||||||
|
@UseGuards(SuperAdminGuard)
|
||||||
|
@ApiOperation({ summary: 'Отключить 2FA пользователя', description: 'Супер-администратор может принудительно отключить TOTP без кода пользователя.' })
|
||||||
|
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||||
|
adminDisableTotp(@Param('userId') userId: string, @CurrentAdmin() admin: AdminRequestUser) {
|
||||||
|
return firstValueFrom(
|
||||||
|
this.core.security.AdminDisableTotp({
|
||||||
|
actorUserId: admin.id,
|
||||||
|
userId
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':userId/verification')
|
||||||
|
@ApiOperation({ summary: 'Верифицировать или снять верификацию', description: 'Требуется право users.verify.' })
|
||||||
|
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||||
|
@ApiBody({ type: SetUserVerificationDto })
|
||||||
|
setUserVerification(@Param('userId') userId: string, @Body() dto: SetUserVerificationDto, @CurrentAdmin() admin: AdminRequestUser) {
|
||||||
|
if (!admin.canVerifyUsers) {
|
||||||
|
throw new ForbiddenException('Недостаточно прав для верификации пользователей');
|
||||||
|
}
|
||||||
|
return this.core.admin.SetUserVerification({
|
||||||
|
actorUserId: admin.id,
|
||||||
|
userId,
|
||||||
|
isVerified: dto.isVerified,
|
||||||
|
verificationIcon: dto.verificationIcon
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':userId/sign-in-history')
|
||||||
|
@ApiOperation({ summary: 'История входов пользователя', description: 'Журнал SignInEvent с поиском по IP, устройству и причине.' })
|
||||||
|
getUserSignInHistory(@Param('userId') userId: string, @Query() query: UserInsightsQueryDto, @CurrentAdmin() admin: AdminRequestUser) {
|
||||||
|
if (!admin.canViewUsers && !admin.canManageUsers) {
|
||||||
|
throw new ForbiddenException('Недостаточно прав для просмотра журнала пользователя');
|
||||||
|
}
|
||||||
|
return firstValueFrom(
|
||||||
|
this.core.admin.GetUserSignInHistory({
|
||||||
|
userId,
|
||||||
|
search: query.search,
|
||||||
|
limit: query.limit,
|
||||||
|
offset: query.offset,
|
||||||
|
dateFrom: query.dateFrom,
|
||||||
|
dateTo: query.dateTo
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':userId/activity')
|
||||||
|
@ApiOperation({ summary: 'Активность пользователя', description: 'Созданные документы, чаты, семьи, OAuth-согласия и журнал действий.' })
|
||||||
|
getUserActivity(@Param('userId') userId: string, @Query() query: UserInsightsQueryDto, @CurrentAdmin() admin: AdminRequestUser) {
|
||||||
|
if (!admin.canViewUsers && !admin.canManageUsers) {
|
||||||
|
throw new ForbiddenException('Недостаточно прав для просмотра активности пользователя');
|
||||||
|
}
|
||||||
|
return firstValueFrom(
|
||||||
|
this.core.admin.GetUserActivity({
|
||||||
|
userId,
|
||||||
|
search: query.search,
|
||||||
|
limit: query.limit,
|
||||||
|
offset: query.offset,
|
||||||
|
dateFrom: query.dateFrom,
|
||||||
|
dateTo: query.dateTo
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':userId/chats')
|
||||||
|
@ApiOperation({ summary: 'Чаты пользователя для модерации', description: 'Обычные чаты без E2E и ботов.' })
|
||||||
|
listUserChats(@Param('userId') userId: string, @Query() query: UserInsightsQueryDto, @CurrentAdmin() admin: AdminRequestUser) {
|
||||||
|
if (!admin.canModerateChats && !admin.isSuperAdmin) {
|
||||||
|
throw new ForbiddenException('Недостаточно прав для модерации чатов');
|
||||||
|
}
|
||||||
|
return firstValueFrom(
|
||||||
|
this.core.admin.ListUserChatRooms({
|
||||||
|
userId,
|
||||||
|
search: query.search,
|
||||||
|
limit: query.limit,
|
||||||
|
offset: query.offset,
|
||||||
|
dateFrom: query.dateFrom,
|
||||||
|
dateTo: query.dateTo
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':userId/chats/search')
|
||||||
|
@ApiOperation({ summary: 'Поиск по сообщениям пользователя', description: 'Поиск по обычным (не E2E) перепискам пользователя.' })
|
||||||
|
searchUserChats(@Param('userId') userId: string, @Query() query: UserInsightsQueryDto, @CurrentAdmin() admin: AdminRequestUser) {
|
||||||
|
if (!admin.canModerateChats && !admin.isSuperAdmin) {
|
||||||
|
throw new ForbiddenException('Недостаточно прав для модерации чатов');
|
||||||
|
}
|
||||||
|
if (!query.search?.trim()) {
|
||||||
|
throw new BadRequestException('Укажите параметр search');
|
||||||
|
}
|
||||||
|
return firstValueFrom(
|
||||||
|
this.core.admin.SearchUserChatMessages({
|
||||||
|
userId,
|
||||||
|
search: query.search.trim(),
|
||||||
|
limit: query.limit,
|
||||||
|
offset: query.offset,
|
||||||
|
dateFrom: query.dateFrom,
|
||||||
|
dateTo: query.dateTo
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':userId/chats/:roomId/messages')
|
||||||
|
@ApiOperation({ summary: 'Сообщения чата пользователя', description: 'Просмотр переписки в обычном чате для модерации.' })
|
||||||
|
listUserChatMessages(
|
||||||
|
@Param('userId') userId: string,
|
||||||
|
@Param('roomId') roomId: string,
|
||||||
|
@Query() query: UserInsightsQueryDto,
|
||||||
|
@CurrentAdmin() admin: AdminRequestUser
|
||||||
|
) {
|
||||||
|
if (!admin.canModerateChats && !admin.isSuperAdmin) {
|
||||||
|
throw new ForbiddenException('Недостаточно прав для модерации чатов');
|
||||||
|
}
|
||||||
|
return firstValueFrom(
|
||||||
|
this.core.admin.ListUserChatMessages({
|
||||||
|
userId,
|
||||||
|
roomId,
|
||||||
|
search: query.search,
|
||||||
|
limit: query.limit,
|
||||||
|
beforeMessageId: query.beforeMessageId,
|
||||||
|
dateFrom: query.dateFrom,
|
||||||
|
dateTo: query.dateTo
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':userId/chat-messages/:messageId')
|
||||||
|
@ApiOperation({ summary: 'Удалить сообщение (модерация)', description: 'Мягкое удаление сообщения в обычном чате.' })
|
||||||
|
deleteUserChatMessage(
|
||||||
|
@Param('userId') userId: string,
|
||||||
|
@Param('messageId') messageId: string,
|
||||||
|
@CurrentAdmin() admin: AdminRequestUser
|
||||||
|
) {
|
||||||
|
if (!admin.canModerateChats && !admin.isSuperAdmin) {
|
||||||
|
throw new ForbiddenException('Недостаточно прав для модерации чатов');
|
||||||
|
}
|
||||||
|
void userId;
|
||||||
|
return firstValueFrom(
|
||||||
|
this.core.admin.AdminDeleteChatMessage({
|
||||||
|
actorUserId: admin.id,
|
||||||
|
messageId
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
|
import { Body, Controller, ForbiddenException, Get, Headers, Param, Post, Req } from '@nestjs/common';
|
||||||
import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
import type { Request } from 'express';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
import { enrichAuthClientMeta } from '../client-request.util';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
import { CoreGrpcService } from '../core-grpc.service';
|
import { CoreGrpcService } from '../core-grpc.service';
|
||||||
import { QrSessionDto, WebAuthnDto } from '../dto/identity.dto';
|
import { QrSessionDto, WebAuthnDto } from '../dto/identity.dto';
|
||||||
|
import { resolveAuthorizedPayload } from '../session-auth';
|
||||||
|
import { resolveFrontendUrl } from '../lib/oauth-issuer';
|
||||||
|
|
||||||
@ApiTags('Биометрия и QR-вход')
|
@ApiTags('Биометрия и QR-вход')
|
||||||
@Controller('auth/advanced')
|
@Controller('auth/advanced')
|
||||||
export class AdvancedAuthController {
|
export class AdvancedAuthController {
|
||||||
constructor(private readonly core: CoreGrpcService) {}
|
constructor(
|
||||||
|
private readonly core: CoreGrpcService,
|
||||||
|
private readonly jwt: JwtService
|
||||||
|
) {}
|
||||||
|
|
||||||
@Post('webauthn/register/challenge')
|
@Post('webauthn/register/challenge')
|
||||||
@ApiOperation({ summary: 'Challenge регистрации WebAuthn', description: 'Создает challenge для регистрации лица/отпечатка. Endpoint готов для подключения настоящего WebAuthn attestation.' })
|
@ApiOperation({ summary: 'Challenge регистрации WebAuthn', description: 'Создает challenge для регистрации лица/отпечатка. Endpoint готов для подключения настоящего WebAuthn attestation.' })
|
||||||
@@ -28,15 +37,55 @@ export class AdvancedAuthController {
|
|||||||
@ApiOperation({ summary: 'Создать QR-сессию', description: 'Создает временную QR-сессию для входа с другого устройства.' })
|
@ApiOperation({ summary: 'Создать QR-сессию', description: 'Создает временную QR-сессию для входа с другого устройства.' })
|
||||||
@ApiBody({ type: QrSessionDto })
|
@ApiBody({ type: QrSessionDto })
|
||||||
@ApiResponse({ status: 201, description: 'QR-сессия создана' })
|
@ApiResponse({ status: 201, description: 'QR-сессия создана' })
|
||||||
createQr(@Body() dto: QrSessionDto) {
|
createQr(@Body() dto: QrSessionDto, @Req() req: Request) {
|
||||||
return this.core.advancedAuth.CreateQrSession(dto);
|
return this.core.advancedAuth.CreateQrSession(
|
||||||
|
enrichAuthClientMeta(req, {
|
||||||
|
deviceName: dto.deviceName,
|
||||||
|
fingerprint: dto.fingerprint,
|
||||||
|
deviceType: dto.deviceType ?? 'WEB'
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('qr/session/:sessionId')
|
@Get('qr/session/:sessionId')
|
||||||
@ApiOperation({ summary: 'Проверить QR-сессию', description: 'Возвращает текущий статус QR-сессии: PENDING/CONFIRMED/EXPIRED.' })
|
@ApiOperation({ summary: 'Проверить QR-сессию', description: 'Возвращает текущий статус QR-сессии: PENDING/APPROVED/EXPIRED.' })
|
||||||
@ApiParam({ name: 'sessionId', description: 'ID QR-сессии' })
|
@ApiParam({ name: 'sessionId', description: 'ID QR-сессии' })
|
||||||
@ApiResponse({ status: 200, description: 'Статус QR-сессии получен' })
|
@ApiResponse({ status: 200, description: 'Статус QR-сессии получен' })
|
||||||
pollQr(@Param('sessionId') sessionId: string) {
|
pollQr(@Param('sessionId') sessionId: string) {
|
||||||
return this.core.advancedAuth.PollQrSession({ sessionId });
|
return this.core.advancedAuth.PollQrSession({ sessionId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('qr/session/:sessionId/claim')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Привязать QR-сессию к устройству',
|
||||||
|
description: 'Новое устройство подтверждает сканирование QR-кода для подключения из раздела «Безопасность».'
|
||||||
|
})
|
||||||
|
@ApiParam({ name: 'sessionId', description: 'ID QR-сессии' })
|
||||||
|
@ApiBody({ type: QrSessionDto })
|
||||||
|
@ApiResponse({ status: 201, description: 'QR-сессия привязана к устройству' })
|
||||||
|
claimQr(@Param('sessionId') sessionId: string, @Body() dto: QrSessionDto, @Req() req: Request) {
|
||||||
|
const enriched = enrichAuthClientMeta(req, {
|
||||||
|
deviceName: dto.deviceName,
|
||||||
|
fingerprint: dto.fingerprint,
|
||||||
|
deviceType: dto.deviceType ?? 'WEB'
|
||||||
|
});
|
||||||
|
return this.core.advancedAuth.ClaimQrSession({
|
||||||
|
sessionId,
|
||||||
|
deviceName: enriched.deviceName,
|
||||||
|
fingerprint: enriched.fingerprint,
|
||||||
|
deviceType: enriched.deviceType,
|
||||||
|
ipAddress: enriched.ipAddress,
|
||||||
|
userAgent: enriched.userAgent
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('qr/session/:sessionId/approve')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Подтвердить QR-вход', description: 'Подтверждает QR-сессию с мобильного приложения уже авторизованным пользователем.' })
|
||||||
|
@ApiParam({ name: 'sessionId', description: 'ID QR-сессии' })
|
||||||
|
@ApiResponse({ status: 201, description: 'QR-сессия подтверждена' })
|
||||||
|
async approveQr(@Param('sessionId') sessionId: string, @Headers('authorization') authorization?: string) {
|
||||||
|
const payload = await resolveAuthorizedPayload(this.jwt, this.core, authorization);
|
||||||
|
return this.core.advancedAuth.ApproveQrSession({ sessionId, userId: payload.sub });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
159
apps/api-gateway/src/controllers/app-release.controller.ts
Normal file
159
apps/api-gateway/src/controllers/app-release.controller.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import { Controller, Get, Param, Query, Res } from '@nestjs/common';
|
||||||
|
import { ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
import { CoreGrpcService } from '../core-grpc.service';
|
||||||
|
import { buildContentDisposition } from '../media-content-disposition';
|
||||||
|
|
||||||
|
type StreamResponse = {
|
||||||
|
setHeader: (key: string, value: string) => void;
|
||||||
|
status: (code: number) => { json: (body: unknown) => void };
|
||||||
|
} & NodeJS.WritableStream;
|
||||||
|
|
||||||
|
@ApiTags('Скачивание приложений')
|
||||||
|
@Controller('releases')
|
||||||
|
export class AppReleaseController {
|
||||||
|
private s3Client: S3Client | null = null;
|
||||||
|
|
||||||
|
constructor(private readonly core: CoreGrpcService) {}
|
||||||
|
|
||||||
|
private getS3Client() {
|
||||||
|
if (!this.s3Client) {
|
||||||
|
const endpoint = process.env.MINIO_ENDPOINT ?? 'localhost:9000';
|
||||||
|
const useSsl = process.env.MINIO_USE_SSL === 'true';
|
||||||
|
this.s3Client = new S3Client({
|
||||||
|
endpoint: `${useSsl ? 'https' : 'http'}://${endpoint}`,
|
||||||
|
region: process.env.MINIO_REGION ?? 'us-east-1',
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: process.env.MINIO_ACCESS_KEY ?? 'minioadmin',
|
||||||
|
secretAccessKey: process.env.MINIO_SECRET_KEY ?? 'minioadmin'
|
||||||
|
},
|
||||||
|
forcePathStyle: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.s3Client;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizePlatform(platform: string) {
|
||||||
|
const normalized = platform.trim().toUpperCase();
|
||||||
|
if (normalized === 'ANDROID' || normalized === 'WINDOWS') {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'Публичный список релизов', description: 'Возвращает опубликованные версии приложений для страницы /downloads.' })
|
||||||
|
@ApiQuery({ name: 'platform', required: false, enum: ['ANDROID', 'WINDOWS'] })
|
||||||
|
list(@Query('platform') platform?: string) {
|
||||||
|
return firstValueFrom(this.core.appRelease.ListPublicAppReleases({ platform }));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('latest/:platform')
|
||||||
|
@ApiOperation({ summary: 'Последний релиз платформы', description: 'Метаданные последней опубликованной версии.' })
|
||||||
|
@ApiParam({ name: 'platform', enum: ['android', 'windows', 'ANDROID', 'WINDOWS'] })
|
||||||
|
latest(@Param('platform') platform: string) {
|
||||||
|
const normalized = this.normalizePlatform(platform);
|
||||||
|
if (!normalized) {
|
||||||
|
return { message: 'Некорректная платформа' };
|
||||||
|
}
|
||||||
|
return firstValueFrom(this.core.appRelease.GetLatestAppRelease({ platform: normalized }));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('check')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Проверка обновления',
|
||||||
|
description: 'Для мобильного приложения: сравнивает versionCode с последним релизом.'
|
||||||
|
})
|
||||||
|
@ApiQuery({ name: 'platform', enum: ['ANDROID', 'WINDOWS'] })
|
||||||
|
@ApiQuery({ name: 'versionCode', type: Number })
|
||||||
|
check(@Query('platform') platform: string, @Query('versionCode') versionCodeRaw: string) {
|
||||||
|
const normalized = this.normalizePlatform(platform);
|
||||||
|
const versionCode = Number(versionCodeRaw);
|
||||||
|
if (!normalized || !Number.isInteger(versionCode) || versionCode < 1) {
|
||||||
|
return { updateAvailable: false };
|
||||||
|
}
|
||||||
|
return firstValueFrom(this.core.appRelease.CheckAppUpdate({ platform: normalized, versionCode }));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('download/:platform')
|
||||||
|
@ApiOperation({ summary: 'Скачать последний релиз', description: 'Отдаёт файл последней опубликованной версии.' })
|
||||||
|
@ApiParam({ name: 'platform', enum: ['android', 'windows', 'ANDROID', 'WINDOWS'] })
|
||||||
|
@ApiQuery({ name: 'variant', required: false, description: 'Вариант сборки, например universal или arm64-v8a' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Файл релиза' })
|
||||||
|
async downloadLatest(
|
||||||
|
@Param('platform') platform: string,
|
||||||
|
@Query('variant') variant: string | undefined,
|
||||||
|
@Res({ passthrough: false }) response: StreamResponse
|
||||||
|
) {
|
||||||
|
const normalized = this.normalizePlatform(platform);
|
||||||
|
if (!normalized) {
|
||||||
|
response.status(400).json({ message: 'Некорректная платформа' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.streamRelease(response, normalized, undefined, variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('download/:platform/:releaseId')
|
||||||
|
@ApiOperation({ summary: 'Скачать конкретный релиз', description: 'Отдаёт файл выбранной опубликованной версии.' })
|
||||||
|
async downloadById(
|
||||||
|
@Param('platform') platform: string,
|
||||||
|
@Param('releaseId') releaseId: string,
|
||||||
|
@Res({ passthrough: false }) response: StreamResponse
|
||||||
|
) {
|
||||||
|
const normalized = this.normalizePlatform(platform);
|
||||||
|
if (!normalized) {
|
||||||
|
response.status(400).json({ message: 'Некорректная платформа' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.streamRelease(response, normalized, releaseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async streamRelease(
|
||||||
|
response: StreamResponse,
|
||||||
|
platform: string,
|
||||||
|
releaseId?: string,
|
||||||
|
variant?: string
|
||||||
|
) {
|
||||||
|
const resolved = (await firstValueFrom(
|
||||||
|
this.core.appRelease.ResolveAppReleaseDownload({ platform, releaseId, variant })
|
||||||
|
)) as {
|
||||||
|
storageKey: string;
|
||||||
|
fileName: string;
|
||||||
|
contentType: string;
|
||||||
|
fileSize: string;
|
||||||
|
sha256: string;
|
||||||
|
version: string;
|
||||||
|
versionCode: number;
|
||||||
|
variant: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const bucket = process.env.MINIO_BUCKET ?? 'lendry-id';
|
||||||
|
const object = await this.getS3Client().send(
|
||||||
|
new GetObjectCommand({
|
||||||
|
Bucket: bucket,
|
||||||
|
Key: resolved.storageKey
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
response.setHeader('Content-Type', resolved.contentType);
|
||||||
|
response.setHeader('Content-Length', resolved.fileSize);
|
||||||
|
response.setHeader('X-Release-Version', resolved.version);
|
||||||
|
response.setHeader('X-Release-Version-Code', String(resolved.versionCode));
|
||||||
|
if (resolved.variant) {
|
||||||
|
response.setHeader('X-Release-Variant', resolved.variant);
|
||||||
|
}
|
||||||
|
response.setHeader('X-Release-Sha256', resolved.sha256);
|
||||||
|
response.setHeader('Cache-Control', 'public, max-age=300');
|
||||||
|
response.setHeader('Content-Disposition', buildContentDisposition('attachment', resolved.fileName));
|
||||||
|
|
||||||
|
const body = object.Body;
|
||||||
|
if (!body) {
|
||||||
|
response.status(404).json({ message: 'Файл релиза не найден' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream = body as NodeJS.ReadableStream;
|
||||||
|
stream.pipe(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,17 @@
|
|||||||
import { Body, Controller, Get, Headers, Post } from '@nestjs/common';
|
import { Body, Controller, Get, Headers, Post, Req, UseInterceptors } from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
import type { Request } from 'express';
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
import { CoreGrpcService } from '../core-grpc.service';
|
import { CoreGrpcService } from '../core-grpc.service';
|
||||||
import { IdentifyDto, LdapLoginDto, LoginDto, PasswordlessOtpDto, PasswordlessVerifyDto, PasswordLoginDto, RefreshSessionDto, RegisterDto, VerifyPinDto } from '../dto/auth.dto';
|
import { enrichAuthClientMeta } from '../client-request.util';
|
||||||
|
import { BeginTotpLoginDto, IdentifyDto, LdapLoginDto, LoginDto, PasswordlessOtpDto, PasswordlessVerifyDto, PasswordLoginDto, RefreshSessionDto, RegisterDto, VerifyPinDto, VerifyTotpLoginDto } from '../dto/auth.dto';
|
||||||
import { resolveAuthorizedPayload, verifyAccessToken } from '../session-auth';
|
import { resolveAuthorizedPayload, verifyAccessToken } from '../session-auth';
|
||||||
|
import { FedcmCookieInterceptor } from '../interceptors/fedcm-cookie.interceptor';
|
||||||
|
|
||||||
@ApiTags('Аутентификация')
|
@ApiTags('Аутентификация')
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
|
@UseInterceptors(FedcmCookieInterceptor)
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly core: CoreGrpcService,
|
private readonly core: CoreGrpcService,
|
||||||
@@ -24,8 +28,8 @@ export class AuthController {
|
|||||||
@Post('login')
|
@Post('login')
|
||||||
@ApiOperation({ summary: 'Вход по почте, телефону или логину', description: 'Возвращает JWT и refresh token. Если PIN включен, сессия создается в ограниченном режиме.' })
|
@ApiOperation({ summary: 'Вход по почте, телефону или логину', description: 'Возвращает JWT и refresh token. Если PIN включен, сессия создается в ограниченном режиме.' })
|
||||||
@ApiBody({ type: LoginDto })
|
@ApiBody({ type: LoginDto })
|
||||||
login(@Body() dto: LoginDto) {
|
login(@Body() dto: LoginDto, @Req() req: Request) {
|
||||||
return this.core.auth.Login(dto);
|
return this.core.auth.Login(enrichAuthClientMeta(req, dto));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('identify')
|
@Post('identify')
|
||||||
@@ -38,29 +42,29 @@ export class AuthController {
|
|||||||
@Post('otp/send')
|
@Post('otp/send')
|
||||||
@ApiOperation({ summary: 'Отправить OTP для входа', description: 'Passwordless-first вход: пользователь вводит почту или телефон, сервер создает 6-значный код и пишет его в console.log.' })
|
@ApiOperation({ summary: 'Отправить OTP для входа', description: 'Passwordless-first вход: пользователь вводит почту или телефон, сервер создает 6-значный код и пишет его в console.log.' })
|
||||||
@ApiBody({ type: PasswordlessOtpDto })
|
@ApiBody({ type: PasswordlessOtpDto })
|
||||||
sendOtp(@Body() dto: PasswordlessOtpDto) {
|
sendOtp(@Body() dto: PasswordlessOtpDto, @Req() req: Request) {
|
||||||
return this.core.auth.SendOtp({ recipient: dto.recipient, channel: dto.channel });
|
return this.core.auth.SendOtp({ recipient: dto.recipient, channel: dto.channel, ipAddress: enrichAuthClientMeta(req, {}).ipAddress });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('otp/verify')
|
@Post('otp/verify')
|
||||||
@ApiOperation({ summary: 'Проверить OTP для входа', description: 'Если пользователь не существует, создает его. Если пароль не задан, сразу возвращает JWT. Если пароль задан, возвращает requiresPassword=true и tempAuthToken.' })
|
@ApiOperation({ summary: 'Проверить OTP для входа', description: 'Если пользователь не существует, создает его. Если пароль не задан, сразу возвращает JWT. Если пароль задан, возвращает requiresPassword=true и tempAuthToken.' })
|
||||||
@ApiBody({ type: PasswordlessVerifyDto })
|
@ApiBody({ type: PasswordlessVerifyDto })
|
||||||
verifyOtp(@Body() dto: PasswordlessVerifyDto) {
|
verifyOtp(@Body() dto: PasswordlessVerifyDto, @Req() req: Request) {
|
||||||
return this.core.auth.VerifyOtp(dto);
|
return this.core.auth.VerifyOtp(enrichAuthClientMeta(req, dto));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('login/password')
|
@Post('login/password')
|
||||||
@ApiOperation({ summary: 'Войти по паролю', description: 'Identifier-first парольный шаг. Принимает login+password, либо tempAuthToken+password для совместимости, затем выдает JWT.' })
|
@ApiOperation({ summary: 'Войти по паролю', description: 'Identifier-first парольный шаг. Принимает login+password, либо tempAuthToken+password для совместимости, затем выдает JWT.' })
|
||||||
@ApiBody({ type: PasswordLoginDto })
|
@ApiBody({ type: PasswordLoginDto })
|
||||||
loginWithPassword(@Body() dto: PasswordLoginDto) {
|
loginWithPassword(@Body() dto: PasswordLoginDto, @Req() req: Request) {
|
||||||
return this.core.auth.LoginWithPassword(dto);
|
return this.core.auth.LoginWithPassword(enrichAuthClientMeta(req, dto));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('ldap/login')
|
@Post('ldap/login')
|
||||||
@ApiOperation({ summary: 'Войти через LDAP/LDAPS', description: 'Аутентификация через корпоративный LDAP-сервер. Требует включённой настройки LDAP_ENABLED.' })
|
@ApiOperation({ summary: 'Войти через LDAP/LDAPS', description: 'Аутентификация через корпоративный LDAP-сервер. Требует включённой настройки LDAP_ENABLED.' })
|
||||||
@ApiBody({ type: LdapLoginDto })
|
@ApiBody({ type: LdapLoginDto })
|
||||||
loginWithLdap(@Body() dto: LdapLoginDto) {
|
loginWithLdap(@Body() dto: LdapLoginDto, @Req() req: Request) {
|
||||||
return this.core.auth.LoginWithLdap(dto);
|
return this.core.auth.LoginWithLdap(enrichAuthClientMeta(req, dto));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('pin/verify')
|
@Post('pin/verify')
|
||||||
@@ -70,6 +74,20 @@ export class AuthController {
|
|||||||
return this.core.auth.VerifyPin(dto);
|
return this.core.auth.VerifyPin(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('totp/begin')
|
||||||
|
@ApiOperation({ summary: 'Начать вход по TOTP', description: 'Создаёт challenge для входа через приложение-аутентификатор вместо SMS/email OTP.' })
|
||||||
|
@ApiBody({ type: BeginTotpLoginDto })
|
||||||
|
beginTotpLogin(@Body() dto: BeginTotpLoginDto, @Req() req: Request) {
|
||||||
|
return this.core.auth.BeginTotpLogin(enrichAuthClientMeta(req, dto));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('totp/verify')
|
||||||
|
@ApiOperation({ summary: 'Подтвердить TOTP при входе', description: 'Завершает вход после проверки кода из Google Authenticator или аналога.' })
|
||||||
|
@ApiBody({ type: VerifyTotpLoginDto })
|
||||||
|
verifyTotpLogin(@Body() dto: VerifyTotpLoginDto, @Req() req: Request) {
|
||||||
|
return this.core.auth.VerifyTotpLogin(enrichAuthClientMeta(req, dto));
|
||||||
|
}
|
||||||
|
|
||||||
@Post('refresh')
|
@Post('refresh')
|
||||||
@ApiOperation({ summary: 'Обновить access token', description: 'Обновляет JWT по refresh token. Если сессия заблокирована PIN-кодом, возвращает requiresPin=true без выхода из аккаунта.' })
|
@ApiOperation({ summary: 'Обновить access token', description: 'Обновляет JWT по refresh token. Если сессия заблокирована PIN-кодом, возвращает requiresPin=true без выхода из аккаунта.' })
|
||||||
@ApiBody({ type: RefreshSessionDto })
|
@ApiBody({ type: RefreshSessionDto })
|
||||||
|
|||||||
159
apps/api-gateway/src/controllers/bot.controller.ts
Normal file
159
apps/api-gateway/src/controllers/bot.controller.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import { Body, Controller, Delete, Get, Headers, Param, Patch, Post, Query } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
import { CoreGrpcService } from '../core-grpc.service';
|
||||||
|
import { CreateBotDto, SetBotWebAppDto, SubmitBotCallbackDto, SubmitBotMessageDto, UpdateBotDto, UpdateBotProfileDto, ValidateWebAppInitDataDto } from '../dto/bot.dto';
|
||||||
|
import { getAuthorizedUserId } from '../document-access';
|
||||||
|
|
||||||
|
@ApiTags('BotFather')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('bots')
|
||||||
|
export class BotController {
|
||||||
|
constructor(
|
||||||
|
private readonly core: CoreGrpcService,
|
||||||
|
private readonly jwt: JwtService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private async auth(authorization?: string) {
|
||||||
|
return getAuthorizedUserId(this.jwt, this.core, authorization);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'Список моих ботов', description: 'Возвращает Telegram-ботов текущего пользователя.' })
|
||||||
|
listMyBots(@Headers('authorization') authorization?: string) {
|
||||||
|
return this.auth(authorization).then((userId) => firstValueFrom(this.core.bot.ListMyBots({ ownerId: userId })));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@ApiOperation({ summary: 'Создать бота', description: 'Регистрирует нового бота и возвращает токен Bot API.' })
|
||||||
|
@ApiBody({ type: CreateBotDto })
|
||||||
|
createBot(@Headers('authorization') authorization: string | undefined, @Body() dto: CreateBotDto) {
|
||||||
|
return this.auth(authorization).then((ownerId) =>
|
||||||
|
firstValueFrom(this.core.bot.CreateBot({ ownerId, name: dto.name, username: dto.username }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('by-username/:botRef/messages')
|
||||||
|
@ApiOperation({ summary: 'История чата с ботом', description: 'Возвращает сообщения пользователя с ботом в хронологическом порядке.' })
|
||||||
|
listBotMessages(@Headers('authorization') authorization: string | undefined, @Param('botRef') botRef: string, @Query('roomId') roomId?: string) {
|
||||||
|
return this.auth(authorization).then((userId) =>
|
||||||
|
firstValueFrom(this.core.bot.ListBotChatMessages({ userId, botRef, roomId }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('by-username/:botRef/messages')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Написать боту',
|
||||||
|
description: 'Публикует inbound-событие в RabbitMQ для доставки боту через webhook или getUpdates.'
|
||||||
|
})
|
||||||
|
@ApiBody({ type: SubmitBotMessageDto })
|
||||||
|
submitMessage(
|
||||||
|
@Headers('authorization') authorization: string | undefined,
|
||||||
|
@Param('botRef') botRef: string,
|
||||||
|
@Body() dto: SubmitBotMessageDto
|
||||||
|
) {
|
||||||
|
return this.auth(authorization).then((senderUserId) =>
|
||||||
|
firstValueFrom(this.core.bot.SubmitBotInboundMessage({ senderUserId, botRef, text: dto.text, roomId: dto.roomId }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('by-username/:botRef/callback')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Нажатие inline-кнопки',
|
||||||
|
description: 'Публикует callback_query Update для webhook или getUpdates.'
|
||||||
|
})
|
||||||
|
@ApiBody({ type: SubmitBotCallbackDto })
|
||||||
|
submitCallback(
|
||||||
|
@Headers('authorization') authorization: string | undefined,
|
||||||
|
@Param('botRef') botRef: string,
|
||||||
|
@Body() dto: SubmitBotCallbackDto
|
||||||
|
) {
|
||||||
|
return this.auth(authorization).then((senderUserId) =>
|
||||||
|
firstValueFrom(
|
||||||
|
this.core.bot.SubmitBotCallbackQuery({
|
||||||
|
senderUserId,
|
||||||
|
botRef,
|
||||||
|
messageId: dto.messageId,
|
||||||
|
callbackData: dto.callbackData
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('web-app/validate')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Проверить initData Mini App',
|
||||||
|
description: 'Валидирует Telegram Web App initData по HMAC-SHA256, как в официальном Bot API.'
|
||||||
|
})
|
||||||
|
@ApiBody({ type: ValidateWebAppInitDataDto })
|
||||||
|
validateWebApp(@Body() dto: ValidateWebAppInitDataDto) {
|
||||||
|
return firstValueFrom(this.core.bot.ValidateWebAppInitData(dto));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':botId')
|
||||||
|
@ApiOperation({ summary: 'Получить бота', description: 'Возвращает настройки бота, принадлежащего пользователю.' })
|
||||||
|
getBot(@Headers('authorization') authorization: string | undefined, @Param('botId') botId: string) {
|
||||||
|
return this.auth(authorization).then((requesterId) =>
|
||||||
|
firstValueFrom(this.core.bot.GetBot({ requesterId, botId, isSuperAdmin: false }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':botId')
|
||||||
|
@ApiOperation({ summary: 'Обновить бота', description: 'Изменяет имя или username бота.' })
|
||||||
|
@ApiBody({ type: UpdateBotDto })
|
||||||
|
updateBot(
|
||||||
|
@Headers('authorization') authorization: string | undefined,
|
||||||
|
@Param('botId') botId: string,
|
||||||
|
@Body() dto: UpdateBotDto
|
||||||
|
) {
|
||||||
|
return this.auth(authorization).then((requesterId) =>
|
||||||
|
firstValueFrom(this.core.bot.UpdateBot({ requesterId, botId, ...dto, isSuperAdmin: false }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':botId')
|
||||||
|
@ApiOperation({ summary: 'Удалить бота', description: 'Удаляет бота и все связанные чаты/сообщения.' })
|
||||||
|
deleteBot(@Headers('authorization') authorization: string | undefined, @Param('botId') botId: string) {
|
||||||
|
return this.auth(authorization).then((requesterId) =>
|
||||||
|
firstValueFrom(this.core.bot.DeleteBot({ requesterId, botId, isSuperAdmin: false }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':botId/revoke-token')
|
||||||
|
@ApiOperation({ summary: 'Перевыпустить токен', description: 'Инвалидирует старый токен и возвращает новый.' })
|
||||||
|
revokeToken(@Headers('authorization') authorization: string | undefined, @Param('botId') botId: string) {
|
||||||
|
return this.auth(authorization).then((requesterId) =>
|
||||||
|
firstValueFrom(this.core.bot.RevokeBotToken({ requesterId, botId, isSuperAdmin: false }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':botId/profile')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Профиль бота',
|
||||||
|
description: 'Обновляет description, aboutText, botPicUrl и глобальную кнопку меню (Web App).'
|
||||||
|
})
|
||||||
|
@ApiBody({ type: UpdateBotProfileDto })
|
||||||
|
updateBotProfile(
|
||||||
|
@Headers('authorization') authorization: string | undefined,
|
||||||
|
@Param('botId') botId: string,
|
||||||
|
@Body() dto: UpdateBotProfileDto
|
||||||
|
) {
|
||||||
|
return this.auth(authorization).then((requesterId) =>
|
||||||
|
firstValueFrom(this.core.bot.UpdateBotProfile({ requesterId, botId, ...dto, isSuperAdmin: false }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':botId/web-app')
|
||||||
|
@ApiOperation({ summary: 'Настроить Mini App', description: 'Привязывает URL Web App к боту.' })
|
||||||
|
@ApiBody({ type: SetBotWebAppDto })
|
||||||
|
setWebApp(
|
||||||
|
@Headers('authorization') authorization: string | undefined,
|
||||||
|
@Param('botId') botId: string,
|
||||||
|
@Body() dto: SetBotWebAppDto
|
||||||
|
) {
|
||||||
|
return this.auth(authorization).then((requesterId) =>
|
||||||
|
firstValueFrom(this.core.bot.SetBotWebApp({ requesterId, botId, webAppUrl: dto.webAppUrl, isSuperAdmin: false }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Body, Controller, Get, Headers, Param, Patch, Post, Query } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, Headers, Param, Patch, Post, Query } from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
@@ -6,9 +6,16 @@ import { map } from 'rxjs/operators';
|
|||||||
import { CoreGrpcService } from '../core-grpc.service';
|
import { CoreGrpcService } from '../core-grpc.service';
|
||||||
import { getAuthorizedUserId } from '../document-access';
|
import { getAuthorizedUserId } from '../document-access';
|
||||||
import {
|
import {
|
||||||
|
AddChatRoomMemberDto,
|
||||||
CreateChatRoomDto,
|
CreateChatRoomDto,
|
||||||
|
CreateE2EChatRoomDto,
|
||||||
|
EditChatMessageDto,
|
||||||
|
ForwardMessagesDto,
|
||||||
|
MarkRoomReadDto,
|
||||||
SendChatMessageDto,
|
SendChatMessageDto,
|
||||||
|
SetMessagePinnedDto,
|
||||||
SetRoomNotificationsMutedDto,
|
SetRoomNotificationsMutedDto,
|
||||||
|
ToggleMessageReactionDto,
|
||||||
UpdateChatRoomDto,
|
UpdateChatRoomDto,
|
||||||
VotePollDto
|
VotePollDto
|
||||||
} from '../dto/chat.dto';
|
} from '../dto/chat.dto';
|
||||||
@@ -53,6 +60,17 @@ export class ChatController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('groups/:groupId/e2e-rooms')
|
||||||
|
@ApiOperation({ summary: 'Создать секретный E2E чат' })
|
||||||
|
async createE2ERoom(
|
||||||
|
@Headers('authorization') authorization: string | undefined,
|
||||||
|
@Param('groupId') groupId: string,
|
||||||
|
@Body() dto: CreateE2EChatRoomDto
|
||||||
|
) {
|
||||||
|
const userId = await this.auth(authorization);
|
||||||
|
return firstValueFrom(this.core.chat.CreateE2ERoom({ userId, groupId, peerUserId: dto.peerUserId }));
|
||||||
|
}
|
||||||
|
|
||||||
@Patch('rooms/:roomId')
|
@Patch('rooms/:roomId')
|
||||||
@ApiOperation({ summary: 'Настройки чата' })
|
@ApiOperation({ summary: 'Настройки чата' })
|
||||||
async updateRoom(
|
async updateRoom(
|
||||||
@@ -66,11 +84,34 @@ export class ChatController {
|
|||||||
userId,
|
userId,
|
||||||
roomId,
|
roomId,
|
||||||
name: dto.name,
|
name: dto.name,
|
||||||
notificationsMuted: dto.notificationsMuted
|
notificationsMuted: dto.notificationsMuted,
|
||||||
|
pinned: dto.pinned
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('rooms/:roomId/members')
|
||||||
|
@ApiOperation({ summary: 'Добавить участника в чат' })
|
||||||
|
async addMember(
|
||||||
|
@Headers('authorization') authorization: string | undefined,
|
||||||
|
@Param('roomId') roomId: string,
|
||||||
|
@Body() dto: AddChatRoomMemberDto
|
||||||
|
) {
|
||||||
|
const userId = await this.auth(authorization);
|
||||||
|
return firstValueFrom(this.core.chat.AddRoomMember({ userId, roomId, memberUserId: dto.memberUserId }));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('rooms/:roomId/members/:memberUserId')
|
||||||
|
@ApiOperation({ summary: 'Удалить участника из чата' })
|
||||||
|
async removeMember(
|
||||||
|
@Headers('authorization') authorization: string | undefined,
|
||||||
|
@Param('roomId') roomId: string,
|
||||||
|
@Param('memberUserId') memberUserId: string
|
||||||
|
) {
|
||||||
|
const userId = await this.auth(authorization);
|
||||||
|
return firstValueFrom(this.core.chat.RemoveRoomMember({ userId, roomId, memberUserId }));
|
||||||
|
}
|
||||||
|
|
||||||
@Get('rooms/:roomId/messages')
|
@Get('rooms/:roomId/messages')
|
||||||
@ApiOperation({ summary: 'Сообщения чата' })
|
@ApiOperation({ summary: 'Сообщения чата' })
|
||||||
async listMessages(
|
async listMessages(
|
||||||
@@ -113,7 +154,8 @@ export class ChatController {
|
|||||||
storageKey: dto.storageKey,
|
storageKey: dto.storageKey,
|
||||||
mimeType: dto.mimeType,
|
mimeType: dto.mimeType,
|
||||||
metadataJson: dto.metadataJson,
|
metadataJson: dto.metadataJson,
|
||||||
poll: dto.poll
|
poll: dto.poll,
|
||||||
|
isEncrypted: dto.isEncrypted
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -139,4 +181,92 @@ export class ChatController {
|
|||||||
const userId = await this.auth(authorization);
|
const userId = await this.auth(authorization);
|
||||||
return firstValueFrom(this.core.chat.SetRoomNotificationsMuted({ userId, roomId, muted: dto.muted }));
|
return firstValueFrom(this.core.chat.SetRoomNotificationsMuted({ userId, roomId, muted: dto.muted }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Patch('messages/:messageId')
|
||||||
|
@ApiOperation({ summary: 'Редактировать сообщение' })
|
||||||
|
async editMessage(
|
||||||
|
@Headers('authorization') authorization: string | undefined,
|
||||||
|
@Param('messageId') messageId: string,
|
||||||
|
@Body() dto: EditChatMessageDto
|
||||||
|
) {
|
||||||
|
const userId = await this.auth(authorization);
|
||||||
|
return firstValueFrom(this.core.chat.EditMessage({ userId, messageId, content: dto.content }));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('messages/:messageId')
|
||||||
|
@ApiOperation({ summary: 'Удалить сообщение' })
|
||||||
|
async deleteMessage(@Headers('authorization') authorization: string | undefined, @Param('messageId') messageId: string) {
|
||||||
|
const userId = await this.auth(authorization);
|
||||||
|
return firstValueFrom(this.core.chat.DeleteMessage({ userId, messageId }));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('messages/:messageId/pin')
|
||||||
|
@ApiOperation({ summary: 'Закрепить или открепить сообщение' })
|
||||||
|
async setMessagePinned(
|
||||||
|
@Headers('authorization') authorization: string | undefined,
|
||||||
|
@Param('messageId') messageId: string,
|
||||||
|
@Body() dto: SetMessagePinnedDto
|
||||||
|
) {
|
||||||
|
const userId = await this.auth(authorization);
|
||||||
|
return firstValueFrom(this.core.chat.SetMessagePinned({ userId, messageId, pinned: dto.pinned }));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('messages/:messageId/reactions')
|
||||||
|
@ApiOperation({ summary: 'Поставить или снять реакцию' })
|
||||||
|
async toggleReaction(
|
||||||
|
@Headers('authorization') authorization: string | undefined,
|
||||||
|
@Param('messageId') messageId: string,
|
||||||
|
@Body() dto: ToggleMessageReactionDto
|
||||||
|
) {
|
||||||
|
const userId = await this.auth(authorization);
|
||||||
|
return firstValueFrom(this.core.chat.ToggleMessageReaction({ userId, messageId, emoji: dto.emoji }));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('rooms/:roomId/forward')
|
||||||
|
@ApiOperation({ summary: 'Переслать сообщения в другой чат' })
|
||||||
|
async forwardMessages(
|
||||||
|
@Headers('authorization') authorization: string | undefined,
|
||||||
|
@Param('roomId') roomId: string,
|
||||||
|
@Body() dto: ForwardMessagesDto
|
||||||
|
) {
|
||||||
|
const userId = await this.auth(authorization);
|
||||||
|
return firstValueFrom(
|
||||||
|
this.core.chat.ForwardMessages({ userId, targetRoomId: roomId, messageIds: dto.messageIds }).pipe(
|
||||||
|
map((response) => {
|
||||||
|
const payload = response as { messages?: unknown[] };
|
||||||
|
return { messages: payload.messages ?? [] };
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('rooms/:roomId/read')
|
||||||
|
@ApiOperation({ summary: 'Отметить сообщения чата прочитанными' })
|
||||||
|
async markRoomRead(
|
||||||
|
@Headers('authorization') authorization: string | undefined,
|
||||||
|
@Param('roomId') roomId: string,
|
||||||
|
@Body() dto: MarkRoomReadDto
|
||||||
|
) {
|
||||||
|
const userId = await this.auth(authorization);
|
||||||
|
return firstValueFrom(
|
||||||
|
this.core.chat.MarkRoomRead({ userId, roomId, lastMessageId: dto.lastMessageId })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('rooms/:roomId/typing')
|
||||||
|
@ApiOperation({ summary: 'Сообщить о наборе текста в чате' })
|
||||||
|
async reportTyping(
|
||||||
|
@Headers('authorization') authorization: string | undefined,
|
||||||
|
@Param('roomId') roomId: string
|
||||||
|
) {
|
||||||
|
const userId = await this.auth(authorization);
|
||||||
|
return firstValueFrom(this.core.chat.ReportTyping({ userId, roomId }));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('rooms/:roomId')
|
||||||
|
@ApiOperation({ summary: 'Удалить чат' })
|
||||||
|
async deleteRoom(@Headers('authorization') authorization: string | undefined, @Param('roomId') roomId: string) {
|
||||||
|
const userId = await this.auth(authorization);
|
||||||
|
return firstValueFrom(this.core.chat.DeleteRoom({ userId, roomId }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Body, Controller, Delete, Get, Headers, Param, Patch, Post } from '@nes
|
|||||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
import { map } from 'rxjs';
|
|
||||||
import { CoreGrpcService } from '../core-grpc.service';
|
import { CoreGrpcService } from '../core-grpc.service';
|
||||||
import { assertDocumentsReadAccess, assertDocumentsWriteAccess } from '../document-access';
|
import { assertDocumentsReadAccess, assertDocumentsWriteAccess } from '../document-access';
|
||||||
import { CreateDocumentDto, UpdateDocumentDto } from '../dto/documents.dto';
|
import { CreateDocumentDto, UpdateDocumentDto } from '../dto/documents.dto';
|
||||||
@@ -43,14 +42,10 @@ export class DocumentsController {
|
|||||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||||
async list(@Headers('authorization') authorization: string | undefined, @Param('userId') userId: string) {
|
async list(@Headers('authorization') authorization: string | undefined, @Param('userId') userId: string) {
|
||||||
await assertDocumentsReadAccess(this.jwt, this.core, authorization, userId);
|
await assertDocumentsReadAccess(this.jwt, this.core, authorization, userId);
|
||||||
return firstValueFrom(
|
const result = (await firstValueFrom(this.core.documents.ListDocuments({ userId }))) as {
|
||||||
this.core.documents.ListDocuments({ userId }).pipe(
|
documents?: unknown[];
|
||||||
map((response) => {
|
};
|
||||||
const payload = response as { documents?: unknown[] };
|
return { documents: result.documents ?? [] };
|
||||||
return { documents: payload.documents ?? [] };
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':documentId')
|
@Get(':documentId')
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Body, Controller, Delete, ForbiddenException, Get, Headers, Param, Patch, Post } from '@nestjs/common';
|
import { Body, Controller, Delete, ForbiddenException, Get, Headers, Param, Patch, Post, Query } from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
import { map } from 'rxjs/operators';
|
import { map } from 'rxjs/operators';
|
||||||
import { CoreGrpcService } from '../core-grpc.service';
|
import { CoreGrpcService } from '../core-grpc.service';
|
||||||
import { getAuthorizedUserId } from '../document-access';
|
import { getAuthorizedUserId } from '../document-access';
|
||||||
|
import { AddFamilyMemberDto, CreateFamilyGroupDto, LeaveFamilyGroupDto, RespondFamilyInviteDto, SendFamilyInviteDto, TransferFamilyOwnershipDto, UpdateFamilyGroupDto } from '../dto/identity.dto';
|
||||||
|
|
||||||
@ApiTags('Семья')
|
@ApiTags('Семья')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@@ -16,8 +16,9 @@ export class FamilyController {
|
|||||||
private readonly jwt: JwtService
|
private readonly jwt: JwtService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ApiTags('Семья')
|
private async auth(authorization?: string, passiveActivityHeader?: string) {
|
||||||
|
const touchActivity = passiveActivityHeader !== '1';
|
||||||
|
return getAuthorizedUserId(this.jwt, this.core, authorization, touchActivity);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('groups')
|
@Post('groups')
|
||||||
@@ -33,8 +34,12 @@ export class FamilyController {
|
|||||||
|
|
||||||
@Get('users/:userId/groups')
|
@Get('users/:userId/groups')
|
||||||
@ApiOperation({ summary: 'Список семей пользователя' })
|
@ApiOperation({ summary: 'Список семей пользователя' })
|
||||||
|
async listGroups(
|
||||||
private async auth(authorization?: string) {
|
@Headers('authorization') authorization: string | undefined,
|
||||||
|
@Headers('x-id-passive-activity') passiveActivity: string | undefined,
|
||||||
|
@Param('userId') userId: string
|
||||||
|
) {
|
||||||
|
const requesterId = await this.auth(authorization, passiveActivity);
|
||||||
if (requesterId !== userId) {
|
if (requesterId !== userId) {
|
||||||
throw new ForbiddenException('Можно просматривать только свои семьи');
|
throw new ForbiddenException('Можно просматривать только свои семьи');
|
||||||
}
|
}
|
||||||
@@ -50,8 +55,12 @@ export class FamilyController {
|
|||||||
|
|
||||||
@Get('groups/:groupId')
|
@Get('groups/:groupId')
|
||||||
@ApiOperation({ summary: 'Получить семейную группу' })
|
@ApiOperation({ summary: 'Получить семейную группу' })
|
||||||
const userId = await this.auth(authorization);
|
async getGroup(
|
||||||
|
@Headers('authorization') authorization: string | undefined,
|
||||||
|
@Headers('x-id-passive-activity') passiveActivity: string | undefined,
|
||||||
|
@Param('groupId') groupId: string
|
||||||
|
) {
|
||||||
|
const requesterId = await this.auth(authorization, passiveActivity);
|
||||||
return firstValueFrom(this.core.family.GetFamilyGroup({ requesterId, groupId }));
|
return firstValueFrom(this.core.family.GetFamilyGroup({ requesterId, groupId }));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +100,51 @@ export class FamilyController {
|
|||||||
return firstValueFrom(this.core.family.RemoveFamilyMember({ requesterId, memberId }));
|
return firstValueFrom(this.core.family.RemoveFamilyMember({ requesterId, memberId }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('groups/:groupId/leave')
|
||||||
|
@ApiOperation({ summary: 'Покинуть семейную группу' })
|
||||||
|
async leaveGroup(
|
||||||
|
@Headers('authorization') authorization: string | undefined,
|
||||||
|
@Param('groupId') groupId: string,
|
||||||
|
@Body() dto: LeaveFamilyGroupDto
|
||||||
|
) {
|
||||||
|
const requesterId = await this.auth(authorization);
|
||||||
|
return firstValueFrom(
|
||||||
|
this.core.family.LeaveFamilyGroup({
|
||||||
|
requesterId,
|
||||||
|
groupId,
|
||||||
|
newOwnerUserId: dto.newOwnerUserId
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('groups/:groupId/transfer-ownership')
|
||||||
|
@ApiOperation({ summary: 'Передать управление семейной группой' })
|
||||||
|
async transferOwnership(
|
||||||
|
@Headers('authorization') authorization: string | undefined,
|
||||||
|
@Param('groupId') groupId: string,
|
||||||
|
@Body() dto: TransferFamilyOwnershipDto
|
||||||
|
) {
|
||||||
|
const requesterId = await this.auth(authorization);
|
||||||
|
return firstValueFrom(
|
||||||
|
this.core.family.TransferFamilyOwnership({
|
||||||
|
requesterId,
|
||||||
|
groupId,
|
||||||
|
newOwnerUserId: dto.newOwnerUserId
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('groups/:groupId/invite-search')
|
||||||
|
@ApiOperation({ summary: 'Поиск пользователей для приглашения в семью' })
|
||||||
|
async searchInviteUsers(
|
||||||
|
@Headers('authorization') authorization: string | undefined,
|
||||||
|
@Param('groupId') groupId: string,
|
||||||
|
@Query('q') query: string
|
||||||
|
) {
|
||||||
|
const requesterId = await this.auth(authorization);
|
||||||
|
return firstValueFrom(this.core.family.SearchFamilyInviteUsers({ requesterId, groupId, query: query ?? '' }));
|
||||||
|
}
|
||||||
|
|
||||||
@Post('groups/:groupId/invites')
|
@Post('groups/:groupId/invites')
|
||||||
@ApiOperation({ summary: 'Пригласить участника в семью' })
|
@ApiOperation({ summary: 'Пригласить участника в семью' })
|
||||||
async sendInvite(
|
async sendInvite(
|
||||||
@@ -99,7 +153,14 @@ export class FamilyController {
|
|||||||
@Body() dto: SendFamilyInviteDto
|
@Body() dto: SendFamilyInviteDto
|
||||||
) {
|
) {
|
||||||
const requesterId = await this.auth(authorization);
|
const requesterId = await this.auth(authorization);
|
||||||
|
return firstValueFrom(
|
||||||
|
this.core.family.SendFamilyInvite({
|
||||||
|
requesterId,
|
||||||
|
groupId,
|
||||||
|
target: dto.target,
|
||||||
|
inviteeUserId: dto.inviteeUserId
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('invites')
|
@Get('invites')
|
||||||
@@ -126,5 +187,17 @@ export class FamilyController {
|
|||||||
const userId = await this.auth(authorization);
|
const userId = await this.auth(authorization);
|
||||||
return firstValueFrom(this.core.family.RespondFamilyInvite({ userId, inviteId, accept: dto.accept }));
|
return firstValueFrom(this.core.family.RespondFamilyInvite({ userId, inviteId, accept: dto.accept }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('groups/:groupId/presence')
|
||||||
|
@ApiOperation({ summary: 'Онлайн-статус участников семьи' })
|
||||||
|
async getPresence(
|
||||||
|
@Headers('authorization') authorization: string | undefined,
|
||||||
|
@Headers('x-id-passive-activity') passiveActivity: string | undefined,
|
||||||
|
@Param('groupId') groupId: string
|
||||||
|
) {
|
||||||
|
const requesterId = await this.auth(authorization, passiveActivity);
|
||||||
|
return firstValueFrom(this.core.family.GetFamilyPresence({ requesterId, groupId }));
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
321
apps/api-gateway/src/controllers/fedcm.controller.ts
Normal file
321
apps/api-gateway/src/controllers/fedcm.controller.ts
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Headers,
|
||||||
|
HttpCode,
|
||||||
|
NotFoundException,
|
||||||
|
Options,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
UnauthorizedException
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import { CoreGrpcService } from '../core-grpc.service';
|
||||||
|
import {
|
||||||
|
applyFedcmCorsHeaders,
|
||||||
|
applyFedcmLoginStatus,
|
||||||
|
applyFedcmPreflightHeaders,
|
||||||
|
assertFedcmWebIdentityRequest,
|
||||||
|
requireFedcmCorsOrigin
|
||||||
|
} from '../lib/fedcm-cors';
|
||||||
|
import {
|
||||||
|
buildFedcmDiscoverPayload,
|
||||||
|
buildFedcmProviderConfig,
|
||||||
|
readOneTapEnabled,
|
||||||
|
resolveFedcmEndpoints
|
||||||
|
} from '../lib/fedcm-config';
|
||||||
|
import { resolveFedcmSessionFromRequest, setFedcmSessionCookie } from '../lib/fedcm-cookie';
|
||||||
|
import { resolveFedcmSessionPinState } from '../lib/fedcm-session';
|
||||||
|
import { verifyAccessToken } from '../session-auth';
|
||||||
|
|
||||||
|
type FedcmAccountsResponse = {
|
||||||
|
accounts: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
given_name?: string;
|
||||||
|
email?: string;
|
||||||
|
picture?: string;
|
||||||
|
tel?: string;
|
||||||
|
approved_clients?: string[];
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
@ApiTags('FedCM')
|
||||||
|
@Controller('fedcm')
|
||||||
|
export class FedcmController {
|
||||||
|
constructor(
|
||||||
|
private readonly core: CoreGrpcService,
|
||||||
|
private readonly jwt: JwtService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Options('config.json')
|
||||||
|
@HttpCode(204)
|
||||||
|
configPreflight(@Headers('origin') origin: string | undefined, @Res({ passthrough: true }) res: Response) {
|
||||||
|
applyFedcmPreflightHeaders(res, origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Options('accounts')
|
||||||
|
@HttpCode(204)
|
||||||
|
accountsPreflight(@Headers('origin') origin: string | undefined, @Res({ passthrough: true }) res: Response) {
|
||||||
|
applyFedcmPreflightHeaders(res, origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Options('id_assertion')
|
||||||
|
@HttpCode(204)
|
||||||
|
idAssertionPreflight(@Headers('origin') origin: string | undefined, @Res({ passthrough: true }) res: Response) {
|
||||||
|
applyFedcmPreflightHeaders(res, origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Options('client_metadata')
|
||||||
|
@HttpCode(204)
|
||||||
|
clientMetadataPreflight(@Headers('origin') origin: string | undefined, @Res({ passthrough: true }) res: Response) {
|
||||||
|
applyFedcmPreflightHeaders(res, origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Options('discover.json')
|
||||||
|
@HttpCode(204)
|
||||||
|
discoverPreflight(@Res({ passthrough: true }) res: Response) {
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||||
|
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
||||||
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Accept');
|
||||||
|
res.setHeader('Access-Control-Max-Age', '86400');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('config.json')
|
||||||
|
@ApiOperation({ summary: 'FedCM provider config', description: 'Конфигурация Identity Provider для Federated Credential Management API.' })
|
||||||
|
async config(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
||||||
|
assertFedcmWebIdentityRequest(req);
|
||||||
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=300');
|
||||||
|
|
||||||
|
const enabled = await readOneTapEnabled(this.core);
|
||||||
|
if (!enabled) {
|
||||||
|
throw new NotFoundException('One Tap Login отключён');
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoints = await resolveFedcmEndpoints(this.core, req);
|
||||||
|
return buildFedcmProviderConfig(endpoints);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('discover.json')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'FedCM discovery',
|
||||||
|
description: 'Публичные URL FedCM для виджета и диагностики (apiBase, configUrl, webIdentityUrl).'
|
||||||
|
})
|
||||||
|
async discover(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
||||||
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=60');
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||||
|
|
||||||
|
const enabled = await readOneTapEnabled(this.core);
|
||||||
|
const endpoints = await resolveFedcmEndpoints(this.core, req);
|
||||||
|
return buildFedcmDiscoverPayload(endpoints, enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('accounts')
|
||||||
|
@ApiOperation({ summary: 'FedCM accounts', description: 'Возвращает аккаунты пользователя по FedCM-сессии (cookie).' })
|
||||||
|
async accounts(
|
||||||
|
@Req() req: Request,
|
||||||
|
@Headers('origin') origin: string | undefined,
|
||||||
|
@Res({ passthrough: true }) res: Response
|
||||||
|
): Promise<FedcmAccountsResponse> {
|
||||||
|
assertFedcmWebIdentityRequest(req);
|
||||||
|
applyFedcmCorsHeaders(res, origin);
|
||||||
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||||
|
|
||||||
|
const { session, requiresPin } = await resolveFedcmSessionPinState(this.jwt, this.core, req.headers.cookie);
|
||||||
|
if (!session) {
|
||||||
|
applyFedcmLoginStatus(res, false);
|
||||||
|
return { accounts: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requiresPin) {
|
||||||
|
applyFedcmLoginStatus(res, false);
|
||||||
|
return { accounts: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = (await firstValueFrom(
|
||||||
|
this.core.fedcm.GetAccounts({ userId: session.sub, sessionId: session.sessionId })
|
||||||
|
)) as FedcmAccountsResponse;
|
||||||
|
const accounts = result?.accounts ?? [];
|
||||||
|
applyFedcmLoginStatus(res, accounts.length > 0);
|
||||||
|
return { accounts };
|
||||||
|
} catch {
|
||||||
|
applyFedcmLoginStatus(res, false);
|
||||||
|
return { accounts: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('client_metadata')
|
||||||
|
@ApiOperation({ summary: 'FedCM client metadata', description: 'Метаданные клиента для UI FedCM.' })
|
||||||
|
async clientMetadata(
|
||||||
|
@Req() req: Request,
|
||||||
|
@Query('client_id') clientId: string | undefined,
|
||||||
|
@Headers('origin') origin: string | undefined,
|
||||||
|
@Res({ passthrough: true }) res: Response
|
||||||
|
) {
|
||||||
|
assertFedcmWebIdentityRequest(req);
|
||||||
|
const rpOrigin = requireFedcmCorsOrigin(origin);
|
||||||
|
applyFedcmCorsHeaders(res, rpOrigin);
|
||||||
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||||
|
|
||||||
|
if (!clientId?.trim()) {
|
||||||
|
throw new BadRequestException('Передайте client_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
return firstValueFrom(this.core.fedcm.GetClientMetadata({ clientId: clientId.trim() }));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('id_assertion')
|
||||||
|
@HttpCode(200)
|
||||||
|
@ApiOperation({ summary: 'FedCM id assertion', description: 'Выдаёт OIDC id_token для выбранного аккаунта и OAuth-клиента.' })
|
||||||
|
async idAssertion(
|
||||||
|
@Req() req: Request,
|
||||||
|
@Headers('origin') origin: string | undefined,
|
||||||
|
@Res({ passthrough: true }) res: Response
|
||||||
|
) {
|
||||||
|
assertFedcmWebIdentityRequest(req);
|
||||||
|
const rpOrigin = requireFedcmCorsOrigin(origin);
|
||||||
|
applyFedcmCorsHeaders(res, rpOrigin);
|
||||||
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||||
|
|
||||||
|
const { session, requiresPin } = await resolveFedcmSessionPinState(this.jwt, this.core, req.headers.cookie);
|
||||||
|
if (!session) {
|
||||||
|
throw new UnauthorizedException('Сессия FedCM не найдена');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requiresPin) {
|
||||||
|
applyFedcmLoginStatus(res, false);
|
||||||
|
throw new UnauthorizedException('Требуется подтверждение PIN-кода');
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (req.body ?? {}) as Record<string, unknown>;
|
||||||
|
const clientId = String(body.client_id ?? body.clientId ?? '').trim();
|
||||||
|
const accountId = String(body.account_id ?? body.accountId ?? '').trim();
|
||||||
|
if (!clientId || !accountId) {
|
||||||
|
throw new BadRequestException('Передайте client_id и account_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
applyFedcmLoginStatus(res, true);
|
||||||
|
|
||||||
|
return firstValueFrom(
|
||||||
|
this.core.fedcm.IssueIdAssertion({
|
||||||
|
userId: session.sub,
|
||||||
|
sessionId: session.sessionId,
|
||||||
|
clientId,
|
||||||
|
accountId
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async syncFedcmSessionFromAuthorization(
|
||||||
|
authorization: string | undefined,
|
||||||
|
res: Response
|
||||||
|
) {
|
||||||
|
const payload = await verifyAccessToken(this.jwt, authorization);
|
||||||
|
if (!payload.sessionId) {
|
||||||
|
throw new UnauthorizedException('Сессия не найдена');
|
||||||
|
}
|
||||||
|
|
||||||
|
const validation = (await firstValueFrom(
|
||||||
|
this.core.auth.ValidateSession({
|
||||||
|
userId: payload.sub,
|
||||||
|
sessionId: payload.sessionId,
|
||||||
|
touchActivity: false
|
||||||
|
})
|
||||||
|
)) as { requiresPin: boolean; sessionId: string; pinVerified: boolean };
|
||||||
|
|
||||||
|
await setFedcmSessionCookie(res, this.jwt, {
|
||||||
|
sub: payload.sub,
|
||||||
|
sessionId: payload.sessionId,
|
||||||
|
pinVerified: !validation.requiresPin
|
||||||
|
});
|
||||||
|
applyFedcmLoginStatus(res, true);
|
||||||
|
return { synced: true, requiresPin: validation.requiresPin };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('session/sync')
|
||||||
|
@HttpCode(200)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Синхронизировать FedCM cookie',
|
||||||
|
description: 'Устанавливает HttpOnly cookie для FedCM по Bearer access token (для уже авторизованных пользователей IdP).'
|
||||||
|
})
|
||||||
|
syncSessionPost(
|
||||||
|
@Headers('authorization') authorization: string | undefined,
|
||||||
|
@Res({ passthrough: true }) res: Response
|
||||||
|
) {
|
||||||
|
return this.syncFedcmSessionFromAuthorization(authorization, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('session/sync')
|
||||||
|
@HttpCode(200)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Синхронизировать FedCM cookie (GET)',
|
||||||
|
description: 'Тот же sync по Bearer token. GET нужен для совместимости с редиректами прокси и prefetch.'
|
||||||
|
})
|
||||||
|
syncSessionGet(
|
||||||
|
@Headers('authorization') authorization: string | undefined,
|
||||||
|
@Res({ passthrough: true }) res: Response
|
||||||
|
) {
|
||||||
|
return this.syncFedcmSessionFromAuthorization(authorization, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('session/status')
|
||||||
|
@HttpCode(200)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Состояние FedCM-сессии',
|
||||||
|
description: 'Для login_url на API-домене: проверяет cookie и PIN-блокировку без Bearer token.'
|
||||||
|
})
|
||||||
|
async sessionStatus(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
||||||
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||||
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
|
|
||||||
|
const session = await resolveFedcmSessionFromRequest(this.jwt, req.headers.cookie);
|
||||||
|
if (!session) {
|
||||||
|
applyFedcmLoginStatus(res, false);
|
||||||
|
return { active: false, requiresPin: false, sessionId: null, userId: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const validation = (await firstValueFrom(
|
||||||
|
this.core.auth.ValidateSession({
|
||||||
|
userId: session.sub,
|
||||||
|
sessionId: session.sessionId,
|
||||||
|
touchActivity: false
|
||||||
|
})
|
||||||
|
)) as { requiresPin: boolean; sessionId: string; pinVerified: boolean };
|
||||||
|
|
||||||
|
const requiresPin = validation.requiresPin || !session.pinVerified;
|
||||||
|
applyFedcmLoginStatus(res, true);
|
||||||
|
|
||||||
|
return {
|
||||||
|
active: true,
|
||||||
|
requiresPin,
|
||||||
|
sessionId: validation.sessionId,
|
||||||
|
userId: session.sub
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('login-status')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'FedCM Login Status (API origin)',
|
||||||
|
description:
|
||||||
|
'Минимальная HTML-страница на API-домене: Set-Login + navigator.login.setStatus для Chrome FedCM (origin login_url).'
|
||||||
|
})
|
||||||
|
async loginStatus(@Req() req: Request, @Res() res: Response) {
|
||||||
|
const { session, requiresPin } = await resolveFedcmSessionPinState(this.jwt, this.core, req.headers.cookie);
|
||||||
|
const loggedIn = Boolean(session && !requiresPin);
|
||||||
|
applyFedcmLoginStatus(res, loggedIn);
|
||||||
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||||
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
|
res.status(200).send(`<!DOCTYPE html><html lang="ru"><head><meta charset="utf-8"></head><body><script>
|
||||||
|
try{if(navigator.login&&navigator.login.setStatus){navigator.login.setStatus('${loggedIn ? 'logged-in' : 'logged-out'}');}}catch(e){}
|
||||||
|
</script></body></html>`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,34 @@
|
|||||||
import { Controller, Get } from '@nestjs/common';
|
import { Controller, Get, ServiceUnavailableException } from '@nestjs/common';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
import { CoreGrpcService } from '../core-grpc.service';
|
||||||
|
|
||||||
@Controller('health')
|
@Controller('health')
|
||||||
export class HealthController {
|
export class HealthController {
|
||||||
|
constructor(private readonly core: CoreGrpcService) {}
|
||||||
|
|
||||||
|
// Liveness-проба. ВАЖНО: проверяет только то, что HTTP-сервер api-gateway
|
||||||
|
// поднят, и НЕ зависит от sso-core. Иначе временная недоступность gRPC
|
||||||
|
// помечает весь api-gateway как unhealthy в Docker healthcheck, что роняет
|
||||||
|
// зависимые сервисы и приводит к 502 на всех /idp-api/* маршрутах.
|
||||||
@Get()
|
@Get()
|
||||||
check() {
|
check() {
|
||||||
return { status: 'ok', service: 'api-gateway' };
|
return { status: 'ok', service: 'api-gateway' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Readiness/диагностика связи с sso-core по gRPC.
|
||||||
|
// НЕ используется в Docker healthcheck, чтобы сбой sso-core не каскадил.
|
||||||
|
@Get('ready')
|
||||||
|
async ready() {
|
||||||
|
try {
|
||||||
|
await firstValueFrom(this.core.settings.GetSetting({ key: 'PROJECT_NAME' }));
|
||||||
|
return { status: 'ok', service: 'api-gateway', grpc: 'ok' };
|
||||||
|
} catch {
|
||||||
|
throw new ServiceUnavailableException({
|
||||||
|
status: 'error',
|
||||||
|
service: 'api-gateway',
|
||||||
|
grpc: 'unavailable',
|
||||||
|
message: 'Не удалось связаться с sso-core по gRPC'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Body, Controller, Get, Headers, Param, Post, Query, Res } from '@nestjs/common';
|
import { BadRequestException, Body, Controller, ForbiddenException, Get, Headers, Param, Post, Query, Res, UploadedFile, UseInterceptors } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3';
|
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
import { CoreGrpcService } from '../core-grpc.service';
|
import { CoreGrpcService } from '../core-grpc.service';
|
||||||
import { getAuthorizedUserId } from '../document-access';
|
import { getAuthorizedUserId } from '../document-access';
|
||||||
@@ -44,6 +45,49 @@ export class MediaController {
|
|||||||
return getAuthorizedUserId(this.jwt, this.core, authorization);
|
return getAuthorizedUserId(this.jwt, this.core, authorization);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('upload')
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@ApiOperation({ summary: 'Загрузить файл через API', description: 'Принимает файл по uploadToken из upload-url ответа. Обходит прямой доступ браузера к MinIO.' })
|
||||||
|
@ApiResponse({ status: 201, description: 'Файл загружен' })
|
||||||
|
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 50 * 1024 * 1024 } }))
|
||||||
|
async uploadObject(
|
||||||
|
@Headers('x-upload-token') uploadToken: string | undefined,
|
||||||
|
@UploadedFile() file: { buffer: Buffer; mimetype: string } | undefined
|
||||||
|
) {
|
||||||
|
if (!uploadToken) {
|
||||||
|
throw new BadRequestException('Не передан uploadToken');
|
||||||
|
}
|
||||||
|
if (!file) {
|
||||||
|
throw new BadRequestException('Файл не передан');
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload: { purpose?: string; storageKey?: string; contentType?: string };
|
||||||
|
try {
|
||||||
|
payload = await this.jwt.verifyAsync(uploadToken, {
|
||||||
|
secret: process.env.JWT_ACCESS_SECRET ?? 'docker-access-secret',
|
||||||
|
issuer: 'id.lendry.ru'
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new ForbiddenException('Ссылка загрузки недействительна или истекла');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.purpose !== 'media-upload' || !payload.storageKey) {
|
||||||
|
throw new ForbiddenException('Ссылка загрузки недействительна');
|
||||||
|
}
|
||||||
|
|
||||||
|
const bucket = process.env.MINIO_BUCKET ?? 'lendry-id';
|
||||||
|
await this.getS3Client().send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: bucket,
|
||||||
|
Key: payload.storageKey,
|
||||||
|
Body: file.buffer,
|
||||||
|
ContentType: file.mimetype || payload.contentType || 'application/octet-stream'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return { ok: true, storageKey: payload.storageKey };
|
||||||
|
}
|
||||||
|
|
||||||
@Post('avatars/upload-url')
|
@Post('avatars/upload-url')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({ summary: 'Получить URL для загрузки аватара', description: 'Возвращает presigned URL MinIO для загрузки изображения аватара.' })
|
@ApiOperation({ summary: 'Получить URL для загрузки аватара', description: 'Возвращает presigned URL MinIO для загрузки изображения аватара.' })
|
||||||
@@ -73,7 +117,7 @@ export class MediaController {
|
|||||||
|
|
||||||
@Post('documents/:documentId/photo/upload-url')
|
@Post('documents/:documentId/photo/upload-url')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({ summary: 'Получить URL для фото документа', description: 'Presigned URL для загрузки скана/фото документа в MinIO.' })
|
@ApiOperation({ summary: 'Получить URL для файла документа', description: 'Presigned URL для загрузки скана, фото или файла документа в MinIO.' })
|
||||||
@ApiBody({ type: DocumentPhotoUploadDto })
|
@ApiBody({ type: DocumentPhotoUploadDto })
|
||||||
async createDocumentPhotoUploadUrl(
|
async createDocumentPhotoUploadUrl(
|
||||||
@Headers('authorization') authorization: string | undefined,
|
@Headers('authorization') authorization: string | undefined,
|
||||||
@@ -81,22 +125,31 @@ export class MediaController {
|
|||||||
@Body() dto: DocumentPhotoUploadDto
|
@Body() dto: DocumentPhotoUploadDto
|
||||||
) {
|
) {
|
||||||
const userId = await this.authUserId(authorization);
|
const userId = await this.authUserId(authorization);
|
||||||
return firstValueFrom(this.core.media.CreateDocumentPhotoUploadUrl({ userId, documentId, contentType: dto.contentType }));
|
return firstValueFrom(
|
||||||
|
this.core.media.CreateDocumentPhotoUploadUrl({
|
||||||
|
userId,
|
||||||
|
documentId,
|
||||||
|
contentType: dto.contentType,
|
||||||
|
fileName: dto.fileName
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('users/:userId/documents/photo-url')
|
@Get('users/:userId/documents/photo-url')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({ summary: 'Получить ссылку на фото документа', description: 'Временная ссылка на просмотр фото документа (15 минут).' })
|
@ApiOperation({ summary: 'Получить ссылку на файл документа', description: 'Временная ссылка на просмотр файла документа (15 минут).' })
|
||||||
@ApiParam({ name: 'userId', description: 'ID владельца документа' })
|
@ApiParam({ name: 'userId', description: 'ID владельца документа' })
|
||||||
@ApiQuery({ name: 'storageKey', description: 'Ключ объекта в MinIO' })
|
@ApiQuery({ name: 'storageKey', description: 'Ключ объекта в MinIO' })
|
||||||
|
@ApiQuery({ name: 'fileName', required: false, description: 'Имя файла для скачивания' })
|
||||||
async getDocumentPhotoUrl(
|
async getDocumentPhotoUrl(
|
||||||
@Headers('authorization') authorization: string | undefined,
|
@Headers('authorization') authorization: string | undefined,
|
||||||
@Param('userId') userId: string,
|
@Param('userId') userId: string,
|
||||||
@Query('storageKey') storageKey: string
|
@Query('storageKey') storageKey: string,
|
||||||
|
@Query('fileName') fileName?: string
|
||||||
) {
|
) {
|
||||||
const requesterId = await this.authUserId(authorization);
|
const requesterId = await this.authUserId(authorization);
|
||||||
return firstValueFrom(
|
return firstValueFrom(
|
||||||
this.core.media.GetDocumentPhotoAccessUrl({ requesterId, targetUserId: userId, storageKey })
|
this.core.media.GetDocumentPhotoAccessUrl({ requesterId, targetUserId: userId, storageKey, fileName })
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,4 +265,37 @@ export class MediaController {
|
|||||||
this.core.media.GetChatMediaAccessUrl({ requesterId, roomId, storageKey, fileName })
|
this.core.media.GetChatMediaAccessUrl({ requesterId, roomId, storageKey, fileName })
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('chat/:roomId/avatar/upload-url')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
async createChatRoomAvatarUploadUrl(
|
||||||
|
@Headers('authorization') authorization: string | undefined,
|
||||||
|
@Param('roomId') roomId: string,
|
||||||
|
@Body() dto: AvatarUploadDto
|
||||||
|
) {
|
||||||
|
const requesterId = await this.authUserId(authorization);
|
||||||
|
return firstValueFrom(
|
||||||
|
this.core.media.CreateChatRoomAvatarUploadUrl({ requesterId, roomId, contentType: dto.contentType })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('chat/:roomId/avatar/confirm')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
async confirmChatRoomAvatar(
|
||||||
|
@Headers('authorization') authorization: string | undefined,
|
||||||
|
@Param('roomId') roomId: string,
|
||||||
|
@Body() dto: ConfirmAvatarDto
|
||||||
|
) {
|
||||||
|
const requesterId = await this.authUserId(authorization);
|
||||||
|
return firstValueFrom(
|
||||||
|
this.core.media.ConfirmChatRoomAvatar({ requesterId, roomId, storageKey: dto.storageKey })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('chat/:roomId/avatar/url')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
async getChatRoomAvatarUrl(@Headers('authorization') authorization: string | undefined, @Param('roomId') roomId: string) {
|
||||||
|
const requesterId = await this.authUserId(authorization);
|
||||||
|
return firstValueFrom(this.core.media.GetChatRoomAvatarAccessUrl({ requesterId, roomId }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { firstValueFrom } from 'rxjs';
|
|||||||
import { map } from 'rxjs/operators';
|
import { map } from 'rxjs/operators';
|
||||||
import { CoreGrpcService } from '../core-grpc.service';
|
import { CoreGrpcService } from '../core-grpc.service';
|
||||||
import { getAuthorizedUserId } from '../document-access';
|
import { getAuthorizedUserId } from '../document-access';
|
||||||
import { MarkNotificationReadDto } from '../dto/notifications.dto';
|
import { MarkNotificationReadDto, RegisterPushTokenDto, UnregisterPushTokenDto } from '../dto/notifications.dto';
|
||||||
|
|
||||||
@ApiTags('Уведомления')
|
@ApiTags('Уведомления')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@@ -40,8 +40,12 @@ export class NotificationsController {
|
|||||||
|
|
||||||
@Get('unread-count')
|
@Get('unread-count')
|
||||||
@ApiOperation({ summary: 'Количество непрочитанных уведомлений' })
|
@ApiOperation({ summary: 'Количество непрочитанных уведомлений' })
|
||||||
async unreadCount(@Headers('authorization') authorization: string | undefined) {
|
async unreadCount(
|
||||||
const userId = await getAuthorizedUserId(this.jwt, this.core, authorization);
|
@Headers('authorization') authorization: string | undefined,
|
||||||
|
@Headers('x-id-passive-activity') passiveActivity: string | undefined
|
||||||
|
) {
|
||||||
|
const touchActivity = passiveActivity !== '1';
|
||||||
|
const userId = await getAuthorizedUserId(this.jwt, this.core, authorization, touchActivity);
|
||||||
return firstValueFrom(this.core.notifications.GetUnreadCount({ userId }));
|
return firstValueFrom(this.core.notifications.GetUnreadCount({ userId }));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,4 +83,36 @@ export class NotificationsController {
|
|||||||
const userId = await getAuthorizedUserId(this.jwt, this.core, authorization);
|
const userId = await getAuthorizedUserId(this.jwt, this.core, authorization);
|
||||||
return firstValueFrom(this.core.notifications.DeleteAllNotifications({ userId }));
|
return firstValueFrom(this.core.notifications.DeleteAllNotifications({ userId }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('push/register')
|
||||||
|
@ApiOperation({ summary: 'Зарегистрировать FCM-токен устройства' })
|
||||||
|
async registerPushToken(
|
||||||
|
@Headers('authorization') authorization: string | undefined,
|
||||||
|
@Body() dto: RegisterPushTokenDto
|
||||||
|
) {
|
||||||
|
const userId = await getAuthorizedUserId(this.jwt, this.core, authorization);
|
||||||
|
return firstValueFrom(
|
||||||
|
this.core.notifications.RegisterPushToken({
|
||||||
|
userId,
|
||||||
|
token: dto.token,
|
||||||
|
platform: dto.platform ?? 'WEB',
|
||||||
|
deviceLabel: dto.deviceLabel
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('push/register')
|
||||||
|
@ApiOperation({ summary: 'Удалить FCM-токен устройства' })
|
||||||
|
async unregisterPushToken(
|
||||||
|
@Headers('authorization') authorization: string | undefined,
|
||||||
|
@Body() dto: UnregisterPushTokenDto
|
||||||
|
) {
|
||||||
|
const userId = await getAuthorizedUserId(this.jwt, this.core, authorization);
|
||||||
|
return firstValueFrom(
|
||||||
|
this.core.notifications.UnregisterPushToken({
|
||||||
|
userId,
|
||||||
|
token: dto.token
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +1,274 @@
|
|||||||
import { Body, Controller, Get, Headers, Post, Query } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, Headers, Param, Post, Query, Res, UnauthorizedException, UsePipes, ValidationPipe } from '@nestjs/common';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiBody, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
import { CoreGrpcService } from '../core-grpc.service';
|
import { CoreGrpcService } from '../core-grpc.service';
|
||||||
import { OAuthAuthorizeQueryDto, OAuthTokenDto } from '../dto/identity.dto';
|
import { OAuthAuthorizeIncomingDto, OAuthConsentActionDto, OAuthTokenIncomingDto } from '../dto/oauth.dto';
|
||||||
import { extractBearerToken } from '../auth-token';
|
import { extractBearerToken } from '../auth-token';
|
||||||
|
import { appendQueryParams, mapOAuthClientPublicInfo, mapOAuthConsentCheckResponse, mapUserInfoToOidc, mapUserOAuthConsentsResponse, mergeTokenCredentials, mapTokenResponseToStandard, normalizeAuthorizeQuery, normalizeConsentQuery, normalizeTokenBody } from '../lib/oauth-params';
|
||||||
|
import { resolveFrontendUrl } from '../lib/oauth-issuer';
|
||||||
|
import { verifyAccessToken } from '../session-auth';
|
||||||
|
|
||||||
|
type HttpResponse = {
|
||||||
|
redirect(status: number, url: string): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const oauthValidationPipe = new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
transform: true,
|
||||||
|
forbidNonWhitelisted: false
|
||||||
|
});
|
||||||
|
|
||||||
@ApiTags('OAuth 2.0')
|
@ApiTags('OAuth 2.0')
|
||||||
@Controller('oauth')
|
@Controller('oauth')
|
||||||
export class OAuthController {
|
export class OAuthController {
|
||||||
constructor(private readonly core: CoreGrpcService) {}
|
constructor(
|
||||||
|
private readonly core: CoreGrpcService,
|
||||||
|
private readonly jwt: JwtService
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get('authorize')
|
@Get('authorize')
|
||||||
@ApiOperation({ summary: 'OAuth авторизация', description: 'Создает authorization_code и возвращает redirectUrl для OAuth клиента. Consent считается подтвержденным для переданного userId.' })
|
@UsePipes(oauthValidationPipe)
|
||||||
@ApiQuery({ name: 'userId', description: 'ID пользователя' })
|
@ApiOperation({
|
||||||
@ApiQuery({ name: 'clientId', description: 'OAuth client_id' })
|
summary: 'OAuth / OIDC авторизация',
|
||||||
@ApiQuery({ name: 'redirectUri', description: 'redirect_uri' })
|
description:
|
||||||
@ApiQuery({ name: 'scope', description: 'Scopes через пробел' })
|
'Поддерживает стандартные query-параметры RFC 6749 / OIDC (client_id, redirect_uri, response_type, code_challenge) и legacy camelCase. ' +
|
||||||
@ApiResponse({ status: 200, description: 'Authorization code создан' })
|
'Без userId и без Bearer-токена перенаправляет на страницу подтверждения доступа.'
|
||||||
authorize(@Query() query: OAuthAuthorizeQueryDto) {
|
})
|
||||||
return this.core.oauth.Authorize(query);
|
@ApiQuery({ name: 'client_id', required: false, description: 'OAuth client_id (RFC 6749)' })
|
||||||
|
@ApiQuery({ name: 'clientId', required: false, description: 'OAuth client_id (legacy)' })
|
||||||
|
@ApiQuery({ name: 'redirect_uri', required: false })
|
||||||
|
@ApiQuery({ name: 'redirectUri', required: false })
|
||||||
|
@ApiQuery({ name: 'scope', required: false })
|
||||||
|
@ApiQuery({ name: 'state', required: false })
|
||||||
|
@ApiQuery({ name: 'response_type', required: false, example: 'code' })
|
||||||
|
@ApiQuery({ name: 'code_challenge', required: false })
|
||||||
|
@ApiQuery({ name: 'code_challenge_method', required: false })
|
||||||
|
@ApiQuery({ name: 'userId', required: false, description: 'Legacy: ID пользователя после входа' })
|
||||||
|
@ApiResponse({ status: 302, description: 'Redirect на redirect_uri с authorization code' })
|
||||||
|
async authorize(
|
||||||
|
@Query() query: OAuthAuthorizeIncomingDto,
|
||||||
|
@Headers('authorization') authorization: string | undefined,
|
||||||
|
@Headers('accept') acceptHeader: string | undefined,
|
||||||
|
@Res({ passthrough: true }) res: HttpResponse
|
||||||
|
) {
|
||||||
|
const normalized = normalizeAuthorizeQuery(query as Record<string, unknown>);
|
||||||
|
let userId: string | undefined;
|
||||||
|
|
||||||
|
if (authorization) {
|
||||||
|
try {
|
||||||
|
const payload = await verifyAccessToken(this.jwt, authorization);
|
||||||
|
userId = payload.sub;
|
||||||
|
} catch {
|
||||||
|
// токен недействителен — не доверяем userId из query
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
const frontendUrl = await resolveFrontendUrl(this.core);
|
||||||
|
const consentQuery = { ...(query as Record<string, unknown>) };
|
||||||
|
delete consentQuery.userId;
|
||||||
|
delete consentQuery.user_id;
|
||||||
|
const consentUrl = appendQueryParams(`${frontendUrl}/auth/oauth/authorize`, consentQuery);
|
||||||
|
res.redirect(302, consentUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const consentCheck = (await firstValueFrom(
|
||||||
|
this.core.oauth.CheckOAuthConsent({
|
||||||
|
userId,
|
||||||
|
clientId: normalized.clientId,
|
||||||
|
scope: normalized.scope
|
||||||
|
})
|
||||||
|
)) as { granted?: boolean };
|
||||||
|
|
||||||
|
const accept = acceptHeader ?? '';
|
||||||
|
const wantsJson = authorization?.startsWith('Bearer') || accept.includes('application/json');
|
||||||
|
|
||||||
|
if (!consentCheck.granted) {
|
||||||
|
if (wantsJson) {
|
||||||
|
const pendingConsent = (await firstValueFrom(
|
||||||
|
this.core.oauth.CheckOAuthConsent({
|
||||||
|
userId,
|
||||||
|
clientId: normalized.clientId,
|
||||||
|
scope: normalized.scope
|
||||||
|
})
|
||||||
|
)) as Record<string, unknown>;
|
||||||
|
return mapOAuthConsentCheckResponse(pendingConsent);
|
||||||
|
}
|
||||||
|
const frontendUrl = await resolveFrontendUrl(this.core);
|
||||||
|
const consentQuery = { ...(query as Record<string, unknown>) };
|
||||||
|
delete consentQuery.userId;
|
||||||
|
delete consentQuery.user_id;
|
||||||
|
const consentUrl = appendQueryParams(`${frontendUrl}/auth/oauth/authorize`, consentQuery);
|
||||||
|
res.redirect(302, consentUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = (await firstValueFrom(
|
||||||
|
this.core.oauth.Authorize({
|
||||||
|
userId,
|
||||||
|
clientId: normalized.clientId,
|
||||||
|
redirectUri: normalized.redirectUri,
|
||||||
|
scope: normalized.scope,
|
||||||
|
state: normalized.state,
|
||||||
|
nonce: normalized.nonce
|
||||||
|
})
|
||||||
|
)) as { redirectUrl?: string; code?: string; state?: string };
|
||||||
|
|
||||||
|
if (wantsJson) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.redirectUrl) {
|
||||||
|
throw new UnauthorizedException('Не удалось создать authorization code');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.redirect(302, result.redirectUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('consent/check')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UsePipes(oauthValidationPipe)
|
||||||
|
@ApiOperation({ summary: 'Проверить сохранённое согласие OAuth', description: 'Возвращает статус согласия пользователя для указанного OAuth-приложения и scopes.' })
|
||||||
|
@ApiQuery({ name: 'client_id', required: false })
|
||||||
|
@ApiQuery({ name: 'clientId', required: false })
|
||||||
|
@ApiQuery({ name: 'scope', required: false })
|
||||||
|
async checkConsent(
|
||||||
|
@Query() query: OAuthConsentActionDto,
|
||||||
|
@Headers('authorization') authorization: string | undefined
|
||||||
|
) {
|
||||||
|
const payload = await verifyAccessToken(this.jwt, authorization);
|
||||||
|
const normalized = normalizeConsentQuery(query as Record<string, unknown>);
|
||||||
|
const result = (await firstValueFrom(
|
||||||
|
this.core.oauth.CheckOAuthConsent({
|
||||||
|
userId: payload.sub,
|
||||||
|
clientId: normalized.clientId,
|
||||||
|
scope: normalized.scope
|
||||||
|
})
|
||||||
|
)) as Record<string, unknown>;
|
||||||
|
return mapOAuthConsentCheckResponse(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('clients/:clientId/public')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Публичная информация об OAuth-приложении',
|
||||||
|
description: 'Возвращает человекочитаемое название приложения для экрана согласия.'
|
||||||
|
})
|
||||||
|
async getClientPublicInfo(@Param('clientId') clientId: string) {
|
||||||
|
const result = (await firstValueFrom(
|
||||||
|
this.core.oauth.GetOAuthClientPublicInfo({ clientId })
|
||||||
|
)) as Record<string, unknown>;
|
||||||
|
return mapOAuthClientPublicInfo(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('consent/approve')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UsePipes(oauthValidationPipe)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Подтвердить OAuth-согласие',
|
||||||
|
description: 'Сохраняет согласие пользователя и выдаёт authorization code для redirect_uri.'
|
||||||
|
})
|
||||||
|
@ApiBody({ type: OAuthConsentActionDto })
|
||||||
|
async approveConsent(
|
||||||
|
@Body() body: OAuthConsentActionDto,
|
||||||
|
@Headers('authorization') authorization: string | undefined
|
||||||
|
) {
|
||||||
|
const payload = await verifyAccessToken(this.jwt, authorization);
|
||||||
|
const normalized = normalizeAuthorizeQuery(body as Record<string, unknown>);
|
||||||
|
return firstValueFrom(
|
||||||
|
this.core.oauth.Authorize({
|
||||||
|
userId: payload.sub,
|
||||||
|
clientId: normalized.clientId,
|
||||||
|
redirectUri: normalized.redirectUri,
|
||||||
|
scope: normalized.scope,
|
||||||
|
state: normalized.state,
|
||||||
|
nonce: normalized.nonce,
|
||||||
|
grantConsent: true
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('consents/users/:userId')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Список OAuth-согласий пользователя', description: 'Возвращает все приложения, которым пользователь выдал доступ к данным.' })
|
||||||
|
async listUserConsents(
|
||||||
|
@Param('userId') userId: string,
|
||||||
|
@Headers('authorization') authorization: string | undefined
|
||||||
|
) {
|
||||||
|
const payload = await verifyAccessToken(this.jwt, authorization);
|
||||||
|
if (payload.sub !== userId) {
|
||||||
|
throw new UnauthorizedException('Можно просматривать только свои согласия');
|
||||||
|
}
|
||||||
|
const result = (await firstValueFrom(this.core.oauth.ListUserOAuthConsents({ userId }))) as Record<string, unknown>;
|
||||||
|
return mapUserOAuthConsentsResponse(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('consents/users/:userId/:consentId')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Отозвать OAuth-согласие', description: 'Удаляет сохранённое согласие. При следующем входе приложение снова запросит доступ.' })
|
||||||
|
async revokeConsent(
|
||||||
|
@Param('userId') userId: string,
|
||||||
|
@Param('consentId') consentId: string,
|
||||||
|
@Headers('authorization') authorization: string | undefined
|
||||||
|
) {
|
||||||
|
const payload = await verifyAccessToken(this.jwt, authorization);
|
||||||
|
if (payload.sub !== userId) {
|
||||||
|
throw new UnauthorizedException('Можно отзывать только свои согласия');
|
||||||
|
}
|
||||||
|
return firstValueFrom(this.core.oauth.RevokeOAuthConsent({ userId, consentId }));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('token')
|
@Post('token')
|
||||||
@ApiOperation({ summary: 'Выдать OAuth токены', description: 'Поддерживает grant_type=authorization_code и grant_type=refresh_token.' })
|
@UsePipes(oauthValidationPipe)
|
||||||
@ApiBody({ type: OAuthTokenDto })
|
@ApiOperation({
|
||||||
|
summary: 'Выдать OAuth токены',
|
||||||
|
description:
|
||||||
|
'Поддерживает grant_type=authorization_code и grant_type=refresh_token. ' +
|
||||||
|
'Принимает application/x-www-form-urlencoded (RFC 6749) и JSON. ' +
|
||||||
|
'Ответ — snake_case: access_token, token_type, expires_in, refresh_token, id_token.'
|
||||||
|
})
|
||||||
|
@ApiBody({ type: OAuthTokenIncomingDto })
|
||||||
@ApiResponse({ status: 201, description: 'OAuth токены выданы' })
|
@ApiResponse({ status: 201, description: 'OAuth токены выданы' })
|
||||||
token(@Body() dto: OAuthTokenDto) {
|
async token(@Body() body: OAuthTokenIncomingDto, @Headers('authorization') authorization?: string) {
|
||||||
return this.core.oauth.Token(dto);
|
const normalized = mergeTokenCredentials(normalizeTokenBody(body as Record<string, unknown>), authorization);
|
||||||
|
const result = (await firstValueFrom(
|
||||||
|
this.core.oauth.Token({
|
||||||
|
grantType: normalized.grantType,
|
||||||
|
code: normalized.code,
|
||||||
|
refreshToken: normalized.refreshToken,
|
||||||
|
clientId: normalized.clientId,
|
||||||
|
clientSecret: normalized.clientSecret,
|
||||||
|
redirectUri: normalized.redirectUri
|
||||||
|
})
|
||||||
|
)) as {
|
||||||
|
accessToken?: string;
|
||||||
|
tokenType?: string;
|
||||||
|
expiresIn?: number;
|
||||||
|
refreshToken?: string;
|
||||||
|
idToken?: string;
|
||||||
|
};
|
||||||
|
return mapTokenResponseToStandard(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('userinfo')
|
@Get('userinfo')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({ summary: 'OAuth userinfo', description: 'Возвращает профиль пользователя по OAuth access token.' })
|
@ApiOperation({ summary: 'OAuth userinfo', description: 'Возвращает профиль пользователя по OAuth access token.' })
|
||||||
@ApiResponse({ status: 200, description: 'Профиль пользователя получен' })
|
@ApiResponse({ status: 200, description: 'Профиль пользователя получен' })
|
||||||
userInfo(@Headers('authorization') authorization?: string) {
|
async userInfo(@Headers('authorization') authorization?: string) {
|
||||||
return this.core.oauth.UserInfo({ accessToken: extractBearerToken(authorization) });
|
const result = (await firstValueFrom(
|
||||||
|
this.core.oauth.UserInfo({ accessToken: extractBearerToken(authorization) })
|
||||||
|
)) as {
|
||||||
|
sub?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
name?: string;
|
||||||
|
picture?: string;
|
||||||
|
emailVerified?: boolean;
|
||||||
|
preferredUsername?: string;
|
||||||
|
phoneNumber?: string;
|
||||||
|
phoneNumberVerified?: boolean;
|
||||||
|
};
|
||||||
|
return mapUserInfoToOidc(result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
import { Body, Controller, ForbiddenException, Get, Headers, Param, Patch, Post } from '@nestjs/common';
|
import { Body, Controller, Delete, ForbiddenException, Get, Headers, Param, Patch, Post } from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
import { CoreGrpcService } from '../core-grpc.service';
|
import { CoreGrpcService } from '../core-grpc.service';
|
||||||
import { getAuthorizedUserId } from '../document-access';
|
import { getAuthorizedUserId } from '../document-access';
|
||||||
|
import {
|
||||||
import { CoreGrpcService } from '../core-grpc.service';
|
ChangePasswordDto,
|
||||||
|
PasswordVerificationDto,
|
||||||
|
SendPasswordVerificationOtpDto,
|
||||||
|
SetPasswordDto,
|
||||||
|
UpdateAvatarDto,
|
||||||
|
UpdateContactsDto,
|
||||||
|
UpdateProfileDto
|
||||||
|
} from '../dto/profile.dto';
|
||||||
@ApiTags('Профиль и биометрия')
|
@ApiTags('Профиль и биометрия')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Controller('profile/users/:userId')
|
@Controller('profile/users/:userId')
|
||||||
@@ -60,24 +67,94 @@ export class ProfileController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('password')
|
@Post('password')
|
||||||
}
|
@ApiOperation({ summary: 'Установить пароль', description: 'Устанавливает пароль для аккаунта без ранее заданного пароля.' })
|
||||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||||
@ApiBody({ type: SetPasswordDto })
|
@ApiBody({ type: SetPasswordDto })
|
||||||
@ApiResponse({ status: 201, description: 'Пароль установлен' })
|
@ApiResponse({ status: 201, description: 'Пароль установлен' })
|
||||||
@Patch('avatar')
|
async setPassword(
|
||||||
|
@Headers('authorization') authorization: string | undefined,
|
||||||
|
@Param('userId') userId: string,
|
||||||
|
@Body() dto: SetPasswordDto
|
||||||
|
) {
|
||||||
|
await this.assertSelfAccess(authorization, userId);
|
||||||
return this.core.profile.SetPassword({ userId, password: dto.password });
|
return this.core.profile.SetPassword({ userId, password: dto.password });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('password/otp')
|
||||||
|
@ApiOperation({ summary: 'Отправить OTP для смены пароля' })
|
||||||
|
async sendPasswordVerificationOtp(
|
||||||
|
@Headers('authorization') authorization: string | undefined,
|
||||||
|
@Param('userId') userId: string,
|
||||||
|
@Body() dto: SendPasswordVerificationOtpDto
|
||||||
|
) {
|
||||||
|
await this.assertSelfAccess(authorization, userId);
|
||||||
|
return this.core.profile.SendPasswordVerificationOtp({ userId, channel: dto.channel });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('password')
|
||||||
|
@ApiOperation({ summary: 'Сменить пароль с подтверждением личности' })
|
||||||
|
async changePassword(
|
||||||
|
@Headers('authorization') authorization: string | undefined,
|
||||||
|
@Param('userId') userId: string,
|
||||||
|
@Body() dto: ChangePasswordDto
|
||||||
|
) {
|
||||||
|
await this.assertSelfAccess(authorization, userId);
|
||||||
|
return this.core.profile.ChangePassword({ userId, ...dto });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('password')
|
||||||
|
@ApiOperation({ summary: 'Удалить пароль с подтверждением личности' })
|
||||||
|
async removePassword(
|
||||||
|
@Headers('authorization') authorization: string | undefined,
|
||||||
|
@Param('userId') userId: string,
|
||||||
|
@Body() dto: PasswordVerificationDto
|
||||||
|
) {
|
||||||
|
await this.assertSelfAccess(authorization, userId);
|
||||||
|
return this.core.profile.RemovePassword({ userId, ...dto });
|
||||||
|
}
|
||||||
|
|
||||||
@Post('self-delete')
|
@Post('self-delete')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
@ApiBody({ type: UpdateAvatarDto })
|
summary: 'Запросить удаление своего профиля',
|
||||||
|
description:
|
||||||
|
'Планирует удаление аккаунта через период ожидания ACCOUNT_DELETE_GRACE_DAYS. До истечения срока пользователь может отменить удаление.'
|
||||||
})
|
})
|
||||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||||
updateAvatar(@Param('userId') userId: string, @Body() dto: UpdateAvatarDto) {
|
@ApiResponse({ status: 201, description: 'Удаление запланировано' })
|
||||||
async selfDelete(@Headers('authorization') authorization: string | undefined, @Param('userId') userId: string) {
|
async selfDelete(@Headers('authorization') authorization: string | undefined, @Param('userId') userId: string) {
|
||||||
await this.assertSelfAccess(authorization, userId);
|
await this.assertSelfAccess(authorization, userId);
|
||||||
|
return this.core.profile.RequestAccountDeletion({ userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('self-delete/cancel')
|
||||||
|
@ApiOperation({ summary: 'Отменить запланированное удаление профиля' })
|
||||||
|
async cancelSelfDelete(@Headers('authorization') authorization: string | undefined, @Param('userId') userId: string) {
|
||||||
|
await this.assertSelfAccess(authorization, userId);
|
||||||
|
return this.core.profile.CancelAccountDeletion({ userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('self-delete/status')
|
||||||
|
@ApiOperation({ summary: 'Статус запланированного удаления профиля' })
|
||||||
|
async selfDeleteStatus(@Headers('authorization') authorization: string | undefined, @Param('userId') userId: string) {
|
||||||
|
await this.assertSelfAccess(authorization, userId);
|
||||||
|
return this.core.profile.GetAccountDeletionStatus({ userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('e2e-public-key')
|
||||||
|
@ApiOperation({ summary: 'Получить публичный E2E-ключ пользователя' })
|
||||||
|
async getE2EPublicKey(@Param('userId') userId: string) {
|
||||||
|
return this.core.profile.GetE2EPublicKey({ userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('e2e-public-key')
|
||||||
|
@ApiOperation({ summary: 'Сохранить свой публичный E2E-ключ' })
|
||||||
|
async setE2EPublicKey(
|
||||||
|
@Headers('authorization') authorization: string | undefined,
|
||||||
|
@Param('userId') userId: string,
|
||||||
|
@Body() dto: { publicKey: string }
|
||||||
|
) {
|
||||||
|
await this.assertSelfAccess(authorization, userId);
|
||||||
|
return this.core.profile.SetE2EPublicKey({ userId, publicKey: dto.publicKey });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3,8 +3,22 @@ import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiTags } from '@nestjs
|
|||||||
import { map } from 'rxjs';
|
import { map } from 'rxjs';
|
||||||
import { CoreGrpcService } from '../core-grpc.service';
|
import { CoreGrpcService } from '../core-grpc.service';
|
||||||
import { CurrentAdmin } from '../decorators/current-admin.decorator';
|
import { CurrentAdmin } from '../decorators/current-admin.decorator';
|
||||||
import { AssignUserRoleDto, CreateOAuthClientDto, CreateRoleDto, UpdateOAuthClientDto } from '../dto/rbac.dto';
|
import {
|
||||||
import { AdminGuard, AdminRequestUser, assertAdminPermission, SuperAdminGuard } from '../guards/admin.guard';
|
AssignUserPermissionDto,
|
||||||
|
AssignUserRoleDto,
|
||||||
|
CreateOAuthClientDto,
|
||||||
|
CreateRoleDto,
|
||||||
|
UpdateOAuthClientDto,
|
||||||
|
UpdateRoleDto
|
||||||
|
} from '../dto/rbac.dto';
|
||||||
|
import {
|
||||||
|
AdminGuard,
|
||||||
|
AdminRequestUser,
|
||||||
|
assertAdminAnyPermission,
|
||||||
|
assertAdminPermission,
|
||||||
|
RbacManageGuard,
|
||||||
|
SuperAdminGuard
|
||||||
|
} from '../guards/admin.guard';
|
||||||
|
|
||||||
@ApiTags('RBAC и OAuth')
|
@ApiTags('RBAC и OAuth')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@@ -30,7 +44,7 @@ export class RbacController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('permissions')
|
@Get('permissions')
|
||||||
@UseGuards(SuperAdminGuard)
|
@UseGuards(RbacManageGuard)
|
||||||
@ApiOperation({ summary: 'Список прав', description: 'Возвращает все доступные permissions.' })
|
@ApiOperation({ summary: 'Список прав', description: 'Возвращает все доступные permissions.' })
|
||||||
listPermissions() {
|
listPermissions() {
|
||||||
return this.core.rbac.ListPermissions({});
|
return this.core.rbac.ListPermissions({});
|
||||||
@@ -39,28 +53,45 @@ export class RbacController {
|
|||||||
@Get('oauth-scopes')
|
@Get('oauth-scopes')
|
||||||
@ApiOperation({ summary: 'OAuth scopes', description: 'Возвращает доступные scopes для OAuth-приложений.' })
|
@ApiOperation({ summary: 'OAuth scopes', description: 'Возвращает доступные scopes для OAuth-приложений.' })
|
||||||
listOAuthScopes(@CurrentAdmin() admin: AdminRequestUser) {
|
listOAuthScopes(@CurrentAdmin() admin: AdminRequestUser) {
|
||||||
assertAdminPermission(admin, 'canManageOAuth');
|
assertAdminAnyPermission(admin, 'canViewOAuth', 'canManageOAuth');
|
||||||
return this.core.rbac.ListOAuthScopes({});
|
return this.core.rbac.ListOAuthScopes({});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('oauth-clients')
|
@Get('oauth-clients')
|
||||||
@ApiOperation({ summary: 'OAuth приложения', description: 'Возвращает OAuth-клиенты и доступные scopes.' })
|
@ApiOperation({ summary: 'OAuth приложения', description: 'Возвращает OAuth-клиенты и доступные scopes.' })
|
||||||
listOAuthClients(@CurrentAdmin() admin: AdminRequestUser) {
|
listOAuthClients(@CurrentAdmin() admin: AdminRequestUser) {
|
||||||
assertAdminPermission(admin, 'canManageOAuth');
|
assertAdminPermission(admin, 'canViewOAuth');
|
||||||
return this.core.rbac.ListOAuthClients({});
|
return this.core.rbac.ListOAuthClients({ actorUserId: admin.id });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('roles')
|
@Post('roles')
|
||||||
@UseGuards(SuperAdminGuard)
|
@UseGuards(RbacManageGuard)
|
||||||
@ApiOperation({ summary: 'Создать роль', description: 'Создаёт новую роль с набором прав. Только супер-администратор.' })
|
@ApiOperation({ summary: 'Создать роль', description: 'Создаёт новую роль с набором прав.' })
|
||||||
@ApiBody({ type: CreateRoleDto })
|
@ApiBody({ type: CreateRoleDto })
|
||||||
createRole(@Body() dto: CreateRoleDto, @CurrentAdmin() admin: AdminRequestUser) {
|
createRole(@Body() dto: CreateRoleDto, @CurrentAdmin() admin: AdminRequestUser) {
|
||||||
return this.core.rbac.CreateRole({ actorUserId: admin.id, ...dto });
|
return this.core.rbac.CreateRole({ actorUserId: admin.id, ...dto });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Patch('roles/:roleSlug')
|
||||||
|
@UseGuards(RbacManageGuard)
|
||||||
|
@ApiOperation({ summary: 'Обновить роль', description: 'Изменяет название, описание и права роли.' })
|
||||||
|
@ApiParam({ name: 'roleSlug', description: 'Slug роли' })
|
||||||
|
@ApiBody({ type: UpdateRoleDto })
|
||||||
|
updateRole(@Param('roleSlug') roleSlug: string, @Body() dto: UpdateRoleDto, @CurrentAdmin() admin: AdminRequestUser) {
|
||||||
|
return this.core.rbac.UpdateRole({ actorUserId: admin.id, roleSlug, ...dto });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('roles/:roleSlug')
|
||||||
|
@UseGuards(RbacManageGuard)
|
||||||
|
@ApiOperation({ summary: 'Удалить роль', description: 'Удаляет пользовательскую роль. Системные роли удалить нельзя.' })
|
||||||
|
@ApiParam({ name: 'roleSlug', description: 'Slug роли' })
|
||||||
|
deleteRole(@Param('roleSlug') roleSlug: string, @CurrentAdmin() admin: AdminRequestUser) {
|
||||||
|
return this.core.rbac.DeleteRole({ actorUserId: admin.id, roleSlug });
|
||||||
|
}
|
||||||
|
|
||||||
@Post('users/:userId/roles')
|
@Post('users/:userId/roles')
|
||||||
@UseGuards(SuperAdminGuard)
|
@UseGuards(RbacManageGuard)
|
||||||
@ApiOperation({ summary: 'Назначить роль пользователю', description: 'Только супер-администратор может назначать роли.' })
|
@ApiOperation({ summary: 'Назначить роль пользователю' })
|
||||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||||
@ApiBody({ type: AssignUserRoleDto })
|
@ApiBody({ type: AssignUserRoleDto })
|
||||||
assignRole(@Param('userId') userId: string, @Body() dto: AssignUserRoleDto, @CurrentAdmin() admin: AdminRequestUser) {
|
assignRole(@Param('userId') userId: string, @Body() dto: AssignUserRoleDto, @CurrentAdmin() admin: AdminRequestUser) {
|
||||||
@@ -68,14 +99,36 @@ export class RbacController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete('users/:userId/roles/:roleSlug')
|
@Delete('users/:userId/roles/:roleSlug')
|
||||||
@UseGuards(SuperAdminGuard)
|
@UseGuards(RbacManageGuard)
|
||||||
@ApiOperation({ summary: 'Снять роль с пользователя', description: 'Только супер-администратор может снимать роли.' })
|
@ApiOperation({ summary: 'Снять роль с пользователя' })
|
||||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||||
@ApiParam({ name: 'roleSlug', description: 'Slug роли' })
|
@ApiParam({ name: 'roleSlug', description: 'Slug роли' })
|
||||||
removeRole(@Param('userId') userId: string, @Param('roleSlug') roleSlug: string, @CurrentAdmin() admin: AdminRequestUser) {
|
removeRole(@Param('userId') userId: string, @Param('roleSlug') roleSlug: string, @CurrentAdmin() admin: AdminRequestUser) {
|
||||||
return this.core.rbac.RemoveUserRole({ actorUserId: admin.id, userId, roleSlug });
|
return this.core.rbac.RemoveUserRole({ actorUserId: admin.id, userId, roleSlug });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('users/:userId/permissions')
|
||||||
|
@UseGuards(RbacManageGuard)
|
||||||
|
@ApiOperation({ summary: 'Назначить право пользователю', description: 'Прямое назначение permission без смены роли.' })
|
||||||
|
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||||
|
@ApiBody({ type: AssignUserPermissionDto })
|
||||||
|
assignPermission(@Param('userId') userId: string, @Body() dto: AssignUserPermissionDto, @CurrentAdmin() admin: AdminRequestUser) {
|
||||||
|
return this.core.rbac.AssignUserPermission({ actorUserId: admin.id, userId, permissionSlug: dto.permissionSlug });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('users/:userId/permissions/:permissionSlug')
|
||||||
|
@UseGuards(RbacManageGuard)
|
||||||
|
@ApiOperation({ summary: 'Снять право с пользователя' })
|
||||||
|
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||||
|
@ApiParam({ name: 'permissionSlug', description: 'Slug права' })
|
||||||
|
removePermission(
|
||||||
|
@Param('userId') userId: string,
|
||||||
|
@Param('permissionSlug') permissionSlug: string,
|
||||||
|
@CurrentAdmin() admin: AdminRequestUser
|
||||||
|
) {
|
||||||
|
return this.core.rbac.RemoveUserPermission({ actorUserId: admin.id, userId, permissionSlug });
|
||||||
|
}
|
||||||
|
|
||||||
@Post('oauth-clients')
|
@Post('oauth-clients')
|
||||||
@ApiOperation({ summary: 'Создать OAuth-приложение', description: 'Создаёт OAuth2-клиент и возвращает client secret один раз.' })
|
@ApiOperation({ summary: 'Создать OAuth-приложение', description: 'Создаёт OAuth2-клиент и возвращает client secret один раз.' })
|
||||||
@ApiBody({ type: CreateOAuthClientDto })
|
@ApiBody({ type: CreateOAuthClientDto })
|
||||||
@@ -93,6 +146,14 @@ export class RbacController {
|
|||||||
return this.core.rbac.UpdateOAuthClient({ actorUserId: admin.id, clientId, ...dto });
|
return this.core.rbac.UpdateOAuthClient({ actorUserId: admin.id, clientId, ...dto });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Delete('oauth-clients/:clientId')
|
||||||
|
@ApiOperation({ summary: 'Удалить OAuth-приложение', description: 'Полностью удаляет OAuth2-клиент и связанные данные.' })
|
||||||
|
@ApiParam({ name: 'clientId', description: 'Client ID приложения' })
|
||||||
|
deleteOAuthClient(@Param('clientId') clientId: string, @CurrentAdmin() admin: AdminRequestUser) {
|
||||||
|
assertAdminPermission(admin, 'canManageOAuth');
|
||||||
|
return this.core.rbac.DeleteOAuthClient({ actorUserId: admin.id, clientId });
|
||||||
|
}
|
||||||
|
|
||||||
@Post('oauth-clients/:clientId/rotate-secret')
|
@Post('oauth-clients/:clientId/rotate-secret')
|
||||||
@ApiOperation({ summary: 'Перевыпустить client secret', description: 'Генерирует новый secret для confidential-клиента.' })
|
@ApiOperation({ summary: 'Перевыпустить client secret', description: 'Генерирует новый secret для confidential-клиента.' })
|
||||||
@ApiParam({ name: 'clientId', description: 'Client ID приложения' })
|
@ApiParam({ name: 'clientId', description: 'Client ID приложения' })
|
||||||
|
|||||||
@@ -1,27 +1,36 @@
|
|||||||
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
|
import { Body, Controller, ForbiddenException, Get, Headers, Param, Post } from '@nestjs/common';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
import { CoreGrpcService } from '../core-grpc.service';
|
import { CoreGrpcService } from '../core-grpc.service';
|
||||||
import { OptionalPinDto, PinDto, VerifySecurityPinDto } from '../dto/security.dto';
|
import { OptionalPinDto, PinDto, TotpCodeDto, VerifySecurityPinDto } from '../dto/security.dto';
|
||||||
|
import { resolveAuthorizedPayload } from '../session-auth';
|
||||||
|
import { resolveFrontendUrl } from '../lib/oauth-issuer';
|
||||||
|
|
||||||
@ApiTags('Безопасность')
|
@ApiTags('Безопасность')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Controller('security')
|
@Controller('security')
|
||||||
export class SecurityController {
|
export class SecurityController {
|
||||||
constructor(private readonly core: CoreGrpcService) {}
|
constructor(
|
||||||
|
private readonly core: CoreGrpcService,
|
||||||
|
private readonly jwt: JwtService
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get('users/:userId/devices')
|
@Get('users/:userId/devices')
|
||||||
@ApiOperation({ summary: 'Активные устройства', description: 'Показывает устройства пользователя и связанные активные сессии.' })
|
@ApiOperation({ summary: 'Активные устройства', description: 'Показывает устройства пользователя и связанные активные сессии. Текущее устройство не отображается.' })
|
||||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||||
@ApiResponse({ status: 200, description: 'Список устройств получен' })
|
@ApiResponse({ status: 200, description: 'Список устройств получен' })
|
||||||
listDevices(@Param('userId') userId: string) {
|
async listDevices(@Param('userId') userId: string, @Headers('authorization') authorization?: string) {
|
||||||
return this.core.security.ListActiveDevices({ userId });
|
const payload = await resolveAuthorizedPayload(this.jwt, this.core, authorization);
|
||||||
|
return this.core.security.ListActiveDevices({ userId, exceptSessionId: payload.sessionId });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('users/:userId/sessions')
|
@Get('users/:userId/sessions')
|
||||||
@ApiOperation({ summary: 'Активные сессии', description: 'Возвращает ACTIVE и LOCKED сессии пользователя для управления устройствами.' })
|
@ApiOperation({ summary: 'Активные сессии', description: 'Возвращает ACTIVE и LOCKED сессии пользователя для управления устройствами.' })
|
||||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||||
@ApiResponse({ status: 200, description: 'Список активных сессий получен' })
|
@ApiResponse({ status: 200, description: 'Список активных сессий получен' })
|
||||||
listSessions(@Param('userId') userId: string) {
|
async listSessions(@Param('userId') userId: string, @Headers('authorization') authorization?: string) {
|
||||||
|
await resolveAuthorizedPayload(this.jwt, this.core, authorization);
|
||||||
return this.core.security.ListActiveSessions({ userId });
|
return this.core.security.ListActiveSessions({ userId });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,8 +38,40 @@ export class SecurityController {
|
|||||||
@ApiOperation({ summary: 'История входов', description: 'Показывает последние попытки входа и причины отказов.' })
|
@ApiOperation({ summary: 'История входов', description: 'Показывает последние попытки входа и причины отказов.' })
|
||||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||||
@ApiResponse({ status: 200, description: 'История входов получена' })
|
@ApiResponse({ status: 200, description: 'История входов получена' })
|
||||||
listHistory(@Param('userId') userId: string) {
|
listHistory(@Param('userId') userId: string, @Headers('authorization') authorization?: string) {
|
||||||
return this.core.security.ListSignInHistory({ userId });
|
return resolveAuthorizedPayload(this.jwt, this.core, authorization).then(() =>
|
||||||
|
this.core.security.ListSignInHistory({ userId })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('users/:userId/totp/status')
|
||||||
|
@ApiOperation({ summary: 'Статус TOTP', description: 'Показывает, включена ли двухфакторная аутентификация через приложение.' })
|
||||||
|
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||||
|
getTotpStatus(@Param('userId') userId: string) {
|
||||||
|
return this.core.security.GetTotpStatus({ userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('users/:userId/totp/setup')
|
||||||
|
@ApiOperation({ summary: 'Настроить TOTP', description: 'Генерирует секрет и otpauth URL для Google Authenticator и аналогов.' })
|
||||||
|
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||||
|
setupTotp(@Param('userId') userId: string) {
|
||||||
|
return this.core.security.SetupTotp({ userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('users/:userId/totp/enable')
|
||||||
|
@ApiOperation({ summary: 'Включить TOTP', description: 'Подтверждает код из приложения и включает двухфакторную аутентификацию.' })
|
||||||
|
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||||
|
@ApiBody({ type: TotpCodeDto })
|
||||||
|
enableTotp(@Param('userId') userId: string, @Body() dto: TotpCodeDto) {
|
||||||
|
return this.core.security.EnableTotp({ userId, code: dto.code });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('users/:userId/totp/disable')
|
||||||
|
@ApiOperation({ summary: 'Отключить TOTP', description: 'Отключает двухфакторную аутентификацию после проверки кода.' })
|
||||||
|
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||||
|
@ApiBody({ type: TotpCodeDto })
|
||||||
|
disableTotp(@Param('userId') userId: string, @Body() dto: TotpCodeDto) {
|
||||||
|
return this.core.security.DisableTotp({ userId, code: dto.code });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('users/:userId/pin/setup')
|
@Post('users/:userId/pin/setup')
|
||||||
@@ -102,10 +143,27 @@ export class SecurityController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('users/:userId/revoke-all-sessions')
|
@Post('users/:userId/revoke-all-sessions')
|
||||||
@ApiOperation({ summary: 'Выйти везде', description: 'Отзывает все активные и PIN-заблокированные сессии пользователя.' })
|
@ApiOperation({ summary: 'Выйти везде', description: 'Отзывает все активные и PIN-заблокированные сессии пользователя, кроме текущей.' })
|
||||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||||
@ApiResponse({ status: 201, description: 'Все сессии отозваны' })
|
@ApiResponse({ status: 201, description: 'Остальные сессии отозваны' })
|
||||||
revokeAll(@Param('userId') userId: string) {
|
async revokeAll(@Param('userId') userId: string, @Headers('authorization') authorization?: string) {
|
||||||
return this.core.security.RevokeAllSessions({ userId });
|
const payload = await resolveAuthorizedPayload(this.jwt, this.core, authorization);
|
||||||
|
return this.core.security.RevokeAllSessions({ userId, exceptSessionId: payload.sessionId });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('users/:userId/device-link/session')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Создать QR для подключения устройства',
|
||||||
|
description: 'Генерирует QR-код (5 минут) для входа на новом устройстве через раздел «Безопасность».'
|
||||||
|
})
|
||||||
|
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||||
|
@ApiResponse({ status: 201, description: 'QR-сессия для подключения устройства создана' })
|
||||||
|
async createDeviceLinkSession(@Param('userId') userId: string, @Headers('authorization') authorization?: string) {
|
||||||
|
const payload = await resolveAuthorizedPayload(this.jwt, this.core, authorization);
|
||||||
|
if (payload.sub !== userId) {
|
||||||
|
throw new ForbiddenException('Недостаточно прав для подключения устройства');
|
||||||
|
}
|
||||||
|
const frontendUrl = await resolveFrontendUrl(this.core);
|
||||||
|
return firstValueFrom(this.core.advancedAuth.CreateDeviceLinkSession({ userId, frontendUrl }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Body, Controller, Delete, Get, Param, Put, UseGuards, UsePipes, ValidationPipe } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, Param, Post, Put, UseGuards, UsePipes, ValidationPipe } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
import { map } from 'rxjs';
|
import { map } from 'rxjs';
|
||||||
import { CoreGrpcService } from '../core-grpc.service';
|
import { CoreGrpcService } from '../core-grpc.service';
|
||||||
import { CurrentAdmin } from '../decorators/current-admin.decorator';
|
import { CurrentAdmin } from '../decorators/current-admin.decorator';
|
||||||
import { ConnectLinkedAccountDto, UpsertSettingDto, UpsertSocialProviderDto } from '../dto/settings.dto';
|
import { ConnectLinkedAccountDto, TestMessagingDeliveryDto, UpsertSettingDto, UpsertSocialProviderDto } from '../dto/settings.dto';
|
||||||
import { AdminGuard, AdminRequestUser, assertAdminPermission } from '../guards/admin.guard';
|
import { AdminGuard, AdminRequestUser, assertAdminPermission } from '../guards/admin.guard';
|
||||||
|
|
||||||
const settingsWritePipe = new ValidationPipe({
|
const settingsWritePipe = new ValidationPipe({
|
||||||
@@ -97,6 +97,24 @@ export class SettingsController {
|
|||||||
return this.core.settings.DeleteSocialProvider({ providerName });
|
return this.core.settings.DeleteSocialProvider({ providerName });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('messaging/test')
|
||||||
|
@UsePipes(settingsWritePipe)
|
||||||
|
@ApiOperation({ summary: 'Тест email/SMS', description: 'Отправляет тестовый OTP-код через настроенного провайдера.' })
|
||||||
|
@ApiBody({ type: TestMessagingDeliveryDto })
|
||||||
|
@ApiResponse({ status: 200, description: 'Тестовое сообщение отправлено' })
|
||||||
|
testMessaging(@Body() dto: TestMessagingDeliveryDto, @CurrentAdmin() admin: AdminRequestUser) {
|
||||||
|
assertAdminPermission(admin, 'canManageSettings');
|
||||||
|
return this.core.settings.TestMessagingDelivery({ channel: dto.channel, target: dto.target });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('firebase/test')
|
||||||
|
@ApiOperation({ summary: 'Тест Firebase Push', description: 'Отправляет тестовое push-уведомление на устройства текущего администратора.' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Тестовое push-уведомление отправлено' })
|
||||||
|
testFirebasePush(@CurrentAdmin() admin: AdminRequestUser) {
|
||||||
|
assertAdminPermission(admin, 'canManageSettings');
|
||||||
|
return this.core.settings.TestFirebasePush({ userId: admin.id });
|
||||||
|
}
|
||||||
|
|
||||||
@Get('linked-accounts/users/:userId')
|
@Get('linked-accounts/users/:userId')
|
||||||
@ApiOperation({ summary: 'Связанные внешние аккаунты', description: 'Возвращает LinkedAccount записи пользователя для Google/Yandex и других провайдеров.' })
|
@ApiOperation({ summary: 'Связанные внешние аккаунты', description: 'Возвращает LinkedAccount записи пользователя для Google/Yandex и других провайдеров.' })
|
||||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||||
|
|||||||
244
apps/api-gateway/src/controllers/telegram-bot-api.controller.ts
Normal file
244
apps/api-gateway/src/controllers/telegram-bot-api.controller.ts
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import { All, Body, Controller, Get, HttpCode, Param, Post, Query, Req, Res } from '@nestjs/common';
|
||||||
|
import { ApiBody, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { firstValueFrom, timeout } from 'rxjs';
|
||||||
|
import { CoreGrpcService } from '../core-grpc.service';
|
||||||
|
|
||||||
|
type HttpRequest = {
|
||||||
|
method: string;
|
||||||
|
query: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type HttpResponse = {
|
||||||
|
status(code: number): HttpResponse;
|
||||||
|
setHeader(name: string, value: string): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const botPathParam = {
|
||||||
|
name: 'botPath',
|
||||||
|
description: 'Путь токена в формате bot<token>, совместимо с Telegram Bot API.',
|
||||||
|
example: 'bot123456:ABC-DEF'
|
||||||
|
};
|
||||||
|
|
||||||
|
const chatIdProperty = {
|
||||||
|
oneOf: [{ type: 'string' }, { type: 'number' }],
|
||||||
|
description: 'ID пользователя/чата в приложении или numeric chat_id из ответа Bot API.'
|
||||||
|
};
|
||||||
|
|
||||||
|
@ApiTags('Telegram Bot API')
|
||||||
|
@Controller()
|
||||||
|
export class TelegramBotApiController {
|
||||||
|
constructor(private readonly core: CoreGrpcService) {}
|
||||||
|
|
||||||
|
@Get(':botPath/getMe')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Bot API: getMe',
|
||||||
|
description: 'Возвращает профиль бота в формате Telegram Bot API.'
|
||||||
|
})
|
||||||
|
@ApiParam(botPathParam)
|
||||||
|
getMe(@Res({ passthrough: true }) response: HttpResponse, @Param('botPath') botPath: string) {
|
||||||
|
return this.executeTelegramMethod(response, botPath, 'getMe', {});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':botPath/getChat')
|
||||||
|
@HttpCode(200)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Bot API: getChat',
|
||||||
|
description: 'Возвращает приватный чат приложения и pinned_message, если сообщение закреплено.'
|
||||||
|
})
|
||||||
|
@ApiParam(botPathParam)
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['chat_id'],
|
||||||
|
properties: {
|
||||||
|
chat_id: chatIdProperty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
getChatPost(
|
||||||
|
@Res({ passthrough: true }) response: HttpResponse,
|
||||||
|
@Param('botPath') botPath: string,
|
||||||
|
@Body() body: Record<string, unknown>
|
||||||
|
) {
|
||||||
|
return this.executeTelegramMethod(response, botPath, 'getChat', body ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':botPath/getChat')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Bot API: getChat (GET)',
|
||||||
|
description: 'GET-вариант метода getChat для клиентов, которые передают параметры в query.'
|
||||||
|
})
|
||||||
|
@ApiParam(botPathParam)
|
||||||
|
getChatGet(
|
||||||
|
@Res({ passthrough: true }) response: HttpResponse,
|
||||||
|
@Param('botPath') botPath: string,
|
||||||
|
@Query() query: Record<string, unknown>
|
||||||
|
) {
|
||||||
|
return this.executeTelegramMethod(response, botPath, 'getChat', query ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':botPath/sendMessage')
|
||||||
|
@HttpCode(200)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Bot API: sendMessage',
|
||||||
|
description: 'Отправляет сообщение пользователю внутри приложения. Поддерживаются text, parse_mode и reply_markup.'
|
||||||
|
})
|
||||||
|
@ApiParam(botPathParam)
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['chat_id', 'text'],
|
||||||
|
properties: {
|
||||||
|
chat_id: chatIdProperty,
|
||||||
|
text: { type: 'string' },
|
||||||
|
parse_mode: { type: 'string', example: 'HTML' },
|
||||||
|
reply_markup: { type: 'object', additionalProperties: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
sendMessage(
|
||||||
|
@Res({ passthrough: true }) response: HttpResponse,
|
||||||
|
@Param('botPath') botPath: string,
|
||||||
|
@Body() body: Record<string, unknown>
|
||||||
|
) {
|
||||||
|
return this.executeTelegramMethod(response, botPath, 'sendMessage', body ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':botPath/editMessageText')
|
||||||
|
@HttpCode(200)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Bot API: editMessageText',
|
||||||
|
description: 'Редактирует текст ранее отправленного ботом сообщения.'
|
||||||
|
})
|
||||||
|
@ApiParam(botPathParam)
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['chat_id', 'message_id', 'text'],
|
||||||
|
properties: {
|
||||||
|
chat_id: chatIdProperty,
|
||||||
|
message_id: { type: 'integer' },
|
||||||
|
text: { type: 'string' },
|
||||||
|
parse_mode: { type: 'string', example: 'HTML' },
|
||||||
|
reply_markup: { type: 'object', additionalProperties: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
editMessageText(
|
||||||
|
@Res({ passthrough: true }) response: HttpResponse,
|
||||||
|
@Param('botPath') botPath: string,
|
||||||
|
@Body() body: Record<string, unknown>
|
||||||
|
) {
|
||||||
|
return this.executeTelegramMethod(response, botPath, 'editMessageText', body ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':botPath/pinChatMessage')
|
||||||
|
@HttpCode(200)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Bot API: pinChatMessage',
|
||||||
|
description: 'Закрепляет сообщение бота в чате приложения.'
|
||||||
|
})
|
||||||
|
@ApiParam(botPathParam)
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['chat_id', 'message_id'],
|
||||||
|
properties: {
|
||||||
|
chat_id: chatIdProperty,
|
||||||
|
message_id: { type: 'integer' },
|
||||||
|
disable_notification: { type: 'boolean', default: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
pinChatMessage(
|
||||||
|
@Res({ passthrough: true }) response: HttpResponse,
|
||||||
|
@Param('botPath') botPath: string,
|
||||||
|
@Body() body: Record<string, unknown>
|
||||||
|
) {
|
||||||
|
return this.executeTelegramMethod(response, botPath, 'pinChatMessage', body ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':botPath/unpinChatMessage')
|
||||||
|
@HttpCode(200)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Bot API: unpinChatMessage',
|
||||||
|
description: 'Открепляет указанное сообщение. Если message_id не передан, открепляет последнее закреплённое сообщение.'
|
||||||
|
})
|
||||||
|
@ApiParam(botPathParam)
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['chat_id'],
|
||||||
|
properties: {
|
||||||
|
chat_id: chatIdProperty,
|
||||||
|
message_id: { type: 'integer' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
unpinChatMessage(
|
||||||
|
@Res({ passthrough: true }) response: HttpResponse,
|
||||||
|
@Param('botPath') botPath: string,
|
||||||
|
@Body() body: Record<string, unknown>
|
||||||
|
) {
|
||||||
|
return this.executeTelegramMethod(response, botPath, 'unpinChatMessage', body ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
|
@All(':botPath/:method')
|
||||||
|
@HttpCode(200)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Bot API: универсальный метод',
|
||||||
|
description:
|
||||||
|
'Совместимый endpoint /bot<TOKEN>/<method>. Поддерживаются getMe, getChat, sendMessage, editMessageText, editMessageReplyMarkup, sendPhoto, sendDocument, pinChatMessage, unpinChatMessage, answerCallbackQuery, setWebhook, deleteWebhook, getWebhookInfo, getUpdates и setChatMenuButton.'
|
||||||
|
})
|
||||||
|
@ApiParam(botPathParam)
|
||||||
|
@ApiParam({
|
||||||
|
name: 'method',
|
||||||
|
description: 'Название метода Telegram Bot API.',
|
||||||
|
example: 'sendMessage'
|
||||||
|
})
|
||||||
|
async handleTelegramMethod(
|
||||||
|
@Req() request: HttpRequest,
|
||||||
|
@Res({ passthrough: true }) response: HttpResponse,
|
||||||
|
@Param('botPath') botPath: string,
|
||||||
|
@Param('method') method: string,
|
||||||
|
@Body() body: Record<string, unknown>
|
||||||
|
) {
|
||||||
|
const payload = request.method === 'GET' ? { ...request.query } : body ?? {};
|
||||||
|
return this.executeTelegramMethod(response, botPath, method, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async executeTelegramMethod(
|
||||||
|
response: HttpResponse,
|
||||||
|
botPath: string,
|
||||||
|
method: string,
|
||||||
|
payload: Record<string, unknown>
|
||||||
|
) {
|
||||||
|
if (!botPath.startsWith('bot')) {
|
||||||
|
response.status(404);
|
||||||
|
return { ok: false, error_code: 404, description: 'Not Found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = decodeURIComponent(botPath.slice(3));
|
||||||
|
if (!token) {
|
||||||
|
response.status(401);
|
||||||
|
return { ok: false, error_code: 401, description: 'Unauthorized' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const grpcCall = this.core.bot.ExecuteBotMethod({
|
||||||
|
token,
|
||||||
|
method,
|
||||||
|
payloadJson: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
const waitSeconds = method === 'getUpdates' ? Number(payload.timeout ?? 0) : 0;
|
||||||
|
const grpcTimeoutMs = method === 'getUpdates' ? Math.min(Math.max(waitSeconds, 0), 50) * 1000 + 10_000 : 30_000;
|
||||||
|
|
||||||
|
const result = (await firstValueFrom(
|
||||||
|
grpcCall.pipe(timeout(grpcTimeoutMs)) as typeof grpcCall
|
||||||
|
)) as { responseJson: string; httpStatus: number };
|
||||||
|
|
||||||
|
response.status(result.httpStatus ?? 200);
|
||||||
|
response.setHeader('Content-Type', 'application/json');
|
||||||
|
return JSON.parse(result.responseJson);
|
||||||
|
}
|
||||||
|
}
|
||||||
62
apps/api-gateway/src/controllers/well-known.controller.ts
Normal file
62
apps/api-gateway/src/controllers/well-known.controller.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { Controller, Get, Req, Res } from '@nestjs/common';
|
||||||
|
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import { CoreGrpcService } from '../core-grpc.service';
|
||||||
|
import { buildFedcmWebIdentityManifest, resolveFedcmEndpoints } from '../lib/fedcm-config';
|
||||||
|
import { buildOpenIdConfiguration, resolveOAuthIssuer } from '../lib/oauth-issuer';
|
||||||
|
|
||||||
|
@ApiTags('OpenID Connect')
|
||||||
|
@Controller('.well-known')
|
||||||
|
export class WellKnownController {
|
||||||
|
constructor(private readonly core: CoreGrpcService) {}
|
||||||
|
|
||||||
|
@Get('web-identity')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'FedCM web identity manifest',
|
||||||
|
description: 'Манифест Federated Credential Management API, указывающий на конфигурацию провайдера.'
|
||||||
|
})
|
||||||
|
async webIdentity(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
||||||
|
// Публичный discovery-манифест: без Sec-Fetch-Dest проверки (браузер FedCM шлёт
|
||||||
|
// webidentity или empty; ручной заход с document не должен ломать One Tap).
|
||||||
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=300');
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||||
|
const endpoints = await resolveFedcmEndpoints(this.core, req);
|
||||||
|
return buildFedcmWebIdentityManifest(endpoints);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('openid-configuration')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'OpenID Connect Discovery',
|
||||||
|
description: 'Метаданные OIDC-провайдера. Issuer и endpoints берутся из PUBLIC_API_URL в настройках.'
|
||||||
|
})
|
||||||
|
async openIdConfiguration() {
|
||||||
|
const issuer = await resolveOAuthIssuer(this.core);
|
||||||
|
return buildOpenIdConfiguration(issuer);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('jwks.json')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'JWKS',
|
||||||
|
description: 'Пустой набор ключей: токены подписываются HS256 на стороне IdP. Для проверки id_token используйте userinfo.'
|
||||||
|
})
|
||||||
|
jwks() {
|
||||||
|
return { keys: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiTags('OpenID Connect')
|
||||||
|
@Controller('oauth/authorize')
|
||||||
|
export class OAuthAuthorizeDiscoveryController {
|
||||||
|
constructor(private readonly core: CoreGrpcService) {}
|
||||||
|
|
||||||
|
@Get('.well-known/openid-configuration')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'OpenID Discovery (alias)',
|
||||||
|
description: 'Совместимость с клиентами, ошибочно использующими authorization endpoint как issuer.'
|
||||||
|
})
|
||||||
|
async openIdConfigurationAlias() {
|
||||||
|
const issuer = await resolveOAuthIssuer(this.core);
|
||||||
|
return buildOpenIdConfiguration(issuer);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,12 +15,15 @@ export class CoreGrpcService implements OnModuleInit {
|
|||||||
documents!: Record<string, GrpcMethod>;
|
documents!: Record<string, GrpcMethod>;
|
||||||
addresses!: Record<string, GrpcMethod>;
|
addresses!: Record<string, GrpcMethod>;
|
||||||
oauth!: Record<string, GrpcMethod>;
|
oauth!: Record<string, GrpcMethod>;
|
||||||
|
fedcm!: Record<string, GrpcMethod>;
|
||||||
otp!: Record<string, GrpcMethod>;
|
otp!: Record<string, GrpcMethod>;
|
||||||
advancedAuth!: Record<string, GrpcMethod>;
|
advancedAuth!: Record<string, GrpcMethod>;
|
||||||
notifications!: Record<string, GrpcMethod>;
|
notifications!: Record<string, GrpcMethod>;
|
||||||
chat!: Record<string, GrpcMethod>;
|
chat!: Record<string, GrpcMethod>;
|
||||||
family!: Record<string, GrpcMethod>;
|
family!: Record<string, GrpcMethod>;
|
||||||
media!: Record<string, GrpcMethod>;
|
media!: Record<string, GrpcMethod>;
|
||||||
|
bot!: Record<string, GrpcMethod>;
|
||||||
|
appRelease!: Record<string, GrpcMethod>;
|
||||||
|
|
||||||
constructor(@Inject('SSO_CORE') private readonly client: ClientGrpc) {}
|
constructor(@Inject('SSO_CORE') private readonly client: ClientGrpc) {}
|
||||||
|
|
||||||
@@ -34,11 +37,14 @@ export class CoreGrpcService implements OnModuleInit {
|
|||||||
this.documents = this.client.getService<Record<string, GrpcMethod>>('DocumentsService');
|
this.documents = this.client.getService<Record<string, GrpcMethod>>('DocumentsService');
|
||||||
this.addresses = this.client.getService<Record<string, GrpcMethod>>('AddressesService');
|
this.addresses = this.client.getService<Record<string, GrpcMethod>>('AddressesService');
|
||||||
this.oauth = this.client.getService<Record<string, GrpcMethod>>('OAuthCoreService');
|
this.oauth = this.client.getService<Record<string, GrpcMethod>>('OAuthCoreService');
|
||||||
|
this.fedcm = this.client.getService<Record<string, GrpcMethod>>('FedcmService');
|
||||||
this.otp = this.client.getService<Record<string, GrpcMethod>>('OtpService');
|
this.otp = this.client.getService<Record<string, GrpcMethod>>('OtpService');
|
||||||
this.advancedAuth = this.client.getService<Record<string, GrpcMethod>>('AdvancedAuthService');
|
this.advancedAuth = this.client.getService<Record<string, GrpcMethod>>('AdvancedAuthService');
|
||||||
this.family = this.client.getService<Record<string, GrpcMethod>>('FamilyService');
|
this.family = this.client.getService<Record<string, GrpcMethod>>('FamilyService');
|
||||||
this.notifications = this.client.getService<Record<string, GrpcMethod>>('NotificationsService');
|
this.notifications = this.client.getService<Record<string, GrpcMethod>>('NotificationsService');
|
||||||
this.chat = this.client.getService<Record<string, GrpcMethod>>('ChatService');
|
this.chat = this.client.getService<Record<string, GrpcMethod>>('ChatService');
|
||||||
this.media = this.client.getService<Record<string, GrpcMethod>>('MediaService');
|
this.media = this.client.getService<Record<string, GrpcMethod>>('MediaService');
|
||||||
|
this.bot = this.client.getService<Record<string, GrpcMethod>>('BotService');
|
||||||
|
this.appRelease = this.client.getService<Record<string, GrpcMethod>>('AppReleaseService');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,19 @@ interface RequesterProfile {
|
|||||||
canViewUserDocuments?: boolean;
|
canViewUserDocuments?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getRequesterProfile(jwt: JwtService, core: CoreGrpcService, authorization?: string): Promise<RequesterProfile> {
|
async function getRequesterProfile(
|
||||||
|
jwt: JwtService,
|
||||||
|
core: CoreGrpcService,
|
||||||
|
authorization?: string,
|
||||||
|
touchActivity = true
|
||||||
|
): Promise<RequesterProfile> {
|
||||||
const payload = await verifyAccessToken(jwt, authorization);
|
const payload = await verifyAccessToken(jwt, authorization);
|
||||||
await assertSessionUnlocked(core, payload);
|
await assertSessionUnlocked(core, payload, touchActivity);
|
||||||
|
|
||||||
|
if (payload.isSuperAdmin) {
|
||||||
|
return { id: payload.sub, isSuperAdmin: true, canViewUserDocuments: true };
|
||||||
|
}
|
||||||
|
|
||||||
const profile = (await firstValueFrom(core.auth.GetMe({ userId: payload.sub }))) as RequesterProfile & { id: string };
|
const profile = (await firstValueFrom(core.auth.GetMe({ userId: payload.sub }))) as RequesterProfile & { id: string };
|
||||||
return { id: profile.id, isSuperAdmin: profile.isSuperAdmin, canViewUserDocuments: profile.canViewUserDocuments };
|
return { id: profile.id, isSuperAdmin: profile.isSuperAdmin, canViewUserDocuments: profile.canViewUserDocuments };
|
||||||
}
|
}
|
||||||
@@ -46,7 +56,12 @@ export async function assertDocumentsWriteAccess(
|
|||||||
return requester.id;
|
return requester.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAuthorizedUserId(jwt: JwtService, core: CoreGrpcService, authorization?: string) {
|
export async function getAuthorizedUserId(
|
||||||
const requester = await getRequesterProfile(jwt, core, authorization);
|
jwt: JwtService,
|
||||||
|
core: CoreGrpcService,
|
||||||
|
authorization?: string,
|
||||||
|
touchActivity = true
|
||||||
|
) {
|
||||||
|
const requester = await getRequesterProfile(jwt, core, authorization, touchActivity);
|
||||||
return requester.id;
|
return requester.id;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,3 +58,44 @@ export class SetSuperAdminDto {
|
|||||||
@IsBoolean({ message: 'isSuperAdmin должно быть boolean' })
|
@IsBoolean({ message: 'isSuperAdmin должно быть boolean' })
|
||||||
isSuperAdmin!: boolean;
|
isSuperAdmin!: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class SetUserVerificationDto {
|
||||||
|
@ApiProperty({ description: 'Верифицировать или снять верификацию' })
|
||||||
|
@IsBoolean({ message: 'isVerified должно быть boolean' })
|
||||||
|
isVerified!: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Slug значка из списка (badge-check, star, moon и т.д.)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'Значок должен быть строкой' })
|
||||||
|
verificationIcon?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UserInsightsQueryDto {
|
||||||
|
@ApiPropertyOptional({ description: 'Поиск по событиям, IP, user-agent, тексту активности или чатам' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'Поисковая строка должна быть текстом' })
|
||||||
|
search?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Лимит записей', default: 50 })
|
||||||
|
@IsOptional()
|
||||||
|
limit?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Смещение для пагинации', default: 0 })
|
||||||
|
@IsOptional()
|
||||||
|
offset?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'ID сообщения для пагинации истории чата' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'beforeMessageId должно быть строкой' })
|
||||||
|
beforeMessageId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Начало периода (ISO 8601 или datetime-local)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'dateFrom должно быть строкой' })
|
||||||
|
dateFrom?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Конец периода (ISO 8601 или datetime-local)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'dateTo должно быть строкой' })
|
||||||
|
dateTo?: string;
|
||||||
|
}
|
||||||
|
|||||||
20
apps/api-gateway/src/dto/app-release.dto.ts
Normal file
20
apps/api-gateway/src/dto/app-release.dto.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { IsIn, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateAppReleaseDto {
|
||||||
|
@IsOptional()
|
||||||
|
isPublished?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(4000)
|
||||||
|
releaseNotes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CheckAppUpdateQueryDto {
|
||||||
|
@IsIn(['ANDROID', 'WINDOWS', 'android', 'windows'])
|
||||||
|
platform!: string;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
versionCode!: number;
|
||||||
|
}
|
||||||
@@ -150,6 +150,41 @@ export class VerifyPinDto {
|
|||||||
pin!: string;
|
pin!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class VerifyTotpLoginDto {
|
||||||
|
@ApiProperty({ description: 'Временный токен после первичной аутентификации' })
|
||||||
|
@IsString({ message: 'Токен подтверждения должен быть строкой' })
|
||||||
|
@IsNotEmpty({ message: 'Передайте токен подтверждения' })
|
||||||
|
totpChallengeToken!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '6-значный код из приложения-аутентификатора', example: '123456' })
|
||||||
|
@IsString({ message: 'Код должен быть строкой' })
|
||||||
|
@Length(6, 6, { message: 'Код должен содержать 6 цифр' })
|
||||||
|
@Matches(/^\d+$/, { message: 'Код должен содержать только цифры' })
|
||||||
|
code!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BeginTotpLoginDto {
|
||||||
|
@ApiProperty({ description: 'Почта или телефон пользователя', example: 'user@example.com' })
|
||||||
|
@IsString({ message: 'Получатель должен быть строкой' })
|
||||||
|
@IsNotEmpty({ message: 'Укажите почту или телефон' })
|
||||||
|
@Matches(EMAIL_OR_PHONE_PATTERN, { message: 'Укажите корректную почту или телефон в формате +79991234567' })
|
||||||
|
recipient!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Уникальный отпечаток устройства' })
|
||||||
|
@IsString({ message: 'Отпечаток устройства должен быть строкой' })
|
||||||
|
fingerprint!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Название устройства' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'Название устройства должно быть строкой' })
|
||||||
|
deviceName?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Тип устройства', example: 'WEB' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'Тип устройства должен быть строкой' })
|
||||||
|
deviceType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class RefreshSessionDto {
|
export class RefreshSessionDto {
|
||||||
@ApiProperty({ description: 'Refresh token текущей сессии' })
|
@ApiProperty({ description: 'Refresh token текущей сессии' })
|
||||||
@IsString({ message: 'Refresh token должен быть строкой' })
|
@IsString({ message: 'Refresh token должен быть строкой' })
|
||||||
|
|||||||
108
apps/api-gateway/src/dto/bot.dto.ts
Normal file
108
apps/api-gateway/src/dto/bot.dto.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsBoolean, IsInt, IsOptional, IsString, IsUrl, Max, Min, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateBotDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2, { message: 'Название бота должно содержать минимум 2 символа' })
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(5, { message: 'Username бота должен содержать минимум 5 символов' })
|
||||||
|
username!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateBotDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2, { message: 'Название бота должно содержать минимум 2 символа' })
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(5, { message: 'Username бота должен содержать минимум 5 символов' })
|
||||||
|
username?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SetBotWebAppDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsUrl({}, { message: 'Укажите корректный URL Mini App' })
|
||||||
|
webAppUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateBotProfileDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
aboutText?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
botPicUrl?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
menuButtonJson?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
menuButtonUrl?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
menuButtonText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SetBotActiveDto {
|
||||||
|
@IsBoolean()
|
||||||
|
isActive!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ListAdminBotsQueryDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
search?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ValidateWebAppInitDataDto {
|
||||||
|
@IsString()
|
||||||
|
initData!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
botToken!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SubmitBotMessageDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1, { message: 'Текст сообщения не может быть пустым' })
|
||||||
|
text!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
roomId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SubmitBotCallbackDto {
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
messageId!: number;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1, { message: 'callbackData не может быть пустым' })
|
||||||
|
callbackData!: string;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsArray, IsBoolean, IsIn, IsOptional, IsString, MinLength } from 'class-validator';
|
import { ArrayNotEmpty, IsArray, IsBoolean, IsIn, IsOptional, IsString, MinLength } from 'class-validator';
|
||||||
|
|
||||||
export class CreateChatRoomDto {
|
export class CreateChatRoomDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -11,6 +11,11 @@ export class CreateChatRoomDto {
|
|||||||
memberUserIds?: string[];
|
memberUserIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class CreateE2EChatRoomDto {
|
||||||
|
@IsString()
|
||||||
|
peerUserId!: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class UpdateChatRoomDto {
|
export class UpdateChatRoomDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -20,11 +25,15 @@ export class UpdateChatRoomDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
notificationsMuted?: boolean;
|
notificationsMuted?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
pinned?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SendChatMessageDto {
|
export class SendChatMessageDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsIn(['TEXT', 'IMAGE', 'AUDIO', 'VOICE', 'FILE', 'EMOJI', 'POLL'])
|
@IsIn(['TEXT', 'IMAGE', 'AUDIO', 'VOICE', 'VIDEO', 'VIDEO_NOTE', 'FILE', 'EMOJI', 'POLL', 'LOCATION'])
|
||||||
type!: string;
|
type!: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -54,6 +63,10 @@ export class SendChatMessageDto {
|
|||||||
allowsMultiple?: boolean;
|
allowsMultiple?: boolean;
|
||||||
isAnonymous?: boolean;
|
isAnonymous?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isEncrypted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class VotePollDto {
|
export class VotePollDto {
|
||||||
@@ -66,3 +79,38 @@ export class SetRoomNotificationsMutedDto {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
muted!: boolean;
|
muted!: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class AddChatRoomMemberDto {
|
||||||
|
@IsString()
|
||||||
|
memberUserId!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EditChatMessageDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
content!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MarkRoomReadDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
lastMessageId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ToggleMessageReactionDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
emoji!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SetMessagePinnedDto {
|
||||||
|
@IsBoolean()
|
||||||
|
pinned!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ForwardMessagesDto {
|
||||||
|
@IsArray()
|
||||||
|
@ArrayNotEmpty()
|
||||||
|
@IsString({ each: true })
|
||||||
|
messageIds!: string[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -97,6 +97,16 @@ export class QrSessionDto {
|
|||||||
@ApiProperty({ description: 'Название устройства', example: 'Chrome на Windows' })
|
@ApiProperty({ description: 'Название устройства', example: 'Chrome на Windows' })
|
||||||
@IsString({ message: 'Название устройства должно быть строкой' })
|
@IsString({ message: 'Название устройства должно быть строкой' })
|
||||||
deviceName!: string;
|
deviceName!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Отпечаток устройства браузера' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'Отпечаток устройства должен быть строкой' })
|
||||||
|
fingerprint?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Тип устройства', example: 'WEB' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'Тип устройства должен быть строкой' })
|
||||||
|
deviceType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CreateFamilyGroupDto {
|
export class CreateFamilyGroupDto {
|
||||||
@@ -127,10 +137,15 @@ export class AddFamilyMemberDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class SendFamilyInviteDto {
|
export class SendFamilyInviteDto {
|
||||||
@ApiProperty({ description: 'Email, телефон или логин приглашаемого' })
|
@ApiPropertyOptional({ description: 'Email, телефон или логин (legacy)' })
|
||||||
|
@IsOptional()
|
||||||
@IsString({ message: 'Укажите контакт приглашаемого' })
|
@IsString({ message: 'Укажите контакт приглашаемого' })
|
||||||
@IsNotEmpty({ message: 'Укажите контакт приглашаемого' })
|
target?: string;
|
||||||
target!: string;
|
|
||||||
|
@ApiPropertyOptional({ description: 'ID пользователя из результатов поиска' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'ID пользователя должен быть строкой' })
|
||||||
|
inviteeUserId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RespondFamilyInviteDto {
|
export class RespondFamilyInviteDto {
|
||||||
@@ -138,3 +153,18 @@ export class RespondFamilyInviteDto {
|
|||||||
@IsBoolean({ message: 'Укажите accept: true или false' })
|
@IsBoolean({ message: 'Укажите accept: true или false' })
|
||||||
accept!: boolean;
|
accept!: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class LeaveFamilyGroupDto {
|
||||||
|
@ApiPropertyOptional({ description: 'ID нового главы семьи (обязателен для создателя при наличии других участников)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'ID нового главы семьи должен быть строкой' })
|
||||||
|
newOwnerUserId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TransferFamilyOwnershipDto {
|
||||||
|
@ApiProperty({ description: 'ID пользователя, которому передаётся управление семьёй' })
|
||||||
|
@IsString({ message: 'ID нового главы семьи должен быть строкой' })
|
||||||
|
@IsNotEmpty({ message: 'Укажите нового главу семьи' })
|
||||||
|
newOwnerUserId!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,21 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|||||||
import { IsIn, IsOptional, IsString } from 'class-validator';
|
import { IsIn, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
const IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'] as const;
|
const IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'] as const;
|
||||||
|
|
||||||
|
const DOCUMENT_ATTACHMENT_TYPES = [
|
||||||
|
...IMAGE_TYPES,
|
||||||
|
'application/pdf',
|
||||||
|
'application/msword',
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
'application/vnd.ms-excel',
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
'application/vnd.ms-powerpoint',
|
||||||
|
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||||
|
'text/plain',
|
||||||
|
'application/rtf',
|
||||||
|
'application/vnd.oasis.opendocument.text'
|
||||||
|
] as const;
|
||||||
|
|
||||||
export class AvatarUploadDto {
|
export class AvatarUploadDto {
|
||||||
@ApiProperty({ description: 'MIME-тип изображения', example: 'image/jpeg', enum: IMAGE_TYPES })
|
@ApiProperty({ description: 'MIME-тип изображения', example: 'image/jpeg', enum: IMAGE_TYPES })
|
||||||
@IsString({ message: 'Укажите MIME-тип изображения' })
|
@IsString({ message: 'Укажите MIME-тип изображения' })
|
||||||
@@ -16,10 +31,17 @@ export class ConfirmAvatarDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class DocumentPhotoUploadDto {
|
export class DocumentPhotoUploadDto {
|
||||||
@ApiProperty({ description: 'MIME-тип изображения', example: 'image/jpeg', enum: IMAGE_TYPES })
|
@ApiProperty({ description: 'MIME-тип файла', example: 'application/pdf', enum: DOCUMENT_ATTACHMENT_TYPES })
|
||||||
@IsString({ message: 'Укажите MIME-тип изображения' })
|
@IsString({ message: 'Укажите MIME-тип файла' })
|
||||||
@IsIn([...IMAGE_TYPES], { message: 'Допустимы только JPEG, PNG, WEBP или GIF' })
|
@IsIn([...DOCUMENT_ATTACHMENT_TYPES], {
|
||||||
|
message: 'Допустимы изображения, PDF, Word, Excel, PowerPoint, TXT и ODT'
|
||||||
|
})
|
||||||
contentType!: string;
|
contentType!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Имя файла для определения расширения', example: 'passport.pdf' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'Имя файла должно быть строкой' })
|
||||||
|
fileName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ChatMediaUploadDto {
|
export class ChatMediaUploadDto {
|
||||||
|
|||||||
@@ -1 +1,28 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
export class MarkNotificationReadDto {}
|
export class MarkNotificationReadDto {}
|
||||||
|
|
||||||
|
export class RegisterPushTokenDto {
|
||||||
|
@ApiProperty({ description: 'FCM-токен устройства' })
|
||||||
|
@IsString({ message: 'FCM-токен должен быть строкой' })
|
||||||
|
@IsNotEmpty({ message: 'Укажите FCM-токен' })
|
||||||
|
token!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Платформа устройства', enum: ['WEB', 'ANDROID', 'IOS'], default: 'WEB' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['WEB', 'ANDROID', 'IOS'], { message: 'Платформа должна быть WEB, ANDROID или IOS' })
|
||||||
|
platform?: 'WEB' | 'ANDROID' | 'IOS';
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Метка устройства для отладки' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'Метка устройства должна быть строкой' })
|
||||||
|
deviceLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UnregisterPushTokenDto {
|
||||||
|
@ApiProperty({ description: 'FCM-токен устройства' })
|
||||||
|
@IsString({ message: 'FCM-токен должен быть строкой' })
|
||||||
|
@IsNotEmpty({ message: 'Укажите FCM-токен' })
|
||||||
|
token!: string;
|
||||||
|
}
|
||||||
|
|||||||
170
apps/api-gateway/src/dto/oauth.dto.ts
Normal file
170
apps/api-gateway/src/dto/oauth.dto.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class OAuthAuthorizeIncomingDto {
|
||||||
|
@ApiPropertyOptional({ description: 'ID пользователя (legacy camelCase)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
userId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'ID пользователя (OIDC legacy alias)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
user_id?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'OAuth client_id (legacy camelCase)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
clientId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'OAuth client_id (RFC 6749)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
client_id?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'redirect_uri (legacy camelCase)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
redirectUri?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'redirect_uri (RFC 6749)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
redirect_uri?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Scopes через пробел', example: 'openid profile email' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
scope?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'OAuth state' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
state?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'OAuth response_type', example: 'code' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
response_type?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Legacy camelCase alias response_type' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
responseType?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'PKCE code_challenge' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
code_challenge?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Legacy camelCase alias code_challenge' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
codeChallenge?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'PKCE code_challenge_method', example: 'S256' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
code_challenge_method?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Legacy camelCase alias code_challenge_method' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
codeChallengeMethod?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'OpenID Connect nonce' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
nonce?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OAuthTokenIncomingDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
grantType?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
grant_type?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
code?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
refreshToken?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
refresh_token?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
clientId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
client_id?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
clientSecret?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
client_secret?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
redirectUri?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
redirect_uri?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
codeVerifier?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
code_verifier?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OAuthConsentActionDto {
|
||||||
|
@ApiPropertyOptional({ description: 'OAuth client_id (RFC 6749)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
clientId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'OAuth client_id (legacy camelCase)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
client_id?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Scopes через пробел', example: 'openid profile email' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
scope?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'redirect_uri (RFC 6749)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
redirectUri?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'redirect_uri (legacy camelCase)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
redirect_uri?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'OAuth state' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
state?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'OpenID Connect nonce' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
nonce?: string;
|
||||||
|
}
|
||||||
@@ -40,6 +40,11 @@ export class UpdateProfileDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString({ message: 'Пол должен быть строкой' })
|
@IsString({ message: 'Пол должен быть строкой' })
|
||||||
gender?: string;
|
gender?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Дата рождения в формате YYYY-MM-DD', example: '1990-05-15' })
|
||||||
|
@IsOptional()
|
||||||
|
@Matches(/^\d{4}-\d{2}-\d{2}$/, { message: 'Укажите дату рождения в формате ГГГГ-ММ-ДД' })
|
||||||
|
birthDate?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateContactsDto {
|
export class UpdateContactsDto {
|
||||||
@@ -71,6 +76,41 @@ export class SetPasswordDto {
|
|||||||
password!: string;
|
password!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class SendPasswordVerificationOtpDto {
|
||||||
|
@ApiProperty({ description: 'Канал OTP', enum: ['sms', 'email'] })
|
||||||
|
@IsString({ message: 'Канал должен быть строкой' })
|
||||||
|
channel!: 'sms' | 'email';
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PasswordVerificationDto {
|
||||||
|
@ApiPropertyOptional({ description: 'Текущий пароль' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'Текущий пароль должен быть строкой' })
|
||||||
|
currentPassword?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'OTP-код из SMS или почты' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'OTP-код должен быть строкой' })
|
||||||
|
otpCode?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Канал OTP', enum: ['sms', 'email'] })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'Канал OTP должен быть строкой' })
|
||||||
|
otpChannel?: 'sms' | 'email';
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Код из приложения-аутентификатора' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'Код аутентификатора должен быть строкой' })
|
||||||
|
totpCode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ChangePasswordDto extends PasswordVerificationDto {
|
||||||
|
@ApiProperty({ description: 'Новый пароль', minLength: 8 })
|
||||||
|
@IsString({ message: 'Пароль должен быть строкой' })
|
||||||
|
@MinLength(8, { message: 'Пароль должен содержать минимум 8 символов' })
|
||||||
|
newPassword!: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class UserIdParamDto {
|
export class UserIdParamDto {
|
||||||
@ApiProperty({ description: 'ID пользователя' })
|
@ApiProperty({ description: 'ID пользователя' })
|
||||||
@IsString({ message: 'ID пользователя должен быть строкой' })
|
@IsString({ message: 'ID пользователя должен быть строкой' })
|
||||||
|
|||||||
@@ -33,6 +33,30 @@ export class AssignUserRoleDto {
|
|||||||
roleSlug!: string;
|
roleSlug!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class AssignUserPermissionDto {
|
||||||
|
@ApiProperty({ description: 'Slug права', example: 'oauth.manage' })
|
||||||
|
@IsString({ message: 'Slug права должен быть строкой' })
|
||||||
|
permissionSlug!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateRoleDto {
|
||||||
|
@ApiPropertyOptional({ description: 'Название роли' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'Название роли должно быть строкой' })
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Описание роли' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'Описание должно быть строкой' })
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Список slug прав', type: [String] })
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray({ message: 'Права должны быть массивом' })
|
||||||
|
@IsString({ each: true, message: 'Каждое право должно быть строкой' })
|
||||||
|
permissionSlugs?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export class CreateOAuthClientDto {
|
export class CreateOAuthClientDto {
|
||||||
@ApiProperty({ description: 'Название приложения', example: 'Lendry Docs' })
|
@ApiProperty({ description: 'Название приложения', example: 'Lendry Docs' })
|
||||||
@IsString({ message: 'Название должно быть строкой' })
|
@IsString({ message: 'Название должно быть строкой' })
|
||||||
|
|||||||
@@ -23,3 +23,11 @@ export class VerifySecurityPinDto extends PinDto {
|
|||||||
@IsString({ message: 'ID сессии должен быть строкой' })
|
@IsString({ message: 'ID сессии должен быть строкой' })
|
||||||
sessionId!: string;
|
sessionId!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class TotpCodeDto {
|
||||||
|
@ApiProperty({ description: '6-значный код из приложения-аутентификатора', example: '123456' })
|
||||||
|
@IsString({ message: 'Код должен быть строкой' })
|
||||||
|
@Length(6, 6, { message: 'Код должен содержать 6 цифр' })
|
||||||
|
@Matches(/^\d+$/, { message: 'Код должен содержать только цифры' })
|
||||||
|
code!: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
import { IsBoolean, IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
export class UpsertSettingDto {
|
export class UpsertSettingDto {
|
||||||
@ApiProperty({ description: 'Ключ настройки', example: 'PIN_LOCK_TIMEOUT_MINUTES' })
|
@ApiProperty({ description: 'Ключ настройки', example: 'PIN_LOCK_TIMEOUT_MINUTES' })
|
||||||
@@ -43,6 +43,17 @@ export class UpsertSocialProviderDto {
|
|||||||
isEnabled!: boolean;
|
isEnabled!: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class TestMessagingDeliveryDto {
|
||||||
|
@ApiProperty({ description: 'Канал доставки', enum: ['email', 'sms'] })
|
||||||
|
@IsIn(['email', 'sms'], { message: 'Канал должен быть email или sms' })
|
||||||
|
channel!: 'email' | 'sms';
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Email или номер телефона получателя', example: 'user@example.com' })
|
||||||
|
@IsString({ message: 'Получатель должен быть строкой' })
|
||||||
|
@IsNotEmpty({ message: 'Укажите email или телефон' })
|
||||||
|
target!: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class ConnectLinkedAccountDto {
|
export class ConnectLinkedAccountDto {
|
||||||
@ApiProperty({ description: 'Название провайдера', example: 'google' })
|
@ApiProperty({ description: 'Название провайдера', example: 'google' })
|
||||||
@IsString({ message: 'Название провайдера должно быть строкой' })
|
@IsString({ message: 'Название провайдера должно быть строкой' })
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common';
|
import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common';
|
||||||
import { status as GrpcStatus } from '@grpc/grpc-js';
|
import { status as GrpcStatus } from '@grpc/grpc-js';
|
||||||
|
import { applyFedcmCorsHeaders } from './lib/fedcm-cors';
|
||||||
|
|
||||||
interface HttpResponseLike {
|
interface HttpResponseLike {
|
||||||
status(code: number): { json(body: unknown): unknown };
|
status(code: number): { json(body: unknown): unknown };
|
||||||
|
setHeader?(name: string, value: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HttpRequestLike {
|
||||||
|
path?: string;
|
||||||
|
url?: string;
|
||||||
|
headers?: Record<string, string | string[] | undefined>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function grpcToHttp(code?: number): number {
|
function grpcToHttp(code?: number): number {
|
||||||
@@ -19,6 +27,8 @@ function grpcToHttp(code?: number): number {
|
|||||||
return 404;
|
return 404;
|
||||||
case GrpcStatus.ALREADY_EXISTS:
|
case GrpcStatus.ALREADY_EXISTS:
|
||||||
return 409;
|
return 409;
|
||||||
|
case GrpcStatus.RESOURCE_EXHAUSTED:
|
||||||
|
return 429;
|
||||||
default:
|
default:
|
||||||
return 500;
|
return 500;
|
||||||
}
|
}
|
||||||
@@ -27,7 +37,16 @@ function grpcToHttp(code?: number): number {
|
|||||||
@Catch()
|
@Catch()
|
||||||
export class AllExceptionsFilter implements ExceptionFilter {
|
export class AllExceptionsFilter implements ExceptionFilter {
|
||||||
catch(exception: unknown, host: ArgumentsHost): void {
|
catch(exception: unknown, host: ArgumentsHost): void {
|
||||||
const response = host.switchToHttp().getResponse<HttpResponseLike>();
|
const ctx = host.switchToHttp();
|
||||||
|
const response = ctx.getResponse<HttpResponseLike>();
|
||||||
|
const request = ctx.getRequest<HttpRequestLike>();
|
||||||
|
const requestPath = request.path ?? request.url ?? '';
|
||||||
|
const originHeader = request.headers?.origin;
|
||||||
|
const origin = Array.isArray(originHeader) ? originHeader[0] : originHeader;
|
||||||
|
|
||||||
|
if ((requestPath.includes('/fedcm/') || requestPath.includes('/.well-known/')) && origin && response.setHeader) {
|
||||||
|
applyFedcmCorsHeaders(response as never, origin);
|
||||||
|
}
|
||||||
|
|
||||||
if (exception instanceof HttpException) {
|
if (exception instanceof HttpException) {
|
||||||
const statusCode = exception.getStatus();
|
const statusCode = exception.getStatus();
|
||||||
|
|||||||
@@ -9,10 +9,15 @@ export interface AdminRequestUser {
|
|||||||
isSuperAdmin: boolean;
|
isSuperAdmin: boolean;
|
||||||
canAccessAdmin: boolean;
|
canAccessAdmin: boolean;
|
||||||
canManageRoles: boolean;
|
canManageRoles: boolean;
|
||||||
|
canViewOAuth: boolean;
|
||||||
canManageOAuth: boolean;
|
canManageOAuth: boolean;
|
||||||
|
canManageAllOAuth: boolean;
|
||||||
canManageUsers: boolean;
|
canManageUsers: boolean;
|
||||||
|
canManageAllUsers: boolean;
|
||||||
canViewUsers: boolean;
|
canViewUsers: boolean;
|
||||||
canManageSettings: boolean;
|
canManageSettings: boolean;
|
||||||
|
canVerifyUsers: boolean;
|
||||||
|
canModerateChats: boolean;
|
||||||
roles: string[];
|
roles: string[];
|
||||||
permissions: string[];
|
permissions: string[];
|
||||||
}
|
}
|
||||||
@@ -48,10 +53,15 @@ export class AdminGuard implements CanActivate {
|
|||||||
isSuperAdmin: profile.isSuperAdmin,
|
isSuperAdmin: profile.isSuperAdmin,
|
||||||
canAccessAdmin: Boolean(profile.canAccessAdmin),
|
canAccessAdmin: Boolean(profile.canAccessAdmin),
|
||||||
canManageRoles: Boolean(profile.canManageRoles),
|
canManageRoles: Boolean(profile.canManageRoles),
|
||||||
|
canViewOAuth: Boolean(profile.canViewOAuth),
|
||||||
canManageOAuth: Boolean(profile.canManageOAuth),
|
canManageOAuth: Boolean(profile.canManageOAuth),
|
||||||
|
canManageAllOAuth: Boolean(profile.canManageAllOAuth),
|
||||||
canManageUsers: Boolean(profile.canManageUsers),
|
canManageUsers: Boolean(profile.canManageUsers),
|
||||||
|
canManageAllUsers: Boolean(profile.canManageAllUsers),
|
||||||
canManageSettings: Boolean(profile.canManageSettings),
|
canManageSettings: Boolean(profile.canManageSettings),
|
||||||
canViewUsers: Boolean(profile.canViewUsers),
|
canViewUsers: Boolean(profile.canViewUsers),
|
||||||
|
canVerifyUsers: Boolean(profile.canVerifyUsers),
|
||||||
|
canModerateChats: Boolean(profile.canModerateChats),
|
||||||
roles: profile.roles ?? [],
|
roles: profile.roles ?? [],
|
||||||
permissions: profile.permissions ?? []
|
permissions: profile.permissions ?? []
|
||||||
};
|
};
|
||||||
@@ -60,6 +70,17 @@ export class AdminGuard implements CanActivate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RbacManageGuard implements CanActivate {
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const request = context.switchToHttp().getRequest<{ adminUser?: AdminRequestUser }>();
|
||||||
|
if (request.adminUser?.isSuperAdmin || request.adminUser?.canManageRoles) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
throw new ForbiddenException('Недостаточно прав для управления ролями и правами');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SuperAdminGuard implements CanActivate {
|
export class SuperAdminGuard implements CanActivate {
|
||||||
canActivate(context: ExecutionContext): boolean {
|
canActivate(context: ExecutionContext): boolean {
|
||||||
@@ -71,8 +92,19 @@ export class SuperAdminGuard implements CanActivate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function assertAdminPermission(user: AdminRequestUser | undefined, permission: keyof Pick<AdminRequestUser, 'canManageOAuth' | 'canManageUsers' | 'canManageSettings'>) {
|
type AdminPermissionKey = keyof Pick<
|
||||||
|
AdminRequestUser,
|
||||||
|
'canViewOAuth' | 'canManageOAuth' | 'canManageUsers' | 'canViewUsers' | 'canManageSettings'
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function assertAdminPermission(user: AdminRequestUser | undefined, permission: AdminPermissionKey) {
|
||||||
if (!user?.[permission]) {
|
if (!user?.[permission]) {
|
||||||
throw new ForbiddenException('Недостаточно прав для выполнения действия');
|
throw new ForbiddenException('Недостаточно прав для выполнения действия');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function assertAdminAnyPermission(user: AdminRequestUser | undefined, ...permissions: AdminPermissionKey[]) {
|
||||||
|
if (!user || !permissions.some((permission) => user[permission])) {
|
||||||
|
throw new ForbiddenException('Недостаточно прав для выполнения действия');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { Observable, mergeMap } from 'rxjs';
|
||||||
|
import type { Response } from 'express';
|
||||||
|
import { attachFedcmCookieFromAuthResult } from '../lib/fedcm-cookie';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FedcmCookieInterceptor implements NestInterceptor {
|
||||||
|
constructor(private readonly jwt: JwtService) {}
|
||||||
|
|
||||||
|
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||||
|
const http = context.switchToHttp();
|
||||||
|
const response = http.getResponse<Response>();
|
||||||
|
|
||||||
|
return next.handle().pipe(
|
||||||
|
mergeMap(async (data) => {
|
||||||
|
await attachFedcmCookieFromAuthResult(response, this.jwt, data);
|
||||||
|
return data;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
160
apps/api-gateway/src/lib/fedcm-config.ts
Normal file
160
apps/api-gateway/src/lib/fedcm-config.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import type { Request } from 'express';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
import { CoreGrpcService } from '../core-grpc.service';
|
||||||
|
import {
|
||||||
|
resolveFrontendUrl,
|
||||||
|
resolveOAuthIssuer,
|
||||||
|
resolveProjectDomainApiUrl,
|
||||||
|
resolveProjectDomainFrontendUrl
|
||||||
|
} from './oauth-issuer';
|
||||||
|
import {
|
||||||
|
isLocalDevBaseUrl,
|
||||||
|
isValidFedcmEndpointUrl,
|
||||||
|
normalizePublicBaseUrl,
|
||||||
|
pickBestPublicBase,
|
||||||
|
resolveFedcmWebIdentityOrigin
|
||||||
|
} from './public-url';
|
||||||
|
|
||||||
|
export interface FedcmEndpoints {
|
||||||
|
issuer: string;
|
||||||
|
frontendUrl: string;
|
||||||
|
projectName: string;
|
||||||
|
configUrl: string;
|
||||||
|
accountsEndpoint: string;
|
||||||
|
clientMetadataEndpoint: string;
|
||||||
|
idAssertionEndpoint: string;
|
||||||
|
loginUrl: string;
|
||||||
|
signupUrl: string;
|
||||||
|
webIdentityUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFedcmEndpointUrls(issuer: string, frontendUrl: string) {
|
||||||
|
const base = normalizePublicBaseUrl(issuer);
|
||||||
|
const front = normalizePublicBaseUrl(frontendUrl);
|
||||||
|
const webIdentityOrigin = resolveFedcmWebIdentityOrigin(base, front);
|
||||||
|
|
||||||
|
return {
|
||||||
|
configUrl: `${base}/fedcm/config.json`,
|
||||||
|
accountsEndpoint: `${base}/fedcm/accounts`,
|
||||||
|
clientMetadataEndpoint: `${base}/fedcm/client_metadata`,
|
||||||
|
idAssertionEndpoint: `${base}/fedcm/id_assertion`,
|
||||||
|
// FedCM: login_url MUST be same-origin with config.json (W3C FedCM / Chrome).
|
||||||
|
// На split-domain UI проксируется через nginx: api.idpmvk.lpr/auth/login → frontend.
|
||||||
|
loginUrl: `${base}/auth/login?fedcm=1`,
|
||||||
|
signupUrl: `${front}/auth/register`,
|
||||||
|
webIdentityUrl: `${webIdentityOrigin}/.well-known/web-identity`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveCanonicalFedcmIssuer(core: CoreGrpcService): Promise<string> {
|
||||||
|
const stored = await resolveOAuthIssuer(core);
|
||||||
|
const fromProject = await resolveProjectDomainApiUrl(core);
|
||||||
|
const issuer = pickBestPublicBase(
|
||||||
|
[stored, fromProject],
|
||||||
|
stored
|
||||||
|
);
|
||||||
|
if (isValidFedcmEndpointUrl(issuer)) {
|
||||||
|
return issuer;
|
||||||
|
}
|
||||||
|
if (fromProject && isValidFedcmEndpointUrl(fromProject)) {
|
||||||
|
return fromProject;
|
||||||
|
}
|
||||||
|
return issuer;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveCanonicalFedcmFrontend(core: CoreGrpcService): Promise<string> {
|
||||||
|
const stored = await resolveFrontendUrl(core);
|
||||||
|
const fromProject = await resolveProjectDomainFrontendUrl(core);
|
||||||
|
const frontend = pickBestPublicBase(
|
||||||
|
[stored, fromProject],
|
||||||
|
stored
|
||||||
|
);
|
||||||
|
if (isValidFedcmEndpointUrl(frontend) && !isLocalDevBaseUrl(frontend)) {
|
||||||
|
return frontend;
|
||||||
|
}
|
||||||
|
if (fromProject && isValidFedcmEndpointUrl(fromProject)) {
|
||||||
|
return fromProject;
|
||||||
|
}
|
||||||
|
return frontend;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveFedcmEndpoints(core: CoreGrpcService, _req?: Request): Promise<FedcmEndpoints> {
|
||||||
|
// FedCM: web-identity (idpmvk.lpr) и config.json (api.idpmvk.lpr) ОБЯЗАНЫ
|
||||||
|
// возвращать одинаковые login_url / accounts_endpoint / configUrl.
|
||||||
|
// Хост запроса разный — никогда не выводим URL из req.headers.host.
|
||||||
|
const issuer = await resolveCanonicalFedcmIssuer(core);
|
||||||
|
const frontendUrl = await resolveCanonicalFedcmFrontend(core);
|
||||||
|
let projectName = 'MVK ID';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const setting = (await firstValueFrom(core.settings.GetSetting({ key: 'PROJECT_NAME' }))) as { value?: string };
|
||||||
|
if (setting.value?.trim()) {
|
||||||
|
projectName = setting.value.trim();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
issuer,
|
||||||
|
frontendUrl,
|
||||||
|
projectName,
|
||||||
|
...buildFedcmEndpointUrls(issuer, frontendUrl)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFedcmProviderConfig(endpoints: FedcmEndpoints) {
|
||||||
|
const base = normalizePublicBaseUrl(endpoints.issuer);
|
||||||
|
const front = normalizePublicBaseUrl(endpoints.frontendUrl);
|
||||||
|
return {
|
||||||
|
accounts_endpoint: `${base}/fedcm/accounts`,
|
||||||
|
client_metadata_endpoint: `${base}/fedcm/client_metadata`,
|
||||||
|
id_assertion_endpoint: `${base}/fedcm/id_assertion`,
|
||||||
|
login_url: endpoints.loginUrl,
|
||||||
|
branding: {
|
||||||
|
name: endpoints.projectName,
|
||||||
|
background_color: '#ffffff',
|
||||||
|
color: '#1f2430',
|
||||||
|
icons: [
|
||||||
|
{ url: `${front}/icon.svg`, size: 40 },
|
||||||
|
{ url: `${base}/favicon.ico`, size: 32 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFedcmWebIdentityManifest(endpoints: FedcmEndpoints) {
|
||||||
|
return {
|
||||||
|
provider_urls: [endpoints.configUrl],
|
||||||
|
accounts_endpoint: endpoints.accountsEndpoint,
|
||||||
|
login_url: endpoints.loginUrl
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFedcmDiscoverPayload(endpoints: FedcmEndpoints, enabled = true) {
|
||||||
|
return {
|
||||||
|
enabled,
|
||||||
|
apiBase: endpoints.issuer,
|
||||||
|
frontendUrl: endpoints.frontendUrl,
|
||||||
|
projectName: endpoints.projectName,
|
||||||
|
configUrl: endpoints.configUrl,
|
||||||
|
webIdentityUrl: endpoints.webIdentityUrl,
|
||||||
|
accountsEndpoint: endpoints.accountsEndpoint,
|
||||||
|
loginUrl: endpoints.loginUrl,
|
||||||
|
// Поля для navigator.credentials.get({ identity: { fields: [...] } }) на стороне RP (Chrome 132+).
|
||||||
|
suggestedFields: ['name', 'email', 'picture', 'tel']
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readOneTapEnabled(core: CoreGrpcService): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const setting = (await firstValueFrom(core.settings.GetSetting({ key: 'ONE_TAP_ENABLED' }))) as { value?: string };
|
||||||
|
const raw = setting.value?.trim().toLowerCase();
|
||||||
|
if (!raw) return true;
|
||||||
|
return ['true', '1', 'yes'].includes(raw);
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { readOneTapEnabled };
|
||||||
189
apps/api-gateway/src/lib/fedcm-cookie.ts
Normal file
189
apps/api-gateway/src/lib/fedcm-cookie.ts
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import type { CookieOptions, Response } from 'express';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { resolveRegistrableDomain } from './public-url';
|
||||||
|
|
||||||
|
export const FEDCM_SESSION_COOKIE = 'lendry_fedcm_sess';
|
||||||
|
|
||||||
|
export interface FedcmSessionPayload {
|
||||||
|
sub: string;
|
||||||
|
sessionId: string;
|
||||||
|
pinVerified: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cookieSecureEnabled() {
|
||||||
|
if (process.env.FEDCM_COOKIE_SECURE === 'true') return true;
|
||||||
|
if (process.env.FEDCM_COOKIE_SECURE === 'false') return false;
|
||||||
|
const urls = [process.env.PUBLIC_API_URL, process.env.PUBLIC_FRONTEND_URL];
|
||||||
|
if (urls.some((url) => url?.trim().startsWith('https://'))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return process.env.NODE_ENV === 'production';
|
||||||
|
}
|
||||||
|
|
||||||
|
function cookieMaxAgeSeconds() {
|
||||||
|
const parsed = Number(process.env.FEDCM_COOKIE_MAX_AGE ?? 2_592_000);
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : 2_592_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Общий домен cookie для api.* / sso.* / apex (FedCM accounts на api-домене). */
|
||||||
|
export function resolveFedcmCookieDomain(): string | undefined {
|
||||||
|
const explicit = process.env.FEDCM_COOKIE_DOMAIN?.trim();
|
||||||
|
if (explicit) {
|
||||||
|
if (['none', 'off', 'localhost'].includes(explicit.toLowerCase())) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return explicit.startsWith('.') ? explicit : `.${explicit}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const raw of [process.env.PUBLIC_API_URL, process.env.PUBLIC_FRONTEND_URL]) {
|
||||||
|
const candidate = raw?.trim();
|
||||||
|
if (!candidate) continue;
|
||||||
|
try {
|
||||||
|
const hostname = new URL(candidate).hostname.toLowerCase();
|
||||||
|
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const apex = resolveRegistrableDomain(hostname);
|
||||||
|
if (apex.includes('.')) {
|
||||||
|
return `.${apex}`;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFedcmCookieOptions(maxAgeMs?: number): CookieOptions {
|
||||||
|
const secure = cookieSecureEnabled();
|
||||||
|
const options: CookieOptions = {
|
||||||
|
httpOnly: true,
|
||||||
|
secure,
|
||||||
|
sameSite: secure ? 'none' : 'lax',
|
||||||
|
path: '/',
|
||||||
|
maxAge: maxAgeMs ?? cookieMaxAgeSeconds() * 1000
|
||||||
|
};
|
||||||
|
const domain = resolveFedcmCookieDomain();
|
||||||
|
if (domain) {
|
||||||
|
options.domain = domain;
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signFedcmSessionPayload(jwt: JwtService, payload: FedcmSessionPayload) {
|
||||||
|
return jwt.signAsync(
|
||||||
|
{ sub: payload.sub, sessionId: payload.sessionId, pinVerified: payload.pinVerified, typ: 'fedcm_session' },
|
||||||
|
{
|
||||||
|
secret: process.env.JWT_ACCESS_SECRET ?? 'docker-access-secret',
|
||||||
|
expiresIn: cookieMaxAgeSeconds(),
|
||||||
|
issuer: 'id.lendry.ru'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyFedcmSessionToken(jwt: JwtService, token: string): Promise<FedcmSessionPayload | null> {
|
||||||
|
try {
|
||||||
|
const payload = await jwt.verifyAsync<{ sub: string; sessionId: string; pinVerified?: boolean; typ?: string }>(token, {
|
||||||
|
secret: process.env.JWT_ACCESS_SECRET ?? 'docker-access-secret',
|
||||||
|
issuer: 'id.lendry.ru'
|
||||||
|
});
|
||||||
|
if (payload.typ !== 'fedcm_session' || !payload.sub || !payload.sessionId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
sub: payload.sub,
|
||||||
|
sessionId: payload.sessionId,
|
||||||
|
pinVerified: payload.pinVerified !== false
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setFedcmSessionCookie(res: Response, jwt: JwtService, payload: FedcmSessionPayload) {
|
||||||
|
if (!payload.sub || !payload.sessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (res.headersSent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = await signFedcmSessionPayload(jwt, payload);
|
||||||
|
if (res.headersSent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.cookie(FEDCM_SESSION_COOKIE, value, buildFedcmCookieOptions());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearFedcmSessionCookie(res: Response) {
|
||||||
|
if (res.headersSent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.clearCookie(FEDCM_SESSION_COOKIE, buildFedcmCookieOptions(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readFedcmSessionCookie(cookieHeader?: string): string | null {
|
||||||
|
if (!cookieHeader) return null;
|
||||||
|
const parts = cookieHeader.split(';');
|
||||||
|
for (const part of parts) {
|
||||||
|
const [rawKey, ...rawValue] = part.trim().split('=');
|
||||||
|
if (rawKey === FEDCM_SESSION_COOKIE) {
|
||||||
|
const value = rawValue.join('=').trim();
|
||||||
|
return value || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveFedcmSessionFromRequest(
|
||||||
|
jwt: JwtService,
|
||||||
|
cookieHeader?: string
|
||||||
|
): Promise<FedcmSessionPayload | null> {
|
||||||
|
const token = readFedcmSessionCookie(cookieHeader);
|
||||||
|
if (!token) return null;
|
||||||
|
return verifyFedcmSessionToken(jwt, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function attachFedcmCookieFromAuthResult(
|
||||||
|
res: Response,
|
||||||
|
jwt: JwtService,
|
||||||
|
result: unknown
|
||||||
|
): Promise<void> {
|
||||||
|
if (!result || typeof result !== 'object') return;
|
||||||
|
const data = result as {
|
||||||
|
accessToken?: string;
|
||||||
|
sessionId?: string;
|
||||||
|
pinVerified?: boolean;
|
||||||
|
requiresPin?: boolean;
|
||||||
|
user?: { id?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
if (data.requiresPin || data.pinVerified === false) {
|
||||||
|
let userId = data.user?.id;
|
||||||
|
if (!userId && data.accessToken) {
|
||||||
|
const decoded = jwt.decode(data.accessToken) as { sub?: string } | null;
|
||||||
|
userId = decoded?.sub;
|
||||||
|
}
|
||||||
|
if (userId && data.sessionId) {
|
||||||
|
await setFedcmSessionCookie(res, jwt, {
|
||||||
|
sub: userId,
|
||||||
|
sessionId: data.sessionId,
|
||||||
|
pinVerified: false
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
clearFedcmSessionCookie(res);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = data.user?.id;
|
||||||
|
if (!userId || !data.sessionId) return;
|
||||||
|
|
||||||
|
await setFedcmSessionCookie(res, jwt, {
|
||||||
|
sub: userId,
|
||||||
|
sessionId: data.sessionId,
|
||||||
|
pinVerified: true
|
||||||
|
});
|
||||||
|
}
|
||||||
70
apps/api-gateway/src/lib/fedcm-cors.ts
Normal file
70
apps/api-gateway/src/lib/fedcm-cors.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
|
||||||
|
export function normalizeFedcmOrigin(origin?: string) {
|
||||||
|
return origin?.trim().replace(/\/+$/, '') || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyFedcmCorsHeaders(res: Response, origin?: string) {
|
||||||
|
const normalized = normalizeFedcmOrigin(origin);
|
||||||
|
if (!normalized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', normalized);
|
||||||
|
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||||
|
res.setHeader('Vary', 'Origin');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireFedcmCorsOrigin(origin?: string) {
|
||||||
|
const normalized = normalizeFedcmOrigin(origin);
|
||||||
|
if (!normalized) {
|
||||||
|
throw new BadRequestException('FedCM требует заголовок Origin');
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyFedcmPreflightHeaders(res: Response, origin?: string) {
|
||||||
|
applyFedcmCorsHeaders(res, origin);
|
||||||
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||||
|
res.setHeader(
|
||||||
|
'Access-Control-Allow-Headers',
|
||||||
|
'Content-Type, Authorization, Sec-Fetch-Dest, Sec-Fetch-Mode, Sec-Fetch-Site'
|
||||||
|
);
|
||||||
|
res.setHeader('Access-Control-Max-Age', '86400');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FedCM-запросы должны иметь Sec-Fetch-Dest: webidentity (или empty, если прокси
|
||||||
|
* не пробрасывает заголовок). Блокируем только явно «не-FedCM» dest (document,
|
||||||
|
* script, image…) — иначе well-known/config отдают 400 и One Tap ломается.
|
||||||
|
*/
|
||||||
|
const BLOCKED_FEDCM_FETCH_DEST = new Set([
|
||||||
|
'document',
|
||||||
|
'embed',
|
||||||
|
'frame',
|
||||||
|
'iframe',
|
||||||
|
'object',
|
||||||
|
'script',
|
||||||
|
'style',
|
||||||
|
'image',
|
||||||
|
'font',
|
||||||
|
'audio',
|
||||||
|
'video',
|
||||||
|
'track',
|
||||||
|
'worker',
|
||||||
|
'serviceworker',
|
||||||
|
'sharedworker',
|
||||||
|
'manifest',
|
||||||
|
'xslt'
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function assertFedcmWebIdentityRequest(req: Request) {
|
||||||
|
const dest = String(req.headers['sec-fetch-dest'] ?? '').toLowerCase();
|
||||||
|
if (dest && BLOCKED_FEDCM_FETCH_DEST.has(dest)) {
|
||||||
|
throw new BadRequestException('Недопустимый Sec-Fetch-Dest для FedCM');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyFedcmLoginStatus(res: Response, loggedIn: boolean) {
|
||||||
|
res.setHeader('Set-Login', loggedIn ? 'logged-in' : 'logged-out');
|
||||||
|
}
|
||||||
26
apps/api-gateway/src/lib/fedcm-session.ts
Normal file
26
apps/api-gateway/src/lib/fedcm-session.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
import { CoreGrpcService } from '../core-grpc.service';
|
||||||
|
import { FedcmSessionPayload, resolveFedcmSessionFromRequest } from './fedcm-cookie';
|
||||||
|
|
||||||
|
export async function resolveFedcmSessionPinState(
|
||||||
|
jwt: JwtService,
|
||||||
|
core: CoreGrpcService,
|
||||||
|
cookieHeader?: string
|
||||||
|
): Promise<{ session: FedcmSessionPayload | null; requiresPin: boolean }> {
|
||||||
|
const session = await resolveFedcmSessionFromRequest(jwt, cookieHeader);
|
||||||
|
if (!session) {
|
||||||
|
return { session: null, requiresPin: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const validation = (await firstValueFrom(
|
||||||
|
core.auth.ValidateSession({
|
||||||
|
userId: session.sub,
|
||||||
|
sessionId: session.sessionId,
|
||||||
|
touchActivity: false
|
||||||
|
})
|
||||||
|
)) as { requiresPin: boolean; pinVerified?: boolean };
|
||||||
|
|
||||||
|
const requiresPin = Boolean(validation.requiresPin || !session.pinVerified);
|
||||||
|
return { session, requiresPin };
|
||||||
|
}
|
||||||
113
apps/api-gateway/src/lib/oauth-issuer.ts
Normal file
113
apps/api-gateway/src/lib/oauth-issuer.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
import { CoreGrpcService } from '../core-grpc.service';
|
||||||
|
import { normalizePublicBaseUrl, pickBestPublicBase, isInternalHostname, normalizeDedicatedApiIssuer } from './public-url';
|
||||||
|
|
||||||
|
function normalizeBaseUrl(url: string) {
|
||||||
|
return normalizePublicBaseUrl(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendIdpApiPath(base: string) {
|
||||||
|
const normalized = normalizeBaseUrl(base);
|
||||||
|
return normalized.endsWith('/idp-api') ? normalized : `${normalized}/idp-api`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeStoredPublicUrl(url: string) {
|
||||||
|
const normalized = normalizeBaseUrl(url);
|
||||||
|
try {
|
||||||
|
if (isInternalHostname(new URL(normalized).hostname)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveProjectDomainUrl(core: CoreGrpcService, withIdpApiPath = false): Promise<string | undefined> {
|
||||||
|
try {
|
||||||
|
const response = (await firstValueFrom(core.settings.GetSetting({ key: 'PROJECT_DOMAIN' }))) as { value?: string };
|
||||||
|
const domain = response.value?.trim();
|
||||||
|
if (!domain) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (domain.startsWith('http://') || domain.startsWith('https://')) {
|
||||||
|
return withIdpApiPath ? appendIdpApiPath(domain) : normalizeBaseUrl(domain);
|
||||||
|
}
|
||||||
|
const base = `https://${domain.replace(/^\/+/, '')}`;
|
||||||
|
return withIdpApiPath ? appendIdpApiPath(base) : base;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveOAuthIssuer(core: CoreGrpcService, fallback = 'http://localhost:3000'): Promise<string> {
|
||||||
|
const envIssuer = sanitizeStoredPublicUrl(process.env.PUBLIC_API_URL?.trim() ?? '');
|
||||||
|
let fromDb: string | undefined;
|
||||||
|
try {
|
||||||
|
const response = (await firstValueFrom(core.settings.GetSetting({ key: 'PUBLIC_API_URL' }))) as { value?: string };
|
||||||
|
fromDb = sanitizeStoredPublicUrl(response.value ?? '');
|
||||||
|
} catch {
|
||||||
|
// fallback ниже
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromDomain = await resolveProjectDomainUrl(core, true);
|
||||||
|
// env первым: в Docker .env перекрывает seed localhost в БД после первого деплоя.
|
||||||
|
const picked = pickBestPublicBase([envIssuer, fromDb, fromDomain], fallback);
|
||||||
|
return normalizeDedicatedApiIssuer(picked);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveFrontendUrl(core: CoreGrpcService, fallback = 'http://localhost:3002'): Promise<string> {
|
||||||
|
const envFrontend = sanitizeStoredPublicUrl(process.env.PUBLIC_FRONTEND_URL?.trim() ?? '');
|
||||||
|
let fromDb: string | undefined;
|
||||||
|
try {
|
||||||
|
const response = (await firstValueFrom(core.settings.GetSetting({ key: 'PUBLIC_FRONTEND_URL' }))) as { value?: string };
|
||||||
|
fromDb = sanitizeStoredPublicUrl(response.value ?? '');
|
||||||
|
} catch {
|
||||||
|
// fallback ниже
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromDomain = await resolveProjectDomainUrl(core, false);
|
||||||
|
return pickBestPublicBase([envFrontend, fromDb, fromDomain], fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveProjectDomainFrontendUrl(core: CoreGrpcService): Promise<string | undefined> {
|
||||||
|
return resolveProjectDomainUrl(core, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveProjectDomainApiUrl(core: CoreGrpcService): Promise<string | undefined> {
|
||||||
|
return resolveProjectDomainUrl(core, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildOpenIdConfiguration(issuer: string) {
|
||||||
|
const base = normalizeBaseUrl(issuer);
|
||||||
|
return {
|
||||||
|
issuer: base,
|
||||||
|
authorization_endpoint: `${base}/oauth/authorize`,
|
||||||
|
token_endpoint: `${base}/oauth/token`,
|
||||||
|
userinfo_endpoint: `${base}/oauth/userinfo`,
|
||||||
|
jwks_uri: `${base}/.well-known/jwks.json`,
|
||||||
|
response_types_supported: ['code'],
|
||||||
|
subject_types_supported: ['public'],
|
||||||
|
id_token_signing_alg_values_supported: ['HS256'],
|
||||||
|
scopes_supported: ['openid', 'profile', 'email', 'phone', 'address', 'documents'],
|
||||||
|
token_endpoint_auth_methods_supported: ['client_secret_post', 'client_secret_basic'],
|
||||||
|
grant_types_supported: ['authorization_code', 'refresh_token'],
|
||||||
|
code_challenge_methods_supported: ['S256', 'plain'],
|
||||||
|
claims_supported: [
|
||||||
|
'sub',
|
||||||
|
'iss',
|
||||||
|
'aud',
|
||||||
|
'iat',
|
||||||
|
'exp',
|
||||||
|
'auth_time',
|
||||||
|
'nonce',
|
||||||
|
'email',
|
||||||
|
'email_verified',
|
||||||
|
'phone_number',
|
||||||
|
'phone_number_verified',
|
||||||
|
'name',
|
||||||
|
'preferred_username',
|
||||||
|
'picture'
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
248
apps/api-gateway/src/lib/oauth-params.ts
Normal file
248
apps/api-gateway/src/lib/oauth-params.ts
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
|
||||||
|
export interface NormalizedAuthorizeQuery {
|
||||||
|
userId?: string;
|
||||||
|
clientId: string;
|
||||||
|
redirectUri: string;
|
||||||
|
scope: string;
|
||||||
|
state?: string;
|
||||||
|
responseType?: string;
|
||||||
|
codeChallenge?: string;
|
||||||
|
codeChallengeMethod?: string;
|
||||||
|
nonce?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NormalizedTokenBody {
|
||||||
|
grantType: string;
|
||||||
|
code?: string;
|
||||||
|
refreshToken?: string;
|
||||||
|
clientId: string;
|
||||||
|
clientSecret?: string;
|
||||||
|
redirectUri?: string;
|
||||||
|
codeVerifier?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readString(value: unknown) {
|
||||||
|
if (typeof value === 'string') return value.trim();
|
||||||
|
if (Array.isArray(value) && typeof value[0] === 'string') return value[0].trim();
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeAuthorizeQuery(query: Record<string, unknown>): NormalizedAuthorizeQuery {
|
||||||
|
const clientId = readString(query.clientId) ?? readString(query.client_id);
|
||||||
|
const redirectUri = readString(query.redirectUri) ?? readString(query.redirect_uri);
|
||||||
|
const scope = readString(query.scope) ?? 'openid profile';
|
||||||
|
const userId = readString(query.userId) ?? readString(query.user_id);
|
||||||
|
const state = readString(query.state);
|
||||||
|
const responseType = readString(query.response_type) ?? readString(query.responseType);
|
||||||
|
const codeChallenge = readString(query.code_challenge) ?? readString(query.codeChallenge);
|
||||||
|
const codeChallengeMethod = readString(query.code_challenge_method) ?? readString(query.codeChallengeMethod);
|
||||||
|
const nonce = readString(query.nonce);
|
||||||
|
|
||||||
|
if (!clientId) {
|
||||||
|
throw new BadRequestException('Укажите client_id');
|
||||||
|
}
|
||||||
|
if (!redirectUri) {
|
||||||
|
throw new BadRequestException('Укажите redirect_uri');
|
||||||
|
}
|
||||||
|
if (responseType && responseType !== 'code') {
|
||||||
|
throw new BadRequestException('Поддерживается только response_type=code');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId,
|
||||||
|
clientId,
|
||||||
|
redirectUri,
|
||||||
|
scope,
|
||||||
|
state,
|
||||||
|
responseType,
|
||||||
|
codeChallenge,
|
||||||
|
codeChallengeMethod,
|
||||||
|
nonce
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeConsentQuery(query: Record<string, unknown>) {
|
||||||
|
const clientId = readString(query.clientId) ?? readString(query.client_id);
|
||||||
|
const scope = readString(query.scope) ?? 'openid profile';
|
||||||
|
const redirectUri = readString(query.redirectUri) ?? readString(query.redirect_uri);
|
||||||
|
const state = readString(query.state);
|
||||||
|
const nonce = readString(query.nonce);
|
||||||
|
|
||||||
|
if (!clientId) {
|
||||||
|
throw new BadRequestException('Укажите client_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { clientId, scope, redirectUri, state, nonce };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeTokenBody(body: Record<string, unknown>): NormalizedTokenBody {
|
||||||
|
const grantType = readString(body.grantType) ?? readString(body.grant_type);
|
||||||
|
const clientId = readString(body.clientId) ?? readString(body.client_id);
|
||||||
|
const clientSecret = readString(body.clientSecret) ?? readString(body.client_secret);
|
||||||
|
const code = readString(body.code);
|
||||||
|
const refreshToken = readString(body.refreshToken) ?? readString(body.refresh_token);
|
||||||
|
const redirectUri = readString(body.redirectUri) ?? readString(body.redirect_uri);
|
||||||
|
const codeVerifier = readString(body.codeVerifier) ?? readString(body.code_verifier);
|
||||||
|
|
||||||
|
if (!grantType) {
|
||||||
|
throw new BadRequestException('Укажите grant_type');
|
||||||
|
}
|
||||||
|
if (!clientId) {
|
||||||
|
throw new BadRequestException('Укажите client_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
grantType,
|
||||||
|
clientId,
|
||||||
|
clientSecret,
|
||||||
|
code,
|
||||||
|
refreshToken,
|
||||||
|
redirectUri,
|
||||||
|
codeVerifier
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appendQueryParams(baseUrl: string, query: Record<string, unknown>) {
|
||||||
|
const url = new URL(baseUrl);
|
||||||
|
for (const [key, value] of Object.entries(query)) {
|
||||||
|
if (value === undefined || value === null || value === '') continue;
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
value.forEach((item) => url.searchParams.append(key, String(item)));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
url.searchParams.set(key, String(value));
|
||||||
|
}
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseBasicClientCredentials(authorization?: string) {
|
||||||
|
if (!authorization?.startsWith('Basic ')) return null;
|
||||||
|
try {
|
||||||
|
const decoded = Buffer.from(authorization.slice(6), 'base64').toString('utf8');
|
||||||
|
const separator = decoded.indexOf(':');
|
||||||
|
if (separator < 0) return null;
|
||||||
|
return {
|
||||||
|
clientId: decoded.slice(0, separator),
|
||||||
|
clientSecret: decoded.slice(separator + 1)
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapTokenResponseToStandard(result: {
|
||||||
|
accessToken?: string;
|
||||||
|
tokenType?: string;
|
||||||
|
expiresIn?: number;
|
||||||
|
refreshToken?: string;
|
||||||
|
idToken?: string;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
access_token: result.accessToken,
|
||||||
|
token_type: result.tokenType ?? 'Bearer',
|
||||||
|
expires_in: result.expiresIn ?? 900,
|
||||||
|
refresh_token: result.refreshToken,
|
||||||
|
id_token: result.idToken
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapUserInfoToOidc(result: {
|
||||||
|
sub?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
name?: string;
|
||||||
|
picture?: string;
|
||||||
|
emailVerified?: boolean;
|
||||||
|
preferredUsername?: string;
|
||||||
|
phoneNumber?: string;
|
||||||
|
phoneNumberVerified?: boolean;
|
||||||
|
}) {
|
||||||
|
const claims: Record<string, unknown> = { sub: result.sub };
|
||||||
|
if (result.name) claims.name = result.name;
|
||||||
|
if (result.picture) claims.picture = result.picture;
|
||||||
|
if (result.preferredUsername) claims.preferred_username = result.preferredUsername;
|
||||||
|
if (result.email) {
|
||||||
|
claims.email = result.email;
|
||||||
|
if (result.emailVerified !== undefined) claims.email_verified = result.emailVerified;
|
||||||
|
}
|
||||||
|
const phoneNumber = result.phoneNumber ?? result.phone;
|
||||||
|
if (phoneNumber) {
|
||||||
|
claims.phone_number = phoneNumber;
|
||||||
|
if (result.phoneNumberVerified !== undefined) claims.phone_number_verified = result.phoneNumberVerified;
|
||||||
|
}
|
||||||
|
return claims;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeTokenCredentials(
|
||||||
|
body: NormalizedTokenBody,
|
||||||
|
authorization?: string
|
||||||
|
): NormalizedTokenBody {
|
||||||
|
const basic = parseBasicClientCredentials(authorization);
|
||||||
|
if (!basic) return body;
|
||||||
|
return {
|
||||||
|
...body,
|
||||||
|
clientId: body.clientId || basic.clientId,
|
||||||
|
clientSecret: body.clientSecret || basic.clientSecret
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRecord(value: unknown) {
|
||||||
|
return value && typeof value === 'object' ? (value as Record<string, unknown>) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapOAuthClientPublicInfo(raw: Record<string, unknown>) {
|
||||||
|
return {
|
||||||
|
clientId: String(raw.clientId ?? raw.client_id ?? ''),
|
||||||
|
name: String(raw.name ?? '')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapOAuthScopeInfo(raw: Record<string, unknown>) {
|
||||||
|
const slug = String(raw.slug ?? '');
|
||||||
|
return {
|
||||||
|
slug,
|
||||||
|
name: String(raw.name ?? slug),
|
||||||
|
description: raw.description ? String(raw.description) : undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapOAuthConsentCheckResponse(raw: Record<string, unknown>) {
|
||||||
|
const clientRaw = readRecord(raw.client);
|
||||||
|
const scopesRaw = Array.isArray(raw.requestedScopes)
|
||||||
|
? raw.requestedScopes
|
||||||
|
: Array.isArray(raw.requested_scopes)
|
||||||
|
? raw.requested_scopes
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
granted: Boolean(raw.granted),
|
||||||
|
client: clientRaw
|
||||||
|
? mapOAuthClientPublicInfo(clientRaw)
|
||||||
|
: undefined,
|
||||||
|
requestedScopes: scopesRaw
|
||||||
|
.map((item) => readRecord(item))
|
||||||
|
.filter((item): item is Record<string, unknown> => Boolean(item))
|
||||||
|
.map(mapOAuthScopeInfo)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapUserOAuthConsentsResponse(raw: Record<string, unknown>) {
|
||||||
|
const consentsRaw = Array.isArray(raw.consents) ? raw.consents : [];
|
||||||
|
return {
|
||||||
|
consents: consentsRaw
|
||||||
|
.map((item) => readRecord(item))
|
||||||
|
.filter((item): item is Record<string, unknown> => Boolean(item))
|
||||||
|
.map((consent) => ({
|
||||||
|
id: String(consent.id ?? ''),
|
||||||
|
clientId: String(consent.clientId ?? consent.client_id ?? ''),
|
||||||
|
clientName: String(consent.clientName ?? consent.client_name ?? consent.clientId ?? consent.client_id ?? ''),
|
||||||
|
scopes: (Array.isArray(consent.scopes) ? consent.scopes : [])
|
||||||
|
.map((item) => readRecord(item))
|
||||||
|
.filter((item): item is Record<string, unknown> => Boolean(item))
|
||||||
|
.map(mapOAuthScopeInfo),
|
||||||
|
grantedAt: String(consent.grantedAt ?? consent.granted_at ?? ''),
|
||||||
|
updatedAt: String(consent.updatedAt ?? consent.updated_at ?? '')
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
235
apps/api-gateway/src/lib/public-url.ts
Normal file
235
apps/api-gateway/src/lib/public-url.ts
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
import type { Request } from 'express';
|
||||||
|
|
||||||
|
const INTERNAL_HOSTNAMES = new Set([
|
||||||
|
'api-gateway',
|
||||||
|
'sso-core',
|
||||||
|
'frontend',
|
||||||
|
'docs',
|
||||||
|
'media-ws',
|
||||||
|
'minio',
|
||||||
|
'postgres',
|
||||||
|
'redis',
|
||||||
|
'rabbitmq',
|
||||||
|
'ldap-auth'
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function normalizePublicBaseUrl(url: string) {
|
||||||
|
return url.trim().replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* На выделенном API-домене (api.example.com) gateway слушает в корне (/fedcm, /oauth).
|
||||||
|
* Префикс /idp-api нужен только на SSO-домене. Убираем лишний суффикс из PUBLIC_API_URL.
|
||||||
|
*/
|
||||||
|
export function normalizeDedicatedApiIssuer(url: string) {
|
||||||
|
const normalized = normalizePublicBaseUrl(url);
|
||||||
|
try {
|
||||||
|
const parsed = new URL(normalized);
|
||||||
|
const host = parsed.hostname.toLowerCase();
|
||||||
|
const path = parsed.pathname.replace(/\/+$/, '') || '/';
|
||||||
|
if (host.startsWith('api.') && (path === '/idp-api' || path.endsWith('/idp-api'))) {
|
||||||
|
return `${parsed.protocol}//${parsed.host}`;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isInternalHostname(hostname: string) {
|
||||||
|
const host = hostname.trim().toLowerCase();
|
||||||
|
if (!host) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (INTERNAL_HOSTNAMES.has(host)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^\d+\.\d+\.\d+\.\d+$/.test(host) || host === 'localhost') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Одно слово без точки — типичное имя Docker-сервиса (api-gateway, sso-core).
|
||||||
|
return !host.includes('.');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBrowserReachableBaseUrl(url: string) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !isInternalHostname(parsed.hostname);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Абсолютный HTTPS (или localhost HTTP) URL для FedCM login_url / configUrl. */
|
||||||
|
export function isValidFedcmEndpointUrl(url: string) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (isInternalHostname(parsed.hostname)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (isLocalDevBaseUrl(url)) {
|
||||||
|
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
||||||
|
}
|
||||||
|
return parsed.protocol === 'https:';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readRequestHost(req?: Request): string | undefined {
|
||||||
|
if (!req) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const forwarded = req.headers['x-forwarded-host'];
|
||||||
|
const host = (Array.isArray(forwarded) ? forwarded[0] : forwarded) ?? req.headers.host;
|
||||||
|
return host?.split(',')[0]?.trim() || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readRequestProto(req?: Request): string {
|
||||||
|
if (!req) {
|
||||||
|
return 'https';
|
||||||
|
}
|
||||||
|
|
||||||
|
const forwarded = req.headers['x-forwarded-proto'];
|
||||||
|
const proto = (Array.isArray(forwarded) ? forwarded[0] : forwarded) ?? req.protocol;
|
||||||
|
return proto?.split(',')[0]?.trim() || 'https';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePublicOriginFromRequest(req?: Request): string | null {
|
||||||
|
const host = readRequestHost(req);
|
||||||
|
if (!host) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostname = host.split(':')[0];
|
||||||
|
if (isInternalHostname(hostname)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizePublicBaseUrl(`${readRequestProto(req)}://${host}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePublicApiBaseFromRequest(req?: Request): string | null {
|
||||||
|
const origin = resolvePublicOriginFromRequest(req);
|
||||||
|
if (!origin) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${origin}/idp-api`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePublicFrontendBaseFromRequest(req?: Request): string | null {
|
||||||
|
return resolvePublicOriginFromRequest(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Возвращает канонический публичный base-URL для FedCM.
|
||||||
|
*
|
||||||
|
* FedCM требует, чтобы well-known файл (всегда запрашивается с eTLD+1, например
|
||||||
|
* idpmvk.lpr) и discover.json (запрашивается с поддомена sso.idpmvk.lpr)
|
||||||
|
* возвращали ОДИН и тот же configUrl. Поэтому здесь приоритет всегда у значения
|
||||||
|
* из настроек (PUBLIC_API_URL / PUBLIC_FRONTEND_URL), а на хост запроса
|
||||||
|
* переключаемся только если в настройках указан внутренний Docker-хост.
|
||||||
|
*/
|
||||||
|
export function isLocalDevHostname(hostname: string) {
|
||||||
|
const host = hostname.trim().toLowerCase();
|
||||||
|
return host === 'localhost' || host === '127.0.0.1' || host === '[::1]';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLocalDevBaseUrl(url: string) {
|
||||||
|
try {
|
||||||
|
return isLocalDevHostname(new URL(url).hostname);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Выбирает публичный URL: приоритет у реального домена, не localhost из .env/seed. */
|
||||||
|
export function pickBestPublicBase(candidates: Array<string | null | undefined>, fallback: string) {
|
||||||
|
const normalized = candidates
|
||||||
|
.map((candidate) => candidate?.trim())
|
||||||
|
.filter((candidate): candidate is string => Boolean(candidate))
|
||||||
|
.map((candidate) => normalizePublicBaseUrl(candidate));
|
||||||
|
|
||||||
|
const productionReachable = normalized.find(
|
||||||
|
(candidate) => isBrowserReachableBaseUrl(candidate) && !isLocalDevBaseUrl(candidate)
|
||||||
|
);
|
||||||
|
if (productionReachable) {
|
||||||
|
return productionReachable;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reachable = normalized.find((candidate) => isBrowserReachableBaseUrl(candidate));
|
||||||
|
if (reachable) {
|
||||||
|
return reachable;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized[0]) {
|
||||||
|
return normalized[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizePublicBaseUrl(fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function preferCanonicalBase(stored: string, fromRequest: string | null) {
|
||||||
|
return pickBestPublicBase([stored, fromRequest], stored || fromRequest || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function preferBrowserReachableBase(stored: string, fromRequest: string | null) {
|
||||||
|
if (fromRequest && isBrowserReachableBaseUrl(fromRequest)) {
|
||||||
|
try {
|
||||||
|
const storedUrl = new URL(stored);
|
||||||
|
if (isInternalHostname(storedUrl.hostname)) {
|
||||||
|
return fromRequest;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return fromRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizePublicBaseUrl(stored) !== normalizePublicBaseUrl(fromRequest)) {
|
||||||
|
return fromRequest;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hostsMatch(a: string, b: string) {
|
||||||
|
if (a === b) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return a === 'localhost' && b === 'localhost';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** eTLD+1 для FedCM (sso.idpmvk.lpr → idpmvk.lpr). Совпадает с registrable_domain в install.sh */
|
||||||
|
export function resolveRegistrableDomain(hostname: string): string {
|
||||||
|
const host = hostname.trim().toLowerCase();
|
||||||
|
if (!host) return host;
|
||||||
|
const labels = host.split('.').filter(Boolean);
|
||||||
|
if (labels.length <= 2) return host;
|
||||||
|
return `${labels[labels.length - 2]}.${labels[labels.length - 1]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveFedcmWebIdentityOrigin(issuer: string, frontendUrl: string): string {
|
||||||
|
for (const candidate of [issuer, frontendUrl]) {
|
||||||
|
try {
|
||||||
|
const hostname = new URL(candidate).hostname;
|
||||||
|
const apex = resolveRegistrableDomain(hostname);
|
||||||
|
const proto = new URL(candidate).protocol;
|
||||||
|
return `${proto}//${apex}`;
|
||||||
|
} catch {
|
||||||
|
// try next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return frontendUrl;
|
||||||
|
}
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
import { ValidationPipe } from '@nestjs/common';
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
|
import cookieParser from 'cookie-parser';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { AllExceptionsFilter } from './grpc-exception.filter';
|
import { AllExceptionsFilter } from './grpc-exception.filter';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||||
|
app.set('trust proxy', 1);
|
||||||
|
app.use(cookieParser());
|
||||||
app.enableCors({ origin: true, credentials: true });
|
app.enableCors({ origin: true, credentials: true });
|
||||||
app.useGlobalPipes(
|
app.useGlobalPipes(
|
||||||
new ValidationPipe({
|
new ValidationPipe({
|
||||||
@@ -18,14 +22,15 @@ async function bootstrap() {
|
|||||||
|
|
||||||
const config = new DocumentBuilder()
|
const config = new DocumentBuilder()
|
||||||
.setTitle('Lendry ID API')
|
.setTitle('Lendry ID API')
|
||||||
.setDescription('REST API для единого входа, безопасности, RBAC и администрирования Lendry ID.')
|
.setDescription('REST API для единого входа, безопасности, RBAC и администрирования.')
|
||||||
.setVersion('0.1.0')
|
.setVersion('0.1.0')
|
||||||
.addBearerAuth()
|
.addBearerAuth()
|
||||||
.build();
|
.build();
|
||||||
const document = SwaggerModule.createDocument(app, config);
|
const document = SwaggerModule.createDocument(app, config);
|
||||||
SwaggerModule.setup('docs', app, document, {
|
SwaggerModule.setup('docs', app, document, {
|
||||||
|
yamlDocumentUrl: '/openapi.yaml',
|
||||||
swaggerOptions: { persistAuthorization: true },
|
swaggerOptions: { persistAuthorization: true },
|
||||||
customSiteTitle: 'Документация Lendry ID API'
|
customSiteTitle: 'Документация API'
|
||||||
});
|
});
|
||||||
|
|
||||||
await app.listen(process.env.PORT ? Number(process.env.PORT) : 3000);
|
await app.listen(process.env.PORT ? Number(process.env.PORT) : 3000);
|
||||||
|
|||||||
@@ -28,7 +28,11 @@ export async function verifyAccessToken(jwt: JwtService, authorization?: string)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function assertSessionUnlocked(core: CoreGrpcService, payload: AccessTokenPayload) {
|
export async function assertSessionUnlocked(
|
||||||
|
core: CoreGrpcService,
|
||||||
|
payload: AccessTokenPayload,
|
||||||
|
touchActivity = true
|
||||||
|
) {
|
||||||
if (!payload.sessionId) {
|
if (!payload.sessionId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -37,7 +41,7 @@ export async function assertSessionUnlocked(core: CoreGrpcService, payload: Acce
|
|||||||
core.auth.ValidateSession({
|
core.auth.ValidateSession({
|
||||||
userId: payload.sub,
|
userId: payload.sub,
|
||||||
sessionId: payload.sessionId,
|
sessionId: payload.sessionId,
|
||||||
touchActivity: true
|
touchActivity
|
||||||
})
|
})
|
||||||
)) as { requiresPin: boolean; sessionId: string };
|
)) as { requiresPin: boolean; sessionId: string };
|
||||||
|
|
||||||
@@ -54,9 +58,10 @@ export async function assertSessionUnlocked(core: CoreGrpcService, payload: Acce
|
|||||||
export async function resolveAuthorizedPayload(
|
export async function resolveAuthorizedPayload(
|
||||||
jwt: JwtService,
|
jwt: JwtService,
|
||||||
core: CoreGrpcService,
|
core: CoreGrpcService,
|
||||||
authorization?: string
|
authorization?: string,
|
||||||
|
touchActivity = true
|
||||||
): Promise<AccessTokenPayload> {
|
): Promise<AccessTokenPayload> {
|
||||||
const payload = await verifyAccessToken(jwt, authorization);
|
const payload = await verifyAccessToken(jwt, authorization);
|
||||||
await assertSessionUnlocked(core, payload);
|
await assertSessionUnlocked(core, payload, touchActivity);
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
# syntax=docker/dockerfile:1.4
|
|
||||||
|
|
||||||
FROM node:24-alpine
|
FROM node:24-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -27,6 +27,31 @@ body {
|
|||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgb(168 173 188 / 55%) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgb(168 173 188 / 55%) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-thumb {
|
||||||
|
background: rgb(168 173 188 / 45%);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgb(102 112 133 / 65%);
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
|
|||||||
31
apps/docs/components/bot-code-tabs.tsx
Normal file
31
apps/docs/components/bot-code-tabs.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { buildBotExamples } from '@/lib/bot-examples';
|
||||||
|
import { fetchPublicSettingsClient } from '@/lib/api';
|
||||||
|
import { resolveOAuthApiBase } from '@/lib/oauth-url';
|
||||||
|
import { CodeExampleTabs } from '@/components/code-example-tabs';
|
||||||
|
|
||||||
|
export function BotCodeTabs() {
|
||||||
|
const [apiBase, setApiBase] = useState('http://localhost:3000');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchPublicSettingsClient()
|
||||||
|
.then((settings) => setApiBase(resolveOAuthApiBase(settings)))
|
||||||
|
.catch(() => undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const examples = useMemo(() => buildBotExamples(apiBase), [apiBase]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
|
Базовый URL Bot API:{' '}
|
||||||
|
<code className="rounded bg-zinc-100 px-1.5 py-0.5 dark:bg-zinc-800">{apiBase}/bot{'{token}'}/{'{method}'}</code>
|
||||||
|
{' — '}
|
||||||
|
подставляется из <strong>PUBLIC_API_URL</strong> (как для OAuth).
|
||||||
|
</p>
|
||||||
|
<CodeExampleTabs examples={examples} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import type { DocBlock } from '@/lib/docs-pages';
|
import type { DocBlock } from '@/lib/docs-pages';
|
||||||
import { OAuthCodeTabs } from '@/components/oauth-code-tabs';
|
import { OAuthCodeTabs } from '@/components/oauth-code-tabs';
|
||||||
|
import { OneTapCodeTabs } from '@/components/one-tap-code-tabs';
|
||||||
|
import { OneTapButtonBuilder } from '@/components/one-tap-button-builder';
|
||||||
import { AuthLoginCodeTabs } from '@/components/auth-code-tabs';
|
import { AuthLoginCodeTabs } from '@/components/auth-code-tabs';
|
||||||
import { AuthLdapCodeTabs } from '@/components/auth-ldap-code-tabs';
|
import { AuthLdapCodeTabs } from '@/components/auth-ldap-code-tabs';
|
||||||
|
import { BotCodeTabs } from '@/components/bot-code-tabs';
|
||||||
import { ApiReferenceSection } from '@/components/api-endpoint-card';
|
import { ApiReferenceSection } from '@/components/api-endpoint-card';
|
||||||
import { CodeBlock } from '@/components/code-block';
|
import { CodeBlock } from '@/components/code-block';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -66,10 +69,16 @@ export function DocBlockRenderer({ block }: { block: DocBlock }) {
|
|||||||
);
|
);
|
||||||
case 'oauth-examples':
|
case 'oauth-examples':
|
||||||
return <OAuthCodeTabs />;
|
return <OAuthCodeTabs />;
|
||||||
|
case 'one-tap-examples':
|
||||||
|
return <OneTapCodeTabs />;
|
||||||
|
case 'one-tap-builder':
|
||||||
|
return <OneTapButtonBuilder />;
|
||||||
case 'auth-login-examples':
|
case 'auth-login-examples':
|
||||||
return <AuthLoginCodeTabs />;
|
return <AuthLoginCodeTabs />;
|
||||||
case 'auth-ldap-examples':
|
case 'auth-ldap-examples':
|
||||||
return <AuthLdapCodeTabs />;
|
return <AuthLdapCodeTabs />;
|
||||||
|
case 'bot-examples':
|
||||||
|
return <BotCodeTabs />;
|
||||||
case 'api-reference':
|
case 'api-reference':
|
||||||
return <ApiReferenceSection />;
|
return <ApiReferenceSection />;
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { ArrowRight, BookOpen, Code2, Rocket, Shield } from 'lucide-react';
|
import { ArrowRight, BookOpen, Bot, Code2, MousePointerClick, Rocket, Shield } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { docNavigation, groupDocNavigation } from '@/lib/navigation';
|
import { docNavigation, groupDocNavigation } from '@/lib/navigation';
|
||||||
@@ -16,10 +16,22 @@ const highlights = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Code2,
|
icon: Code2,
|
||||||
title: 'OAuth 2.0',
|
title: 'OAuth 2.0 / OIDC',
|
||||||
description: 'Примеры интеграции на JavaScript, Python, PHP, Go и других языках.',
|
description: 'Стандартный OpenID Connect: Discovery, client_id, PKCE, form-urlencoded token. Примеры для PHP без доработки OidcProvider.',
|
||||||
href: '/docs/oauth'
|
href: '/docs/oauth'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: MousePointerClick,
|
||||||
|
title: 'One Tap Login',
|
||||||
|
description: 'FedCM и виджет sso-widget.js: вход в один клик на сайтах-клиентов без полного редиректа.',
|
||||||
|
href: '/docs/one-tap-login'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Bot,
|
||||||
|
title: 'Telegram Bot API',
|
||||||
|
description: 'Telegraf, BotFather, профиль бота, setChatMenuButton и Mini Apps — совместимость с Telegram без смены кода.',
|
||||||
|
href: '/docs/bot-api'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: Shield,
|
icon: Shield,
|
||||||
title: 'Безопасность',
|
title: 'Безопасность',
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function DocsHeader() {
|
|||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="ghost" size="sm" asChild className="hidden md:inline-flex">
|
<Button variant="ghost" size="sm" asChild className="hidden md:inline-flex">
|
||||||
<a href={`${apiUrl}/api`} target="_blank" rel="noreferrer">
|
<a href={`${apiUrl}/docs`} target="_blank" rel="noreferrer">
|
||||||
Swagger
|
Swagger
|
||||||
<ExternalLink className="h-3.5 w-3.5" />
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -1,8 +1,30 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { oauthExamples } from '@/lib/oauth-examples';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { buildOAuthExamples } from '@/lib/oauth-examples';
|
||||||
|
import { fetchPublicSettingsClient } from '@/lib/api';
|
||||||
|
import { resolveOAuthApiBase } from '@/lib/oauth-url';
|
||||||
import { CodeExampleTabs } from '@/components/code-example-tabs';
|
import { CodeExampleTabs } from '@/components/code-example-tabs';
|
||||||
|
|
||||||
export function OAuthCodeTabs() {
|
export function OAuthCodeTabs() {
|
||||||
return <CodeExampleTabs examples={oauthExamples} />;
|
const [apiBase, setApiBase] = useState('http://localhost:3000');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchPublicSettingsClient()
|
||||||
|
.then((settings) => setApiBase(resolveOAuthApiBase(settings)))
|
||||||
|
.catch(() => undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const examples = useMemo(() => buildOAuthExamples(apiBase), [apiBase]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
|
Базовый URL API (issuer): <code className="rounded bg-zinc-100 px-1.5 py-0.5 dark:bg-zinc-800">{apiBase}</code>
|
||||||
|
{' — '}
|
||||||
|
берётся из настройки <strong>PUBLIC_API_URL</strong> или <strong>Домен IdP</strong> в админ-панели.
|
||||||
|
</p>
|
||||||
|
<CodeExampleTabs examples={examples} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
292
apps/docs/components/one-tap-button-builder.tsx
Normal file
292
apps/docs/components/one-tap-button-builder.tsx
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react';
|
||||||
|
import { CodeBlock } from '@/components/code-block';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import {
|
||||||
|
buildButtonSnippet,
|
||||||
|
DEFAULT_BUILDER_OPTIONS,
|
||||||
|
ICON_OPTIONS,
|
||||||
|
resolveButtonStyle,
|
||||||
|
SIZE_OPTIONS,
|
||||||
|
THEME_OPTIONS,
|
||||||
|
VIEW_OPTIONS,
|
||||||
|
type ButtonBuilderOptions
|
||||||
|
} from '@/lib/one-tap-builder';
|
||||||
|
import { useOneTapUrls } from '@/lib/use-one-tap-urls';
|
||||||
|
|
||||||
|
type ColorKey = 'bg' | 'bgHover' | 'border' | 'borderHover' | 'text';
|
||||||
|
|
||||||
|
const COLOR_FIELDS: Array<{ key: ColorKey; label: string }> = [
|
||||||
|
{ key: 'bg', label: 'Цвет фона' },
|
||||||
|
{ key: 'bgHover', label: 'Фон при наведении' },
|
||||||
|
{ key: 'border', label: 'Цвет обводки' },
|
||||||
|
{ key: 'borderHover', label: 'Обводка при наведении' },
|
||||||
|
{ key: 'text', label: 'Цвет текста' }
|
||||||
|
];
|
||||||
|
|
||||||
|
function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<label className="flex flex-col gap-1.5">
|
||||||
|
<span className="text-xs font-medium text-zinc-500 dark:text-zinc-400">{label}</span>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
'h-9 w-full rounded-lg border border-zinc-300 bg-white px-3 text-sm text-zinc-900 outline-none transition focus:border-zinc-400 focus:ring-2 focus:ring-zinc-200 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:focus:ring-zinc-700';
|
||||||
|
|
||||||
|
export function OneTapButtonBuilder() {
|
||||||
|
const { urls, loading, error, frontendBase, projectName } = useOneTapUrls();
|
||||||
|
const [options, setOptions] = useState<ButtonBuilderOptions>(DEFAULT_BUILDER_OPTIONS);
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
|
const [status, setStatus] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const snippet = useMemo(() => (urls ? buildButtonSnippet(options, urls) : ''), [options, urls]);
|
||||||
|
const style = useMemo(() => resolveButtonStyle(options), [options]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!urls?.projectName) return;
|
||||||
|
setOptions((prev) =>
|
||||||
|
prev.providerName === DEFAULT_BUILDER_OPTIONS.providerName ? { ...prev, providerName: urls.projectName } : prev
|
||||||
|
);
|
||||||
|
}, [urls?.projectName]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <p className="text-sm text-zinc-500 dark:text-zinc-400">Загрузка актуальных URL из настроек IdP…</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !urls) {
|
||||||
|
return (
|
||||||
|
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-200">
|
||||||
|
{error ?? 'Не удалось загрузить настройки для конструктора кнопок.'}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function update<K extends keyof ButtonBuilderOptions>(key: K, value: ButtonBuilderOptions[K]) {
|
||||||
|
setOptions((prev) => ({ ...prev, [key]: value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleColor(key: ColorKey, enabled: boolean) {
|
||||||
|
setOptions((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[key]: enabled ? (prev[key] || style.palette[key] || '#ffffff') : ''
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const buttonStyle: CSSProperties = {
|
||||||
|
height: style.preset.height,
|
||||||
|
fontSize: style.preset.font,
|
||||||
|
gap: style.preset.gap,
|
||||||
|
padding: style.iconOnly ? 0 : `0 ${style.preset.padX}px`,
|
||||||
|
width: style.iconOnly ? style.preset.height : 'auto',
|
||||||
|
borderRadius: style.radius,
|
||||||
|
background: hovered ? style.bgHover : style.bg,
|
||||||
|
color: style.text,
|
||||||
|
border: `1px solid ${hovered ? style.borderHover : style.border}`,
|
||||||
|
boxShadow: '0 8px 24px rgba(31,36,48,.12)',
|
||||||
|
transition: 'all .18s ease'
|
||||||
|
};
|
||||||
|
|
||||||
|
const badgeStyle: CSSProperties = {
|
||||||
|
minWidth: style.preset.badge,
|
||||||
|
height: style.preset.badge,
|
||||||
|
padding: `0 ${Math.round(style.preset.badge / 4)}px`,
|
||||||
|
fontSize: Math.round(style.preset.font * 0.85),
|
||||||
|
background: style.palette.badgeBg,
|
||||||
|
color: style.palette.badgeColor
|
||||||
|
};
|
||||||
|
|
||||||
|
function tryLogin() {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: options.clientId || 'YOUR_CLIENT_ID',
|
||||||
|
redirect_uri: options.redirectUri,
|
||||||
|
response_type: 'code',
|
||||||
|
scope: 'openid profile email',
|
||||||
|
display: 'popup',
|
||||||
|
popup_origin: window.location.origin,
|
||||||
|
state: 'preview_' + Math.random().toString(36).slice(2)
|
||||||
|
});
|
||||||
|
const url = `${frontendBase}/auth/oauth/authorize?${params.toString()}`;
|
||||||
|
const popup = window.open(url, 'mvkid_preview', 'popup,width=480,height=640');
|
||||||
|
if (!popup) {
|
||||||
|
setStatus('Браузер заблокировал popup — разрешите всплывающие окна для теста.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus('Открыт popup авторизации. После входа токен придёт через postMessage.');
|
||||||
|
function onMessage(event: MessageEvent) {
|
||||||
|
if (event.data?.type !== 'lendry-sso-onetap') return;
|
||||||
|
window.removeEventListener('message', onMessage);
|
||||||
|
setStatus(`Получен ответ: метод ${event.data.method ?? 'popup'}.`);
|
||||||
|
}
|
||||||
|
window.addEventListener('message', onMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="my-4 grid gap-4 lg:grid-cols-[1.1fr_1fr]">
|
||||||
|
{/* Левая колонка: превью + код */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex min-h-[140px] items-center justify-center rounded-xl border border-zinc-200 bg-[#e9edf3] p-6 dark:border-zinc-800 dark:bg-[#15171c]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex cursor-pointer items-center justify-center font-semibold leading-none"
|
||||||
|
style={buttonStyle}
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
onClick={tryLogin}
|
||||||
|
aria-label={`Войти через ${options.providerName || projectName}`}
|
||||||
|
>
|
||||||
|
{style.showBadge ? (
|
||||||
|
<span className="inline-flex items-center justify-center rounded-full font-bold" style={badgeStyle}>
|
||||||
|
ID
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{!style.iconOnly ? <span>Войти через {options.providerName || projectName}</span> : null}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status ? (
|
||||||
|
<p className="rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-700 dark:border-blue-900 dark:bg-blue-950/40 dark:text-blue-300">
|
||||||
|
{status}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<CodeBlock code={snippet} language="html" title="Код для вставки" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Правая колонка: параметры */}
|
||||||
|
<div className="space-y-3 rounded-xl border border-zinc-200 p-4 dark:border-zinc-800">
|
||||||
|
<Field label="Client ID (из админки: RBAC → OAuth приложения)">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
value={options.clientId}
|
||||||
|
onChange={(event) => update('clientId', event.target.value)}
|
||||||
|
placeholder="YOUR_CLIENT_ID"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Redirect URI">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
value={options.redirectUri}
|
||||||
|
onChange={(event) => update('redirectUri', event.target.value)}
|
||||||
|
placeholder="https://app.example.com/auth/callback"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Название провайдера (текст на кнопке)">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
value={options.providerName}
|
||||||
|
onChange={(event) => update('providerName', event.target.value)}
|
||||||
|
placeholder={projectName}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field label="Размер">
|
||||||
|
<select
|
||||||
|
className={inputClass}
|
||||||
|
value={options.size}
|
||||||
|
onChange={(event) => update('size', event.target.value as ButtonBuilderOptions['size'])}
|
||||||
|
>
|
||||||
|
{SIZE_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Тема">
|
||||||
|
<select
|
||||||
|
className={inputClass}
|
||||||
|
value={options.theme}
|
||||||
|
onChange={(event) => update('theme', event.target.value as ButtonBuilderOptions['theme'])}
|
||||||
|
>
|
||||||
|
{THEME_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Вид">
|
||||||
|
<select
|
||||||
|
className={inputClass}
|
||||||
|
value={options.view}
|
||||||
|
onChange={(event) => update('view', event.target.value as ButtonBuilderOptions['view'])}
|
||||||
|
>
|
||||||
|
{VIEW_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Тип иконки">
|
||||||
|
<select
|
||||||
|
className={inputClass}
|
||||||
|
value={options.icon}
|
||||||
|
onChange={(event) => update('icon', event.target.value as ButtonBuilderOptions['icon'])}
|
||||||
|
>
|
||||||
|
{ICON_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field label={`Радиус скругления — ${options.radius}px`}>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={32}
|
||||||
|
value={options.radius}
|
||||||
|
onChange={(event) => update('radius', Number(event.target.value))}
|
||||||
|
className="w-full accent-zinc-900 dark:accent-zinc-100"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className="space-y-2 border-t border-zinc-200 pt-3 dark:border-zinc-800">
|
||||||
|
<p className="text-xs font-medium text-zinc-500 dark:text-zinc-400">
|
||||||
|
CSS-цвета (необязательно — иначе берётся цвет темы)
|
||||||
|
</p>
|
||||||
|
{COLOR_FIELDS.map((field) => {
|
||||||
|
const enabled = Boolean(options[field.key]);
|
||||||
|
const fallback = style.palette[field.key] || '#ffffff';
|
||||||
|
const value = options[field.key] || fallback;
|
||||||
|
return (
|
||||||
|
<div key={field.key} className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={enabled}
|
||||||
|
onChange={(event) => toggleColor(field.key, event.target.checked)}
|
||||||
|
className="h-4 w-4 accent-zinc-900 dark:accent-zinc-100"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={value.startsWith('#') ? value : '#ffffff'}
|
||||||
|
disabled={!enabled}
|
||||||
|
onChange={(event) => update(field.key, event.target.value)}
|
||||||
|
className={cn('h-8 w-10 cursor-pointer rounded border border-zinc-300 bg-transparent dark:border-zinc-700', !enabled && 'opacity-40')}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-zinc-600 dark:text-zinc-300">{field.label}</span>
|
||||||
|
{enabled ? (
|
||||||
|
<code className="ml-auto rounded bg-zinc-100 px-1.5 py-0.5 text-xs dark:bg-zinc-800">{options[field.key]}</code>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
apps/docs/components/one-tap-code-tabs.tsx
Normal file
36
apps/docs/components/one-tap-code-tabs.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { buildOneTapExamples } from '@/lib/one-tap-examples';
|
||||||
|
import { useOneTapUrls } from '@/lib/use-one-tap-urls';
|
||||||
|
import { CodeExampleTabs } from '@/components/code-example-tabs';
|
||||||
|
|
||||||
|
export function OneTapCodeTabs() {
|
||||||
|
const { urls, loading, error, apiBase, frontendBase } = useOneTapUrls();
|
||||||
|
const examples = useMemo(() => (urls ? buildOneTapExamples(urls) : []), [urls]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <p className="text-sm text-zinc-500 dark:text-zinc-400">Загрузка актуальных URL из настроек IdP…</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !urls) {
|
||||||
|
return (
|
||||||
|
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-200">
|
||||||
|
{error ?? 'Не удалось построить примеры интеграции.'}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
|
API (issuer): <code className="rounded bg-zinc-100 px-1.5 py-0.5 dark:bg-zinc-800">{apiBase}</code>
|
||||||
|
{' · '}
|
||||||
|
Frontend / виджет: <code className="rounded bg-zinc-100 px-1.5 py-0.5 dark:bg-zinc-800">{frontendBase}</code>
|
||||||
|
{' — '}
|
||||||
|
из настроек <strong>PUBLIC_API_URL</strong>, <strong>PUBLIC_FRONTEND_URL</strong> и <strong>PROJECT_NAME</strong>.
|
||||||
|
</p>
|
||||||
|
<CodeExampleTabs examples={examples} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,9 +17,11 @@ export const apiReference: ApiTagGroup[] = [
|
|||||||
endpoints: [
|
endpoints: [
|
||||||
{ method: 'POST', path: '/auth/register', summary: 'Регистрация пользователя', description: 'Первый пользователь получает isSuperAdmin.' },
|
{ method: 'POST', path: '/auth/register', summary: 'Регистрация пользователя', description: 'Первый пользователь получает isSuperAdmin.' },
|
||||||
{ method: 'POST', path: '/auth/login', summary: 'Вход по почте, телефону или логину' },
|
{ method: 'POST', path: '/auth/login', summary: 'Вход по почте, телефону или логину' },
|
||||||
{ method: 'POST', path: '/auth/identify', summary: 'Проверить способ входа (identifier-first)' },
|
{ method: 'POST', path: '/auth/identify', summary: 'Проверить способ входа (identifier-first)', description: 'Возвращает isTotpEnabled, otpChannels, methods.' },
|
||||||
{ method: 'POST', path: '/auth/otp/send', summary: 'Отправить OTP для passwordless-входа' },
|
{ method: 'POST', path: '/auth/totp/begin', summary: 'Начать вход по TOTP', description: 'Challenge для Google Authenticator вместо SMS/email OTP.' },
|
||||||
{ method: 'POST', path: '/auth/otp/verify', summary: 'Проверить OTP' },
|
{ method: 'POST', path: '/auth/totp/verify', summary: 'Подтвердить TOTP при входе', description: 'Завершает вход после кода из приложения-аутентификатора.' },
|
||||||
|
{ method: 'POST', path: '/auth/otp/send', summary: 'Отправить OTP для passwordless-входа', description: 'Альтернатива TOTP; channel: email | phone | backupEmail | backupPhone.' },
|
||||||
|
{ method: 'POST', path: '/auth/otp/verify', summary: 'Проверить OTP', description: 'Завершает вход по SMS/email без повторного TOTP.' },
|
||||||
{ method: 'POST', path: '/auth/login/password', summary: 'Войти по паролю' },
|
{ method: 'POST', path: '/auth/login/password', summary: 'Войти по паролю' },
|
||||||
{ method: 'POST', path: '/auth/ldap/login', summary: 'Войти через LDAP/LDAPS' },
|
{ method: 'POST', path: '/auth/ldap/login', summary: 'Войти через LDAP/LDAPS' },
|
||||||
{ method: 'POST', path: '/auth/pin/verify', summary: 'Подтвердить PIN-код' },
|
{ method: 'POST', path: '/auth/pin/verify', summary: 'Подтвердить PIN-код' },
|
||||||
@@ -29,11 +31,93 @@ export const apiReference: ApiTagGroup[] = [
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tag: 'OAuth 2.0',
|
tag: 'OAuth 2.0 / OIDC',
|
||||||
endpoints: [
|
endpoints: [
|
||||||
{ method: 'GET', path: '/oauth/authorize', summary: 'Создать authorization code' },
|
{
|
||||||
{ method: 'POST', path: '/oauth/token', summary: 'Выдать OAuth токены (code / refresh_token)' },
|
method: 'GET',
|
||||||
{ method: 'GET', path: '/oauth/userinfo', summary: 'Профиль по OAuth access token', auth: true }
|
path: '/.well-known/openid-configuration',
|
||||||
|
summary: 'OpenID Connect Discovery',
|
||||||
|
description: 'Метаданные провайдера: issuer, authorization_endpoint, token_endpoint, userinfo_endpoint, scopes_supported, code_challenge_methods_supported.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/oauth/authorize',
|
||||||
|
summary: 'Authorization endpoint (OIDC)',
|
||||||
|
description:
|
||||||
|
'Стандартные query: client_id, redirect_uri, response_type=code, scope, state, code_challenge, code_challenge_method. ' +
|
||||||
|
'HTTP 302 на redirect_uri?code=... или редирект на экран входа/подтверждения. Legacy: clientId, redirectUri, userId.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/oauth/token',
|
||||||
|
summary: 'Token endpoint',
|
||||||
|
description:
|
||||||
|
'Content-Type: application/x-www-form-urlencoded или JSON. ' +
|
||||||
|
'Поля: grant_type, code, client_id, client_secret, redirect_uri, refresh_token. ' +
|
||||||
|
'Ответ (snake_case): access_token, token_type, expires_in, refresh_token, id_token. ' +
|
||||||
|
'Поддерживается Authorization: Basic (client_id:client_secret).'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/oauth/userinfo',
|
||||||
|
summary: 'UserInfo endpoint',
|
||||||
|
description: 'Профиль по Bearer access_token. Поля: sub, email, phone, name, picture.',
|
||||||
|
auth: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: 'FedCM / One Tap Login',
|
||||||
|
endpoints: [
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/.well-known/web-identity',
|
||||||
|
summary: 'FedCM web identity manifest',
|
||||||
|
description: 'Манифест Federated Credential Management: provider_urls → /fedcm/config.json.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/fedcm/config.json',
|
||||||
|
summary: 'Конфигурация FedCM IdP',
|
||||||
|
description: 'accounts_endpoint, id_assertion_endpoint, login_url (same-origin с config), branding.name = PROJECT_NAME, branding.icons.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/fedcm/discover.json',
|
||||||
|
summary: 'Discovery для виджета',
|
||||||
|
description: 'configUrl, apiBase, frontendUrl, projectName, suggestedFields для RP.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/fedcm/accounts',
|
||||||
|
summary: 'Список аккаунтов FedCM',
|
||||||
|
description: 'Credentialed GET по cookie lendry_fedcm_sess. Поля: name, email, picture, tel. CORS с credentials для RP.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/fedcm/id_assertion',
|
||||||
|
summary: 'Выдача id_token FedCM',
|
||||||
|
description: 'Form POST: client_id, account_id, fields, disclosure_shown_for. Возвращает { token } — OIDC id_token.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/fedcm/client_metadata',
|
||||||
|
summary: 'Метаданные клиента FedCM',
|
||||||
|
description: 'Query: client_id. privacy_policy_url, terms_of_service_url.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/fedcm/session/sync',
|
||||||
|
summary: 'Синхронизация FedCM cookie',
|
||||||
|
description: 'Bearer access token → установка HttpOnly cookie lendry_fedcm_sess.',
|
||||||
|
auth: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/fedcm/login-status',
|
||||||
|
summary: 'FedCM Login Status bridge',
|
||||||
|
description: 'HTML на API origin для navigator.login.setStatus (origin login_url).'
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -44,7 +128,40 @@ export const apiReference: ApiTagGroup[] = [
|
|||||||
{ method: 'PATCH', path: '/profile/users/{userId}/avatar', summary: 'Обновить аватар', auth: true },
|
{ method: 'PATCH', path: '/profile/users/{userId}/avatar', summary: 'Обновить аватар', auth: true },
|
||||||
{ method: 'PATCH', path: '/profile/users/{userId}/contacts', 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}/password', summary: 'Установить пароль', auth: true },
|
||||||
{ method: 'POST', path: '/profile/users/{userId}/self-delete', summary: 'Удалить свой профиль', auth: true }
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/profile/users/{userId}/e2e-public-key',
|
||||||
|
summary: 'Публичный E2E-ключ пользователя',
|
||||||
|
description: 'SPKI base64 для ECDH P-256. Нужен перед созданием секретного чата.',
|
||||||
|
auth: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
path: '/profile/users/{userId}/e2e-public-key',
|
||||||
|
summary: 'Сохранить свой E2E-ключ',
|
||||||
|
description: 'Тело: { "publicKey": "..." }. Только для своего userId.',
|
||||||
|
auth: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/profile/users/{userId}/self-delete',
|
||||||
|
summary: 'Запланировать удаление профиля',
|
||||||
|
description: 'Не удаляет аккаунт сразу. Запускает период ожидания ACCOUNT_DELETE_GRACE_DAYS (по умолчанию 30 дней).',
|
||||||
|
auth: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/profile/users/{userId}/self-delete/cancel',
|
||||||
|
summary: 'Отменить запланированное удаление профиля',
|
||||||
|
auth: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/profile/users/{userId}/self-delete/status',
|
||||||
|
summary: 'Статус запланированного удаления профиля',
|
||||||
|
description: 'Возвращает pending, deletionRequestedAt, effectiveAt и graceDays.',
|
||||||
|
auth: true
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -52,6 +169,10 @@ export const apiReference: ApiTagGroup[] = [
|
|||||||
endpoints: [
|
endpoints: [
|
||||||
{ method: 'GET', path: '/security/users/{userId}/devices', summary: 'Активные устройства', auth: true },
|
{ method: 'GET', path: '/security/users/{userId}/devices', summary: 'Активные устройства', auth: true },
|
||||||
{ method: 'GET', path: '/security/users/{userId}/sessions', summary: 'Активные сессии', auth: true },
|
{ method: 'GET', path: '/security/users/{userId}/sessions', summary: 'Активные сессии', auth: true },
|
||||||
|
{ method: 'GET', path: '/security/users/{userId}/totp/status', summary: 'Статус TOTP', auth: true },
|
||||||
|
{ method: 'POST', path: '/security/users/{userId}/totp/setup', summary: 'Настроить TOTP (QR + секрет)', auth: true },
|
||||||
|
{ method: 'POST', path: '/security/users/{userId}/totp/enable', summary: 'Включить TOTP', auth: true },
|
||||||
|
{ method: 'POST', path: '/security/users/{userId}/totp/disable', summary: 'Отключить TOTP', auth: true },
|
||||||
{ method: 'POST', path: '/security/users/{userId}/pin/setup', summary: 'Настроить PIN', 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 }
|
{ method: 'POST', path: '/security/users/{userId}/revoke-all-sessions', summary: 'Выйти везде', auth: true }
|
||||||
]
|
]
|
||||||
@@ -86,15 +207,83 @@ export const apiReference: ApiTagGroup[] = [
|
|||||||
endpoints: [
|
endpoints: [
|
||||||
{ method: 'POST', path: '/family/groups', summary: 'Создать семейную группу', auth: true },
|
{ method: 'POST', path: '/family/groups', summary: 'Создать семейную группу', auth: true },
|
||||||
{ method: 'GET', path: '/family/users/{userId}/groups', summary: 'Список семей пользователя', auth: true },
|
{ method: 'GET', path: '/family/users/{userId}/groups', summary: 'Список семей пользователя', auth: true },
|
||||||
{ method: 'POST', path: '/family/groups/{groupId}/invites', summary: 'Пригласить участника', auth: true }
|
{ method: 'GET', path: '/family/groups/{groupId}', summary: 'Получить семейную группу', auth: true },
|
||||||
|
{ method: 'PATCH', path: '/family/groups/{groupId}', summary: 'Обновить семейную группу (название)', auth: true },
|
||||||
|
{
|
||||||
|
method: 'DELETE',
|
||||||
|
path: '/family/groups/{groupId}',
|
||||||
|
summary: 'Удалить семейную группу',
|
||||||
|
description: 'Только создатель семьи. Удаляет всех участников, приглашения, чаты, сообщения и медиа семьи.',
|
||||||
|
auth: true
|
||||||
|
},
|
||||||
|
{ method: 'POST', path: '/family/groups/{groupId}/members', summary: 'Добавить участника', auth: true },
|
||||||
|
{
|
||||||
|
method: 'DELETE',
|
||||||
|
path: '/family/members/{memberId}',
|
||||||
|
summary: 'Исключить участника или выйти из семьи',
|
||||||
|
description: 'Создатель может удалить участника; участник может удалить себя («Выйти»). Владельца семьи удалить нельзя.',
|
||||||
|
auth: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/family/groups/{groupId}/invites',
|
||||||
|
summary: 'Пригласить участника или бота',
|
||||||
|
description: 'Люди получают pending-приглашение. Системные боты и боты других пользователей добавляются автоматически через auto-accept приглашения.',
|
||||||
|
auth: true
|
||||||
|
},
|
||||||
|
{ method: 'GET', path: '/family/groups/{groupId}/invite-search', summary: 'Поиск пользователей и ботов для приглашения', auth: true },
|
||||||
|
{ method: 'GET', path: '/family/invites', summary: 'Входящие приглашения', auth: true },
|
||||||
|
{ method: 'POST', path: '/family/invites/{inviteId}/respond', summary: 'Принять или отклонить приглашение', auth: true },
|
||||||
|
{ method: 'GET', path: '/family/groups/{groupId}/presence', summary: 'Онлайн-статус участников семьи', auth: true }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tag: 'Чат',
|
tag: 'Чат',
|
||||||
endpoints: [
|
endpoints: [
|
||||||
{ method: 'GET', path: '/chat/groups/{groupId}/rooms', summary: 'Список чатов семьи', auth: true },
|
{
|
||||||
{ method: 'POST', path: '/chat/rooms/{roomId}/messages', summary: 'Отправить сообщение', auth: true },
|
method: 'GET',
|
||||||
{ method: 'GET', path: '/chat/rooms/{roomId}/messages', summary: 'Сообщения чата', auth: true }
|
path: '/chat/groups/{groupId}/rooms',
|
||||||
|
summary: 'Список чатов семьи',
|
||||||
|
description: 'Автосинхронизация DIRECT/BOT через syncFamilyChats. Поля: type, peerUserId, botUsername, isE2E.',
|
||||||
|
auth: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/chat/groups/{groupId}/rooms',
|
||||||
|
summary: 'Создать групповой чат',
|
||||||
|
description: 'Минимум 3 участника. Личные DIRECT создаются автоматически.',
|
||||||
|
auth: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/chat/groups/{groupId}/e2e-rooms',
|
||||||
|
summary: 'Создать секретный E2E-чат',
|
||||||
|
description: 'Тело: { "peerUserId": "..." }. Только с участниками семьи (не ботами).',
|
||||||
|
auth: true
|
||||||
|
},
|
||||||
|
{ method: 'PATCH', path: '/chat/rooms/{roomId}', summary: 'Настройки чата (название, mute)', auth: true },
|
||||||
|
{ method: 'DELETE', path: '/chat/rooms/{roomId}', summary: 'Удалить чат', description: 'GENERAL удалить нельзя. E2E, DIRECT, BOT, GROUP — доступно участникам.', auth: true },
|
||||||
|
{ method: 'POST', path: '/chat/rooms/{roomId}/members', summary: 'Добавить участника в групповой чат', auth: true },
|
||||||
|
{ method: 'DELETE', path: '/chat/rooms/{roomId}/members/{memberUserId}', summary: 'Удалить участника из чата', auth: true },
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/chat/rooms/{roomId}/messages',
|
||||||
|
summary: 'Сообщения чата',
|
||||||
|
description: 'Query: limit (по умолчанию 50), beforeMessageId. Поле isEncrypted на сообщениях E2E.',
|
||||||
|
auth: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/chat/rooms/{roomId}/messages',
|
||||||
|
summary: 'Отправить сообщение',
|
||||||
|
description: 'type: TEXT|IMAGE|AUDIO|VOICE|FILE|EMOJI|POLL. isEncrypted обязателен для E2E. BOT — только через /bots/...',
|
||||||
|
auth: true
|
||||||
|
},
|
||||||
|
{ method: 'PATCH', path: '/chat/messages/{messageId}', summary: 'Редактировать сообщение', auth: true },
|
||||||
|
{ method: 'DELETE', path: '/chat/messages/{messageId}', summary: 'Удалить сообщение', auth: true },
|
||||||
|
{ method: 'POST', path: '/chat/messages/{messageId}/vote', summary: 'Голос в опросе', auth: true },
|
||||||
|
{ method: 'POST', path: '/chat/rooms/{roomId}/read', summary: 'Отметить чат прочитанным', auth: true },
|
||||||
|
{ method: 'POST', path: '/chat/rooms/{roomId}/mute', summary: 'Включить/выключить уведомления', auth: true }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -105,6 +294,123 @@ export const apiReference: ApiTagGroup[] = [
|
|||||||
{ method: 'POST', path: '/media/chat/{roomId}/media/upload-url', summary: 'URL для медиа чата', auth: true }
|
{ method: 'POST', path: '/media/chat/{roomId}/media/upload-url', summary: 'URL для медиа чата', auth: true }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
tag: 'Telegram Bot API',
|
||||||
|
endpoints: [
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/bot{token}/sendMessage',
|
||||||
|
summary: 'Отправить текстовое сообщение',
|
||||||
|
description: 'Параметры: chat_id, text, reply_markup? (inline/reply/remove).'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/bot{token}/editMessageText',
|
||||||
|
summary: 'Редактировать текст сообщения',
|
||||||
|
description: 'Параметры: chat_id, message_id, text, reply_markup?'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/bot{token}/editMessageReplyMarkup',
|
||||||
|
summary: 'Редактировать клавиатуру сообщения',
|
||||||
|
description: 'Параметры: chat_id, message_id, reply_markup'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/bot{token}/answerCallbackQuery',
|
||||||
|
summary: 'Ответ на callback_query',
|
||||||
|
description: 'Параметры: callback_query_id, text?, show_alert?, url?'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/bot{token}/sendPhoto',
|
||||||
|
summary: 'Отправить фото',
|
||||||
|
description: 'Параметры: chat_id, photo (URL или file_id), caption?, reply_markup?'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/bot{token}/sendDocument',
|
||||||
|
summary: 'Отправить документ',
|
||||||
|
description: 'Параметры: chat_id, document (URL или file_id), caption?, reply_markup?'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/bot{token}/getMe',
|
||||||
|
summary: 'Информация о боте',
|
||||||
|
description: 'Telegram-совместимый формат ответа { ok, result }. Авторизация — токен в URL.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/bot{token}/getUpdates',
|
||||||
|
summary: 'Long polling входящих Update',
|
||||||
|
description: 'Параметры: offset, limit (до 100), timeout (до 50 сек). Недоступен при активном webhook.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/bot{token}/setWebhook',
|
||||||
|
summary: 'Установить webhook URL',
|
||||||
|
description: 'Тело: url, secret_token?, drop_pending_updates?'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/bot{token}/deleteWebhook',
|
||||||
|
summary: 'Удалить webhook'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/bot{token}/getWebhookInfo',
|
||||||
|
summary: 'Информация о webhook'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: 'BotFather',
|
||||||
|
endpoints: [
|
||||||
|
{ method: 'GET', path: '/bots', summary: 'Список моих ботов', auth: true },
|
||||||
|
{ method: 'POST', path: '/bots', summary: 'Создать бота', description: 'Возвращает token один раз.', auth: true },
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/bots/by-username/{botRef}/messages',
|
||||||
|
summary: 'История чата с ботом',
|
||||||
|
description: 'Сообщения пользователя с ботом в хронологическом порядке.',
|
||||||
|
auth: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/bots/by-username/{botRef}/messages',
|
||||||
|
summary: 'Написать боту',
|
||||||
|
description: 'Inbound-сообщение пользователя → RabbitMQ → webhook/getUpdates',
|
||||||
|
auth: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/bots/by-username/{botRef}/callback',
|
||||||
|
summary: 'Нажатие inline-кнопки',
|
||||||
|
description: 'Тело: { messageId, callbackData } → callback_query Update → webhook/getUpdates',
|
||||||
|
auth: true
|
||||||
|
},
|
||||||
|
{ method: 'GET', path: '/bots/{botId}', summary: 'Получить бота', auth: true },
|
||||||
|
{ method: 'PATCH', path: '/bots/{botId}', summary: 'Обновить name / username', auth: true },
|
||||||
|
{ method: 'DELETE', path: '/bots/{botId}', summary: 'Удалить бота', auth: true },
|
||||||
|
{ method: 'POST', path: '/bots/{botId}/revoke-token', summary: 'Перевыпустить токен', auth: true },
|
||||||
|
{ method: 'PATCH', path: '/bots/{botId}/web-app', summary: 'Настроить Mini App URL', auth: true },
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/bots/web-app/validate',
|
||||||
|
summary: 'Проверить initData Mini App',
|
||||||
|
description: 'HMAC-SHA256 валидация как в Telegram Web Apps.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: 'Администрирование ботов',
|
||||||
|
endpoints: [
|
||||||
|
{ method: 'GET', path: '/admin/bots', summary: 'Список всех ботов', description: 'Требует bots.manage.all', auth: true },
|
||||||
|
{ method: 'GET', path: '/admin/bots/metrics', summary: 'Метрики ботов', auth: true },
|
||||||
|
{ method: 'GET', path: '/admin/bots/{botId}', summary: 'Получить бота (админ)', auth: true },
|
||||||
|
{ method: 'PATCH', path: '/admin/bots/{botId}/active', summary: 'Заблокировать / разблокировать бота', auth: true }
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
tag: 'Уведомления',
|
tag: 'Уведомления',
|
||||||
endpoints: [
|
endpoints: [
|
||||||
|
|||||||
187
apps/docs/lib/bot-examples.ts
Normal file
187
apps/docs/lib/bot-examples.ts
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import type { OAuthExample } from '@/lib/oauth-examples';
|
||||||
|
|
||||||
|
export function buildBotExamples(apiBase: string): OAuthExample[] {
|
||||||
|
const API_BASE = apiBase.replace(/\/+$/, '');
|
||||||
|
const BOT_TOKEN = '123456789:AAHdqTcvCH1vGWJxfSeofS0As2XJarzRz5Q';
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'telegraf',
|
||||||
|
label: 'Telegraf',
|
||||||
|
language: 'javascript',
|
||||||
|
code: `import { Telegraf } from 'telegraf';
|
||||||
|
|
||||||
|
// Достаточно сменить apiRoot — код бота остаётся без изменений
|
||||||
|
const bot = new Telegraf(process.env.BOT_TOKEN, {
|
||||||
|
telegram: {
|
||||||
|
apiRoot: '${API_BASE}/bot'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
bot.start((ctx) => ctx.reply('Привет! Бот работает через Lendry Bot API.'));
|
||||||
|
bot.command('ping', (ctx) => ctx.reply('pong'));
|
||||||
|
|
||||||
|
// Inline-клавиатура и callback_query
|
||||||
|
bot.command('menu', (ctx) =>
|
||||||
|
ctx.reply('Выберите действие:', {
|
||||||
|
reply_markup: {
|
||||||
|
inline_keyboard: [
|
||||||
|
[{ text: '✅ OK', callback_data: 'ok' }, { text: '❌ Cancel', callback_data: 'cancel' }]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
bot.action('ok', async (ctx) => {
|
||||||
|
await ctx.answerCbQuery('Принято!');
|
||||||
|
await ctx.editMessageText('Вы нажали OK ✅');
|
||||||
|
});
|
||||||
|
|
||||||
|
bot.launch();`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'node-telegram-bot-api',
|
||||||
|
label: 'node-telegram-bot-api',
|
||||||
|
language: 'javascript',
|
||||||
|
code: `import TelegramBot from 'node-telegram-bot-api';
|
||||||
|
|
||||||
|
const token = process.env.BOT_TOKEN;
|
||||||
|
const bot = new TelegramBot(token, {
|
||||||
|
polling: true,
|
||||||
|
baseApiUrl: '${API_BASE}/bot'
|
||||||
|
});
|
||||||
|
|
||||||
|
bot.on('message', (msg) => {
|
||||||
|
bot.sendMessage(msg.chat.id, \`Вы написали: \${msg.text}\`);
|
||||||
|
});`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'curl',
|
||||||
|
label: 'cURL',
|
||||||
|
language: 'bash',
|
||||||
|
code: `# getMe — проверка токена
|
||||||
|
curl -s -X POST '${API_BASE}/bot${BOT_TOKEN}/getMe' | jq
|
||||||
|
|
||||||
|
# sendMessage — chat_id = UUID пользователя Lendry ID
|
||||||
|
curl -s -X POST '${API_BASE}/bot${BOT_TOKEN}/sendMessage' \\
|
||||||
|
-H 'Content-Type: application/json' \\
|
||||||
|
-d '{
|
||||||
|
"chat_id": "USER_UUID",
|
||||||
|
"text": "Привет из Bot API!"
|
||||||
|
}' | jq
|
||||||
|
|
||||||
|
# sendMessage с inline-клавиатурой
|
||||||
|
curl -s -X POST '${API_BASE}/bot${BOT_TOKEN}/sendMessage' \\
|
||||||
|
-H 'Content-Type: application/json' \\
|
||||||
|
-d '{
|
||||||
|
"chat_id": "USER_UUID",
|
||||||
|
"text": "Выберите:",
|
||||||
|
"reply_markup": {
|
||||||
|
"inline_keyboard": [[{ "text": "OK", "callback_data": "ok" }]]
|
||||||
|
}
|
||||||
|
}' | jq
|
||||||
|
|
||||||
|
# editMessageText
|
||||||
|
curl -s -X POST '${API_BASE}/bot${BOT_TOKEN}/editMessageText' \\
|
||||||
|
-H 'Content-Type: application/json' \\
|
||||||
|
-d '{
|
||||||
|
"chat_id": 1000000000000,
|
||||||
|
"message_id": 1,
|
||||||
|
"text": "Текст обновлён"
|
||||||
|
}' | jq
|
||||||
|
|
||||||
|
# answerCallbackQuery
|
||||||
|
curl -s -X POST '${API_BASE}/bot${BOT_TOKEN}/answerCallbackQuery' \\
|
||||||
|
-H 'Content-Type: application/json' \\
|
||||||
|
-d '{
|
||||||
|
"callback_query_id": "CALLBACK_QUERY_ID",
|
||||||
|
"text": "Готово!"
|
||||||
|
}' | jq
|
||||||
|
|
||||||
|
# setChatMenuButton — глобальная кнопка меню (Web App)
|
||||||
|
curl -s -X POST '${API_BASE}/bot${BOT_TOKEN}/setChatMenuButton' \\
|
||||||
|
-H 'Content-Type: application/json' \\
|
||||||
|
-d '{
|
||||||
|
"menu_button": {
|
||||||
|
"type": "web_app",
|
||||||
|
"text": "Открыть",
|
||||||
|
"web_app": { "url": "https://app.example.com" }
|
||||||
|
}
|
||||||
|
}' | jq
|
||||||
|
|
||||||
|
# setChatMenuButton — per-chat override
|
||||||
|
curl -s -X POST '${API_BASE}/bot${BOT_TOKEN}/setChatMenuButton' \\
|
||||||
|
-H 'Content-Type: application/json' \\
|
||||||
|
-d '{
|
||||||
|
"chat_id": 1000000000000,
|
||||||
|
"menu_button": {
|
||||||
|
"type": "web_app",
|
||||||
|
"text": "Персонально",
|
||||||
|
"web_app": { "url": "https://app.example.com/user" }
|
||||||
|
}
|
||||||
|
}' | jq`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'python',
|
||||||
|
label: 'Python',
|
||||||
|
language: 'python',
|
||||||
|
code: `import os
|
||||||
|
import requests
|
||||||
|
|
||||||
|
API_BASE = '${API_BASE}'
|
||||||
|
TOKEN = os.environ['BOT_TOKEN']
|
||||||
|
CHAT_ID = 'USER_UUID'
|
||||||
|
|
||||||
|
def bot_api(method: str, payload: dict | None = None):
|
||||||
|
url = f"{API_BASE}/bot{TOKEN}/{method}"
|
||||||
|
response = requests.post(url, json=payload or {}, timeout=30)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
print(bot_api('getMe'))
|
||||||
|
print(bot_api('sendMessage', {'chat_id': CHAT_ID, 'text': 'Привет!'}))`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'botfather',
|
||||||
|
label: 'BotFather (REST)',
|
||||||
|
language: 'bash',
|
||||||
|
code: `# Создание бота (JWT пользователя Lendry ID)
|
||||||
|
curl -s -X POST '${API_BASE}/bots' \\
|
||||||
|
-H 'Authorization: Bearer ACCESS_TOKEN' \\
|
||||||
|
-H 'Content-Type: application/json' \\
|
||||||
|
-d '{
|
||||||
|
"name": "Мой сервисный бот",
|
||||||
|
"username": "my_service"
|
||||||
|
}' | jq
|
||||||
|
|
||||||
|
# Ответ содержит token — сохраните его, повторно не показывается при GET
|
||||||
|
|
||||||
|
# BotFather добавляется в семью через поиск и POST /family/groups/{id}/invites.
|
||||||
|
# Профиль бота через BotFather в чате:
|
||||||
|
# /setdescription my_service Описание перед /start
|
||||||
|
# /setabouttext my_service Краткое описание
|
||||||
|
# /setuserpic my_service https://cdn.example.com/avatar.png
|
||||||
|
# /setmenubutton my_service https://app.example.com|Открыть приложение`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'webapp',
|
||||||
|
label: 'Mini App initData',
|
||||||
|
language: 'javascript',
|
||||||
|
code: `// На backend Mini App проверяйте initData через Bot API
|
||||||
|
const response = await fetch('${API_BASE}/bots/web-app/validate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
initData: window.Telegram.WebApp.initData,
|
||||||
|
botToken: process.env.BOT_TOKEN
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
if (!result.valid) {
|
||||||
|
throw new Error(result.error ?? 'Невалидный initData');
|
||||||
|
}
|
||||||
|
const user = result.userJson ? JSON.parse(result.userJson) : null;`
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -9,10 +9,12 @@ export const docNavigation: DocNavItem[] = [
|
|||||||
{ slug: 'architecture', title: 'Архитектура', group: 'Введение' },
|
{ slug: 'architecture', title: 'Архитектура', group: 'Введение' },
|
||||||
{ slug: 'deployment', title: 'Развёртывание на сервере', group: 'Введение' },
|
{ slug: 'deployment', title: 'Развёртывание на сервере', group: 'Введение' },
|
||||||
{ slug: 'authentication', title: 'Аутентификация', group: 'Интеграция' },
|
{ slug: 'authentication', title: 'Аутентификация', group: 'Интеграция' },
|
||||||
{ slug: 'oauth', title: 'OAuth 2.0', group: 'Интеграция' },
|
{ slug: 'oauth', title: 'OAuth 2.0 / OIDC', group: 'Интеграция' },
|
||||||
|
{ slug: 'one-tap-login', title: 'One Tap Login', group: 'Интеграция' },
|
||||||
{ slug: 'ldap', title: 'LDAP / LDAPS', group: 'Интеграция' },
|
{ slug: 'ldap', title: 'LDAP / LDAPS', group: 'Интеграция' },
|
||||||
{ slug: 'sessions', title: 'Сессии и PIN', group: 'Безопасность' },
|
{ slug: 'sessions', title: 'Сессии, PIN и удаление аккаунта', group: 'Безопасность' },
|
||||||
{ slug: 'family-chat', title: 'Семья и чат', group: 'Функции' },
|
{ slug: 'family-chat', title: 'Семья и чат', group: 'Функции' },
|
||||||
|
{ slug: 'bot-api', title: 'Telegram Bot API', group: 'Интеграция' },
|
||||||
{ slug: 'api-reference', title: 'Справочник API', group: 'Справочник' }
|
{ slug: 'api-reference', title: 'Справочник API', group: 'Справочник' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -5,14 +5,15 @@ export interface OAuthExample {
|
|||||||
code: string;
|
code: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const API_BASE = 'https://id.lendry.ru';
|
export function buildOAuthExamples(apiBase: string): OAuthExample[] {
|
||||||
|
const API_BASE = apiBase.replace(/\/+$/, '');
|
||||||
|
|
||||||
export const oauthExamples: OAuthExample[] = [
|
return [
|
||||||
{
|
{
|
||||||
id: 'javascript',
|
id: 'javascript',
|
||||||
label: 'JavaScript',
|
label: 'JavaScript',
|
||||||
language: 'javascript',
|
language: 'javascript',
|
||||||
code: `// Authorization Code Flow (Node.js / браузер)
|
code: `// Authorization Code Flow — стандартный OIDC (RFC 6749)
|
||||||
const clientId = 'YOUR_CLIENT_ID';
|
const clientId = 'YOUR_CLIENT_ID';
|
||||||
const redirectUri = 'https://app.example.com/oauth/callback';
|
const redirectUri = 'https://app.example.com/oauth/callback';
|
||||||
const scope = 'openid profile email';
|
const scope = 'openid profile email';
|
||||||
@@ -20,31 +21,63 @@ const state = crypto.randomUUID();
|
|||||||
|
|
||||||
// Шаг 1: перенаправить пользователя на IdP
|
// Шаг 1: перенаправить пользователя на IdP
|
||||||
const authorizeUrl = new URL('${API_BASE}/oauth/authorize');
|
const authorizeUrl = new URL('${API_BASE}/oauth/authorize');
|
||||||
authorizeUrl.searchParams.set('userId', 'USER_ID_AFTER_LOGIN');
|
authorizeUrl.searchParams.set('client_id', clientId);
|
||||||
authorizeUrl.searchParams.set('clientId', clientId);
|
authorizeUrl.searchParams.set('redirect_uri', redirectUri);
|
||||||
authorizeUrl.searchParams.set('redirectUri', redirectUri);
|
authorizeUrl.searchParams.set('response_type', 'code');
|
||||||
authorizeUrl.searchParams.set('scope', scope);
|
authorizeUrl.searchParams.set('scope', scope);
|
||||||
authorizeUrl.searchParams.set('state', state);
|
authorizeUrl.searchParams.set('state', state);
|
||||||
window.location.href = authorizeUrl.toString();
|
window.location.href = authorizeUrl.toString();
|
||||||
|
|
||||||
|
// OIDC Discovery
|
||||||
|
// GET ${API_BASE}/.well-known/openid-configuration
|
||||||
|
|
||||||
// Шаг 2: обменять code на токены (на backend!)
|
// Шаг 2: обменять code на токены (на backend!)
|
||||||
const tokenResponse = await fetch('${API_BASE}/oauth/token', {
|
const tokenResponse = await fetch('${API_BASE}/oauth/token', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
body: JSON.stringify({
|
body: new URLSearchParams({
|
||||||
grantType: 'authorization_code',
|
grant_type: 'authorization_code',
|
||||||
code: 'AUTHORIZATION_CODE',
|
code: 'AUTHORIZATION_CODE',
|
||||||
clientId,
|
client_id: clientId,
|
||||||
clientSecret: 'YOUR_CLIENT_SECRET',
|
client_secret: 'YOUR_CLIENT_SECRET',
|
||||||
redirectUri
|
redirect_uri: redirectUri
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
const tokens = await tokenResponse.json();
|
const tokens = await tokenResponse.json();
|
||||||
|
// tokens.access_token, tokens.id_token, tokens.refresh_token
|
||||||
|
|
||||||
// Шаг 3: получить профиль
|
// Шаг 3: получить профиль
|
||||||
const profile = await fetch('${API_BASE}/oauth/userinfo', {
|
const profile = await fetch('${API_BASE}/oauth/userinfo', {
|
||||||
headers: { Authorization: \`Bearer \${tokens.accessToken}\` }
|
headers: { Authorization: \`Bearer \${tokens.access_token}\` }
|
||||||
}).then((r) => r.json());`
|
}).then((r) => r.json());`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'php',
|
||||||
|
label: 'PHP (OIDC)',
|
||||||
|
language: 'php',
|
||||||
|
code: `<?php
|
||||||
|
// composer require jumbojett/openid-connect-php
|
||||||
|
require 'vendor/autoload.php';
|
||||||
|
|
||||||
|
use Jumbojett\\OpenIDConnectClient;
|
||||||
|
|
||||||
|
$issuer = '${API_BASE}'; // PUBLIC_API_URL из админки Lendry ID
|
||||||
|
$clientId = getenv('OAUTH_CLIENT_ID');
|
||||||
|
$clientSecret = getenv('OAUTH_CLIENT_SECRET');
|
||||||
|
$redirectUri = 'https://app.example.com/oauth/callback';
|
||||||
|
|
||||||
|
$oidc = new OpenIDConnectClient($issuer, $clientId, $clientSecret);
|
||||||
|
$oidc->setRedirectURL($redirectUri);
|
||||||
|
$oidc->addScope(['openid', 'profile', 'email']);
|
||||||
|
|
||||||
|
// Библиотека сама использует discovery, client_id, redirect_uri, response_type=code
|
||||||
|
$oidc->authenticate();
|
||||||
|
|
||||||
|
$sub = $oidc->requestUserInfo('sub');
|
||||||
|
$name = $oidc->requestUserInfo('name');
|
||||||
|
$email = $oidc->requestUserInfo('email');
|
||||||
|
|
||||||
|
// userId передавать НЕ нужно — IdP определяет пользователя после входа`
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'typescript-next',
|
id: 'typescript-next',
|
||||||
@@ -53,26 +86,27 @@ const profile = await fetch('${API_BASE}/oauth/userinfo', {
|
|||||||
code: `// app/api/oauth/callback/route.ts
|
code: `// app/api/oauth/callback/route.ts
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
const ISSUER = '${API_BASE}';
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const code = request.nextUrl.searchParams.get('code');
|
const code = request.nextUrl.searchParams.get('code');
|
||||||
const state = request.nextUrl.searchParams.get('state');
|
|
||||||
if (!code) return NextResponse.redirect('/login?error=oauth');
|
if (!code) return NextResponse.redirect('/login?error=oauth');
|
||||||
|
|
||||||
const tokenRes = await fetch('${API_BASE}/oauth/token', {
|
const tokenRes = await fetch(\`\${ISSUER}/oauth/token\`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
body: JSON.stringify({
|
body: new URLSearchParams({
|
||||||
grantType: 'authorization_code',
|
grant_type: 'authorization_code',
|
||||||
code,
|
code,
|
||||||
clientId: process.env.OAUTH_CLIENT_ID,
|
client_id: process.env.OAUTH_CLIENT_ID!,
|
||||||
clientSecret: process.env.OAUTH_CLIENT_SECRET,
|
client_secret: process.env.OAUTH_CLIENT_SECRET!,
|
||||||
redirectUri: process.env.OAUTH_REDIRECT_URI
|
redirect_uri: process.env.OAUTH_REDIRECT_URI!
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
const tokens = await tokenRes.json();
|
const tokens = await tokenRes.json();
|
||||||
const response = NextResponse.redirect('/dashboard');
|
const response = NextResponse.redirect('/dashboard');
|
||||||
response.cookies.set('access_token', tokens.accessToken, { httpOnly: true, secure: true });
|
response.cookies.set('access_token', tokens.access_token, { httpOnly: true, secure: true });
|
||||||
return response;
|
return response;
|
||||||
}`
|
}`
|
||||||
},
|
},
|
||||||
@@ -81,193 +115,68 @@ export async function GET(request: NextRequest) {
|
|||||||
label: 'Python',
|
label: 'Python',
|
||||||
language: 'python',
|
language: 'python',
|
||||||
code: `import requests
|
code: `import requests
|
||||||
from urllib.parse import urlencode
|
|
||||||
|
|
||||||
API_BASE = '${API_BASE}'
|
API_BASE = '${API_BASE}'
|
||||||
CLIENT_ID = 'YOUR_CLIENT_ID'
|
CLIENT_ID = 'YOUR_CLIENT_ID'
|
||||||
CLIENT_SECRET = 'YOUR_CLIENT_SECRET'
|
CLIENT_SECRET = 'YOUR_CLIENT_SECRET'
|
||||||
REDIRECT_URI = 'https://app.example.com/oauth/callback'
|
REDIRECT_URI = 'https://app.example.com/oauth/callback'
|
||||||
|
|
||||||
# Ссылка для входа пользователя
|
discovery = requests.get(f'{API_BASE}/.well-known/openid-configuration', timeout=15).json()
|
||||||
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 на токены
|
# Authorization URL — стандартные параметры, userId не нужен
|
||||||
token_response = requests.post(f'{API_BASE}/oauth/token', json={
|
authorize_url = (
|
||||||
'grantType': 'authorization_code',
|
f"{discovery['authorization_endpoint']}"
|
||||||
'code': 'AUTHORIZATION_CODE',
|
f"?client_id={CLIENT_ID}"
|
||||||
'clientId': CLIENT_ID,
|
f"&redirect_uri={requests.utils.quote(REDIRECT_URI, safe='')}"
|
||||||
'clientSecret': CLIENT_SECRET,
|
f"&response_type=code"
|
||||||
'redirectUri': REDIRECT_URI
|
f"&scope=openid%20profile%20email"
|
||||||
}, timeout=15)
|
f"&state=random-state"
|
||||||
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}"
|
token_response = requests.post(
|
||||||
|
discovery['token_endpoint'],
|
||||||
func buildAuthorizeURL(userID, clientID, redirectURI, scope, state string) string {
|
data={
|
||||||
q := url.Values{}
|
'grant_type': 'authorization_code',
|
||||||
q.Set("userId", userID)
|
'code': 'AUTHORIZATION_CODE',
|
||||||
q.Set("clientId", clientID)
|
'client_id': CLIENT_ID,
|
||||||
q.Set("redirectUri", redirectURI)
|
'client_secret': CLIENT_SECRET,
|
||||||
q.Set("scope", scope)
|
'redirect_uri': REDIRECT_URI,
|
||||||
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)
|
|
||||||
}`
|
|
||||||
},
|
},
|
||||||
{
|
timeout=15,
|
||||||
id: 'csharp',
|
)
|
||||||
label: 'C#',
|
tokens = token_response.json()
|
||||||
language: 'csharp',
|
|
||||||
code: `using System.Net.Http.Json;
|
|
||||||
|
|
||||||
var apiBase = "${API_BASE}";
|
profile = requests.get(
|
||||||
var clientId = Environment.GetEnvironmentVariable("OAUTH_CLIENT_ID");
|
discovery['userinfo_endpoint'],
|
||||||
var clientSecret = Environment.GetEnvironmentVariable("OAUTH_CLIENT_SECRET");
|
headers={'Authorization': f"Bearer {tokens['access_token']}"},
|
||||||
var redirectUri = "https://app.example.com/oauth/callback";
|
timeout=15,
|
||||||
|
).json()`
|
||||||
// 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',
|
id: 'curl',
|
||||||
label: 'cURL',
|
label: 'cURL',
|
||||||
language: 'bash',
|
language: 'bash',
|
||||||
code: `# Authorization (браузер пользователя)
|
code: `# OIDC Discovery
|
||||||
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"
|
curl ${API_BASE}/.well-known/openid-configuration
|
||||||
|
|
||||||
# Обмен code на токены
|
# Authorization (браузер пользователя, стандартный OIDC)
|
||||||
|
open "${API_BASE}/oauth/authorize?client_id=CLIENT_ID&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback&response_type=code&scope=openid%20profile%20email&state=xyz"
|
||||||
|
|
||||||
|
# Обмен code на токены (form-urlencoded)
|
||||||
curl -X POST ${API_BASE}/oauth/token \\
|
curl -X POST ${API_BASE}/oauth/token \\
|
||||||
-H "Content-Type: application/json" \\
|
-H "Content-Type: application/x-www-form-urlencoded" \\
|
||||||
-d '{
|
-d "grant_type=authorization_code" \\
|
||||||
"grantType": "authorization_code",
|
-d "code=AUTHORIZATION_CODE" \\
|
||||||
"code": "AUTHORIZATION_CODE",
|
-d "client_id=CLIENT_ID" \\
|
||||||
"clientId": "CLIENT_ID",
|
-d "client_secret=CLIENT_SECRET" \\
|
||||||
"clientSecret": "CLIENT_SECRET",
|
-d "redirect_uri=https://app.example.com/callback"
|
||||||
"redirectUri": "https://app.example.com/callback"
|
|
||||||
}'
|
|
||||||
|
|
||||||
# UserInfo
|
# UserInfo
|
||||||
curl ${API_BASE}/oauth/userinfo \\
|
curl ${API_BASE}/oauth/userinfo \\
|
||||||
-H "Authorization: Bearer ACCESS_TOKEN"
|
-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"
|
|
||||||
}'`
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Используйте buildOAuthExamples(apiBase) */
|
||||||
|
export const oauthExamples = buildOAuthExamples('http://localhost:3000');
|
||||||
|
|||||||
81
apps/docs/lib/oauth-url.ts
Normal file
81
apps/docs/lib/oauth-url.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
export function normalizeBaseUrl(url: string) {
|
||||||
|
return url.trim().replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveOAuthApiBase(settings: Record<string, string>, fallback = 'http://localhost:3000') {
|
||||||
|
const publicApi = settings.PUBLIC_API_URL?.trim();
|
||||||
|
if (publicApi) {
|
||||||
|
return normalizeBaseUrl(publicApi);
|
||||||
|
}
|
||||||
|
|
||||||
|
const domain = settings.PROJECT_DOMAIN?.trim();
|
||||||
|
if (domain) {
|
||||||
|
if (domain.startsWith('http://') || domain.startsWith('https://')) {
|
||||||
|
return normalizeBaseUrl(domain);
|
||||||
|
}
|
||||||
|
return `https://${domain.replace(/^\/+/, '')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeBaseUrl(fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveFrontendBase(settings: Record<string, string>, fallback = 'http://localhost:3002') {
|
||||||
|
const publicFrontend = settings.PUBLIC_FRONTEND_URL?.trim();
|
||||||
|
if (publicFrontend) {
|
||||||
|
return normalizeBaseUrl(publicFrontend);
|
||||||
|
}
|
||||||
|
|
||||||
|
const domain = settings.PROJECT_DOMAIN?.trim();
|
||||||
|
if (domain) {
|
||||||
|
if (domain.startsWith('http://') || domain.startsWith('https://')) {
|
||||||
|
return normalizeBaseUrl(domain);
|
||||||
|
}
|
||||||
|
return `https://${domain.replace(/^\/+/, '')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeBaseUrl(fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OAuthEndpoints {
|
||||||
|
issuer: string;
|
||||||
|
authorizationEndpoint: string;
|
||||||
|
tokenEndpoint: string;
|
||||||
|
userInfoEndpoint: string;
|
||||||
|
openIdConfigurationUrl: string;
|
||||||
|
jwksUrl: string;
|
||||||
|
webIdentityUrl: string;
|
||||||
|
fedcmConfigUrl: string;
|
||||||
|
fedcmAccountsUrl: string;
|
||||||
|
fedcmIdAssertionUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildOAuthEndpoints(apiBase: string): OAuthEndpoints {
|
||||||
|
const base = normalizeBaseUrl(apiBase);
|
||||||
|
return {
|
||||||
|
issuer: base,
|
||||||
|
authorizationEndpoint: `${base}/oauth/authorize`,
|
||||||
|
tokenEndpoint: `${base}/oauth/token`,
|
||||||
|
userInfoEndpoint: `${base}/oauth/userinfo`,
|
||||||
|
openIdConfigurationUrl: `${base}/.well-known/openid-configuration`,
|
||||||
|
jwksUrl: `${base}/.well-known/jwks.json`,
|
||||||
|
webIdentityUrl: `${base}/.well-known/web-identity`,
|
||||||
|
fedcmConfigUrl: `${base}/fedcm/config.json`,
|
||||||
|
fedcmAccountsUrl: `${base}/fedcm/accounts`,
|
||||||
|
fedcmIdAssertionUrl: `${base}/fedcm/id_assertion`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAuthorizeUrl(
|
||||||
|
apiBase: string,
|
||||||
|
params: { clientId: string; redirectUri: string; scope: string; state?: string; codeChallenge?: string; codeChallengeMethod?: string }
|
||||||
|
) {
|
||||||
|
const url = new URL(`${normalizeBaseUrl(apiBase)}/oauth/authorize`);
|
||||||
|
url.searchParams.set('client_id', params.clientId);
|
||||||
|
url.searchParams.set('redirect_uri', params.redirectUri);
|
||||||
|
url.searchParams.set('response_type', 'code');
|
||||||
|
url.searchParams.set('scope', params.scope);
|
||||||
|
if (params.state) url.searchParams.set('state', params.state);
|
||||||
|
if (params.codeChallenge) url.searchParams.set('code_challenge', params.codeChallenge);
|
||||||
|
if (params.codeChallengeMethod) url.searchParams.set('code_challenge_method', params.codeChallengeMethod);
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
186
apps/docs/lib/one-tap-builder.ts
Normal file
186
apps/docs/lib/one-tap-builder.ts
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import type { OneTapUrls } from '@/lib/one-tap-examples';
|
||||||
|
|
||||||
|
export type ButtonSize = 's' | 'm' | 'l' | 'xl';
|
||||||
|
export type ButtonTheme = 'light' | 'dark';
|
||||||
|
export type ButtonView = 'main' | 'icon';
|
||||||
|
export type ButtonIcon = 'id' | 'none';
|
||||||
|
|
||||||
|
export interface ButtonBuilderOptions {
|
||||||
|
clientId: string;
|
||||||
|
providerName: string;
|
||||||
|
redirectUri: string;
|
||||||
|
size: ButtonSize;
|
||||||
|
theme: ButtonTheme;
|
||||||
|
view: ButtonView;
|
||||||
|
radius: number;
|
||||||
|
icon: ButtonIcon;
|
||||||
|
// Пустая строка = использовать цвет темы по умолчанию.
|
||||||
|
bg: string;
|
||||||
|
bgHover: string;
|
||||||
|
border: string;
|
||||||
|
borderHover: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SizePreset {
|
||||||
|
height: number;
|
||||||
|
font: number;
|
||||||
|
padX: number;
|
||||||
|
gap: number;
|
||||||
|
badge: number;
|
||||||
|
radius: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Должно совпадать с SIZE_PRESETS в apps/frontend/public/sso-widget.js.
|
||||||
|
export const SIZE_PRESETS: Record<ButtonSize, SizePreset> = {
|
||||||
|
s: { height: 32, font: 13, padX: 12, gap: 8, badge: 20, radius: 16 },
|
||||||
|
m: { height: 40, font: 14, padX: 16, gap: 10, badge: 24, radius: 20 },
|
||||||
|
l: { height: 44, font: 15, padX: 18, gap: 10, badge: 28, radius: 22 },
|
||||||
|
xl: { height: 52, font: 16, padX: 22, gap: 12, badge: 32, radius: 26 }
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ButtonPalette {
|
||||||
|
bg: string;
|
||||||
|
bgHover: string;
|
||||||
|
border: string;
|
||||||
|
borderHover: string;
|
||||||
|
text: string;
|
||||||
|
badgeBg: string;
|
||||||
|
badgeColor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Должно совпадать с themePalette в apps/frontend/public/sso-widget.js.
|
||||||
|
export function themePalette(theme: ButtonTheme): ButtonPalette {
|
||||||
|
if (theme === 'dark') {
|
||||||
|
return {
|
||||||
|
bg: '#1f2430',
|
||||||
|
bgHover: '#2a3040',
|
||||||
|
border: 'transparent',
|
||||||
|
borderHover: 'transparent',
|
||||||
|
text: '#ffffff',
|
||||||
|
badgeBg: '#ffffff',
|
||||||
|
badgeColor: '#1f2430'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
bg: '#ffffff',
|
||||||
|
bgHover: '#f6f8fb',
|
||||||
|
border: '#e4e8ef',
|
||||||
|
borderHover: '#d4dae6',
|
||||||
|
text: '#1f2430',
|
||||||
|
badgeBg: '#111111',
|
||||||
|
badgeColor: '#ffffff'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SIZE_OPTIONS: Array<{ value: ButtonSize; label: string }> = [
|
||||||
|
{ value: 's', label: 'S — 32px' },
|
||||||
|
{ value: 'm', label: 'M — 40px' },
|
||||||
|
{ value: 'l', label: 'L — 44px' },
|
||||||
|
{ value: 'xl', label: 'XL — 52px' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const THEME_OPTIONS: Array<{ value: ButtonTheme; label: string }> = [
|
||||||
|
{ value: 'light', label: 'Светлая' },
|
||||||
|
{ value: 'dark', label: 'Тёмная' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const VIEW_OPTIONS: Array<{ value: ButtonView; label: string }> = [
|
||||||
|
{ value: 'main', label: 'Кнопка с текстом' },
|
||||||
|
{ value: 'icon', label: 'Только иконка' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ICON_OPTIONS: Array<{ value: ButtonIcon; label: string }> = [
|
||||||
|
{ value: 'id', label: 'Значок ID' },
|
||||||
|
{ value: 'none', label: 'Без значка' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DEFAULT_BUILDER_OPTIONS: ButtonBuilderOptions = {
|
||||||
|
clientId: 'YOUR_CLIENT_ID',
|
||||||
|
providerName: 'MVK ID',
|
||||||
|
redirectUri: 'https://app.example.com/auth/callback',
|
||||||
|
size: 'xl',
|
||||||
|
theme: 'light',
|
||||||
|
view: 'main',
|
||||||
|
radius: 26,
|
||||||
|
icon: 'id',
|
||||||
|
bg: '',
|
||||||
|
bgHover: '',
|
||||||
|
border: '',
|
||||||
|
borderHover: '',
|
||||||
|
text: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ResolvedButtonStyle {
|
||||||
|
preset: SizePreset;
|
||||||
|
palette: ButtonPalette;
|
||||||
|
bg: string;
|
||||||
|
bgHover: string;
|
||||||
|
border: string;
|
||||||
|
borderHover: string;
|
||||||
|
text: string;
|
||||||
|
radius: number;
|
||||||
|
iconOnly: boolean;
|
||||||
|
showBadge: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveButtonStyle(options: ButtonBuilderOptions): ResolvedButtonStyle {
|
||||||
|
const preset = SIZE_PRESETS[options.size] ?? SIZE_PRESETS.xl;
|
||||||
|
const palette = themePalette(options.theme === 'dark' ? 'dark' : 'light');
|
||||||
|
const radius = Number.isFinite(options.radius) ? options.radius : preset.radius;
|
||||||
|
return {
|
||||||
|
preset,
|
||||||
|
palette,
|
||||||
|
bg: options.bg || palette.bg,
|
||||||
|
bgHover: options.bgHover || palette.bgHover,
|
||||||
|
border: options.border || palette.border,
|
||||||
|
borderHover: options.borderHover || palette.borderHover,
|
||||||
|
text: options.text || palette.text,
|
||||||
|
radius,
|
||||||
|
iconOnly: options.view === 'icon',
|
||||||
|
showBadge: options.icon !== 'none'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildButtonSnippet(options: ButtonBuilderOptions, urls: OneTapUrls): string {
|
||||||
|
const providerName = options.providerName || urls.projectName;
|
||||||
|
const lines: string[] = [
|
||||||
|
` src="${urls.widgetUrl}"`,
|
||||||
|
` data-client-id="${options.clientId || 'YOUR_CLIENT_ID'}"`,
|
||||||
|
` data-idp-url="${urls.apiBase}"`,
|
||||||
|
` data-idp-frontend-url="${urls.frontendBase}"`,
|
||||||
|
` data-provider-name="${providerName}"`,
|
||||||
|
` data-redirect-uri="${options.redirectUri}"`,
|
||||||
|
` data-button-container="mvkid-button"`,
|
||||||
|
` data-button-size="${options.size}"`,
|
||||||
|
` data-button-theme="${options.theme}"`,
|
||||||
|
` data-button-view="${options.view}"`,
|
||||||
|
` data-button-radius="${options.radius}"`,
|
||||||
|
` data-button-icon="${options.icon}"`
|
||||||
|
];
|
||||||
|
|
||||||
|
if (options.bg) lines.push(` data-button-bg="${options.bg}"`);
|
||||||
|
if (options.bgHover) lines.push(` data-button-bg-hover="${options.bgHover}"`);
|
||||||
|
if (options.border) lines.push(` data-button-border="${options.border}"`);
|
||||||
|
if (options.borderHover) lines.push(` data-button-border-hover="${options.borderHover}"`);
|
||||||
|
if (options.text) lines.push(` data-button-text="${options.text}"`);
|
||||||
|
|
||||||
|
lines.push(' data-on-success="onMvkIdLogin"');
|
||||||
|
|
||||||
|
return `<!-- 1) Контейнер, в котором появится кнопка -->
|
||||||
|
<div id="mvkid-button"></div>
|
||||||
|
|
||||||
|
<!-- 2) Подключение виджета ${providerName} -->
|
||||||
|
<script
|
||||||
|
${lines.join('\n')}
|
||||||
|
></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function onMvkIdLogin(payload) {
|
||||||
|
// payload.token — id_token (FedCM) или токен из popup
|
||||||
|
// payload.method — 'fedcm' | 'popup'
|
||||||
|
console.log('${providerName}: вход через', payload.method, payload.token);
|
||||||
|
// Отправьте токен на ваш backend для проверки и создания сессии
|
||||||
|
}
|
||||||
|
</script>`;
|
||||||
|
}
|
||||||
173
apps/docs/lib/one-tap-examples.ts
Normal file
173
apps/docs/lib/one-tap-examples.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import type { OAuthExample } from '@/lib/oauth-examples';
|
||||||
|
|
||||||
|
export interface OneTapUrls {
|
||||||
|
apiBase: string;
|
||||||
|
frontendBase: string;
|
||||||
|
widgetUrl: string;
|
||||||
|
fedcmConfigUrl: string;
|
||||||
|
fedcmDiscoverUrl: string;
|
||||||
|
webIdentityUrl: string;
|
||||||
|
projectName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildOneTapUrls(
|
||||||
|
apiBase: string,
|
||||||
|
frontendBase: string,
|
||||||
|
projectName = 'MVK ID'
|
||||||
|
): OneTapUrls {
|
||||||
|
const base = apiBase.replace(/\/+$/, '');
|
||||||
|
const front = frontendBase.replace(/\/+$/, '');
|
||||||
|
return {
|
||||||
|
apiBase: base,
|
||||||
|
frontendBase: front,
|
||||||
|
widgetUrl: `${front}/sso-widget.js`,
|
||||||
|
fedcmConfigUrl: `${base}/fedcm/config.json`,
|
||||||
|
fedcmDiscoverUrl: `${base}/fedcm/discover.json`,
|
||||||
|
webIdentityUrl: `${base}/.well-known/web-identity`,
|
||||||
|
projectName
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildOneTapExamples(urls: OneTapUrls, clientIdPlaceholder = 'YOUR_CLIENT_ID'): OAuthExample[] {
|
||||||
|
const { apiBase, frontendBase, widgetUrl, fedcmConfigUrl, fedcmDiscoverUrl, webIdentityUrl, projectName } = urls;
|
||||||
|
const redirectUri = 'https://app.example.com/auth/callback';
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'widget-script',
|
||||||
|
label: 'Виджет (script tag)',
|
||||||
|
language: 'html',
|
||||||
|
code: `<!-- Подключите на любой странице вашего сайта -->
|
||||||
|
<script
|
||||||
|
src="${widgetUrl}"
|
||||||
|
data-client-id="${clientIdPlaceholder}"
|
||||||
|
data-idp-url="${apiBase}"
|
||||||
|
data-idp-frontend-url="${frontendBase}"
|
||||||
|
data-provider-name="${projectName}"
|
||||||
|
data-redirect-uri="${redirectUri}"
|
||||||
|
data-on-success="handleLendryLogin"
|
||||||
|
></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function handleLendryLogin(payload) {
|
||||||
|
// payload.token — id_token (FedCM) или токен из popup
|
||||||
|
// payload.method — 'fedcm' | 'popup'
|
||||||
|
console.log('Вход через', payload.method, payload.token);
|
||||||
|
// Отправьте token на ваш backend для проверки и создания сессии
|
||||||
|
}
|
||||||
|
</script>`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fedcm-native',
|
||||||
|
label: 'FedCM (нативный API)',
|
||||||
|
language: 'javascript',
|
||||||
|
code: `// Работает в Chrome/Edge 132+ (поля name/email/picture/tel — с Chrome 141 для tel).
|
||||||
|
// Пользователь должен быть залогинен на ${frontendBase} (cookie lendry_fedcm_sess на домене IdP).
|
||||||
|
|
||||||
|
async function loginWithFedCM() {
|
||||||
|
if (!('IdentityCredential' in window)) {
|
||||||
|
throw new Error('FedCM не поддерживается в этом браузере');
|
||||||
|
}
|
||||||
|
|
||||||
|
const credential = await navigator.credentials.get({
|
||||||
|
identity: {
|
||||||
|
providers: [{
|
||||||
|
configURL: '${fedcmConfigUrl}',
|
||||||
|
clientId: '${clientIdPlaceholder}',
|
||||||
|
// Chrome 132+: disclosure в диалоге FedCM (имя, email, аватар, телефон)
|
||||||
|
fields: ['name', 'email', 'picture', 'tel']
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
mediation: 'optional'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!credential?.token) {
|
||||||
|
throw new Error('Пользователь отменил вход или сессия IdP отсутствует');
|
||||||
|
}
|
||||||
|
|
||||||
|
return credential.token; // OIDC id_token
|
||||||
|
}
|
||||||
|
|
||||||
|
loginWithFedCM()
|
||||||
|
.then((idToken) => fetch('/api/auth/lendry', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ idToken })
|
||||||
|
}))
|
||||||
|
.catch(console.error);`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sdk-manual',
|
||||||
|
label: 'SDK — ручной вызов',
|
||||||
|
language: 'javascript',
|
||||||
|
code: `<script src="${widgetUrl}" data-auto-init="false"></script>
|
||||||
|
<script>
|
||||||
|
LendryIdOneTap.init({
|
||||||
|
clientId: '${clientIdPlaceholder}',
|
||||||
|
idpUrl: '${apiBase}',
|
||||||
|
frontendUrl: '${frontendBase}',
|
||||||
|
providerName: '${projectName}',
|
||||||
|
redirectUri: '${redirectUri}',
|
||||||
|
scope: 'openid profile email'
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('lendry-sso-onetap-success', (event) => {
|
||||||
|
const { token, method, accessToken, idToken } = event.detail;
|
||||||
|
console.log(method, token ?? idToken ?? accessToken);
|
||||||
|
});
|
||||||
|
</script>`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'verify-backend',
|
||||||
|
label: 'Проверка токена на backend',
|
||||||
|
language: 'javascript',
|
||||||
|
code: `// Node.js — после получения id_token от FedCM или popup
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
|
const ISSUER = '${apiBase}';
|
||||||
|
const CLIENT_ID = '${clientIdPlaceholder}';
|
||||||
|
|
||||||
|
function verifyIdToken(idToken) {
|
||||||
|
const payload = jwt.verify(idToken, process.env.IDP_JWT_SECRET, {
|
||||||
|
issuer: ISSUER,
|
||||||
|
audience: CLIENT_ID
|
||||||
|
});
|
||||||
|
return payload; // { sub, email, name, ... }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Альтернатива: userinfo по access_token
|
||||||
|
async function fetchProfile(accessToken) {
|
||||||
|
const res = await fetch('${apiBase}/oauth/userinfo', {
|
||||||
|
headers: { Authorization: \`Bearer \${accessToken}\` }
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('userinfo failed');
|
||||||
|
return res.json();
|
||||||
|
}`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'curl-fedcm',
|
||||||
|
label: 'FedCM endpoints (curl)',
|
||||||
|
language: 'bash',
|
||||||
|
code: `# Манифест FedCM (заголовок Sec-Fetch-Dest обязателен для Chrome)
|
||||||
|
curl -s ${webIdentityUrl} \\
|
||||||
|
-H "Sec-Fetch-Dest: webidentity" | jq
|
||||||
|
|
||||||
|
# Конфигурация провайдера (branding.name = PROJECT_NAME из админки)
|
||||||
|
curl -s ${fedcmConfigUrl} \\
|
||||||
|
-H "Sec-Fetch-Dest: webidentity" | jq
|
||||||
|
|
||||||
|
# Discovery для виджета и диагностики
|
||||||
|
curl -s ${fedcmDiscoverUrl} | jq
|
||||||
|
|
||||||
|
# Список аккаунтов (cookie lendry_fedcm_sess после входа на IdP)
|
||||||
|
curl -s ${apiBase}/fedcm/accounts \\
|
||||||
|
-H "Sec-Fetch-Dest: webidentity" \\
|
||||||
|
-H "Cookie: lendry_fedcm_sess=..." \\
|
||||||
|
--include
|
||||||
|
|
||||||
|
# Синхронизация FedCM cookie для уже залогиненного пользователя IdP
|
||||||
|
curl -s -X POST ${apiBase}/fedcm/session/sync \\
|
||||||
|
-H "Authorization: Bearer ACCESS_TOKEN"`
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
57
apps/docs/lib/use-one-tap-urls.ts
Normal file
57
apps/docs/lib/use-one-tap-urls.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { fetchPublicSettingsClient } from '@/lib/api';
|
||||||
|
import { buildOneTapUrls, type OneTapUrls } from '@/lib/one-tap-examples';
|
||||||
|
import { resolveFrontendBase, resolveOAuthApiBase } from '@/lib/oauth-url';
|
||||||
|
|
||||||
|
export function useOneTapUrls() {
|
||||||
|
const [urls, setUrls] = useState<OneTapUrls | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
void fetchPublicSettingsClient()
|
||||||
|
.then((settings) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
const apiBase = resolveOAuthApiBase(settings, '');
|
||||||
|
const frontendBase = resolveFrontendBase(settings, '');
|
||||||
|
|
||||||
|
if (!apiBase || !frontendBase) {
|
||||||
|
setError('Укажите PUBLIC_API_URL и PUBLIC_FRONTEND_URL в настройках IdP — примеры подставят актуальные URL автоматически.');
|
||||||
|
setUrls(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUrls(buildOneTapUrls(apiBase, frontendBase, settings.PROJECT_NAME?.trim() || 'MVK ID'));
|
||||||
|
setError(null);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setError('Не удалось загрузить публичные настройки IdP.');
|
||||||
|
setUrls(null);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
urls,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
apiBase: urls?.apiBase ?? '',
|
||||||
|
frontendBase: urls?.frontendBase ?? '',
|
||||||
|
projectName: urls?.projectName ?? 'MVK ID'
|
||||||
|
}),
|
||||||
|
[error, loading, urls]
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,36 @@
|
|||||||
import type { NextConfig } from 'next';
|
import type { NextConfig } from 'next';
|
||||||
|
|
||||||
const nextConfig: NextConfig = {};
|
function resolveInternalApiUrl(fallback = 'http://localhost:3000') {
|
||||||
|
const explicit = process.env.INTERNAL_API_URL?.trim();
|
||||||
|
if (explicit) {
|
||||||
|
return explicit.replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
return fallback.replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
const internalApiUrl = resolveInternalApiUrl('http://localhost:3000');
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
async rewrites() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: '/idp-api/:path*',
|
||||||
|
destination: `${internalApiUrl}/:path*`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: '/oauth/:path*',
|
||||||
|
destination: `${internalApiUrl}/oauth/:path*`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: '/fedcm/:path*',
|
||||||
|
destination: `${internalApiUrl}/fedcm/:path*`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: '/.well-known/:path*',
|
||||||
|
destination: `${internalApiUrl}/.well-known/:path*`
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
# syntax=docker/dockerfile:1.4
|
|
||||||
|
|
||||||
FROM node:24-alpine
|
FROM node:24-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -7,6 +5,8 @@ WORKDIR /app
|
|||||||
ARG NPM_REGISTRY=https://registry.npmjs.org
|
ARG NPM_REGISTRY=https://registry.npmjs.org
|
||||||
ARG NEXT_PUBLIC_API_URL=http://localhost:3000
|
ARG NEXT_PUBLIC_API_URL=http://localhost:3000
|
||||||
ARG NEXT_PUBLIC_WS_URL=ws://localhost:8085/ws
|
ARG NEXT_PUBLIC_WS_URL=ws://localhost:8085/ws
|
||||||
|
ARG INTERNAL_API_URL=http://api-gateway:3000
|
||||||
|
ARG INTERNAL_WS_URL=http://media-ws:8085
|
||||||
|
|
||||||
COPY package.json package-lock.json .npmrc ./
|
COPY package.json package-lock.json .npmrc ./
|
||||||
COPY apps/sso-core/package.json ./apps/sso-core/
|
COPY apps/sso-core/package.json ./apps/sso-core/
|
||||||
@@ -34,6 +34,8 @@ ENV PORT="3000"
|
|||||||
ENV HOSTNAME="0.0.0.0"
|
ENV HOSTNAME="0.0.0.0"
|
||||||
ENV NEXT_PUBLIC_API_URL="${NEXT_PUBLIC_API_URL}"
|
ENV NEXT_PUBLIC_API_URL="${NEXT_PUBLIC_API_URL}"
|
||||||
ENV NEXT_PUBLIC_WS_URL="${NEXT_PUBLIC_WS_URL}"
|
ENV NEXT_PUBLIC_WS_URL="${NEXT_PUBLIC_WS_URL}"
|
||||||
|
ENV INTERNAL_API_URL="${INTERNAL_API_URL}"
|
||||||
|
ENV INTERNAL_WS_URL="${INTERNAL_WS_URL}"
|
||||||
|
|
||||||
RUN npm --workspace @lendry/frontend run build
|
RUN npm --workspace @lendry/frontend run build
|
||||||
|
|
||||||
|
|||||||
299
apps/frontend/app/admin/bots/page.tsx
Normal file
299
apps/frontend/app/admin/bots/page.tsx
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Ban, Bot, CheckCircle2, Loader2, MessageSquare, Search, Users } from 'lucide-react';
|
||||||
|
import { AdminShell } from '@/components/id/admin-shell';
|
||||||
|
import { useAuth } from '@/components/id/auth-provider';
|
||||||
|
import { useToast } from '@/components/id/toast-provider';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Table, TableBody, TableCell, TableContainer, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||||
|
import {
|
||||||
|
AdminBotMetrics,
|
||||||
|
AdminUser,
|
||||||
|
ManagedBot,
|
||||||
|
fetchAdminBotAccounts,
|
||||||
|
fetchAdminBotMetrics,
|
||||||
|
fetchAdminBots,
|
||||||
|
setAdminBotActive
|
||||||
|
} from '@/lib/api';
|
||||||
|
import { getAdminLandingPath } from '@/lib/admin-access';
|
||||||
|
|
||||||
|
export default function AdminBotsPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { token, user: currentUser } = useAuth();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const [bots, setBots] = useState<ManagedBot[]>([]);
|
||||||
|
const [botAccounts, setBotAccounts] = useState<AdminUser[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [metrics, setMetrics] = useState<AdminBotMetrics | null>(null);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [actionBotId, setActionBotId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadBots = useCallback(async () => {
|
||||||
|
if (!token) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [botsResponse, metricsResponse, accountsResponse] = await Promise.all([
|
||||||
|
fetchAdminBots({ search, page, limit: 20 }, token),
|
||||||
|
fetchAdminBotMetrics(token),
|
||||||
|
fetchAdminBotAccounts(search, token)
|
||||||
|
]);
|
||||||
|
setBots(botsResponse.bots ?? []);
|
||||||
|
setTotal(botsResponse.total ?? 0);
|
||||||
|
setMetrics(metricsResponse);
|
||||||
|
setBotAccounts(accountsResponse.users ?? []);
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error instanceof Error ? error.message : 'Не удалось загрузить ботов');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [page, search, showToast, token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentUser) return;
|
||||||
|
if (!currentUser.canManageBots && !currentUser.isSuperAdmin) {
|
||||||
|
router.replace(getAdminLandingPath(currentUser));
|
||||||
|
}
|
||||||
|
}, [currentUser, router]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentUser?.canManageBots && !currentUser?.isSuperAdmin) return;
|
||||||
|
void loadBots();
|
||||||
|
}, [currentUser?.canManageBots, currentUser?.isSuperAdmin, loadBots]);
|
||||||
|
|
||||||
|
async function handleToggleActive(bot: ManagedBot) {
|
||||||
|
if (!token) return;
|
||||||
|
setActionBotId(bot.id);
|
||||||
|
try {
|
||||||
|
await setAdminBotActive(bot.id, !bot.isActive, token);
|
||||||
|
showToast(bot.isActive ? 'Бот заблокирован' : 'Бот разблокирован');
|
||||||
|
await loadBots();
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error instanceof Error ? error.message : 'Не удалось изменить статус бота');
|
||||||
|
} finally {
|
||||||
|
setActionBotId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / 20));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminShell active="/admin/bots">
|
||||||
|
<h2 className="mb-4 text-2xl font-medium tracking-tight">Telegram-боты</h2>
|
||||||
|
<div className="mb-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<MetricCard label="Всего ботов" value={metrics?.totalBots ?? 0} icon={Bot} />
|
||||||
|
<MetricCard label="Активных" value={metrics?.activeBots ?? 0} icon={CheckCircle2} accent="text-emerald-600" />
|
||||||
|
<MetricCard label="Заблокированных" value={metrics?.blockedBots ?? 0} icon={Ban} accent="text-rose-600" />
|
||||||
|
<MetricCard label="Сообщений" value={metrics?.totalMessages ?? 0} icon={MessageSquare} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||||
|
<div className="relative min-w-[240px] flex-1">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#667085]" />
|
||||||
|
<Input
|
||||||
|
className="rounded-xl pl-9"
|
||||||
|
placeholder="Поиск по названию или @username"
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => {
|
||||||
|
setSearch(event.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-[#667085]">Найдено: {total}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TableContainer className="rounded-2xl border border-[#eceef4] bg-white">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Бот</TableHead>
|
||||||
|
<TableHead>Владелец</TableHead>
|
||||||
|
<TableHead>Статус</TableHead>
|
||||||
|
<TableHead className="text-right">Действия</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} className="py-10 text-center text-[#667085]">
|
||||||
|
<Loader2 className="mx-auto h-5 w-5 animate-spin" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : bots.length ? (
|
||||||
|
bots.map((bot) => (
|
||||||
|
<TableRow key={bot.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#eef4ff] text-[#3390ec]">
|
||||||
|
<Bot className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{bot.name}</p>
|
||||||
|
<p className="text-sm text-[#667085]">@{bot.username}</p>
|
||||||
|
{bot.isSystemBot ? <p className="text-xs text-[#3390ec]">Системный</p> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Users className="h-4 w-4 text-[#667085]" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{bot.owner?.displayName ?? '—'}</p>
|
||||||
|
{bot.owner?.username ? <p className="text-xs text-[#667085]">@{bot.owner.username}</p> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
bot.isActive
|
||||||
|
? 'inline-flex rounded-full bg-emerald-50 px-2.5 py-1 text-xs font-medium text-emerald-700'
|
||||||
|
: 'inline-flex rounded-full bg-rose-50 px-2.5 py-1 text-xs font-medium text-rose-700'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{bot.isActive ? 'Активен' : 'Заблокирован'}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{!bot.isSystemBot ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="rounded-xl"
|
||||||
|
disabled={actionBotId === bot.id}
|
||||||
|
onClick={() => void handleToggleActive(bot)}
|
||||||
|
>
|
||||||
|
{actionBotId === bot.id ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : bot.isActive ? (
|
||||||
|
'Заблокировать'
|
||||||
|
) : (
|
||||||
|
'Разблокировать'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-[#667085]">—</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} className="py-10 text-center text-[#667085]">
|
||||||
|
Боты не найдены
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
|
||||||
|
{totalPages > 1 ? (
|
||||||
|
<div className="mt-4 flex items-center justify-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" className="rounded-xl" disabled={page <= 1} onClick={() => setPage((value) => value - 1)}>
|
||||||
|
Назад
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm text-[#667085]">
|
||||||
|
{page} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="rounded-xl"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => setPage((value) => value + 1)}
|
||||||
|
>
|
||||||
|
Вперёд
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="mt-10">
|
||||||
|
<h3 className="mb-2 text-lg font-medium">Системные учётные записи ботов</h3>
|
||||||
|
<p className="mb-4 text-sm text-[#667085]">
|
||||||
|
Пользователи IdP, связанные с Telegram-ботами (BotFather и боты пользователей). Роль: «Бот».
|
||||||
|
</p>
|
||||||
|
<TableContainer className="rounded-2xl border border-[#eceef4] bg-white">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Учётная запись</TableHead>
|
||||||
|
<TableHead>Telegram-бот</TableHead>
|
||||||
|
<TableHead>Роль</TableHead>
|
||||||
|
<TableHead>Статус</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} className="py-10 text-center text-[#667085]">
|
||||||
|
<Loader2 className="mx-auto h-5 w-5 animate-spin" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : botAccounts.length ? (
|
||||||
|
botAccounts.map((account) => (
|
||||||
|
<TableRow key={account.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#eef4ff] text-[#3390ec]">
|
||||||
|
<Bot className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{account.displayName}</p>
|
||||||
|
<p className="text-sm text-[#667085]">{account.username ? `@${account.username}` : account.id.slice(0, 8)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{account.linkedBotUsername ? `@${account.linkedBotUsername}` : '—'}
|
||||||
|
{account.isSystemBot ? <p className="text-xs text-[#3390ec]">Системный</p> : null}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>Бот</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="inline-flex rounded-full bg-emerald-50 px-2.5 py-1 text-xs font-medium text-emerald-700">
|
||||||
|
{account.status === 'ACTIVE' ? 'Активен' : account.status}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} className="py-10 text-center text-[#667085]">
|
||||||
|
Системные учётные записи не найдены
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
</div>
|
||||||
|
</AdminShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetricCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
icon: Icon,
|
||||||
|
accent
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
icon: typeof Bot;
|
||||||
|
accent?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-[#eceef4] bg-white p-4">
|
||||||
|
<div className="mb-2 flex items-center gap-2 text-sm text-[#667085]">
|
||||||
|
<Icon className={`h-4 w-4 ${accent ?? ''}`} />
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-semibold">{value.toLocaleString('ru-RU')}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Copy, KeyRound, Loader2, LockKeyhole, Plus, RefreshCw } from 'lucide-react';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Copy, ExternalLink, Loader2, LockKeyhole, Plus, UserRound } from 'lucide-react';
|
||||||
import { AdminShell } from '@/components/id/admin-shell';
|
import { AdminShell } from '@/components/id/admin-shell';
|
||||||
|
import { OAuthClientDetailDialog } from '@/components/id/oauth-client-detail-dialog';
|
||||||
import { useAuth } from '@/components/id/auth-provider';
|
import { useAuth } from '@/components/id/auth-provider';
|
||||||
import { useToast } from '@/components/id/toast-provider';
|
import { useToast } from '@/components/id/toast-provider';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -10,38 +12,71 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
|||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { OAuthClient, OAuthScope, apiFetch } from '@/lib/api';
|
import { OAuthClient, OAuthScope, apiFetch } from '@/lib/api';
|
||||||
|
import { getAdminLandingPath } from '@/lib/admin-access';
|
||||||
|
import { buildAuthorizeUrl, buildOAuthEndpoints, resolveFrontendBase, resolveOAuthApiBase } from '@/lib/oauth-url';
|
||||||
|
import { DEFAULT_PUBLIC_API_URL, DEFAULT_PUBLIC_FRONTEND_URL } from '@/lib/project-domains';
|
||||||
|
|
||||||
export default function AdminOAuthPage() {
|
export default function AdminOAuthPage() {
|
||||||
const { token } = useAuth();
|
const router = useRouter();
|
||||||
|
const { token, user } = useAuth();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [clients, setClients] = useState<OAuthClient[]>([]);
|
const [clients, setClients] = useState<OAuthClient[]>([]);
|
||||||
const [scopes, setScopes] = useState<OAuthScope[]>([]);
|
const [scopes, setScopes] = useState<OAuthScope[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [selectedClient, setSelectedClient] = useState<OAuthClient | null>(null);
|
||||||
const [secretDialog, setSecretDialog] = useState<{ clientId: string; clientSecret: string } | null>(null);
|
const [secretDialog, setSecretDialog] = useState<{ clientId: string; clientSecret: string } | null>(null);
|
||||||
|
const [oauthApiBase, setOauthApiBase] = useState(DEFAULT_PUBLIC_API_URL);
|
||||||
|
const [frontendBase, setFrontendBase] = useState(DEFAULT_PUBLIC_FRONTEND_URL);
|
||||||
|
const [projectName, setProjectName] = useState('MVK ID');
|
||||||
const [form, setForm] = useState({ name: '', redirectUris: '', type: 'CONFIDENTIAL', selectedScopes: ['openid', 'profile', 'email'] as string[] });
|
const [form, setForm] = useState({ name: '', redirectUris: '', type: 'CONFIDENTIAL', selectedScopes: ['openid', 'profile', 'email'] as string[] });
|
||||||
|
|
||||||
|
const oauthEndpoints = useMemo(() => buildOAuthEndpoints(oauthApiBase, frontendBase), [oauthApiBase, frontendBase]);
|
||||||
|
|
||||||
|
const loadPublicSettings = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await apiFetch<{ settings: Array<{ key: string; value: string }> }>('/settings/public');
|
||||||
|
const settings = Object.fromEntries((response.settings ?? []).map((item) => [item.key, item.value]));
|
||||||
|
setOauthApiBase(resolveOAuthApiBase(settings));
|
||||||
|
setFrontendBase(resolveFrontendBase(settings));
|
||||||
|
if (settings.PROJECT_NAME?.trim()) {
|
||||||
|
setProjectName(settings.PROJECT_NAME.trim());
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// оставляем fallback
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
if (!token) return;
|
if (!token || !user?.canViewOAuth) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [clientsResponse, scopesResponse] = await Promise.all([
|
const clientsResponse = await apiFetch<{ clients: OAuthClient[] }>('/admin/rbac/oauth-clients', {}, token);
|
||||||
apiFetch<{ clients: OAuthClient[] }>('/admin/rbac/oauth-clients', {}, token),
|
|
||||||
apiFetch<{ scopes: OAuthScope[] }>('/admin/rbac/oauth-scopes', {}, token)
|
|
||||||
]);
|
|
||||||
setClients(clientsResponse.clients ?? []);
|
setClients(clientsResponse.clients ?? []);
|
||||||
|
if (user.canManageOAuth) {
|
||||||
|
const scopesResponse = await apiFetch<{ scopes: OAuthScope[] }>('/admin/rbac/oauth-scopes', {}, token);
|
||||||
setScopes(scopesResponse.scopes ?? []);
|
setScopes(scopesResponse.scopes ?? []);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(error instanceof Error ? error.message : 'Не удалось загрузить OAuth-приложения');
|
showToast(error instanceof Error ? error.message : 'Не удалось загрузить OAuth-приложения');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [showToast, token]);
|
}, [showToast, token, user?.canManageOAuth, user?.canViewOAuth]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
void loadPublicSettings();
|
||||||
|
}, [loadPublicSettings]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) return;
|
||||||
|
if (!user.canViewOAuth && !user.canManageOAuth) {
|
||||||
|
router.replace(getAdminLandingPath(user));
|
||||||
|
return;
|
||||||
|
}
|
||||||
void loadData();
|
void loadData();
|
||||||
}, [loadData]);
|
}, [loadData, router, user]);
|
||||||
|
|
||||||
async function handleCreate() {
|
async function handleCreate() {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
@@ -79,6 +114,29 @@ export default function AdminOAuthPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleSaveRedirectUris(client: OAuthClient, redirectUris: string[]) {
|
||||||
|
if (!token) return;
|
||||||
|
if (!redirectUris.length) {
|
||||||
|
showToast('Укажите хотя бы один redirect URI');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const updated = await apiFetch<OAuthClient>(`/admin/rbac/oauth-clients/${client.clientId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ redirectUris })
|
||||||
|
}, token);
|
||||||
|
showToast('Redirect URI обновлены');
|
||||||
|
setSelectedClient((current) =>
|
||||||
|
current?.clientId === client.clientId
|
||||||
|
? { ...current, redirectUris: updated.redirectUris ?? redirectUris }
|
||||||
|
: current
|
||||||
|
);
|
||||||
|
await loadData();
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error instanceof Error ? error.message : 'Не удалось сохранить redirect URI');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleRotateSecret(clientId: string) {
|
async function handleRotateSecret(clientId: string) {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
try {
|
try {
|
||||||
@@ -98,12 +156,26 @@ export default function AdminOAuthPage() {
|
|||||||
body: JSON.stringify({ isActive: !client.isActive })
|
body: JSON.stringify({ isActive: !client.isActive })
|
||||||
}, token);
|
}, token);
|
||||||
showToast(client.isActive ? 'Приложение отключено' : 'Приложение включено');
|
showToast(client.isActive ? 'Приложение отключено' : 'Приложение включено');
|
||||||
|
setSelectedClient((current) => (current?.id === client.id ? { ...client, isActive: !client.isActive } : current));
|
||||||
await loadData();
|
await loadData();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(error instanceof Error ? error.message : 'Не удалось обновить приложение');
|
showToast(error instanceof Error ? error.message : 'Не удалось обновить приложение');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleDeleteClient(client: OAuthClient) {
|
||||||
|
if (!token) return;
|
||||||
|
if (!window.confirm(`Удалить приложение «${client.name}»? Это действие необратимо.`)) return;
|
||||||
|
try {
|
||||||
|
await apiFetch(`/admin/rbac/oauth-clients/${client.clientId}`, { method: 'DELETE' }, token);
|
||||||
|
showToast('Приложение удалено');
|
||||||
|
setSelectedClient(null);
|
||||||
|
await loadData();
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error instanceof Error ? error.message : 'Не удалось удалить приложение');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function copyText(value: string, label: string) {
|
function copyText(value: string, label: string) {
|
||||||
void navigator.clipboard.writeText(value);
|
void navigator.clipboard.writeText(value);
|
||||||
showToast(`${label} скопирован`);
|
showToast(`${label} скопирован`);
|
||||||
@@ -114,9 +186,25 @@ export default function AdminOAuthPage() {
|
|||||||
<div className="mb-6 flex items-center justify-between gap-4">
|
<div className="mb-6 flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-medium">OAuth-приложения</h2>
|
<h2 className="text-2xl font-medium">OAuth-приложения</h2>
|
||||||
<p className="text-sm text-[#667085]">Создание клиентов как на oauth.yandex.ru: client_id, secret, redirect URI и scopes</p>
|
<p className="text-sm text-[#667085]">
|
||||||
|
Issuer: <code className="rounded bg-[#f4f5f8] px-1.5 py-0.5 text-xs">{oauthEndpoints.issuer}</code>
|
||||||
|
{' · '}
|
||||||
|
Frontend: <code className="rounded bg-[#f4f5f8] px-1.5 py-0.5 text-xs">{frontendBase}</code>
|
||||||
|
{' · '}
|
||||||
|
<a href={oauthEndpoints.openIdConfigurationUrl} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-[#1d4ed8] hover:underline">
|
||||||
|
OpenID Configuration
|
||||||
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-[#667085]">
|
||||||
|
One Tap Login настраивается в{' '}
|
||||||
|
<a href="/admin/settings" className="text-[#1d4ed8] hover:underline">
|
||||||
|
системных настройках
|
||||||
|
</a>{' '}
|
||||||
|
(PUBLIC_API_URL, PUBLIC_FRONTEND_URL, ONE_TAP_ENABLED).
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => setDialogOpen(true)}>
|
<Button onClick={() => setDialogOpen(true)} disabled={!user?.canManageOAuth}>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
Новое приложение
|
Новое приложение
|
||||||
</Button>
|
</Button>
|
||||||
@@ -129,56 +217,60 @@ export default function AdminOAuthPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
{clients.map((client) => (
|
{clients.map((client) => {
|
||||||
<Card key={client.id} className={client.isActive ? '' : 'opacity-70'}>
|
const authorizePreview = buildAuthorizeUrl(oauthEndpoints.issuer, {
|
||||||
|
clientId: client.clientId,
|
||||||
|
redirectUri: client.redirectUris[0] ?? 'https://app.example.com/oauth/callback',
|
||||||
|
scope: client.scopes.join(' ')
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={client.id}
|
||||||
|
className={`cursor-pointer transition hover:shadow-md ${client.isActive ? '' : 'opacity-70'}`}
|
||||||
|
onClick={() => setSelectedClient(client)}
|
||||||
|
>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<LockKeyhole className="h-6 w-6" />
|
<LockKeyhole className="h-6 w-6" />
|
||||||
<CardTitle>{client.name}</CardTitle>
|
<CardTitle>{client.name}</CardTitle>
|
||||||
<CardDescription>{client.type === 'PUBLIC' ? 'Публичное приложение' : 'Confidential приложение'}</CardDescription>
|
<CardDescription>
|
||||||
|
{client.type === 'PUBLIC' ? 'Публичное приложение' : 'Confidential приложение'}
|
||||||
|
{client.createdByDisplayName ? (
|
||||||
|
<span className="mt-1 flex items-center gap-1.5 text-xs text-[#667085]">
|
||||||
|
<UserRound className="h-3.5 w-3.5" />
|
||||||
|
Создал: {client.createdByDisplayName}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
<div className="rounded-2xl bg-[#f4f5f8] p-4 text-sm">
|
<div className="rounded-2xl bg-[#f4f5f8] p-4 text-sm">
|
||||||
<div className="mb-2 flex items-center justify-between gap-2">
|
<div className="mb-1 font-medium">Authorization URL</div>
|
||||||
<span className="font-medium">Client ID</span>
|
<code className="line-clamp-2 break-all text-xs text-[#667085]">{authorizePreview}</code>
|
||||||
<Button variant="ghost" size="icon" aria-label="Скопировать client id" onClick={() => copyText(client.clientId, 'Client ID')}>
|
|
||||||
<Copy className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<code className="break-all text-xs">{client.clientId}</code>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-2xl bg-[#f4f5f8] p-4 text-sm">
|
|
||||||
<div className="mb-2 font-medium">Redirect URI</div>
|
|
||||||
{client.redirectUris.map((uri) => (
|
|
||||||
<div key={uri} className="break-all text-xs text-[#667085]">
|
|
||||||
{uri}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="rounded-2xl bg-[#f4f5f8] p-4 text-sm">
|
|
||||||
<div className="mb-2 flex items-center gap-2 font-medium">
|
|
||||||
<KeyRound className="h-4 w-4" />
|
|
||||||
Scopes
|
|
||||||
</div>
|
|
||||||
<p>{client.scopes.join(', ')}</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{client.type !== 'PUBLIC' ? (
|
|
||||||
<Button variant="secondary" size="sm" onClick={() => void handleRotateSecret(client.clientId)}>
|
|
||||||
<RefreshCw className="h-4 w-4" />
|
|
||||||
Новый secret
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
<Button variant="secondary" size="sm" onClick={() => void handleToggleActive(client)}>
|
|
||||||
{client.isActive ? 'Отключить' : 'Включить'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-xs text-[#667085]">Нажмите на карточку — все endpoints и данные для интеграции</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
{!clients.length ? <p className="text-[#667085]">OAuth-приложения ещё не созданы</p> : null}
|
{!clients.length ? <p className="text-[#667085]">OAuth-приложения ещё не созданы</p> : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<OAuthClientDetailDialog
|
||||||
|
client={selectedClient}
|
||||||
|
endpoints={oauthEndpoints}
|
||||||
|
frontendBase={frontendBase}
|
||||||
|
projectName={projectName}
|
||||||
|
open={Boolean(selectedClient)}
|
||||||
|
onOpenChange={(open) => !open && setSelectedClient(null)}
|
||||||
|
onCopy={copyText}
|
||||||
|
onRotateSecret={(clientId) => void handleRotateSecret(clientId)}
|
||||||
|
onToggleActive={(client) => void handleToggleActive(client)}
|
||||||
|
onDelete={(client) => void handleDeleteClient(client)}
|
||||||
|
onSaveRedirectUris={handleSaveRedirectUris}
|
||||||
|
/>
|
||||||
|
|
||||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|||||||
@@ -1,5 +1,22 @@
|
|||||||
import { redirect } from 'next/navigation';
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useAuth } from '@/components/id/auth-provider';
|
||||||
|
import { getAdminLandingPath } from '@/lib/admin-access';
|
||||||
|
|
||||||
export default function AdminIndexPage() {
|
export default function AdminIndexPage() {
|
||||||
redirect('/admin/users');
|
const router = useRouter();
|
||||||
|
const { user, isLoading } = useAuth();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoading || !user) return;
|
||||||
|
router.replace(getAdminLandingPath(user));
|
||||||
|
}, [isLoading, router, user]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[40vh] items-center justify-center text-[#667085]">
|
||||||
|
Перенаправление в админ-панель...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Loader2, Plus, ShieldCheck } from 'lucide-react';
|
import { Loader2, Pencil, Plus, ShieldCheck, Trash2 } from 'lucide-react';
|
||||||
import { AdminShell } from '@/components/id/admin-shell';
|
import { AdminShell } from '@/components/id/admin-shell';
|
||||||
import { useAuth } from '@/components/id/auth-provider';
|
import { useAuth } from '@/components/id/auth-provider';
|
||||||
import { useToast } from '@/components/id/toast-provider';
|
import { useToast } from '@/components/id/toast-provider';
|
||||||
@@ -11,15 +11,31 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { AdminPermission, AdminRole, apiFetch } from '@/lib/api';
|
import { AdminPermission, AdminRole, apiFetch } from '@/lib/api';
|
||||||
|
|
||||||
|
type RoleFormState = {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
permissionSlugs: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyForm: RoleFormState = { slug: '', name: '', description: '', permissionSlugs: [] };
|
||||||
|
|
||||||
export default function AdminRbacPage() {
|
export default function AdminRbacPage() {
|
||||||
const { token, user } = useAuth();
|
const { token, user } = useAuth();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [roles, setRoles] = useState<AdminRole[]>([]);
|
const [roles, setRoles] = useState<AdminRole[]>([]);
|
||||||
const [permissions, setPermissions] = useState<AdminPermission[]>([]);
|
const [permissions, setPermissions] = useState<AdminPermission[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [creating, setCreating] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [form, setForm] = useState({ slug: '', name: '', description: '', permissionSlugs: [] as string[] });
|
const [editingRole, setEditingRole] = useState<AdminRole | null>(null);
|
||||||
|
const [form, setForm] = useState<RoleFormState>(emptyForm);
|
||||||
|
|
||||||
|
async function reloadRoles() {
|
||||||
|
if (!token) return;
|
||||||
|
const response = await apiFetch<{ roles: AdminRole[] }>('/admin/rbac/roles', {}, token);
|
||||||
|
setRoles(response.roles ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token || !user?.canManageRoles) return;
|
if (!token || !user?.canManageRoles) return;
|
||||||
@@ -35,30 +51,74 @@ export default function AdminRbacPage() {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [showToast, token, user?.canManageRoles]);
|
}, [showToast, token, user?.canManageRoles]);
|
||||||
|
|
||||||
async function handleCreateRole() {
|
function openCreateDialog() {
|
||||||
|
setEditingRole(null);
|
||||||
|
setForm(emptyForm);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDialog(role: AdminRole) {
|
||||||
|
setEditingRole(role);
|
||||||
|
setForm({
|
||||||
|
slug: role.slug,
|
||||||
|
name: role.name,
|
||||||
|
description: role.description ?? '',
|
||||||
|
permissionSlugs: role.permissions.map((permission) => permission.slug)
|
||||||
|
});
|
||||||
|
setDialogOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveRole() {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
setCreating(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
|
if (editingRole) {
|
||||||
|
await apiFetch(`/admin/rbac/roles/${editingRole.slug}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: form.name,
|
||||||
|
description: form.description || undefined,
|
||||||
|
permissionSlugs: form.permissionSlugs
|
||||||
|
})
|
||||||
|
}, token);
|
||||||
|
showToast('Роль обновлена');
|
||||||
|
} else {
|
||||||
await apiFetch('/admin/rbac/roles', {
|
await apiFetch('/admin/rbac/roles', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(form)
|
body: JSON.stringify(form)
|
||||||
}, token);
|
}, token);
|
||||||
showToast('Роль создана');
|
showToast('Роль создана');
|
||||||
|
}
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
setForm({ slug: '', name: '', description: '', permissionSlugs: [] });
|
setForm(emptyForm);
|
||||||
const response = await apiFetch<{ roles: AdminRole[] }>('/admin/rbac/roles', {}, token);
|
setEditingRole(null);
|
||||||
setRoles(response.roles ?? []);
|
await reloadRoles();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(error instanceof Error ? error.message : 'Не удалось создать роль');
|
showToast(error instanceof Error ? error.message : 'Не удалось сохранить роль');
|
||||||
} finally {
|
} finally {
|
||||||
setCreating(false);
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteRole(role: AdminRole) {
|
||||||
|
if (!token || role.isSystem) return;
|
||||||
|
if (!window.confirm(`Удалить роль «${role.name}»? Она будет снята со всех пользователей.`)) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await apiFetch(`/admin/rbac/roles/${role.slug}`, { method: 'DELETE' }, token);
|
||||||
|
showToast('Роль удалена');
|
||||||
|
await reloadRoles();
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error instanceof Error ? error.message : 'Не удалось удалить роль');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user?.canManageRoles) {
|
if (!user?.canManageRoles) {
|
||||||
return (
|
return (
|
||||||
<AdminShell active="/admin/rbac">
|
<AdminShell active="/admin/rbac">
|
||||||
<p className="text-[#667085]">Управление ролями доступно только супер-администратору.</p>
|
<p className="text-[#667085]">Управление ролями доступно пользователям с правом rbac.manage.</p>
|
||||||
</AdminShell>
|
</AdminShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -68,9 +128,9 @@ export default function AdminRbacPage() {
|
|||||||
<div className="mb-6 flex items-center justify-between gap-4">
|
<div className="mb-6 flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-medium">Роли и права</h2>
|
<h2 className="text-2xl font-medium">Роли и права</h2>
|
||||||
<p className="text-sm text-[#667085]">Только супер-администратор может создавать роли и назначать их пользователям</p>
|
<p className="text-sm text-[#667085]">Редактируйте роли, назначайте права всем пользователям или отдельным аккаунтам</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => setDialogOpen(true)}>
|
<Button onClick={openCreateDialog}>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
Создать роль
|
Создать роль
|
||||||
</Button>
|
</Button>
|
||||||
@@ -86,17 +146,37 @@ export default function AdminRbacPage() {
|
|||||||
{roles.map((role) => (
|
{roles.map((role) => (
|
||||||
<Card key={role.id} className="bg-[#f8f9fb] shadow-none">
|
<Card key={role.id} className="bg-[#f8f9fb] shadow-none">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<ShieldCheck className="h-6 w-6" />
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<ShieldCheck className="h-6 w-6 shrink-0" />
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button variant="ghost" size="icon" aria-label="Редактировать роль" onClick={() => openEditDialog(role)}>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
{!role.isSystem ? (
|
||||||
|
<Button variant="ghost" size="icon" aria-label="Удалить роль" disabled={saving} onClick={() => void handleDeleteRole(role)}>
|
||||||
|
<Trash2 className="h-4 w-4 text-red-600" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<CardTitle>{role.name}</CardTitle>
|
<CardTitle>{role.name}</CardTitle>
|
||||||
<CardDescription>{role.permissions.length} прав · {role.slug}</CardDescription>
|
<CardDescription>
|
||||||
|
{role.permissions.length} прав · {role.slug}
|
||||||
|
{role.isSystem ? ' · системная' : ''}
|
||||||
|
{role.isDefault ? ' · по умолчанию' : ''}
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2">
|
<CardContent className="space-y-2">
|
||||||
{role.permissions.map((permission) => (
|
{role.permissions.length ? (
|
||||||
|
role.permissions.map((permission) => (
|
||||||
<div key={permission.id} className="rounded-xl bg-white px-3 py-2 text-sm">
|
<div key={permission.id} className="rounded-xl bg-white px-3 py-2 text-sm">
|
||||||
<div className="font-medium">{permission.name}</div>
|
<div className="font-medium">{permission.name}</div>
|
||||||
<div className="text-xs text-[#667085]">{permission.slug}</div>
|
<div className="text-xs text-[#667085]">{permission.slug}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-[#667085]">Права не назначены</p>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
@@ -106,10 +186,14 @@ export default function AdminRbacPage() {
|
|||||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Новая роль</DialogTitle>
|
<DialogTitle>{editingRole ? `Редактирование: ${editingRole.name}` : 'Новая роль'}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
{!editingRole ? (
|
||||||
<Input value={form.slug} onChange={(event) => setForm((current) => ({ ...current, slug: event.target.value }))} placeholder="slug, например support" />
|
<Input value={form.slug} onChange={(event) => setForm((current) => ({ ...current, slug: event.target.value }))} placeholder="slug, например support" />
|
||||||
|
) : (
|
||||||
|
<p className="rounded-xl bg-[#f4f5f8] px-3 py-2 text-sm text-[#667085]">Slug: {editingRole.slug}</p>
|
||||||
|
)}
|
||||||
<Input value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} placeholder="Название роли" />
|
<Input value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} placeholder="Название роли" />
|
||||||
<Input value={form.description} onChange={(event) => setForm((current) => ({ ...current, description: event.target.value }))} placeholder="Описание" />
|
<Input value={form.description} onChange={(event) => setForm((current) => ({ ...current, description: event.target.value }))} placeholder="Описание" />
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -132,8 +216,8 @@ export default function AdminRbacPage() {
|
|||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Button className="w-full" disabled={creating} onClick={() => void handleCreateRole()}>
|
<Button className="w-full" disabled={saving || !form.name.trim() || (!editingRole && !form.slug.trim())} onClick={() => void handleSaveRole()}>
|
||||||
{creating ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Создать роль'}
|
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : editingRole ? 'Сохранить изменения' : 'Создать роль'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
353
apps/frontend/app/admin/releases/page.tsx
Normal file
353
apps/frontend/app/admin/releases/page.tsx
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Download, Loader2, Smartphone, Trash2, Upload } from 'lucide-react';
|
||||||
|
import { useAuth } from '@/components/id/auth-provider';
|
||||||
|
import { AdminShell } from '@/components/id/admin-shell';
|
||||||
|
import { useToast } from '@/components/id/toast-provider';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
AppRelease,
|
||||||
|
deleteAdminAppRelease,
|
||||||
|
fetchAdminAppReleases,
|
||||||
|
getApiErrorMessage,
|
||||||
|
updateAdminAppRelease,
|
||||||
|
uploadAdminAppRelease
|
||||||
|
} from '@/lib/api';
|
||||||
|
import {
|
||||||
|
detectVariantFromFileName,
|
||||||
|
formatReleaseVariantLabel,
|
||||||
|
groupReleasesByVersion
|
||||||
|
} from '@/lib/app-release-variants';
|
||||||
|
|
||||||
|
const ANDROID_PLATFORM = 'ANDROID' as const;
|
||||||
|
const APK_ACCEPT = '.apk,application/vnd.android.package-archive';
|
||||||
|
const MAX_RELEASE_FILE_BYTES = 350 * 1024 * 1024;
|
||||||
|
|
||||||
|
function formatBytes(value: string) {
|
||||||
|
const size = Number(value);
|
||||||
|
if (!Number.isFinite(size) || size <= 0) return '—';
|
||||||
|
const units = ['Б', 'КБ', 'МБ', 'ГБ'];
|
||||||
|
let amount = size;
|
||||||
|
let unit = 0;
|
||||||
|
while (amount >= 1024 && unit < units.length - 1) {
|
||||||
|
amount /= 1024;
|
||||||
|
unit += 1;
|
||||||
|
}
|
||||||
|
return `${amount.toFixed(amount >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string) {
|
||||||
|
return new Intl.DateTimeFormat('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
}).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminReleasesPage() {
|
||||||
|
const { token, user } = useAuth();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const [releases, setReleases] = useState<AppRelease[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [version, setVersion] = useState('');
|
||||||
|
const [versionCode, setVersionCode] = useState('');
|
||||||
|
const [releaseNotes, setReleaseNotes] = useState('');
|
||||||
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
|
|
||||||
|
const canManage = Boolean(user?.canManageSettings);
|
||||||
|
|
||||||
|
const loadReleases = useCallback(async () => {
|
||||||
|
if (!token || !canManage) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetchAdminAppReleases(token, ANDROID_PLATFORM);
|
||||||
|
setReleases(response.releases ?? []);
|
||||||
|
} catch (error) {
|
||||||
|
showToast(getApiErrorMessage(error, 'Не удалось загрузить релизы') ?? 'Ошибка');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [canManage, showToast, token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadReleases();
|
||||||
|
}, [loadReleases]);
|
||||||
|
|
||||||
|
const androidReleases = useMemo(
|
||||||
|
() => releases.filter((item) => item.platform === ANDROID_PLATFORM),
|
||||||
|
[releases]
|
||||||
|
);
|
||||||
|
|
||||||
|
const groupedReleases = useMemo(() => groupReleasesByVersion(androidReleases), [androidReleases]);
|
||||||
|
|
||||||
|
const detectedVariants = useMemo(
|
||||||
|
() => files.map((file) => ({ file, variant: detectVariantFromFileName(file.name) })),
|
||||||
|
[files]
|
||||||
|
);
|
||||||
|
|
||||||
|
async function handleUpload(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!token || !files.length) {
|
||||||
|
showToast('Выберите один или несколько APK');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oversized = files.find((file) => file.size > MAX_RELEASE_FILE_BYTES);
|
||||||
|
if (oversized) {
|
||||||
|
showToast(`Файл ${oversized.name} превышает лимит 350 МБ`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedVersionCode = Number(versionCode);
|
||||||
|
if (!version.trim() || !Number.isInteger(parsedVersionCode) || parsedVersionCode < 1) {
|
||||||
|
showToast('Укажите версию и положительный код версии');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
const created: AppRelease[] = [];
|
||||||
|
try {
|
||||||
|
for (const file of files) {
|
||||||
|
const item = await uploadAdminAppRelease(token, {
|
||||||
|
platform: ANDROID_PLATFORM,
|
||||||
|
version: version.trim(),
|
||||||
|
versionCode: parsedVersionCode,
|
||||||
|
variant: detectVariantFromFileName(file.name),
|
||||||
|
releaseNotes: releaseNotes.trim() || undefined,
|
||||||
|
file
|
||||||
|
});
|
||||||
|
created.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
setReleases((current) => [...created, ...current]);
|
||||||
|
setVersion('');
|
||||||
|
setVersionCode('');
|
||||||
|
setReleaseNotes('');
|
||||||
|
setFiles([]);
|
||||||
|
showToast(
|
||||||
|
created.length === 1
|
||||||
|
? `Загружена сборка ${formatReleaseVariantLabel(created[0].variant)}`
|
||||||
|
: `Загружено сборок: ${created.length}`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (created.length) {
|
||||||
|
setReleases((current) => [...created, ...current]);
|
||||||
|
}
|
||||||
|
showToast(getApiErrorMessage(error, 'Не удалось загрузить релиз') ?? 'Ошибка');
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function togglePublished(release: AppRelease) {
|
||||||
|
if (!token) return;
|
||||||
|
try {
|
||||||
|
const updated = await updateAdminAppRelease(token, release.id, { isPublished: !release.isPublished });
|
||||||
|
setReleases((current) => current.map((item) => (item.id === release.id ? updated : item)));
|
||||||
|
showToast(updated.isPublished ? 'Релиз опубликован' : 'Релиз скрыт');
|
||||||
|
} catch (error) {
|
||||||
|
showToast(getApiErrorMessage(error, 'Не удалось обновить релиз') ?? 'Ошибка');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeRelease(release: AppRelease) {
|
||||||
|
if (!token) return;
|
||||||
|
if (!window.confirm(`Удалить сборку ${formatReleaseVariantLabel(release.variant)} для v${release.version}?`)) return;
|
||||||
|
try {
|
||||||
|
await deleteAdminAppRelease(token, release.id);
|
||||||
|
setReleases((current) => current.filter((item) => item.id !== release.id));
|
||||||
|
showToast('Сборка удалена');
|
||||||
|
} catch (error) {
|
||||||
|
showToast(getApiErrorMessage(error, 'Не удалось удалить релиз') ?? 'Ошибка');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderReleaseList(emptyLabel: string) {
|
||||||
|
if (loading) {
|
||||||
|
return <div className="rounded-[20px] bg-[#f4f5f8] px-4 py-5 text-[#667085]">Загружаем релизы...</div>;
|
||||||
|
}
|
||||||
|
if (!groupedReleases.length) {
|
||||||
|
return <div className="rounded-[20px] bg-[#f4f5f8] px-4 py-5 text-[#667085]">{emptyLabel}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{groupedReleases.map((group, groupIndex) => {
|
||||||
|
const publishedVariants = group.variants.filter((item) => item.isPublished);
|
||||||
|
const isLatest = groupIndex === 0 && publishedVariants.length > 0;
|
||||||
|
const notes = group.variants.find((item) => item.releaseNotes)?.releaseNotes;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={group.key} className="rounded-[20px] border border-[#eceef4] bg-white p-4 shadow-sm">
|
||||||
|
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||||
|
<h3 className="text-lg font-semibold">v{group.version}</h3>
|
||||||
|
<span className="rounded-full bg-[#eef4ff] px-2 py-0.5 text-xs font-medium text-[#3390ec]">
|
||||||
|
build {group.versionCode}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full bg-[#f4f5f8] px-2 py-0.5 text-xs font-medium text-[#667085]">
|
||||||
|
{group.variants.length} {group.variants.length === 1 ? 'сборка' : 'сборки'}
|
||||||
|
</span>
|
||||||
|
{isLatest ? (
|
||||||
|
<span className="rounded-full bg-[#e8f8ee] px-2 py-0.5 text-xs font-medium text-[#1a7f37]">
|
||||||
|
Последняя версия
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{notes ? (
|
||||||
|
<p className="mb-4 whitespace-pre-wrap rounded-2xl bg-[#f8f9fb] px-4 py-3 text-sm leading-relaxed text-[#1f2430]">
|
||||||
|
{notes}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{group.variants.map((release) => (
|
||||||
|
<div
|
||||||
|
key={release.id}
|
||||||
|
className="flex flex-wrap items-start justify-between gap-3 rounded-2xl bg-[#f8f9fb] px-4 py-3"
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 items-start gap-3">
|
||||||
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-white">
|
||||||
|
<Smartphone className="h-4 w-4 text-[#3390ec]" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="font-medium text-[#1f2430]">
|
||||||
|
{formatReleaseVariantLabel(release.variant)}
|
||||||
|
</span>
|
||||||
|
{!release.isPublished ? (
|
||||||
|
<span className="rounded-full bg-[#fff4e5] px-2 py-0.5 text-xs font-medium text-[#b54708]">
|
||||||
|
Скрыта
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-[#667085]">
|
||||||
|
{release.fileName} · {formatBytes(release.fileSize)} · {formatDate(release.createdAt)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 break-all font-mono text-xs text-[#8f92a0]">SHA-256: {release.sha256}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 flex-wrap gap-2">
|
||||||
|
<Button variant="outline" size="sm" className="rounded-xl" asChild>
|
||||||
|
<a href={`/downloads/android/${release.id}`} target="_blank" rel="noreferrer">
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
Скачать
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" className="rounded-xl" onClick={() => void togglePublished(release)}>
|
||||||
|
{release.isPublished ? 'Скрыть' : 'Опубликовать'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="rounded-xl text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||||
|
onClick={() => void removeRelease(release)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canManage) {
|
||||||
|
return (
|
||||||
|
<AdminShell active="/admin/releases">
|
||||||
|
<div className="rounded-[20px] bg-[#f4f5f8] px-4 py-6 text-[#667085]">Недостаточно прав для управления релизами.</div>
|
||||||
|
</AdminShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminShell active="/admin/releases">
|
||||||
|
<div className="grid gap-8 xl:grid-cols-[380px_minmax(0,1fr)]">
|
||||||
|
<section className="rounded-[24px] border border-[#eceef4] bg-white p-6 shadow-sm">
|
||||||
|
<div className="mb-5 flex items-center gap-3">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#eef4ff] text-[#3390ec]">
|
||||||
|
<Upload className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold">Новая версия Android</h2>
|
||||||
|
<p className="text-sm text-[#667085]">Можно загрузить несколько APK одной версии</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="space-y-4" onSubmit={(event) => void handleUpload(event)}>
|
||||||
|
<Input
|
||||||
|
placeholder="Версия, например 1.2.0"
|
||||||
|
value={version}
|
||||||
|
onChange={(event) => setVersion(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
placeholder="Код версии (versionCode), например 120"
|
||||||
|
value={versionCode}
|
||||||
|
onChange={(event) => setVersionCode(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
className="min-h-[110px] w-full rounded-2xl border border-[#eceef4] bg-[#f8f9fb] px-4 py-3 text-sm outline-none transition focus:border-[#3390ec]"
|
||||||
|
placeholder="Что нового в этой версии"
|
||||||
|
value={releaseNotes}
|
||||||
|
onChange={(event) => setReleaseNotes(event.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label className="flex cursor-pointer flex-col items-center justify-center rounded-[20px] border border-dashed border-[#c7d2fe] bg-[#f8faff] px-4 py-8 text-center transition hover:bg-[#eef4ff]">
|
||||||
|
<Smartphone className="mb-3 h-8 w-8 text-[#3390ec]" />
|
||||||
|
<span className="text-sm font-medium text-[#1f2430]">
|
||||||
|
{files.length ? `Выбрано файлов: ${files.length}` : 'Выберите один или несколько .apk'}
|
||||||
|
</span>
|
||||||
|
<span className="mt-1 text-xs text-[#667085]">
|
||||||
|
universal-release, arm64-v8a-release и другие варианты одной версии
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
multiple
|
||||||
|
accept={APK_ACCEPT}
|
||||||
|
onChange={(event) => setFiles([...(event.target.files ?? [])])}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{detectedVariants.length ? (
|
||||||
|
<div className="rounded-2xl bg-[#f8f9fb] px-4 py-3">
|
||||||
|
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-[#667085]">Определённые варианты</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{detectedVariants.map((item) => (
|
||||||
|
<p key={`${item.file.name}-${item.variant}`} className="text-sm text-[#1f2430]">
|
||||||
|
<span className="font-medium">{formatReleaseVariantLabel(item.variant)}</span>
|
||||||
|
<span className="text-[#667085]"> · {item.file.name}</span>
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full rounded-xl" disabled={uploading || !files.length}>
|
||||||
|
{uploading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Upload className="mr-2 h-4 w-4" />}
|
||||||
|
{uploading ? 'Загружаем...' : files.length > 1 ? `Загрузить ${files.length} сборки` : 'Загрузить релиз'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="mb-4 text-xl font-semibold">Версии Android</h2>
|
||||||
|
{renderReleaseList('Релизы Android пока не загружены')}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</AdminShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,19 +1,41 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Chrome, Loader2, Save, Settings2, ToggleLeft, ToggleRight } from 'lucide-react';
|
import { Chrome, Loader2, Save, ToggleLeft, ToggleRight } from 'lucide-react';
|
||||||
import { useAuth } from '@/components/id/auth-provider';
|
import { useAuth } from '@/components/id/auth-provider';
|
||||||
import { AdminShell } from '@/components/id/admin-shell';
|
import { AdminShell } from '@/components/id/admin-shell';
|
||||||
import { usePublicSettings } from '@/components/id/public-settings-provider';
|
import { usePublicSettings } from '@/components/id/public-settings-provider';
|
||||||
import { useToast } from '@/components/id/toast-provider';
|
import { useToast } from '@/components/id/toast-provider';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { MessagingSettingsSection } from '@/components/id/messaging-settings-section';
|
||||||
|
import { FirebaseSettingsSection } from '@/components/id/firebase-settings-section';
|
||||||
|
import { SettingsFieldRow } from '@/components/id/settings-field-row';
|
||||||
import { apiFetch, buildSystemSettingPayload, SocialProvider, SystemSetting } from '@/lib/api';
|
import { apiFetch, buildSystemSettingPayload, SocialProvider, SystemSetting } from '@/lib/api';
|
||||||
import { getSettingMeta, sortSettingsByCatalog, SYSTEM_SETTING_GROUPS } from '@/lib/system-settings-catalog';
|
import {
|
||||||
|
FIREBASE_SETTING_KEYS,
|
||||||
|
getSettingMeta,
|
||||||
|
isSettingVisible,
|
||||||
|
MESSAGING_SETTING_KEYS,
|
||||||
|
sortSettingsByCatalog,
|
||||||
|
SYSTEM_SETTING_GROUPS
|
||||||
|
} from '@/lib/system-settings-catalog';
|
||||||
|
|
||||||
function parseBoolean(value: string) {
|
const GROUP_LABELS: Record<string, string> = {
|
||||||
return ['true', '1', 'yes'].includes(value.trim().toLowerCase());
|
...SYSTEM_SETTING_GROUPS,
|
||||||
}
|
'messaging-email': 'Email (SMTP)',
|
||||||
|
'messaging-sms': 'SMS'
|
||||||
|
};
|
||||||
|
|
||||||
|
const PUBLIC_SETTINGS_REFRESH_KEYS = new Set([
|
||||||
|
'PROJECT_NAME',
|
||||||
|
'PROJECT_TAGLINE',
|
||||||
|
'PUBLIC_API_URL',
|
||||||
|
'PUBLIC_FRONTEND_URL',
|
||||||
|
'ONE_TAP_ENABLED',
|
||||||
|
'LDAP_ENABLED',
|
||||||
|
'LDAP_USE_LDAPS'
|
||||||
|
]);
|
||||||
|
|
||||||
export default function AdminSettingsPage() {
|
export default function AdminSettingsPage() {
|
||||||
const { token, user } = useAuth();
|
const { token, user } = useAuth();
|
||||||
@@ -22,7 +44,8 @@ export default function AdminSettingsPage() {
|
|||||||
const [settings, setSettings] = useState<SystemSetting[]>([]);
|
const [settings, setSettings] = useState<SystemSetting[]>([]);
|
||||||
const [providers, setProviders] = useState<SocialProvider[]>([]);
|
const [providers, setProviders] = useState<SocialProvider[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [savingKey, setSavingKey] = useState<string | null>(null);
|
const [savingGroup, setSavingGroup] = useState<string | null>(null);
|
||||||
|
const [savingOAuthProvider, setSavingOAuthProvider] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token || !user?.canManageSettings) return;
|
if (!token || !user?.canManageSettings) return;
|
||||||
@@ -41,6 +64,8 @@ export default function AdminSettingsPage() {
|
|||||||
const groupedSettings = useMemo(() => {
|
const groupedSettings = useMemo(() => {
|
||||||
const groups = new Map<string, SystemSetting[]>();
|
const groups = new Map<string, SystemSetting[]>();
|
||||||
for (const setting of settings) {
|
for (const setting of settings) {
|
||||||
|
if (MESSAGING_SETTING_KEYS.includes(setting.key)) continue;
|
||||||
|
if (FIREBASE_SETTING_KEYS.includes(setting.key)) continue;
|
||||||
const meta = getSettingMeta(setting.key);
|
const meta = getSettingMeta(setting.key);
|
||||||
const group = meta?.group ?? 'other';
|
const group = meta?.group ?? 'other';
|
||||||
const list = groups.get(group) ?? [];
|
const list = groups.get(group) ?? [];
|
||||||
@@ -50,10 +75,16 @@ export default function AdminSettingsPage() {
|
|||||||
return groups;
|
return groups;
|
||||||
}, [settings]);
|
}, [settings]);
|
||||||
|
|
||||||
async function saveSetting(setting: SystemSetting) {
|
async function saveSettingsGroup(groupKey: string, groupSettings: SystemSetting[]) {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
setSavingKey(setting.key);
|
|
||||||
|
const visibleSettings = groupSettings.filter((setting) => isSettingVisible(setting, settings));
|
||||||
|
if (visibleSettings.length === 0) return;
|
||||||
|
|
||||||
|
setSavingGroup(groupKey);
|
||||||
try {
|
try {
|
||||||
|
let needsPublicRefresh = false;
|
||||||
|
for (const setting of visibleSettings) {
|
||||||
const updated = await apiFetch<SystemSetting>(
|
const updated = await apiFetch<SystemSetting>(
|
||||||
'/admin/settings',
|
'/admin/settings',
|
||||||
{
|
{
|
||||||
@@ -63,14 +94,18 @@ export default function AdminSettingsPage() {
|
|||||||
token
|
token
|
||||||
);
|
);
|
||||||
setSettings((current) => sortSettingsByCatalog(current.map((item) => (item.key === updated.key ? updated : item))));
|
setSettings((current) => sortSettingsByCatalog(current.map((item) => (item.key === updated.key ? updated : item))));
|
||||||
if (updated.key === 'PROJECT_NAME' || updated.key === 'PROJECT_TAGLINE' || updated.key === 'LDAP_ENABLED' || updated.key === 'LDAP_USE_LDAPS') {
|
if (PUBLIC_SETTINGS_REFRESH_KEYS.has(updated.key)) {
|
||||||
|
needsPublicRefresh = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (needsPublicRefresh) {
|
||||||
await refreshPublicSettings();
|
await refreshPublicSettings();
|
||||||
}
|
}
|
||||||
showToast('Настройка сохранена');
|
showToast(`Блок «${GROUP_LABELS[groupKey] ?? groupKey}» сохранён`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(error instanceof Error ? error.message : 'Не удалось сохранить настройку');
|
showToast(error instanceof Error ? error.message : 'Не удалось сохранить настройки блока');
|
||||||
} finally {
|
} finally {
|
||||||
setSavingKey(null);
|
setSavingGroup(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,10 +124,14 @@ export default function AdminSettingsPage() {
|
|||||||
async function toggleProvider(provider: SocialProvider) {
|
async function toggleProvider(provider: SocialProvider) {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
try {
|
try {
|
||||||
const updated = await apiFetch<SocialProvider>('/admin/settings/oauth/providers', {
|
const updated = await apiFetch<SocialProvider>(
|
||||||
|
'/admin/settings/oauth/providers',
|
||||||
|
{
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ ...toProviderPayload(provider), isEnabled: !provider.isEnabled })
|
body: JSON.stringify({ ...toProviderPayload(provider), isEnabled: !provider.isEnabled })
|
||||||
}, token);
|
},
|
||||||
|
token
|
||||||
|
);
|
||||||
setProviders((current) => current.map((item) => (item.providerName === updated.providerName ? updated : item)));
|
setProviders((current) => current.map((item) => (item.providerName === updated.providerName ? updated : item)));
|
||||||
showToast(updated.isEnabled ? 'Провайдер включён' : 'Провайдер отключён');
|
showToast(updated.isEnabled ? 'Провайдер включён' : 'Провайдер отключён');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -100,6 +139,22 @@ export default function AdminSettingsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveOAuthProvider(provider: SocialProvider) {
|
||||||
|
if (!token) return;
|
||||||
|
setSavingOAuthProvider(provider.providerName);
|
||||||
|
try {
|
||||||
|
await apiFetch('/admin/settings/oauth/providers', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(toProviderPayload(provider))
|
||||||
|
}, token);
|
||||||
|
showToast(`Провайдер ${provider.providerName} сохранён`);
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error instanceof Error ? error.message : 'Ошибка сохранения провайдера');
|
||||||
|
} finally {
|
||||||
|
setSavingOAuthProvider(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!user?.canManageSettings) {
|
if (!user?.canManageSettings) {
|
||||||
return (
|
return (
|
||||||
<AdminShell active="/admin/settings">
|
<AdminShell active="/admin/settings">
|
||||||
@@ -112,7 +167,9 @@ export default function AdminSettingsPage() {
|
|||||||
<AdminShell active="/admin/settings">
|
<AdminShell active="/admin/settings">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h2 className="text-2xl font-medium">Глобальные настройки</h2>
|
<h2 className="text-2xl font-medium">Глобальные настройки</h2>
|
||||||
<p className="mt-2 text-[#667085]">Параметры хранятся в SystemSetting и применяются сервисами без релиза frontend.</p>
|
<p className="mt-2 text-[#667085]">
|
||||||
|
Параметры сгруппированы по блокам — измените нужные поля и сохраните весь блок одной кнопкой.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -122,91 +179,110 @@ export default function AdminSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{[...groupedSettings.entries()].map(([groupKey, groupSettings]) => (
|
{[...groupedSettings.entries()].map(([groupKey, groupSettings]) => {
|
||||||
<section key={groupKey} className="mb-10">
|
const visibleSettings = groupSettings.filter((setting) => isSettingVisible(setting, settings));
|
||||||
<h3 className="mb-4 text-xl font-medium">{SYSTEM_SETTING_GROUPS[groupKey] ?? 'Прочие настройки'}</h3>
|
const saving = savingGroup === groupKey;
|
||||||
<div className="space-y-3">
|
|
||||||
{groupSettings.map((setting) => {
|
|
||||||
const meta = getSettingMeta(setting.key);
|
|
||||||
const label = meta?.label ?? setting.key;
|
|
||||||
const type = meta?.type ?? 'text';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={setting.key} className="rounded-[24px] bg-[#f4f5f8] p-5">
|
<section key={groupKey} className="mb-10">
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
<div className="rounded-[24px] bg-[#f4f5f8] p-5">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="mb-4 flex flex-col gap-3 border-b border-[#e0e3ea] pb-4 md:flex-row md:items-center md:justify-between">
|
||||||
<div className="flex items-center gap-2 font-semibold">
|
<h3 className="text-xl font-medium">{SYSTEM_SETTING_GROUPS[groupKey] ?? 'Прочие настройки'}</h3>
|
||||||
<Settings2 className="h-4 w-4 shrink-0" />
|
<Button
|
||||||
{label}
|
disabled={saving || visibleSettings.length === 0}
|
||||||
</div>
|
onClick={() => void saveSettingsGroup(groupKey, groupSettings)}
|
||||||
<p className="mt-1 text-sm text-[#667085]">{meta?.hint ?? setting.description ?? setting.key}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
{type === 'boolean' ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={label}
|
|
||||||
onClick={() => {
|
|
||||||
updateSettingValue(setting.key, parseBoolean(setting.value) ? 'false' : 'true');
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{parseBoolean(setting.value) ? <ToggleRight className="h-9 w-9 text-green-600" /> : <ToggleLeft className="h-9 w-9 text-[#a8adbc]" />}
|
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||||
</button>
|
Сохранить блок
|
||||||
) : (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Input
|
|
||||||
type={setting.isSecret ? 'password' : type === 'number' ? 'number' : 'text'}
|
|
||||||
value={setting.value}
|
|
||||||
className={setting.isSecret ? 'w-[220px] bg-white' : 'w-[180px] bg-white'}
|
|
||||||
onChange={(event) => updateSettingValue(setting.key, event.target.value)}
|
|
||||||
/>
|
|
||||||
{meta?.unit ? <span className="text-sm text-[#667085]">{meta.unit}</span> : null}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button disabled={savingKey === setting.key} onClick={() => void saveSetting(setting)}>
|
|
||||||
{savingKey === setting.key ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
|
||||||
Сохранить
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{visibleSettings.map((setting) => (
|
||||||
|
<SettingsFieldRow
|
||||||
|
key={setting.key}
|
||||||
|
setting={setting}
|
||||||
|
onChange={(value) => updateSettingValue(setting.key, value)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<MessagingSettingsSection
|
||||||
|
settings={settings}
|
||||||
|
token={token}
|
||||||
|
savingGroup={savingGroup}
|
||||||
|
onUpdateValue={updateSettingValue}
|
||||||
|
onSaveGroup={saveSettingsGroup}
|
||||||
|
showToast={showToast}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FirebaseSettingsSection
|
||||||
|
settings={settings}
|
||||||
|
token={token}
|
||||||
|
savingGroup={savingGroup}
|
||||||
|
onUpdateValue={updateSettingValue}
|
||||||
|
onSaveGroup={saveSettingsGroup}
|
||||||
|
showToast={showToast}
|
||||||
|
/>
|
||||||
|
|
||||||
<section className="mt-10">
|
<section className="mt-10">
|
||||||
<h3 className="text-xl font-medium">OAuth Social Providers</h3>
|
<h3 className="mb-4 text-xl font-medium">OAuth Social Providers</h3>
|
||||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
{(providers.length ? providers : [{ id: 'google', providerName: 'google', clientId: '', isEnabled: false }, { id: 'yandex', providerName: 'yandex', clientId: '', isEnabled: false }]).map((provider) => (
|
{(providers.length
|
||||||
|
? providers
|
||||||
|
: [
|
||||||
|
{ id: 'google', providerName: 'google', clientId: '', isEnabled: false },
|
||||||
|
{ id: 'yandex', providerName: 'yandex', clientId: '', isEnabled: false }
|
||||||
|
]
|
||||||
|
).map((provider) => (
|
||||||
<div key={provider.providerName} className="rounded-[24px] bg-[#f4f5f8] p-5">
|
<div key={provider.providerName} className="rounded-[24px] bg-[#f4f5f8] p-5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
{provider.providerName === 'google' ? <Chrome className="h-7 w-7" /> : <div className="flex h-8 w-8 items-center justify-center rounded-full bg-red-500 font-bold text-white">Я</div>}
|
{provider.providerName === 'google' ? (
|
||||||
|
<Chrome className="h-7 w-7" />
|
||||||
|
) : (
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-red-500 font-bold text-white">
|
||||||
|
Я
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<button type="button" aria-label="Переключить провайдера" onClick={() => void toggleProvider(provider)}>
|
<button type="button" aria-label="Переключить провайдера" onClick={() => void toggleProvider(provider)}>
|
||||||
{provider.isEnabled ? <ToggleRight className="h-8 w-8 text-green-600" /> : <ToggleLeft className="h-8 w-8 text-[#a8adbc]" />}
|
{provider.isEnabled ? (
|
||||||
|
<ToggleRight className="h-8 w-8 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<ToggleLeft className="h-8 w-8 text-[#a8adbc]" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<h4 className="mt-5 font-semibold capitalize">{provider.providerName}</h4>
|
<h4 className="mt-5 font-semibold capitalize">{provider.providerName}</h4>
|
||||||
<p className="mt-1 text-sm text-[#667085]">{provider.isEnabled ? 'Провайдер включен глобально.' : 'Провайдер отключен.'}</p>
|
<p className="mt-1 text-sm text-[#667085]">
|
||||||
|
{provider.isEnabled ? 'Провайдер включен глобально.' : 'Провайдер отключен.'}
|
||||||
|
</p>
|
||||||
<Input
|
<Input
|
||||||
className="mt-4 bg-white"
|
className="mt-4 bg-white"
|
||||||
value={provider.clientId}
|
value={provider.clientId}
|
||||||
placeholder="Client ID"
|
placeholder="Client ID"
|
||||||
onChange={(event) => setProviders((current) => current.map((item) => (item.providerName === provider.providerName ? { ...item, clientId: event.target.value } : item)))}
|
onChange={(event) =>
|
||||||
|
setProviders((current) =>
|
||||||
|
current.map((item) =>
|
||||||
|
item.providerName === provider.providerName ? { ...item, clientId: event.target.value } : item
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
className="mt-3"
|
className="mt-3"
|
||||||
variant="secondary"
|
disabled={savingOAuthProvider === provider.providerName}
|
||||||
onClick={() =>
|
onClick={() => void saveOAuthProvider(provider)}
|
||||||
void apiFetch('/admin/settings/oauth/providers', { method: 'PUT', body: JSON.stringify(toProviderPayload(provider)) }, token)
|
|
||||||
.then(() => showToast('Провайдер сохранён'))
|
|
||||||
.catch((error) => showToast(error instanceof Error ? error.message : 'Ошибка сохранения'))
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
Сохранить провайдера
|
{savingOAuthProvider === provider.providerName ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Сохранить блок
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,16 +1,27 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Ban, Crown, FileText, KeyRound, Loader2, Search, ShieldPlus, UserCog } from 'lucide-react';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Ban, BadgeCheck, Crown, FileText, KeyRound, Loader2, MoreVertical, ScrollText, Search, ShieldOff, ShieldPlus, UserCheck, UserCog } from 'lucide-react';
|
||||||
|
import { UserInspectorDialog } from '@/components/admin/user-inspector-dialog';
|
||||||
|
import { VerificationBadge } from '@/components/id/verification-badge';
|
||||||
import { UserDocumentsDialog } from '@/components/documents/user-documents-dialog';
|
import { UserDocumentsDialog } from '@/components/documents/user-documents-dialog';
|
||||||
import { AdminShell } from '@/components/id/admin-shell';
|
import { AdminShell } from '@/components/id/admin-shell';
|
||||||
import { useAuth } from '@/components/id/auth-provider';
|
import { useAuth } from '@/components/id/auth-provider';
|
||||||
import { useToast } from '@/components/id/toast-provider';
|
import { useToast } from '@/components/id/toast-provider';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
import { Table, TableBody, TableCell, TableContainer, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||||
import { AdminRole, AdminUser, apiFetch } from '@/lib/api';
|
import { AdminPermission, AdminRole, AdminUser, PublicUser, adminDisableUserTotp, apiFetch, fetchUserTotpStatus, getApiErrorMessage } from '@/lib/api';
|
||||||
|
import { getAdminLandingPath } from '@/lib/admin-access';
|
||||||
|
import { DEFAULT_VERIFICATION_ICON, VERIFICATION_ICON_OPTIONS } from '@/lib/verification-icons';
|
||||||
|
|
||||||
const statusLabels: Record<string, string> = {
|
const statusLabels: Record<string, string> = {
|
||||||
ACTIVE: 'Активен',
|
ACTIVE: 'Активен',
|
||||||
@@ -19,24 +30,139 @@ const statusLabels: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const roleLabels: Record<string, string> = {
|
const roleLabels: Record<string, string> = {
|
||||||
|
user: 'Пользователь',
|
||||||
|
bot: 'Бот',
|
||||||
admin: 'Администратор',
|
admin: 'Администратор',
|
||||||
moderator: 'Модератор',
|
moderator: 'Модератор',
|
||||||
manager: 'Менеджер',
|
manager: 'Менеджер',
|
||||||
'super-admin': 'Супер-админ'
|
'super-admin': 'Супер-админ'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DEFAULT_USER_ROLE = 'user';
|
||||||
|
|
||||||
|
function UserActionsMenu({
|
||||||
|
user,
|
||||||
|
currentUser,
|
||||||
|
actionLoading,
|
||||||
|
onOpenInspector,
|
||||||
|
onResetPassword,
|
||||||
|
onSuspend,
|
||||||
|
onUnsuspend,
|
||||||
|
onOpenDocuments,
|
||||||
|
onDisableTotp,
|
||||||
|
onOpenVerification,
|
||||||
|
onOpenRoles,
|
||||||
|
onToggleSuperAdmin
|
||||||
|
}: {
|
||||||
|
user: AdminUser;
|
||||||
|
currentUser: PublicUser | null;
|
||||||
|
actionLoading: boolean;
|
||||||
|
onOpenInspector: () => void;
|
||||||
|
onResetPassword: () => void;
|
||||||
|
onSuspend: () => void;
|
||||||
|
onUnsuspend: () => void;
|
||||||
|
onOpenDocuments: () => void;
|
||||||
|
onDisableTotp: () => void;
|
||||||
|
onOpenVerification: () => void;
|
||||||
|
onOpenRoles: () => void;
|
||||||
|
onToggleSuperAdmin: () => void;
|
||||||
|
}) {
|
||||||
|
const canInspect = (currentUser?.canViewUsers || currentUser?.canManageUsers) && !user.isBot;
|
||||||
|
const canManage = Boolean(currentUser?.canManageUsers);
|
||||||
|
const canDocuments = Boolean(currentUser?.canViewUserDocuments && !user.isBot);
|
||||||
|
const canDisableTotp = Boolean(currentUser?.isSuperAdmin && !user.isBot);
|
||||||
|
const canVerify = Boolean(currentUser?.canVerifyUsers);
|
||||||
|
const canRoles = Boolean(currentUser?.canManageRoles);
|
||||||
|
|
||||||
|
if (!canInspect && !canManage && !canDocuments && !canDisableTotp && !canVerify && !canRoles) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="secondary" size="icon" aria-label="Действия с пользователем" disabled={actionLoading}>
|
||||||
|
<MoreVertical className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-56 rounded-xl">
|
||||||
|
{canInspect ? (
|
||||||
|
<DropdownMenuItem className="gap-2 rounded-lg" onClick={onOpenInspector}>
|
||||||
|
<ScrollText className="h-4 w-4" />
|
||||||
|
Журнал и чаты
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
{canManage ? (
|
||||||
|
<>
|
||||||
|
<DropdownMenuItem className="gap-2 rounded-lg" onClick={onResetPassword}>
|
||||||
|
<KeyRound className="h-4 w-4" />
|
||||||
|
Сбросить пароль
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem className="gap-2 rounded-lg" disabled={user.status === 'SUSPENDED'} onClick={onSuspend}>
|
||||||
|
<Ban className="h-4 w-4" />
|
||||||
|
Заблокировать
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem className="gap-2 rounded-lg" disabled={user.status !== 'SUSPENDED'} onClick={onUnsuspend}>
|
||||||
|
<UserCheck className="h-4 w-4" />
|
||||||
|
Разблокировать
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{canDocuments ? (
|
||||||
|
<DropdownMenuItem className="gap-2 rounded-lg" onClick={onOpenDocuments}>
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
Документы
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
{canDisableTotp ? (
|
||||||
|
<DropdownMenuItem className="gap-2 rounded-lg" onClick={onDisableTotp}>
|
||||||
|
<ShieldOff className="h-4 w-4" />
|
||||||
|
Отключить 2FA
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
{canVerify ? (
|
||||||
|
<DropdownMenuItem className="gap-2 rounded-lg" onClick={onOpenVerification}>
|
||||||
|
<BadgeCheck className="h-4 w-4" />
|
||||||
|
{user.isVerified ? 'Изменить верификацию' : 'Верифицировать'}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
{canRoles ? (
|
||||||
|
<>
|
||||||
|
<DropdownMenuItem className="gap-2 rounded-lg" onClick={onOpenRoles}>
|
||||||
|
<UserCog className="h-4 w-4" />
|
||||||
|
Роли и доступ
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="gap-2 rounded-lg"
|
||||||
|
disabled={user.id === currentUser?.id}
|
||||||
|
onClick={onToggleSuperAdmin}
|
||||||
|
>
|
||||||
|
<Crown className="h-4 w-4" />
|
||||||
|
{user.isSuperAdmin ? 'Снять супер-админа' : 'Назначить супер-админом'}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function AdminUsersPage() {
|
export default function AdminUsersPage() {
|
||||||
|
const router = useRouter();
|
||||||
const { token, user: currentUser } = useAuth();
|
const { token, user: currentUser } = useAuth();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||||
const [roles, setRoles] = useState<AdminRole[]>([]);
|
const [roles, setRoles] = useState<AdminRole[]>([]);
|
||||||
|
const [permissions, setPermissions] = useState<AdminPermission[]>([]);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null);
|
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null);
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [actionLoading, setActionLoading] = useState(false);
|
const [actionLoading, setActionLoading] = useState(false);
|
||||||
const [dialog, setDialog] = useState<'password' | 'roles' | null>(null);
|
const [dialog, setDialog] = useState<'password' | 'roles' | 'verification' | null>(null);
|
||||||
const [documentsUser, setDocumentsUser] = useState<AdminUser | null>(null);
|
const [documentsUser, setDocumentsUser] = useState<AdminUser | null>(null);
|
||||||
|
const [inspectorUser, setInspectorUser] = useState<AdminUser | null>(null);
|
||||||
|
const [verificationIcon, setVerificationIcon] = useState(DEFAULT_VERIFICATION_ICON);
|
||||||
|
|
||||||
const loadUsers = useCallback(async () => {
|
const loadUsers = useCallback(async () => {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
@@ -45,24 +171,47 @@ export default function AdminUsersPage() {
|
|||||||
const response = await apiFetch<{ users: AdminUser[] }>(`/admin/users${search ? `?search=${encodeURIComponent(search)}` : ''}`, {}, token);
|
const response = await apiFetch<{ users: AdminUser[] }>(`/admin/users${search ? `?search=${encodeURIComponent(search)}` : ''}`, {}, token);
|
||||||
setUsers(response.users ?? []);
|
setUsers(response.users ?? []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(error instanceof Error ? error.message : 'Не удалось загрузить пользователей');
|
const message = getApiErrorMessage(error, 'Не удалось загрузить пользователей');
|
||||||
|
if (message) showToast(message);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [search, showToast, token]);
|
}, [search, showToast, token]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!currentUser) return;
|
||||||
|
if (!currentUser.canViewUsers && !currentUser.canManageUsers) {
|
||||||
|
router.replace(getAdminLandingPath(currentUser));
|
||||||
|
}
|
||||||
|
}, [currentUser, router]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentUser?.canViewUsers && !currentUser?.canManageUsers) return;
|
||||||
void loadUsers();
|
void loadUsers();
|
||||||
}, [loadUsers]);
|
}, [currentUser?.canManageUsers, currentUser?.canViewUsers, loadUsers]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token || !currentUser?.canManageRoles) return;
|
if (!token || !currentUser?.canManageRoles) return;
|
||||||
apiFetch<{ roles: AdminRole[] }>('/admin/rbac/roles', {}, token)
|
Promise.all([
|
||||||
.then((response) => setRoles(response.roles ?? []))
|
apiFetch<{ roles: AdminRole[] }>('/admin/rbac/roles', {}, token),
|
||||||
|
apiFetch<{ permissions: AdminPermission[] }>('/admin/rbac/permissions', {}, token)
|
||||||
|
])
|
||||||
|
.then(([rolesResponse, permissionsResponse]) => {
|
||||||
|
setRoles(rolesResponse.roles ?? []);
|
||||||
|
setPermissions(permissionsResponse.permissions ?? []);
|
||||||
|
})
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
}, [currentUser?.canManageRoles, token]);
|
}, [currentUser?.canManageRoles, token]);
|
||||||
|
|
||||||
const assignableRoles = useMemo(() => roles.filter((role) => role.slug !== 'super-admin'), [roles]);
|
const assignableRoles = useMemo(
|
||||||
|
() => roles.filter((role) => role.slug !== 'super-admin' && !role.isDefault),
|
||||||
|
[roles]
|
||||||
|
);
|
||||||
|
|
||||||
|
const permissionLabelBySlug = useMemo(
|
||||||
|
() => Object.fromEntries(permissions.map((permission) => [permission.slug, permission.name])),
|
||||||
|
[permissions]
|
||||||
|
);
|
||||||
|
|
||||||
async function handleSuspend(user: AdminUser) {
|
async function handleSuspend(user: AdminUser) {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
@@ -78,6 +227,23 @@ export default function AdminUsersPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleUnsuspend(user: AdminUser) {
|
||||||
|
if (!token) return;
|
||||||
|
setActionLoading(true);
|
||||||
|
try {
|
||||||
|
await apiFetch(`/admin/users/${user.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ status: 'ACTIVE' })
|
||||||
|
}, token);
|
||||||
|
showToast('Пользователь разблокирован');
|
||||||
|
await loadUsers();
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error instanceof Error ? error.message : 'Не удалось разблокировать пользователя');
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleResetPassword() {
|
async function handleResetPassword() {
|
||||||
if (!token || !selectedUser || password.length < 8) return;
|
if (!token || !selectedUser || password.length < 8) return;
|
||||||
setActionLoading(true);
|
setActionLoading(true);
|
||||||
@@ -143,11 +309,122 @@ export default function AdminUsersPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleAssignPermission(permissionSlug: string) {
|
||||||
|
if (!token || !selectedUser) return;
|
||||||
|
setActionLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await apiFetch<{ permissions: string[] }>(`/admin/rbac/users/${selectedUser.id}/permissions`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ permissionSlug })
|
||||||
|
}, token);
|
||||||
|
setSelectedUser({ ...selectedUser, directPermissions: response.permissions ?? [] });
|
||||||
|
showToast('Право назначено');
|
||||||
|
await loadUsers();
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error instanceof Error ? error.message : 'Не удалось назначить право');
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemovePermission(permissionSlug: string) {
|
||||||
|
if (!token || !selectedUser) return;
|
||||||
|
setActionLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await apiFetch<{ permissions: string[] }>(`/admin/rbac/users/${selectedUser.id}/permissions/${permissionSlug}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
}, token);
|
||||||
|
setSelectedUser({ ...selectedUser, directPermissions: response.permissions ?? [] });
|
||||||
|
showToast('Право снято');
|
||||||
|
await loadUsers();
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error instanceof Error ? error.message : 'Не удалось снять право');
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveVerification() {
|
||||||
|
if (!token || !selectedUser) return;
|
||||||
|
setActionLoading(true);
|
||||||
|
try {
|
||||||
|
const updated = await apiFetch<AdminUser>(`/admin/users/${selectedUser.id}/verification`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({
|
||||||
|
isVerified: true,
|
||||||
|
verificationIcon
|
||||||
|
})
|
||||||
|
}, token);
|
||||||
|
showToast('Пользователь верифицирован');
|
||||||
|
setSelectedUser(updated);
|
||||||
|
setDialog(null);
|
||||||
|
await loadUsers();
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error instanceof Error ? error.message : 'Не удалось верифицировать пользователя');
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemoveVerification() {
|
||||||
|
if (!token || !selectedUser) return;
|
||||||
|
setActionLoading(true);
|
||||||
|
try {
|
||||||
|
await apiFetch<AdminUser>(`/admin/users/${selectedUser.id}/verification`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ isVerified: false })
|
||||||
|
}, token);
|
||||||
|
showToast('Верификация снята');
|
||||||
|
setSelectedUser({ ...selectedUser, isVerified: false, verificationIcon: undefined });
|
||||||
|
setDialog(null);
|
||||||
|
await loadUsers();
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error instanceof Error ? error.message : 'Не удалось снять верификацию');
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openVerificationDialog(user: AdminUser) {
|
||||||
|
setSelectedUser(user);
|
||||||
|
setVerificationIcon(user.verificationIcon ?? DEFAULT_VERIFICATION_ICON);
|
||||||
|
setDialog('verification');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAdminDisableTotp(user: AdminUser) {
|
||||||
|
if (!token || !currentUser?.isSuperAdmin) return;
|
||||||
|
setActionLoading(true);
|
||||||
|
try {
|
||||||
|
const status = await fetchUserTotpStatus(user.id, token);
|
||||||
|
if (!status.isEnabled) {
|
||||||
|
showToast('У пользователя не включена двухфакторная аутентификация');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const confirmed = window.confirm(`Отключить 2FA для ${user.displayName}? Пользователю не потребуется код из приложения-аутентификатора.`);
|
||||||
|
if (!confirmed) return;
|
||||||
|
await adminDisableUserTotp(user.id, token);
|
||||||
|
showToast('Двухфакторная аутентификация отключена');
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error instanceof Error ? error.message : 'Не удалось отключить 2FA');
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderRoles(user: AdminUser) {
|
function renderRoles(user: AdminUser) {
|
||||||
|
if (user.isBot) {
|
||||||
|
return roleLabels.bot;
|
||||||
|
}
|
||||||
const userRoles = user.roles ?? [];
|
const userRoles = user.roles ?? [];
|
||||||
const labels = user.isSuperAdmin ? ['Супер-админ', ...userRoles.map((role) => roleLabels[role] ?? role)] : userRoles.map((role) => roleLabels[role] ?? role);
|
const extraRoles = userRoles.filter((role) => role !== DEFAULT_USER_ROLE && role !== 'bot');
|
||||||
if (!labels.length) return 'Пользователь';
|
const direct = user.directPermissions ?? [];
|
||||||
return labels.join(', ');
|
const labels = user.isSuperAdmin
|
||||||
|
? ['Супер-админ', ...extraRoles.map((role) => roleLabels[role] ?? role)]
|
||||||
|
: extraRoles.map((role) => roleLabels[role] ?? role);
|
||||||
|
const directLabels = direct.map((slug) => permissionLabelBySlug[slug] ?? slug);
|
||||||
|
const parts = [...labels, ...directLabels.map((label) => `+ ${label}`)];
|
||||||
|
if (!parts.length) return roleLabels.user;
|
||||||
|
return parts.join(', ');
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -163,7 +440,7 @@ export default function AdminUsersPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-hidden rounded-[24px] border border-[#eceef4] bg-white">
|
<TableContainer className="rounded-[24px] border border-[#eceef4] bg-white">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center gap-2 py-16 text-[#667085]">
|
<div className="flex items-center justify-center gap-2 py-16 text-[#667085]">
|
||||||
<Loader2 className="h-5 w-5 animate-spin" />
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
@@ -182,9 +459,19 @@ export default function AdminUsersPage() {
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{users.map((user) => (
|
{users.map((user) => (
|
||||||
<TableRow key={user.id}>
|
<TableRow
|
||||||
|
key={user.id}
|
||||||
|
className={user.isBot ? undefined : 'cursor-pointer hover:bg-[#fafbff]'}
|
||||||
|
onClick={() => {
|
||||||
|
if (user.isBot) return;
|
||||||
|
setInspectorUser(user);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="font-medium">{user.displayName}</div>
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">{user.displayName}</span>
|
||||||
|
{user.isVerified ? <VerificationBadge verificationIcon={user.verificationIcon} size="xs" /> : null}
|
||||||
|
</div>
|
||||||
<div className="text-sm text-[#667085]">{user.email ?? user.username ?? '—'}</div>
|
<div className="text-sm text-[#667085]">{user.email ?? user.username ?? '—'}</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{user.phone ?? '—'}</TableCell>
|
<TableCell>{user.phone ?? '—'}</TableCell>
|
||||||
@@ -193,63 +480,28 @@ export default function AdminUsersPage() {
|
|||||||
<span className="rounded-full bg-[#f4f5f8] px-3 py-1 text-xs font-semibold">{statusLabels[user.status] ?? user.status}</span>
|
<span className="rounded-full bg-[#f4f5f8] px-3 py-1 text-xs font-semibold">{statusLabels[user.status] ?? user.status}</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end" onClick={(event) => event.stopPropagation()}>
|
||||||
{currentUser?.canManageUsers ? (
|
<UserActionsMenu
|
||||||
<>
|
user={user}
|
||||||
<Button
|
currentUser={currentUser}
|
||||||
variant="secondary"
|
actionLoading={actionLoading}
|
||||||
size="icon"
|
onOpenInspector={() => setInspectorUser(user)}
|
||||||
aria-label="Сбросить пароль"
|
onResetPassword={() => {
|
||||||
disabled={actionLoading}
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedUser(user);
|
setSelectedUser(user);
|
||||||
setPassword('');
|
setPassword('');
|
||||||
setDialog('password');
|
setDialog('password');
|
||||||
}}
|
}}
|
||||||
>
|
onSuspend={() => void handleSuspend(user)}
|
||||||
<KeyRound className="h-4 w-4" />
|
onUnsuspend={() => void handleUnsuspend(user)}
|
||||||
</Button>
|
onOpenDocuments={() => setDocumentsUser(user)}
|
||||||
<Button variant="secondary" size="icon" aria-label="Заблокировать" disabled={actionLoading || user.status === 'SUSPENDED'} onClick={() => void handleSuspend(user)}>
|
onDisableTotp={() => void handleAdminDisableTotp(user)}
|
||||||
<Ban className="h-4 w-4" />
|
onOpenVerification={() => openVerificationDialog(user)}
|
||||||
</Button>
|
onOpenRoles={() => {
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
{currentUser?.canViewUserDocuments ? (
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="icon"
|
|
||||||
aria-label="Документы пользователя"
|
|
||||||
disabled={actionLoading}
|
|
||||||
onClick={() => setDocumentsUser(user)}
|
|
||||||
>
|
|
||||||
<FileText className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
{currentUser?.canManageRoles ? (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="icon"
|
|
||||||
aria-label="Управление ролями"
|
|
||||||
disabled={actionLoading}
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedUser(user);
|
setSelectedUser(user);
|
||||||
setDialog('roles');
|
setDialog('roles');
|
||||||
}}
|
}}
|
||||||
>
|
onToggleSuperAdmin={() => void handleToggleSuperAdmin(user)}
|
||||||
<UserCog className="h-4 w-4" />
|
/>
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={user.isSuperAdmin ? 'default' : 'secondary'}
|
|
||||||
size="icon"
|
|
||||||
aria-label="Супер-админ"
|
|
||||||
disabled={actionLoading || user.id === currentUser?.id}
|
|
||||||
onClick={() => void handleToggleSuperAdmin(user)}
|
|
||||||
>
|
|
||||||
<Crown className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -257,7 +509,7 @@ export default function AdminUsersPage() {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
)}
|
)}
|
||||||
</div>
|
</TableContainer>
|
||||||
|
|
||||||
<Dialog open={dialog === 'password'} onOpenChange={(open) => !open && setDialog(null)}>
|
<Dialog open={dialog === 'password'} onOpenChange={(open) => !open && setDialog(null)}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
@@ -281,10 +533,14 @@ export default function AdminUsersPage() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{(selectedUser?.roles ?? []).map((role) => (
|
{(selectedUser?.roles ?? []).map((role) => (
|
||||||
<div key={role} className="flex items-center justify-between rounded-xl bg-[#f4f5f8] px-3 py-2">
|
<div key={role} className="flex items-center justify-between rounded-xl bg-[#f4f5f8] px-3 py-2">
|
||||||
<span>{roleLabels[role] ?? role}</span>
|
<span>{roleLabels[role] ?? role}{role === DEFAULT_USER_ROLE ? ' (стандартная)' : ''}</span>
|
||||||
|
{role === DEFAULT_USER_ROLE ? (
|
||||||
|
<span className="text-xs text-[#667085]">Нельзя снять</span>
|
||||||
|
) : (
|
||||||
<Button variant="ghost" size="sm" disabled={actionLoading} onClick={() => void handleRemoveRole(role)}>
|
<Button variant="ghost" size="sm" disabled={actionLoading} onClick={() => void handleRemoveRole(role)}>
|
||||||
Снять
|
Снять
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{!(selectedUser?.roles ?? []).length ? <p className="text-sm text-[#667085]">Роли не назначены</p> : null}
|
{!(selectedUser?.roles ?? []).length ? <p className="text-sm text-[#667085]">Роли не назначены</p> : null}
|
||||||
@@ -304,6 +560,80 @@ export default function AdminUsersPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-6 space-y-2 border-t border-[#eceef4] pt-4">
|
||||||
|
<p className="text-sm font-medium">Прямые права</p>
|
||||||
|
<p className="text-xs text-[#667085]">Дополнительные permissions без смены роли. Например, oauth.manage для одного пользователя.</p>
|
||||||
|
{(selectedUser?.directPermissions ?? []).map((permissionSlug) => (
|
||||||
|
<div key={permissionSlug} className="flex items-center justify-between rounded-xl bg-[#eef4ff] px-3 py-2">
|
||||||
|
<span className="text-sm">{permissionLabelBySlug[permissionSlug] ?? permissionSlug}</span>
|
||||||
|
<Button variant="ghost" size="sm" disabled={actionLoading} onClick={() => void handleRemovePermission(permissionSlug)}>
|
||||||
|
Снять
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!(selectedUser?.directPermissions ?? []).length ? (
|
||||||
|
<p className="text-sm text-[#667085]">Прямые права не назначены</p>
|
||||||
|
) : null}
|
||||||
|
<div className="max-h-48 space-y-1 overflow-y-auto pt-2">
|
||||||
|
{permissions
|
||||||
|
.filter((permission) => !(selectedUser?.directPermissions ?? []).includes(permission.slug))
|
||||||
|
.map((permission) => (
|
||||||
|
<Button
|
||||||
|
key={permission.id}
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="w-full justify-start"
|
||||||
|
disabled={actionLoading}
|
||||||
|
onClick={() => void handleAssignPermission(permission.slug)}
|
||||||
|
>
|
||||||
|
+ {permission.name}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={dialog === 'verification'} onOpenChange={(open) => !open && setDialog(null)}>
|
||||||
|
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Верификация пользователя</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<p className="mb-4 text-sm text-[#667085]">{selectedUser?.displayName}</p>
|
||||||
|
{selectedUser?.isVerified ? (
|
||||||
|
<div className="mb-4 flex items-center gap-2 rounded-xl bg-[#eef4ff] px-3 py-2 text-sm">
|
||||||
|
<VerificationBadge verificationIcon={selectedUser.verificationIcon} size="sm" />
|
||||||
|
<span>Пользователь уже верифицирован</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm font-medium">Выберите значок</p>
|
||||||
|
<div className="grid grid-cols-5 gap-2">
|
||||||
|
{VERIFICATION_ICON_OPTIONS.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.slug}
|
||||||
|
type="button"
|
||||||
|
className={`flex flex-col items-center gap-1 rounded-xl border px-2 py-3 text-xs transition ${
|
||||||
|
verificationIcon === option.slug ? 'border-[#3390ec] bg-[#eef4ff]' : 'border-[#eceef4] bg-[#fafbfd] hover:bg-[#f4f5f8]'
|
||||||
|
}`}
|
||||||
|
onClick={() => setVerificationIcon(option.slug)}
|
||||||
|
>
|
||||||
|
<option.Icon className="h-5 w-5 text-[#3390ec]" />
|
||||||
|
<span className="text-center leading-tight">{option.name}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex flex-col gap-2">
|
||||||
|
<Button className="w-full" disabled={actionLoading} onClick={() => void handleSaveVerification()}>
|
||||||
|
{actionLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : selectedUser?.isVerified ? 'Обновить значок' : 'Верифицировать'}
|
||||||
|
</Button>
|
||||||
|
{selectedUser?.isVerified ? (
|
||||||
|
<Button variant="secondary" className="w-full text-red-600" disabled={actionLoading} onClick={() => void handleRemoveVerification()}>
|
||||||
|
Снять верификацию
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
@@ -318,6 +648,16 @@ export default function AdminUsersPage() {
|
|||||||
token={token}
|
token={token}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<UserInspectorDialog
|
||||||
|
user={inspectorUser}
|
||||||
|
token={token}
|
||||||
|
canModerateChats={Boolean(currentUser?.canModerateChats || currentUser?.isSuperAdmin)}
|
||||||
|
open={Boolean(inspectorUser)}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setInspectorUser(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</AdminShell>
|
</AdminShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
279
apps/frontend/app/auth/oauth/authorize/page.tsx
Normal file
279
apps/frontend/app/auth/oauth/authorize/page.tsx
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import { CheckCircle2, Loader2, ShieldCheck } from 'lucide-react';
|
||||||
|
import { BrandLogo } from '@/components/id/brand-logo';
|
||||||
|
import { useAuth } from '@/components/id/auth-provider';
|
||||||
|
import { usePublicSettings } from '@/components/id/public-settings-provider';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
approveOAuthAuthorization,
|
||||||
|
checkOAuthConsent,
|
||||||
|
fetchOAuthClientPublicInfo,
|
||||||
|
getApiErrorMessage,
|
||||||
|
isGatewayUnavailableError,
|
||||||
|
resetGatewayCircuit,
|
||||||
|
type OAuthConsentCheckResponse
|
||||||
|
} from '@/lib/api';
|
||||||
|
import { deliverOAuthPopupResult, parsePopupOAuthParams, postOneTapResult } from '@/lib/oauth-popup-bridge';
|
||||||
|
|
||||||
|
function buildOAuthQuery(searchParams: URLSearchParams) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
searchParams.forEach((value, key) => {
|
||||||
|
if (key !== 'userId') params.set(key, value);
|
||||||
|
});
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
function OAuthAuthorizeContent() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const { user, token, isPinLocked, isLoading } = useAuth();
|
||||||
|
const { projectName } = usePublicSettings();
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [checkingConsent, setCheckingConsent] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [consentInfo, setConsentInfo] = useState<OAuthConsentCheckResponse | null>(null);
|
||||||
|
const [clientName, setClientName] = useState<string | null>(null);
|
||||||
|
const autoApproveStartedRef = useRef(false);
|
||||||
|
|
||||||
|
const oauthQuery = useMemo(() => buildOAuthQuery(searchParams), [searchParams]);
|
||||||
|
const popupContext = useMemo(() => parsePopupOAuthParams(searchParams), [searchParams]);
|
||||||
|
const isPopupMode = Boolean(popupContext);
|
||||||
|
|
||||||
|
const clientId = searchParams.get('client_id') ?? searchParams.get('clientId');
|
||||||
|
const redirectUri = searchParams.get('redirect_uri') ?? searchParams.get('redirectUri');
|
||||||
|
const scope = searchParams.get('scope') ?? 'openid profile';
|
||||||
|
const state = searchParams.get('state');
|
||||||
|
|
||||||
|
const returnUrl = useMemo(() => `/auth/oauth/authorize?${oauthQuery.toString()}`, [oauthQuery]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
resetGatewayCircuit();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clientLabel = clientName ?? consentInfo?.client?.name ?? 'Приложение';
|
||||||
|
|
||||||
|
const approve = useCallback(async () => {
|
||||||
|
if (!token || !user || isPinLocked) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await approveOAuthAuthorization(oauthQuery, token);
|
||||||
|
if (!data.redirectUrl) {
|
||||||
|
throw new Error('Сервер не вернул redirect URL');
|
||||||
|
}
|
||||||
|
if (deliverOAuthPopupResult(popupContext, data.redirectUrl) === 'popup') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.href = data.redirectUrl;
|
||||||
|
} catch (err) {
|
||||||
|
const message = getApiErrorMessage(err, 'Ошибка OAuth авторизации');
|
||||||
|
setError(
|
||||||
|
message ??
|
||||||
|
(isGatewayUnavailableError(err)
|
||||||
|
? 'Сервер API временно недоступен. Подождите несколько секунд и нажмите «Разрешить» снова.'
|
||||||
|
: 'Ошибка OAuth авторизации')
|
||||||
|
);
|
||||||
|
autoApproveStartedRef.current = false;
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}, [isPinLocked, oauthQuery, popupContext, token, user]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!searchParams.has('userId')) return;
|
||||||
|
router.replace(returnUrl);
|
||||||
|
}, [returnUrl, router, searchParams]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoading) return;
|
||||||
|
if (!clientId || !redirectUri) return;
|
||||||
|
if (isPinLocked) return;
|
||||||
|
if (user && token) return;
|
||||||
|
router.replace(`/auth/login?redirect=${encodeURIComponent(returnUrl)}`);
|
||||||
|
}, [clientId, isLoading, isPinLocked, redirectUri, returnUrl, router, token, user]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoading || !token || !clientId || isPinLocked) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
setCheckingConsent(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
const info = await fetchOAuthClientPublicInfo(clientId);
|
||||||
|
if (!cancelled && info.name.trim()) {
|
||||||
|
setClientName(info.name.trim());
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// название приложения необязательно для consent
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await checkOAuthConsent(oauthQuery, token);
|
||||||
|
if (cancelled) return;
|
||||||
|
setConsentInfo(result);
|
||||||
|
if (result.client?.name?.trim()) {
|
||||||
|
setClientName(result.client.name.trim());
|
||||||
|
}
|
||||||
|
if (result.granted && !autoApproveStartedRef.current) {
|
||||||
|
autoApproveStartedRef.current = true;
|
||||||
|
void approve();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) {
|
||||||
|
const message = getApiErrorMessage(err, 'Не удалось проверить согласие');
|
||||||
|
setError(
|
||||||
|
message ??
|
||||||
|
(isGatewayUnavailableError(err)
|
||||||
|
? 'Сервер API временно недоступен. Подождите несколько секунд — форма обновится автоматически.'
|
||||||
|
: 'Не удалось проверить согласие')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setCheckingConsent(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [approve, clientId, isLoading, isPinLocked, oauthQuery, token]);
|
||||||
|
|
||||||
|
const cancel = useCallback(() => {
|
||||||
|
if (popupContext) {
|
||||||
|
const delivered = postOneTapResult(popupContext.popupOrigin, {
|
||||||
|
error: 'access_denied',
|
||||||
|
errorDescription: 'Пользователь отклонил запрос'
|
||||||
|
});
|
||||||
|
if (delivered) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!redirectUri) {
|
||||||
|
router.push('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const url = new URL(redirectUri);
|
||||||
|
url.searchParams.set('error', 'access_denied');
|
||||||
|
url.searchParams.set('error_description', 'Пользователь отклонил запрос');
|
||||||
|
if (state) url.searchParams.set('state', state);
|
||||||
|
window.location.href = url.toString();
|
||||||
|
} catch {
|
||||||
|
router.push('/');
|
||||||
|
}
|
||||||
|
}, [popupContext, redirectUri, router, state]);
|
||||||
|
|
||||||
|
const authReady = Boolean(user && token && !isPinLocked && !isLoading);
|
||||||
|
const scopeItems = consentInfo?.requestedScopes ?? [];
|
||||||
|
|
||||||
|
if (isLoading || (clientId && redirectUri && !authReady && !isPinLocked)) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[60vh] items-center justify-center">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-[#667085]" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPinLocked) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[60vh] items-center justify-center px-4 text-center">
|
||||||
|
<p className="text-sm text-[#667085]">Подтвердите PIN-код, чтобы продолжить авторизацию приложения.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!clientId || !redirectUri) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-md px-4 py-16 text-center">
|
||||||
|
<h1 className="text-xl font-semibold">Некорректный OAuth запрос</h1>
|
||||||
|
<p className="mt-3 text-sm text-[#667085]">Отсутствуют обязательные параметры client_id и redirect_uri.</p>
|
||||||
|
<Link href="/" className="mt-6 inline-block text-[#3390ec] hover:underline">
|
||||||
|
На главную
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checkingConsent || (consentInfo?.granted && submitting)) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-3 px-4 text-center">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-[#667085]" />
|
||||||
|
<p className="text-sm text-[#667085]">Продолжаем вход в {clientLabel}…</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`mx-auto flex min-h-[70vh] max-w-lg flex-col justify-center px-4 ${isPopupMode ? 'py-6' : 'py-12'}`}>
|
||||||
|
{!isPopupMode ? (
|
||||||
|
<div className="mb-8 flex justify-center">
|
||||||
|
<BrandLogo />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="rounded-[24px] border border-[#eceef4] bg-white p-6 shadow-sm">
|
||||||
|
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-[#eef4ff] text-[#3390ec]">
|
||||||
|
<ShieldCheck className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-semibold">Разрешить доступ?</h1>
|
||||||
|
<p className="mt-2 text-sm leading-relaxed text-[#667085]">
|
||||||
|
Приложение <span className="font-medium text-[#1f2430]">{clientLabel}</span> запрашивает доступ к данным вашего аккаунта {projectName}.
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 space-y-3 rounded-2xl bg-[#f4f5f8] p-4 text-sm">
|
||||||
|
<p>
|
||||||
|
<span className="text-[#667085]">Пользователь:</span> {user?.displayName}
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<p className="text-[#667085]">Запрашиваемые данные:</p>
|
||||||
|
<ul className="mt-2 space-y-2">
|
||||||
|
{scopeItems.length > 0 ? (
|
||||||
|
scopeItems.map((item) => (
|
||||||
|
<li key={item.slug} className="flex items-start gap-2">
|
||||||
|
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-[#3390ec]" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-[#1f2430]">{item.name}</div>
|
||||||
|
{item.description ? <div className="text-xs text-[#667085]">{item.description}</div> : null}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<li className="text-[#667085]">{scope}</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-xs leading-relaxed text-[#667085]">
|
||||||
|
После подтверждения доступ сохранится, и повторно спрашивать не будем. Отозвать его можно в разделе «Данные → Доступы к данным».
|
||||||
|
</p>
|
||||||
|
{error ? <p className="mt-4 text-sm text-red-600">{error}</p> : null}
|
||||||
|
<div className="mt-6 flex flex-col gap-3 sm:flex-row">
|
||||||
|
<Button className="flex-1 rounded-xl" disabled={submitting || !authReady} onClick={() => void approve()}>
|
||||||
|
{submitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||||
|
Разрешить
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" className="flex-1 rounded-xl" disabled={submitting} onClick={cancel}>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OAuthAuthorizePage() {
|
||||||
|
return (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="flex min-h-[60vh] items-center justify-center">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-[#667085]" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<OAuthAuthorizeContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,33 +3,61 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { FormEvent, useState } from 'react';
|
import { FormEvent, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { ShieldCheck } from 'lucide-react';
|
import { ChevronLeft, DoorOpen, UserRound } from 'lucide-react';
|
||||||
import { BrandLogo } from '@/components/id/brand-logo';
|
import { BrandLogo } from '@/components/id/brand-logo';
|
||||||
import { useAuth } from '@/components/id/auth-provider';
|
import { useAuth } from '@/components/id/auth-provider';
|
||||||
import { useToast } from '@/components/id/toast-provider';
|
import { useToast } from '@/components/id/toast-provider';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { PinInput } from '@/components/ui/pin-input';
|
||||||
|
import { isPinInputComplete } from '@/lib/pin-input';
|
||||||
|
import { OtpInput } from '@/components/ui/otp-input';
|
||||||
import { PhoneInput, phoneCountries, toE164 } from '@/components/ui/phone-input';
|
import { PhoneInput, phoneCountries, toE164 } from '@/components/ui/phone-input';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
|
||||||
|
type Step = 'contact' | 'otp' | 'pin';
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { sendLoginOtp } = useAuth();
|
const { sendLoginOtp, verifyLoginOtp, completePin, logout } = useAuth();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [authTab, setAuthTab] = useState<'email' | 'phone'>('email');
|
const [authTab, setAuthTab] = useState<'email' | 'phone'>('email');
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [phoneNumber, setPhoneNumber] = useState('');
|
const [phoneNumber, setPhoneNumber] = useState('');
|
||||||
const [country, setCountry] = useState(phoneCountries[0]);
|
const [country, setCountry] = useState(phoneCountries[0]);
|
||||||
|
const [recipient, setRecipient] = useState('');
|
||||||
|
const [maskedTarget, setMaskedTarget] = useState('');
|
||||||
|
const [otp, setOtp] = useState('');
|
||||||
|
const [pin, setPin] = useState('');
|
||||||
|
const [pendingSessionId, setPendingSessionId] = useState<string | null>(null);
|
||||||
|
const [step, setStep] = useState<Step>('contact');
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
function getIdentifier() {
|
||||||
|
return authTab === 'email' ? email.trim() : toE164(country, phoneNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishRegistration(pinVerified: boolean, sessionId: string) {
|
||||||
|
if (!pinVerified) {
|
||||||
|
setPendingSessionId(sessionId);
|
||||||
|
setStep('pin');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showToast('Добро пожаловать! ID успешно создан.');
|
||||||
|
router.push('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSendCode(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const recipient = authTab === 'email' ? email.trim() : toE164(country, phoneNumber);
|
const identifier = getIdentifier();
|
||||||
await sendLoginOtp(recipient);
|
setRecipient(identifier);
|
||||||
showToast('Код отправлен. Подтвердите его на экране входа.');
|
const response = await sendLoginOtp(identifier);
|
||||||
router.push('/auth/login');
|
setMaskedTarget(response.maskedTarget);
|
||||||
|
setOtp('');
|
||||||
|
setStep('otp');
|
||||||
|
showToast('Код отправлен. Введите его ниже.');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(error instanceof Error ? error.message : 'Не удалось отправить код');
|
showToast(error instanceof Error ? error.message : 'Не удалось отправить код');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -37,15 +65,65 @@ export default function RegisterPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function verifyOtp(code: string) {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
const response = await verifyLoginOtp(recipient, code);
|
||||||
|
if (response.auth) {
|
||||||
|
finishRegistration(response.auth.pinVerified, response.auth.sessionId);
|
||||||
|
} else {
|
||||||
|
showToast('Не удалось завершить регистрацию');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setOtp('');
|
||||||
|
showToast(error instanceof Error ? error.message : 'Неверный код');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePinSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!pendingSessionId) return;
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
await completePin(pendingSessionId, pin);
|
||||||
|
showToast('Добро пожаловать! ID успешно создан.');
|
||||||
|
router.push('/');
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error instanceof Error ? error.message : 'Не удалось проверить PIN-код');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetToContact() {
|
||||||
|
setStep('contact');
|
||||||
|
setOtp('');
|
||||||
|
setPin('');
|
||||||
|
setPendingSessionId(null);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-screen items-center justify-center bg-[radial-gradient(circle_at_center,#5d6578_0%,#2c2736_48%,#111016_100%)] px-5">
|
<main className="flex min-h-screen items-center justify-center bg-[radial-gradient(circle_at_center,#5d6578_0%,#2c2736_48%,#111016_100%)] px-5">
|
||||||
<section className="w-full max-w-[430px] rounded-[34px] bg-[#1f2028] px-9 py-10 text-white shadow-2xl">
|
<section className="w-full max-w-[430px] rounded-[34px] bg-[#1f2028] px-9 py-10 text-white shadow-2xl">
|
||||||
<div className="flex flex-col items-center text-center">
|
<div className="flex flex-col items-center text-center">
|
||||||
<BrandLogo size="lg" variant="light" />
|
<BrandLogo size="lg" variant="light" />
|
||||||
<h1 className="mt-8 text-xl font-bold">Создайте ID</h1>
|
<h1 className="mt-8 text-xl font-bold">Создайте ID</h1>
|
||||||
<p className="mt-2 text-sm text-[#b9bdc9]">Введите почту или телефон. Пароль не нужен.</p>
|
<p className="mt-2 text-sm text-[#b9bdc9]">
|
||||||
|
{step === 'contact' ? 'Введите почту или телефон. Пароль не нужен.' : 'Подтвердите код — аккаунт создастся автоматически.'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<form className="mt-8 space-y-3" onSubmit={handleSubmit}>
|
|
||||||
|
{step !== 'contact' ? (
|
||||||
|
<button type="button" onClick={resetToContact} className="mt-6 flex items-center gap-1 text-sm text-[#b9bdc9] hover:text-white">
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
Изменить почту или телефон
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{step === 'contact' ? (
|
||||||
|
<form className="mt-8 space-y-3" onSubmit={handleSendCode}>
|
||||||
<Tabs value={authTab} onValueChange={(value) => setAuthTab(value as 'email' | 'phone')} className="w-full">
|
<Tabs value={authTab} onValueChange={(value) => setAuthTab(value as 'email' | 'phone')} className="w-full">
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
<TabsTrigger value="email">Почта</TabsTrigger>
|
<TabsTrigger value="email">Почта</TabsTrigger>
|
||||||
@@ -69,13 +147,62 @@ export default function RegisterPage() {
|
|||||||
{isSubmitting ? 'Отправляем...' : 'Получить код'}
|
{isSubmitting ? 'Отправляем...' : 'Получить код'}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
<div className="mt-5 flex items-start gap-3 rounded-[22px] bg-[#2a2c36] p-4 text-sm">
|
) : null}
|
||||||
<ShieldCheck className="mt-0.5 h-5 w-5 text-[#8ec5ff]" />
|
|
||||||
<p>Первый зарегистрированный пользователь автоматически станет суперадминистратором.</p>
|
{step === 'otp' ? (
|
||||||
|
<div className="mt-8">
|
||||||
|
<div className="mb-5 flex items-center gap-3 rounded-[20px] bg-[#2a2c36] p-3">
|
||||||
|
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-white/10">
|
||||||
|
<UserRound className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="min-w-0 text-left">
|
||||||
|
<p className="text-sm text-[#b9bdc9]">Код отправлен на</p>
|
||||||
|
<p className="truncate text-sm font-semibold">{maskedTarget || recipient}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<OtpInput value={otp} onChange={setOtp} onComplete={verifyOtp} disabled={isSubmitting} />
|
||||||
|
{isSubmitting ? <p className="mt-3 text-center text-sm text-[#b9bdc9]">Создаём ваш ID...</p> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{step === 'pin' && pendingSessionId ? (
|
||||||
|
<form onSubmit={handlePinSubmit} className="relative mt-8 space-y-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
logout();
|
||||||
|
setPendingSessionId(null);
|
||||||
|
setPin('');
|
||||||
|
setStep('contact');
|
||||||
|
}}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
title="Выйти из аккаунта"
|
||||||
|
aria-label="Выйти из аккаунта"
|
||||||
|
className="absolute -top-1 right-0 flex h-9 w-9 items-center justify-center rounded-full text-[#b9bdc9] transition hover:bg-white/10 hover:text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<DoorOpen className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<p className="text-center text-sm text-[#b9bdc9]">Установите PIN для завершения регистрации</p>
|
||||||
|
<PinInput
|
||||||
|
className="h-[58px] border-[#555762] bg-transparent text-center text-lg tracking-[0.4em] text-white placeholder:text-[#8f92a0]"
|
||||||
|
placeholder="PIN"
|
||||||
|
value={pin}
|
||||||
|
onChange={setPin}
|
||||||
|
required
|
||||||
|
minLength={4}
|
||||||
|
maxLength={6}
|
||||||
|
/>
|
||||||
|
<Button variant="white" size="lg" className="w-full rounded-[18px] text-base" disabled={isSubmitting || !isPinInputComplete(pin)}>
|
||||||
|
{isSubmitting ? 'Проверяем...' : 'Подтвердить PIN'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{step === 'contact' ? (
|
||||||
<Button asChild variant="ghost" className="mt-5 w-full text-white hover:bg-[#2a2c36]">
|
<Button asChild variant="ghost" className="mt-5 w-full text-white hover:bg-[#2a2c36]">
|
||||||
<Link href="/auth/login">У меня уже есть ID</Link>
|
<Link href="/auth/login">У меня уже есть ID</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
173
apps/frontend/app/data/consents/page.tsx
Normal file
173
apps/frontend/app/data/consents/page.tsx
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Ban, CheckCircle2, ChevronRight, FileKey2, Loader2 } from 'lucide-react';
|
||||||
|
import { useAuth } from '@/components/id/auth-provider';
|
||||||
|
import { IdShell } from '@/components/id/shell';
|
||||||
|
import { usePublicSettings } from '@/components/id/public-settings-provider';
|
||||||
|
import { useToast } from '@/components/id/toast-provider';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import { useRequireAuth } from '@/hooks/use-require-auth';
|
||||||
|
import {
|
||||||
|
fetchUserOAuthConsents,
|
||||||
|
getApiErrorMessage,
|
||||||
|
revokeOAuthConsent,
|
||||||
|
type OAuthUserConsent
|
||||||
|
} from '@/lib/api';
|
||||||
|
|
||||||
|
function formatGrantedAt(value: string) {
|
||||||
|
return new Intl.DateTimeFormat('ru-RU', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
}).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DataConsentsPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { user, token } = useAuth();
|
||||||
|
const { projectName } = usePublicSettings();
|
||||||
|
const { isReady, isPinLocked } = useRequireAuth();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const [consents, setConsents] = useState<OAuthUserConsent[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [selectedConsent, setSelectedConsent] = useState<OAuthUserConsent | null>(null);
|
||||||
|
const [revoking, setRevoking] = useState(false);
|
||||||
|
|
||||||
|
const loadConsents = useCallback(async () => {
|
||||||
|
if (!user || !token || isPinLocked) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetchUserOAuthConsents(user.id, token);
|
||||||
|
setConsents(response.consents ?? []);
|
||||||
|
} catch (error) {
|
||||||
|
showToast(getApiErrorMessage(error, 'Не удалось загрузить доступы') ?? 'Ошибка');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [isPinLocked, showToast, token, user]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isReady && user && !isPinLocked) void loadConsents();
|
||||||
|
}, [isPinLocked, isReady, loadConsents, user]);
|
||||||
|
|
||||||
|
async function handleRevoke() {
|
||||||
|
if (!user || !token || !selectedConsent) return;
|
||||||
|
setRevoking(true);
|
||||||
|
try {
|
||||||
|
await revokeOAuthConsent(user.id, selectedConsent.id, token);
|
||||||
|
setConsents((current) => current.filter((item) => item.id !== selectedConsent.id));
|
||||||
|
setSelectedConsent(null);
|
||||||
|
showToast('Доступ отозван');
|
||||||
|
} catch (error) {
|
||||||
|
showToast(getApiErrorMessage(error, 'Не удалось отозвать доступ') ?? 'Ошибка');
|
||||||
|
} finally {
|
||||||
|
setRevoking(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IdShell active="/data">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.push('/data')}
|
||||||
|
className="mb-6 text-sm text-[#3390ec] transition hover:underline"
|
||||||
|
>
|
||||||
|
← Назад к данным
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<h1 className="text-2xl font-medium">Доступы к данным</h1>
|
||||||
|
<p className="mt-1 text-sm text-[#667085]">
|
||||||
|
Настройте, к каким данным аккаунта есть доступ у сервисов. После отзыва при следующем входе доступ нужно будет подтвердить снова.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-6 overflow-hidden rounded-[24px] bg-[#f4f5f8]">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center gap-2 px-4 py-10 text-sm text-[#667085]">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Загружаем приложения...
|
||||||
|
</div>
|
||||||
|
) : consents.length === 0 ? (
|
||||||
|
<p className="px-4 py-10 text-center text-sm text-[#667085]">Вы ещё не выдавали доступ ни одному приложению</p>
|
||||||
|
) : (
|
||||||
|
consents.map((consent) => (
|
||||||
|
<button
|
||||||
|
key={consent.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedConsent(consent)}
|
||||||
|
className="flex w-full items-center gap-4 border-b border-[#eceef4] px-4 py-4 text-left transition last:border-b-0 hover:bg-[#fafbfd]"
|
||||||
|
>
|
||||||
|
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-white">
|
||||||
|
<FileKey2 className="h-5 w-5 text-[#667085]" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="font-medium">{consent.clientName}</div>
|
||||||
|
<div className="truncate text-sm text-[#667085]">Выданы {formatGrantedAt(consent.grantedAt)}</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="h-5 w-5 shrink-0 text-[#a8adbc]" />
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={Boolean(selectedConsent)} onOpenChange={(open) => !open && setSelectedConsent(null)}>
|
||||||
|
<DialogContent className="rounded-[28px] sm:max-w-[480px]">
|
||||||
|
{selectedConsent ? (
|
||||||
|
<>
|
||||||
|
<DialogHeader>
|
||||||
|
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-[#eef4ff] text-[#3390ec]">
|
||||||
|
<CheckCircle2 className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<DialogTitle className="text-center text-xl">{selectedConsent.clientName}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<p className="text-center text-sm text-[#667085]">
|
||||||
|
Выданы {formatGrantedAt(selectedConsent.grantedAt)}
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 rounded-2xl bg-[#f4f5f8] p-4">
|
||||||
|
<p className="text-sm font-medium">API {projectName}</p>
|
||||||
|
<ul className="mt-3 space-y-2">
|
||||||
|
{selectedConsent.scopes.map((scope) => (
|
||||||
|
<li key={scope.slug} className="flex items-start gap-2 text-sm">
|
||||||
|
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-[#3390ec]" />
|
||||||
|
<div>
|
||||||
|
<div>{scope.description ?? scope.name}</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-center text-xs text-[#667085]">ID: {selectedConsent.id.slice(0, 8).toUpperCase()}</p>
|
||||||
|
<div className="mt-6 space-y-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full rounded-xl"
|
||||||
|
disabled={revoking}
|
||||||
|
onClick={() => void handleRevoke()}
|
||||||
|
>
|
||||||
|
{revoking ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Отзываем...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Ban className="mr-2 h-4 w-4" />
|
||||||
|
Отозвать доступы
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button className="w-full rounded-xl" variant="secondary" onClick={() => setSelectedConsent(null)}>
|
||||||
|
Закрыть
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</IdShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Car, ChevronRight, FileText, Loader2, Mail, Phone, Trash2, UserRound } from 'lucide-react';
|
import { Car, ChevronRight, FileKey2, FileText, Loader2, Mail, Phone, Trash2, UserRound } from 'lucide-react';
|
||||||
import { AddressQuickSection } from '@/components/addresses/address-quick-section';
|
import { AddressQuickSection } from '@/components/addresses/address-quick-section';
|
||||||
import { DocumentFormDialog } from '@/components/documents/document-form-dialog';
|
import { DocumentDialog } from '@/components/documents/document-dialog';
|
||||||
|
import { DocumentPhotosInline } from '@/components/documents/document-photo-gallery';
|
||||||
import { ActionTile } from '@/components/id/action-tile';
|
import { ActionTile } from '@/components/id/action-tile';
|
||||||
import { useAuth } from '@/components/id/auth-provider';
|
import { useAuth } from '@/components/id/auth-provider';
|
||||||
import { IdShell } from '@/components/id/shell';
|
import { IdShell } from '@/components/id/shell';
|
||||||
@@ -13,13 +14,28 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
import { AvatarDisplay, AvatarUpload } from '@/components/id/avatar-upload';
|
import { AvatarDisplay, AvatarUpload } from '@/components/id/avatar-upload';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { DatePicker } from '@/components/ui/date-picker';
|
||||||
|
import { PhoneInput, parseE164Phone, phoneCountries, toE164 } from '@/components/ui/phone-input';
|
||||||
import { useRequireAuth } from '@/hooks/use-require-auth';
|
import { useRequireAuth } from '@/hooks/use-require-auth';
|
||||||
import {
|
import {
|
||||||
getDocumentType,
|
getDocumentType,
|
||||||
indexDocumentsByType,
|
indexDocumentsByType,
|
||||||
|
parseAttachmentMeta,
|
||||||
|
parseDocumentPhotos,
|
||||||
|
parseMetadata,
|
||||||
type DocumentTypeCode
|
type DocumentTypeCode
|
||||||
} from '@/lib/document-catalog';
|
} from '@/lib/document-catalog';
|
||||||
import { apiFetch, getApiErrorMessage, softDeleteProfile, UserDocument } from '@/lib/api';
|
import { apiFetch, getApiErrorMessage, cancelAccountDeletion, fetchAccountDeletionStatus, requestAccountDeletion, type AccountDeletionStatus, UserDocument, UserProfileResponse } from '@/lib/api';
|
||||||
|
|
||||||
|
function formatDeletionDate(value: string) {
|
||||||
|
return new Intl.DateTimeFormat('ru-RU', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
}).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
function Row({
|
function Row({
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
@@ -64,43 +80,80 @@ function Row({
|
|||||||
|
|
||||||
export default function DataPage() {
|
export default function DataPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { user, token, refreshProfile, logout } = useAuth();
|
const { user, token, applyUserPatch } = useAuth();
|
||||||
const { isReady, isPinLocked } = useRequireAuth();
|
const { isReady, isPinLocked } = useRequireAuth();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
|
const userId = user?.id;
|
||||||
const [displayName, setDisplayName] = useState('');
|
const [displayName, setDisplayName] = useState('');
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [backupEmail, setBackupEmail] = useState('');
|
const [backupEmail, setBackupEmail] = useState('');
|
||||||
const [phone, setPhone] = useState('');
|
const [phone, setPhone] = useState('');
|
||||||
const [backupPhone, setBackupPhone] = useState('');
|
const [backupPhone, setBackupPhone] = useState('');
|
||||||
|
const [birthDate, setBirthDate] = useState<string | undefined>();
|
||||||
|
const [phoneCountry, setPhoneCountry] = useState(phoneCountries[0]);
|
||||||
|
const [phoneDigits, setPhoneDigits] = useState('');
|
||||||
|
const [backupPhoneCountry, setBackupPhoneCountry] = useState(phoneCountries[0]);
|
||||||
|
const [backupPhoneDigits, setBackupPhoneDigits] = useState('');
|
||||||
const [documents, setDocuments] = useState<UserDocument[]>([]);
|
const [documents, setDocuments] = useState<UserDocument[]>([]);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [activeDocumentType, setActiveDocumentType] = useState<DocumentTypeCode | null>(null);
|
const [activeDocumentType, setActiveDocumentType] = useState<DocumentTypeCode | null>(null);
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [deletionStatus, setDeletionStatus] = useState<AccountDeletionStatus | null>(null);
|
||||||
|
const [cancellingDeletion, setCancellingDeletion] = useState(false);
|
||||||
|
|
||||||
const loadDocuments = useCallback(async () => {
|
const loadDocuments = useCallback(async () => {
|
||||||
if (!user || !token || isPinLocked) return;
|
if (!userId || !token || isPinLocked) return;
|
||||||
try {
|
try {
|
||||||
const response = await apiFetch<{ documents?: UserDocument[] }>(`/documents/users/${user.id}`, {}, token);
|
const response = await apiFetch<{ documents?: UserDocument[] }>(`/documents/users/${userId}`, {}, token);
|
||||||
setDocuments(response.documents ?? []);
|
setDocuments(response.documents ?? []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = getApiErrorMessage(error, 'Не удалось загрузить документы');
|
const message = getApiErrorMessage(error, 'Не удалось загрузить документы');
|
||||||
if (message) showToast(message);
|
if (message) showToast(message);
|
||||||
}
|
}
|
||||||
}, [isPinLocked, showToast, token, user]);
|
}, [isPinLocked, showToast, token, userId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
setDisplayName(user.displayName);
|
setDisplayName(user.displayName);
|
||||||
setEmail(user.email ?? '');
|
setEmail(user.email ?? '');
|
||||||
setBackupEmail(user.backupEmail ?? '');
|
setBackupEmail(user.backupEmail ?? '');
|
||||||
setPhone(user.phone ?? '');
|
const parsedPhone = parseE164Phone(user.phone ?? '');
|
||||||
setBackupPhone(user.backupPhone ?? '');
|
setPhoneCountry(parsedPhone.country);
|
||||||
|
setPhoneDigits(parsedPhone.digits);
|
||||||
|
setPhone(parsedPhone.digits ? toE164(parsedPhone.country, parsedPhone.digits) : '');
|
||||||
|
const parsedBackupPhone = parseE164Phone(user.backupPhone ?? '');
|
||||||
|
setBackupPhoneCountry(parsedBackupPhone.country);
|
||||||
|
setBackupPhoneDigits(parsedBackupPhone.digits);
|
||||||
|
setBackupPhone(parsedBackupPhone.digits ? toE164(parsedBackupPhone.country, parsedBackupPhone.digits) : '');
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isReady && user && !isPinLocked) void loadDocuments();
|
if (!isReady || !userId || !token || isPinLocked) return;
|
||||||
}, [isPinLocked, isReady, loadDocuments, user]);
|
void apiFetch<UserProfileResponse>(`/profile/users/${userId}`, {}, token)
|
||||||
|
.then((profile) => {
|
||||||
|
if (profile.birthDate) setBirthDate(profile.birthDate);
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
}, [isPinLocked, isReady, token, userId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isReady && userId && !isPinLocked) void loadDocuments();
|
||||||
|
}, [isPinLocked, isReady, loadDocuments, userId]);
|
||||||
|
|
||||||
|
const loadDeletionStatus = useCallback(async () => {
|
||||||
|
if (!userId || !token || isPinLocked) return;
|
||||||
|
try {
|
||||||
|
const status = await fetchAccountDeletionStatus(userId, token);
|
||||||
|
setDeletionStatus(status);
|
||||||
|
} catch {
|
||||||
|
// Фоновый сбой gateway не должен сбрасывать уже показанный статус удаления.
|
||||||
|
}
|
||||||
|
}, [isPinLocked, token, userId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isReady && userId && !isPinLocked) void loadDeletionStatus();
|
||||||
|
}, [isPinLocked, isReady, loadDeletionStatus, userId]);
|
||||||
|
|
||||||
const documentsByType = useMemo(() => indexDocumentsByType(documents), [documents]);
|
const documentsByType = useMemo(() => indexDocumentsByType(documents), [documents]);
|
||||||
|
|
||||||
@@ -116,14 +169,20 @@ export default function DataPage() {
|
|||||||
try {
|
try {
|
||||||
await apiFetch(`/profile/users/${user.id}`, {
|
await apiFetch(`/profile/users/${user.id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify({ firstName: firstName || undefined, lastName: rest.join(' ') || undefined })
|
body: JSON.stringify({
|
||||||
|
firstName: firstName || undefined,
|
||||||
|
lastName: rest.join(' ') || undefined,
|
||||||
|
birthDate: birthDate || undefined
|
||||||
|
})
|
||||||
}, token);
|
}, token);
|
||||||
|
|
||||||
const contacts: Record<string, string> = {};
|
const contacts: Record<string, string> = {};
|
||||||
|
const nextPhone = phoneDigits ? toE164(phoneCountry, phoneDigits) : '';
|
||||||
|
const nextBackupPhone = backupPhoneDigits ? toE164(backupPhoneCountry, backupPhoneDigits) : '';
|
||||||
if (email.trim() && email.trim() !== (user.email ?? '')) contacts.email = email.trim();
|
if (email.trim() && email.trim() !== (user.email ?? '')) contacts.email = email.trim();
|
||||||
if (phone.trim() && phone.trim() !== (user.phone ?? '')) contacts.phone = phone.trim();
|
if (nextPhone && nextPhone !== (user.phone ?? '')) contacts.phone = nextPhone;
|
||||||
if (backupEmail.trim() && backupEmail.trim() !== (user.backupEmail ?? '')) contacts.backupEmail = backupEmail.trim();
|
if (backupEmail.trim() && backupEmail.trim() !== (user.backupEmail ?? '')) contacts.backupEmail = backupEmail.trim();
|
||||||
if (backupPhone.trim() && backupPhone.trim() !== (user.backupPhone ?? '')) contacts.backupPhone = backupPhone.trim();
|
if (nextBackupPhone && nextBackupPhone !== (user.backupPhone ?? '')) contacts.backupPhone = nextBackupPhone;
|
||||||
|
|
||||||
if (Object.keys(contacts).length > 0) {
|
if (Object.keys(contacts).length > 0) {
|
||||||
await apiFetch(`/profile/users/${user.id}/contacts`, {
|
await apiFetch(`/profile/users/${user.id}/contacts`, {
|
||||||
@@ -132,7 +191,13 @@ export default function DataPage() {
|
|||||||
}, token);
|
}, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
await refreshProfile();
|
applyUserPatch({
|
||||||
|
displayName: trimmedName || user.displayName,
|
||||||
|
email: contacts.email ?? user.email,
|
||||||
|
phone: contacts.phone ?? user.phone,
|
||||||
|
backupEmail: contacts.backupEmail ?? user.backupEmail,
|
||||||
|
backupPhone: contacts.backupPhone ?? user.backupPhone
|
||||||
|
});
|
||||||
showToast('Профиль обновлён');
|
showToast('Профиль обновлён');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = getApiErrorMessage(error, 'Не удалось обновить профиль');
|
const message = getApiErrorMessage(error, 'Не удалось обновить профиль');
|
||||||
@@ -146,23 +211,54 @@ export default function DataPage() {
|
|||||||
if (!user || !token) return;
|
if (!user || !token) return;
|
||||||
setIsDeleting(true);
|
setIsDeleting(true);
|
||||||
try {
|
try {
|
||||||
await softDeleteProfile(user.id, token);
|
const response = await requestAccountDeletion(user.id, token);
|
||||||
|
setDeletionStatus(response);
|
||||||
setDeleteDialogOpen(false);
|
setDeleteDialogOpen(false);
|
||||||
showToast('Профиль удалён');
|
showToast(
|
||||||
logout();
|
response.effectiveAt
|
||||||
|
? `Удаление запланировано на ${formatDeletionDate(response.effectiveAt)}`
|
||||||
|
: 'Удаление аккаунта запланировано'
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = getApiErrorMessage(error, 'Не удалось удалить профиль');
|
const message = getApiErrorMessage(error, 'Не удалось запланировать удаление профиля');
|
||||||
if (message) showToast(message);
|
if (message) showToast(message);
|
||||||
} finally {
|
} finally {
|
||||||
setIsDeleting(false);
|
setIsDeleting(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleCancelDeletion() {
|
||||||
|
if (!user || !token) return;
|
||||||
|
setCancellingDeletion(true);
|
||||||
|
try {
|
||||||
|
await cancelAccountDeletion(user.id, token);
|
||||||
|
setDeletionStatus({ pending: false, graceDays: deletionStatus?.graceDays ?? 30 });
|
||||||
|
showToast('Удаление аккаунта отменено');
|
||||||
|
} catch (error) {
|
||||||
|
const message = getApiErrorMessage(error, 'Не удалось отменить удаление');
|
||||||
|
if (message) showToast(message);
|
||||||
|
} finally {
|
||||||
|
setCancellingDeletion(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<IdShell active="/data">
|
<IdShell active="/data">
|
||||||
<div className="mb-8 flex items-center gap-4 rounded-[24px] border border-[#20212b] p-4">
|
<div className="mb-8 flex items-center gap-4 rounded-[24px] border border-[#20212b] p-4">
|
||||||
{user ? (
|
{user ? (
|
||||||
<AvatarUpload userId={user.id} displayName={user.displayName} hasAvatar={user.hasAvatar} token={token} onUpdated={refreshProfile} />
|
<AvatarUpload
|
||||||
|
userId={user.id}
|
||||||
|
displayName={user.displayName}
|
||||||
|
hasAvatar={user.hasAvatar}
|
||||||
|
token={token}
|
||||||
|
isVerified={user.isVerified}
|
||||||
|
verificationIcon={user.verificationIcon}
|
||||||
|
className="h-14 w-14"
|
||||||
|
badgeSize="sm"
|
||||||
|
onUpdated={async () => {
|
||||||
|
applyUserPatch({ hasAvatar: true });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<AvatarDisplay userId="" displayName="" hasAvatar={false} token={null} className="h-14 w-14" />
|
<AvatarDisplay userId="" displayName="" hasAvatar={false} token={null} className="h-14 w-14" />
|
||||||
)}
|
)}
|
||||||
@@ -177,11 +273,31 @@ export default function DataPage() {
|
|||||||
<p className="mt-1 text-sm text-[#667085]">Эти данные помогают быстрее входить в сервисы и восстанавливать доступ.</p>
|
<p className="mt-1 text-sm text-[#667085]">Эти данные помогают быстрее входить в сервисы и восстанавливать доступ.</p>
|
||||||
<div className="mt-5 grid gap-3 sm:grid-cols-2">
|
<div className="mt-5 grid gap-3 sm:grid-cols-2">
|
||||||
<Input placeholder="ФИО" value={displayName} onChange={(event) => setDisplayName(event.target.value)} />
|
<Input placeholder="ФИО" value={displayName} onChange={(event) => setDisplayName(event.target.value)} />
|
||||||
<Input placeholder="Дата рождения" />
|
<DatePicker value={birthDate} onChange={setBirthDate} />
|
||||||
<Input placeholder="Основная почта" value={email} onChange={(event) => setEmail(event.target.value)} />
|
<Input placeholder="Основная почта" value={email} onChange={(event) => setEmail(event.target.value)} />
|
||||||
<Input placeholder="Резервная почта" value={backupEmail} onChange={(event) => setBackupEmail(event.target.value)} />
|
<Input placeholder="Резервная почта" value={backupEmail} onChange={(event) => setBackupEmail(event.target.value)} />
|
||||||
<Input placeholder="Основной телефон" value={phone} onChange={(event) => setPhone(event.target.value)} />
|
<PhoneInput
|
||||||
<Input placeholder="Резервный телефон" value={backupPhone} onChange={(event) => setBackupPhone(event.target.value)} />
|
variant="light"
|
||||||
|
country={phoneCountry}
|
||||||
|
value={phoneDigits}
|
||||||
|
onCountryChange={setPhoneCountry}
|
||||||
|
onValueChange={(digits) => {
|
||||||
|
setPhoneDigits(digits);
|
||||||
|
setPhone(digits ? toE164(phoneCountry, digits) : '');
|
||||||
|
}}
|
||||||
|
required={false}
|
||||||
|
/>
|
||||||
|
<PhoneInput
|
||||||
|
variant="light"
|
||||||
|
country={backupPhoneCountry}
|
||||||
|
value={backupPhoneDigits}
|
||||||
|
onCountryChange={setBackupPhoneCountry}
|
||||||
|
onValueChange={(digits) => {
|
||||||
|
setBackupPhoneDigits(digits);
|
||||||
|
setBackupPhone(digits ? toE164(backupPhoneCountry, digits) : '');
|
||||||
|
}}
|
||||||
|
required={false}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button className="mt-4" onClick={updateProfile} disabled={isSaving}>
|
<Button className="mt-4" onClick={updateProfile} disabled={isSaving}>
|
||||||
{isSaving ? 'Сохраняем...' : 'Обновить профиль'}
|
{isSaving ? 'Сохраняем...' : 'Обновить профиль'}
|
||||||
@@ -208,14 +324,22 @@ export default function DataPage() {
|
|||||||
) : (
|
) : (
|
||||||
documents.map((document) => {
|
documents.map((document) => {
|
||||||
const config = getDocumentType(document.type);
|
const config = getDocumentType(document.type);
|
||||||
|
const photoKeys = parseDocumentPhotos(parseMetadata(document.metadataJson));
|
||||||
|
const attachmentMeta = parseAttachmentMeta(parseMetadata(document.metadataJson));
|
||||||
return (
|
return (
|
||||||
|
<div key={document.id}>
|
||||||
<Row
|
<Row
|
||||||
key={document.id}
|
|
||||||
icon={FileText}
|
icon={FileText}
|
||||||
title={config?.label ?? document.type}
|
title={config?.label ?? document.type}
|
||||||
text={document.number}
|
text={document.number}
|
||||||
onClick={() => openDocument(document.type as DocumentTypeCode)}
|
onClick={() => openDocument(document.type as DocumentTypeCode)}
|
||||||
/>
|
/>
|
||||||
|
{photoKeys.length > 0 && userId && token ? (
|
||||||
|
<div className="border-b border-[#eceef4] px-1 pb-4">
|
||||||
|
<DocumentPhotosInline userId={userId} token={token} storageKeys={photoKeys} attachmentMeta={attachmentMeta} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
@@ -234,19 +358,52 @@ export default function DataPage() {
|
|||||||
|
|
||||||
<section className="mt-10">
|
<section className="mt-10">
|
||||||
<h2 className="text-2xl font-medium">Управление данными</h2>
|
<h2 className="text-2xl font-medium">Управление данными</h2>
|
||||||
|
<div className="mt-2 overflow-hidden rounded-[24px] bg-[#f4f5f8]">
|
||||||
|
<Row
|
||||||
|
icon={FileKey2}
|
||||||
|
title="Доступы к данным"
|
||||||
|
text="Приложения, которым вы разрешили доступ к аккаунту"
|
||||||
|
onClick={() => router.push('/data/consents')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{deletionStatus?.pending && deletionStatus.effectiveAt ? (
|
||||||
|
<div className="mt-4 rounded-[24px] border border-amber-200 bg-amber-50/80 p-5">
|
||||||
|
<p className="font-medium text-amber-900">Удаление аккаунта запланировано</p>
|
||||||
|
<p className="mt-2 text-sm leading-relaxed text-amber-800">
|
||||||
|
Профиль будет окончательно удалён {formatDeletionDate(deletionStatus.effectiveAt)}. До этой даты вы
|
||||||
|
можете пользоваться сервисом или отменить удаление.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
className="mt-4"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={cancellingDeletion}
|
||||||
|
onClick={() => void handleCancelDeletion()}
|
||||||
|
>
|
||||||
|
{cancellingDeletion ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Отменяем...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Отменить удаление'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="mt-2 overflow-hidden rounded-[24px] border border-red-100 bg-red-50/40">
|
<div className="mt-2 overflow-hidden rounded-[24px] border border-red-100 bg-red-50/40">
|
||||||
<Row
|
<Row
|
||||||
icon={Trash2}
|
icon={Trash2}
|
||||||
title="Удалить профиль"
|
title="Удалить профиль"
|
||||||
text="Аккаунт будет деактивирован, вход станет невозможен"
|
text="Аккаунт будет удалён после периода ожидания (настраивается администратором)"
|
||||||
onClick={() => setDeleteDialogOpen(true)}
|
onClick={() => setDeleteDialogOpen(true)}
|
||||||
destructive
|
destructive
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{activeDocumentType && user ? (
|
{activeDocumentType && user ? (
|
||||||
<DocumentFormDialog
|
<DocumentDialog
|
||||||
open={Boolean(activeDocumentType)}
|
open={Boolean(activeDocumentType)}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) setActiveDocumentType(null);
|
if (!open) setActiveDocumentType(null);
|
||||||
@@ -265,9 +422,10 @@ export default function DataPage() {
|
|||||||
<DialogTitle>Удалить профиль?</DialogTitle>
|
<DialogTitle>Удалить профиль?</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<p className="text-sm leading-relaxed text-[#667085]">
|
<p className="text-sm leading-relaxed text-[#667085]">
|
||||||
Ваш аккаунт будет помечен как удалённый. Вы сразу выйдете из системы и больше не сможете войти с текущими
|
Аккаунт не удалится сразу. После подтверждения начнётся период ожидания (по умолчанию 30 дней — срок
|
||||||
данными. Почта, телефон и логин будут освобождены для новой регистрации. Административные роли будут сняты.
|
задаётся в настройках администратора). По истечении срока профиль будет окончательно удалён: контакты и
|
||||||
Запись в базе сохранится в архивном виде.
|
логин освободятся, семьи и чаты будут удалены или покинууты, сессии завершены. До этого момента удаление
|
||||||
|
можно отменить на этой странице.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-6 flex gap-3">
|
<div className="mt-6 flex gap-3">
|
||||||
<Button variant="secondary" className="flex-1" onClick={() => setDeleteDialogOpen(false)} disabled={isDeleting}>
|
<Button variant="secondary" className="flex-1" onClick={() => setDeleteDialogOpen(false)} disabled={isDeleting}>
|
||||||
@@ -280,7 +438,7 @@ export default function DataPage() {
|
|||||||
Удаляем...
|
Удаляем...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
'Удалить профиль'
|
'Запланировать удаление'
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { ChevronRight, Plus } from 'lucide-react';
|
import { ChevronRight, Plus } from 'lucide-react';
|
||||||
import { DocumentFormDialog } from '@/components/documents/document-form-dialog';
|
import { DocumentDialog } from '@/components/documents/document-dialog';
|
||||||
|
import { DocumentPhotosInline } from '@/components/documents/document-photo-gallery';
|
||||||
import { useAuth } from '@/components/id/auth-provider';
|
import { useAuth } from '@/components/id/auth-provider';
|
||||||
import { IdShell } from '@/components/id/shell';
|
import { IdShell } from '@/components/id/shell';
|
||||||
import { useToast } from '@/components/id/toast-provider';
|
import { useToast } from '@/components/id/toast-provider';
|
||||||
@@ -11,6 +12,9 @@ import {
|
|||||||
DOCUMENT_CATEGORIES,
|
DOCUMENT_CATEGORIES,
|
||||||
DOCUMENT_TYPES,
|
DOCUMENT_TYPES,
|
||||||
getDocumentType,
|
getDocumentType,
|
||||||
|
parseAttachmentMeta,
|
||||||
|
parseDocumentPhotos,
|
||||||
|
parseMetadata,
|
||||||
QUICK_DOCUMENT_TYPES,
|
QUICK_DOCUMENT_TYPES,
|
||||||
indexDocumentsByType,
|
indexDocumentsByType,
|
||||||
type DocumentTypeCode
|
type DocumentTypeCode
|
||||||
@@ -52,7 +56,7 @@ export default function DocumentsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<IdShell active="/documents" wide>
|
<IdShell active="/documents" wide>
|
||||||
<h1 className="text-3xl font-medium tracking-tight">Документы</h1>
|
<h1 className="text-2xl font-medium tracking-tight sm:text-3xl">Документы</h1>
|
||||||
<p className="mt-1 text-sm text-[#667085]">Храните документы и заполняйте формы — ID подставит данные там, где вы разрешите.</p>
|
<p className="mt-1 text-sm text-[#667085]">Храните документы и заполняйте формы — ID подставит данные там, где вы разрешите.</p>
|
||||||
|
|
||||||
<div className="mt-6 overflow-hidden rounded-[28px] bg-[linear-gradient(135deg,#fff4f4,#fff8ef)] p-5">
|
<div className="mt-6 overflow-hidden rounded-[28px] bg-[linear-gradient(135deg,#fff4f4,#fff8ef)] p-5">
|
||||||
@@ -93,12 +97,14 @@ export default function DocumentsPage() {
|
|||||||
{DOCUMENT_TYPES.filter((item) => item.category === category.id).map((item) => {
|
{DOCUMENT_TYPES.filter((item) => item.category === category.id).map((item) => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
const existing = documentsByType.get(item.type);
|
const existing = documentsByType.get(item.type);
|
||||||
|
const photoKeys = existing ? parseDocumentPhotos(parseMetadata(existing.metadataJson)) : [];
|
||||||
|
const attachmentMeta = existing ? parseAttachmentMeta(parseMetadata(existing.metadataJson)) : {};
|
||||||
return (
|
return (
|
||||||
|
<div key={item.type} className="border-b border-white/70 last:border-b-0">
|
||||||
<button
|
<button
|
||||||
key={item.type}
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => openCreate(item.type)}
|
onClick={() => openCreate(item.type)}
|
||||||
className="flex w-full items-center gap-4 border-b border-white/70 px-4 py-4 text-left last:border-b-0 hover:bg-white/60"
|
className="flex w-full items-center gap-4 px-4 py-4 text-left hover:bg-white/60"
|
||||||
>
|
>
|
||||||
<div className={cn('flex h-11 w-11 items-center justify-center rounded-2xl', item.accent)}>
|
<div className={cn('flex h-11 w-11 items-center justify-center rounded-2xl', item.accent)}>
|
||||||
<Icon className="h-5 w-5" />
|
<Icon className="h-5 w-5" />
|
||||||
@@ -109,6 +115,12 @@ export default function DocumentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
{existing ? <ChevronRight className="h-5 w-5 text-[#a8adbc]" /> : <Plus className="h-5 w-5 text-[#a8adbc]" />}
|
{existing ? <ChevronRight className="h-5 w-5 text-[#a8adbc]" /> : <Plus className="h-5 w-5 text-[#a8adbc]" />}
|
||||||
</button>
|
</button>
|
||||||
|
{existing && photoKeys.length > 0 && user && token ? (
|
||||||
|
<div className="px-4 pb-4">
|
||||||
|
<DocumentPhotosInline userId={user.id} token={token} storageKeys={photoKeys} attachmentMeta={attachmentMeta} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -116,7 +128,7 @@ export default function DocumentsPage() {
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
{activeType ? (
|
{activeType ? (
|
||||||
<DocumentFormDialog
|
<DocumentDialog
|
||||||
open={Boolean(activeType)}
|
open={Boolean(activeType)}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) setActiveType(null);
|
if (!open) setActiveType(null);
|
||||||
|
|||||||
8
apps/frontend/app/download/android/route.ts
Normal file
8
apps/frontend/app/download/android/route.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const target = new URL('/downloads', request.nextUrl.origin);
|
||||||
|
return NextResponse.redirect(target, 302);
|
||||||
|
}
|
||||||
9
apps/frontend/app/downloads/android/[releaseId]/route.ts
Normal file
9
apps/frontend/app/downloads/android/[releaseId]/route.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
import { proxyReleaseDownload } from '@/lib/proxy-release-download';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest, context: { params: Promise<{ releaseId: string }> }) {
|
||||||
|
const { releaseId } = await context.params;
|
||||||
|
return proxyReleaseDownload(request, 'android', releaseId);
|
||||||
|
}
|
||||||
8
apps/frontend/app/downloads/android/route.ts
Normal file
8
apps/frontend/app/downloads/android/route.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
import { proxyReleaseDownload } from '@/lib/proxy-release-download';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
return proxyReleaseDownload(request, 'android');
|
||||||
|
}
|
||||||
234
apps/frontend/app/downloads/page.tsx
Normal file
234
apps/frontend/app/downloads/page.tsx
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { ArrowRight, Download, ShieldCheck, Smartphone, Sparkles } from 'lucide-react';
|
||||||
|
import { BrandLogo } from '@/components/id/brand-logo';
|
||||||
|
import { usePublicSettings } from '@/components/id/public-settings-provider';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
AppRelease,
|
||||||
|
buildAppReleaseDownloadUrl,
|
||||||
|
fetchPublicAppReleases
|
||||||
|
} from '@/lib/api';
|
||||||
|
import { formatReleaseVariantLabel, groupReleasesByVersion } from '@/lib/app-release-variants';
|
||||||
|
|
||||||
|
function formatBytes(value: string) {
|
||||||
|
const size = Number(value);
|
||||||
|
if (!Number.isFinite(size) || size <= 0) return '—';
|
||||||
|
const units = ['Б', 'КБ', 'МБ', 'ГБ'];
|
||||||
|
let amount = size;
|
||||||
|
let unit = 0;
|
||||||
|
while (amount >= 1024 && unit < units.length - 1) {
|
||||||
|
amount /= 1024;
|
||||||
|
unit += 1;
|
||||||
|
}
|
||||||
|
return `${amount.toFixed(amount >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string) {
|
||||||
|
return new Intl.DateTimeFormat('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric'
|
||||||
|
}).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DownloadsPage() {
|
||||||
|
const { projectName } = usePublicSettings();
|
||||||
|
const [releases, setReleases] = useState<AppRelease[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetchPublicAppReleases('ANDROID');
|
||||||
|
setReleases(response.releases ?? []);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const groupedReleases = useMemo(
|
||||||
|
() => groupReleasesByVersion(releases.filter((item) => item.platform === 'ANDROID')),
|
||||||
|
[releases]
|
||||||
|
);
|
||||||
|
|
||||||
|
const latestGroup = groupedReleases[0] ?? null;
|
||||||
|
const latestVariants = latestGroup?.variants ?? [];
|
||||||
|
const preferredLatest =
|
||||||
|
latestVariants.find((item) => item.variant === 'universal') ?? latestVariants[0] ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-[linear-gradient(180deg,#f7f9fc_0%,#ffffff_42%,#f4f7fb_100%)]">
|
||||||
|
<div className="mx-auto max-w-4xl px-4 py-10 sm:px-6 lg:py-14">
|
||||||
|
|
||||||
|
<section className="relative overflow-hidden rounded-[32px] border border-[#e8edf5] bg-white px-6 py-10 shadow-[0_24px_80px_rgba(31,36,48,0.08)] sm:px-10 sm:py-12">
|
||||||
|
<div className="absolute -right-16 -top-16 h-56 w-56 rounded-full bg-[#eef4ff] blur-3xl" />
|
||||||
|
<div className="absolute -bottom-20 left-10 h-48 w-48 rounded-full bg-[#e8f8ee] blur-3xl" />
|
||||||
|
<div className="relative max-w-3xl">
|
||||||
|
<div className="mb-4 inline-flex items-center gap-2 rounded-full bg-[#eef4ff] px-3 py-1 text-sm font-medium text-[#3390ec]">
|
||||||
|
<Sparkles className="h-4 w-4" />
|
||||||
|
Официальная сборка {projectName}
|
||||||
|
</div>
|
||||||
|
<h1 className="text-4xl font-semibold tracking-tight text-[#1f2430] sm:text-5xl">
|
||||||
|
Скачайте приложение
|
||||||
|
</h1>
|
||||||
|
<p className="mt-4 max-w-2xl text-base leading-relaxed text-[#667085] sm:text-lg">
|
||||||
|
Актуальная версия для Android. Доступны разные сборки: universal, arm64-v8a и другие.
|
||||||
|
Каждый файл подписан SHA-256.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-8">
|
||||||
|
<div className="overflow-hidden rounded-[28px] border border-[#eceef4] bg-white shadow-[0_18px_50px_rgba(31,36,48,0.06)]">
|
||||||
|
<div className="bg-gradient-to-br from-[#34c759] to-[#0f9d58] px-6 py-8 text-white">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-white/15 backdrop-blur">
|
||||||
|
<Smartphone className="h-7 w-7" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-semibold">Android</h2>
|
||||||
|
<p className="mt-1 text-sm text-white/85">Выберите подходящую сборку APK</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-5 px-6 py-6">
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-sm text-[#667085]">Проверяем доступные версии...</p>
|
||||||
|
) : latestGroup && preferredLatest ? (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-[#667085]">Последняя версия</p>
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-2xl font-semibold text-[#1f2430]">v{latestGroup.version}</span>
|
||||||
|
<span className="rounded-full bg-[#eef4ff] px-2.5 py-1 text-xs font-medium text-[#3390ec]">
|
||||||
|
build {latestGroup.versionCode}
|
||||||
|
</span>
|
||||||
|
{latestVariants.length > 1 ? (
|
||||||
|
<span className="rounded-full bg-[#f4f5f8] px-2.5 py-1 text-xs font-medium text-[#667085]">
|
||||||
|
{latestVariants.length} сборки
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm text-[#667085]">
|
||||||
|
{formatDate(preferredLatest.createdAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{preferredLatest.releaseNotes ? (
|
||||||
|
<p className="rounded-2xl bg-[#f8f9fb] px-4 py-3 text-sm leading-relaxed text-[#1f2430]">
|
||||||
|
{preferredLatest.releaseNotes}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{latestVariants.map((release) => (
|
||||||
|
<div
|
||||||
|
key={release.id}
|
||||||
|
className="flex flex-col justify-between rounded-2xl border border-[#eceef4] bg-[#f8f9fb] p-4"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-[#1f2430]">{formatReleaseVariantLabel(release.variant)}</p>
|
||||||
|
<p className="mt-1 text-xs text-[#667085]">
|
||||||
|
{formatBytes(release.fileSize)} · {release.fileName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button className="mt-4 rounded-xl" asChild>
|
||||||
|
<a href={buildAppReleaseDownloadUrl('ANDROID', release.id)}>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
Скачать
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row">
|
||||||
|
<Button className="flex-1 rounded-xl" asChild>
|
||||||
|
<a href={buildAppReleaseDownloadUrl('ANDROID', preferredLatest.id)}>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
Скачать рекомендуемую
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" className="flex-1 rounded-xl" asChild>
|
||||||
|
<a href={buildAppReleaseDownloadUrl('ANDROID', undefined, 'universal')}>
|
||||||
|
Universal по ссылке
|
||||||
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-[#667085]">Сборка для Android пока не опубликована.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-10 rounded-[24px] border border-[#eceef4] bg-white p-6 shadow-sm">
|
||||||
|
<h3 className="text-xl font-semibold">История версий</h3>
|
||||||
|
<div className="mt-4 space-y-4">
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-sm text-[#667085]">Загружаем список версий...</p>
|
||||||
|
) : groupedReleases.length ? (
|
||||||
|
groupedReleases.map((group, groupIndex) => (
|
||||||
|
<div key={group.key} className="rounded-2xl bg-[#f8f9fb] px-4 py-4">
|
||||||
|
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||||
|
<span className="font-medium text-[#1f2430]">v{group.version}</span>
|
||||||
|
<span className="text-xs text-[#667085]">build {group.versionCode}</span>
|
||||||
|
{groupIndex === 0 ? (
|
||||||
|
<span className="rounded-full bg-[#e8f8ee] px-2 py-0.5 text-[11px] font-medium text-[#1a7f37]">
|
||||||
|
Актуальная
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{group.variants.map((release) => (
|
||||||
|
<div key={release.id} className="flex flex-wrap items-center justify-between gap-3 rounded-xl bg-white px-3 py-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-[#1f2430]">
|
||||||
|
{formatReleaseVariantLabel(release.variant)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-[#667085]">
|
||||||
|
{formatDate(release.createdAt)} · {formatBytes(release.fileSize)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" className="rounded-xl" asChild>
|
||||||
|
<a href={buildAppReleaseDownloadUrl('ANDROID', release.id)}>Скачать</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-[#667085]">Версии пока не опубликованы.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-10 rounded-[24px] border border-[#eceef4] bg-white p-6 shadow-sm">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-[#eef4ff] text-[#3390ec]">
|
||||||
|
<ShieldCheck className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold">Проверка обновлений в приложении</h3>
|
||||||
|
<p className="mt-2 text-sm leading-relaxed text-[#667085]">
|
||||||
|
Мобильный клиент может запрашивать{' '}
|
||||||
|
<code className="rounded bg-[#f4f5f8] px-1.5 py-0.5">GET /idp-api/releases/check?platform=ANDROID&versionCode=...</code>{' '}
|
||||||
|
и получать список всех сборок последней версии в поле <code className="rounded bg-[#f4f5f8] px-1.5 py-0.5">variants</code>.
|
||||||
|
Для конкретной архитектуры используйте{' '}
|
||||||
|
<code className="rounded bg-[#f4f5f8] px-1.5 py-0.5">/downloads/android?variant=arm64-v8a</code>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
apps/frontend/app/downloads/windows/[releaseId]/route.ts
Normal file
8
apps/frontend/app/downloads/windows/[releaseId]/route.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const target = new URL('/downloads', request.nextUrl.origin);
|
||||||
|
return NextResponse.redirect(target, 302);
|
||||||
|
}
|
||||||
8
apps/frontend/app/downloads/windows/route.ts
Normal file
8
apps/frontend/app/downloads/windows/route.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const target = new URL('/downloads', request.nextUrl.origin);
|
||||||
|
return NextResponse.redirect(target, 302);
|
||||||
|
}
|
||||||
@@ -1,14 +1,26 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { use } from 'react';
|
import { use, useEffect } from 'react';
|
||||||
import { IdShell } from '@/components/id/shell';
|
|
||||||
import { FamilyGroupView } from '@/components/family/family-group-view';
|
import { FamilyGroupView } from '@/components/family/family-group-view';
|
||||||
|
import { useFamilyOverlay } from '@/components/family/family-overlay-provider';
|
||||||
|
import { IdShell } from '@/components/id/shell';
|
||||||
|
|
||||||
|
function FamilyGroupPageContent({ groupId }: { groupId: string }) {
|
||||||
|
const { setSelectedGroupId } = useFamilyOverlay();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedGroupId(groupId);
|
||||||
|
}, [groupId, setSelectedGroupId]);
|
||||||
|
|
||||||
|
return <FamilyGroupView groupId={groupId} />;
|
||||||
|
}
|
||||||
|
|
||||||
export default function FamilyGroupPage({ params }: { params: Promise<{ groupId: string }> }) {
|
export default function FamilyGroupPage({ params }: { params: Promise<{ groupId: string }> }) {
|
||||||
const { groupId } = use(params);
|
const { groupId } = use(params);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<IdShell active="/family" wide>
|
<IdShell active="/family" fullBleed>
|
||||||
<FamilyGroupView groupId={groupId} />
|
<FamilyGroupPageContent groupId={groupId} />
|
||||||
</IdShell>
|
</IdShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,35 +9,44 @@ import { useToast } from '@/components/id/toast-provider';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { useRequireAuth } from '@/hooks/use-require-auth';
|
import { useRequireAuth } from '@/hooks/use-require-auth';
|
||||||
import { apiFetch, FamilyGroup, fetchFamilyGroups, getApiErrorMessage } from '@/lib/api';
|
import { apiFetch, FamilyGroup, fetchFamilyGroups, getAccessToken, getApiErrorMessage } from '@/lib/api';
|
||||||
|
import { defaultFamilyGroupName } from '@/lib/family-defaults';
|
||||||
|
|
||||||
export default function FamilyPage() {
|
export default function FamilyPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { user, token } = useAuth();
|
const { user, token, isLoading, isPinLocked } = useAuth();
|
||||||
const { isReady, isPinLocked } = useRequireAuth();
|
const { isReady } = useRequireAuth();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [groups, setGroups] = useState<FamilyGroup[]>([]);
|
const [groups, setGroups] = useState<FamilyGroup[]>([]);
|
||||||
const [name, setName] = useState('Моя семья');
|
const [name, setName] = useState('');
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user || !token || isPinLocked) return;
|
if (user?.displayName) {
|
||||||
fetchFamilyGroups(user.id, token)
|
setName(defaultFamilyGroupName(user.displayName));
|
||||||
|
}
|
||||||
|
}, [user?.displayName]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const accessToken = getAccessToken() ?? token?.trim() ?? null;
|
||||||
|
if (!user || !accessToken || isPinLocked || isLoading) return;
|
||||||
|
fetchFamilyGroups(user.id, accessToken)
|
||||||
.then((response) => setGroups(response.groups ?? []))
|
.then((response) => setGroups(response.groups ?? []))
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
const message = getApiErrorMessage(error, 'Не удалось загрузить семью');
|
const message = getApiErrorMessage(error, 'Не удалось загрузить семью');
|
||||||
if (message) showToast(message);
|
if (message) showToast(message);
|
||||||
});
|
});
|
||||||
}, [isPinLocked, showToast, token, user]);
|
}, [isLoading, isPinLocked, showToast, token, user]);
|
||||||
|
|
||||||
async function createGroup() {
|
async function createGroup() {
|
||||||
if (!user || !token) return;
|
const accessToken = getAccessToken() ?? token?.trim() ?? null;
|
||||||
|
if (!user || !accessToken) return;
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
try {
|
try {
|
||||||
const group = await apiFetch<FamilyGroup>('/family/groups', {
|
const group = await apiFetch<FamilyGroup>('/family/groups', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ ownerId: user.id, name })
|
body: JSON.stringify({ ownerId: user.id, name })
|
||||||
}, token);
|
}, accessToken);
|
||||||
router.push(`/family/${group.id}`);
|
router.push(`/family/${group.id}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = getApiErrorMessage(error, 'Не удалось создать семью');
|
const message = getApiErrorMessage(error, 'Не удалось создать семью');
|
||||||
@@ -47,7 +56,7 @@ export default function FamilyPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isReady) {
|
if (!isReady || isLoading) {
|
||||||
return (
|
return (
|
||||||
<IdShell active="/family">
|
<IdShell active="/family">
|
||||||
<div className="py-20 text-center text-[#667085]">Загрузка...</div>
|
<div className="py-20 text-center text-[#667085]">Загрузка...</div>
|
||||||
@@ -58,12 +67,22 @@ export default function FamilyPage() {
|
|||||||
return (
|
return (
|
||||||
<IdShell active="/family" wide>
|
<IdShell active="/family" wide>
|
||||||
<p className="text-sm text-[#667085]">Семья</p>
|
<p className="text-sm text-[#667085]">Семья</p>
|
||||||
<h1 className="text-4xl font-medium tracking-tight">Семейный доступ</h1>
|
<h1 className="text-2xl font-medium tracking-tight sm:text-4xl">Семейный доступ</h1>
|
||||||
<p className="mt-2 text-[#667085]">Приглашайте близких, общайтесь в чатах и управляйте семейной группой.</p>
|
<p className="mt-2 text-sm text-[#667085] sm:text-base">Приглашайте близких, общайтесь в чатах и управляйте семейной группой.</p>
|
||||||
|
|
||||||
<div className="mt-8 flex gap-3">
|
<div className="mt-6 flex flex-col gap-3 sm:mt-8 sm:flex-row sm:items-stretch">
|
||||||
<Input value={name} onChange={(event) => setName(event.target.value)} placeholder="Название семьи" />
|
<Input
|
||||||
<Button onClick={() => void createGroup()} disabled={creating}>
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
placeholder="Название семьи"
|
||||||
|
className="h-14 min-h-14 min-w-0 flex-1 px-4 text-base"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
className="h-14 w-full shrink-0 px-6 sm:w-auto"
|
||||||
|
onClick={() => void createGroup()}
|
||||||
|
disabled={creating || !name.trim()}
|
||||||
|
>
|
||||||
{creating ? 'Создаём...' : (<><Plus className="h-4 w-4" />Создать</>)}
|
{creating ? 'Создаём...' : (<><Plus className="h-4 w-4" />Создать</>)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "leaflet/dist/leaflet.css";
|
@import "leaflet/dist/leaflet.css";
|
||||||
|
@import "react-day-picker/style.css";
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: #ffffff;
|
--background: #ffffff;
|
||||||
@@ -14,6 +15,29 @@
|
|||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgb(168 173 188 / 55%) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-thumb {
|
||||||
|
background: rgb(168 173 188 / 45%);
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgb(102 112 133 / 65%);
|
||||||
|
background-clip: padding-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -21,6 +45,35 @@ body {
|
|||||||
background: var(--background);
|
background: var(--background);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-corner {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rdp-root {
|
||||||
|
--rdp-accent-color: #111827;
|
||||||
|
--rdp-accent-background-color: #f4f5f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Leaflet внутри чата/форм не должен перекрывать модальные окна */
|
||||||
|
.embedded-leaflet-map {
|
||||||
|
position: relative;
|
||||||
|
z-index: 0;
|
||||||
|
isolation: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.embedded-leaflet-map .leaflet-container,
|
||||||
|
.embedded-leaflet-map .leaflet-pane,
|
||||||
|
.embedded-leaflet-map .leaflet-top,
|
||||||
|
.embedded-leaflet-map .leaflet-bottom,
|
||||||
|
.embedded-leaflet-map .leaflet-control {
|
||||||
|
z-index: 1 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
@@ -40,6 +93,20 @@ input {
|
|||||||
background: var(--muted);
|
background: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes chat-message-blink {
|
||||||
|
0%, 100% {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
25%, 75% {
|
||||||
|
background-color: rgb(51 144 236 / 18%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.blink-highlight {
|
||||||
|
animation: chat-message-blink 0.55s ease-in-out 2;
|
||||||
|
border-radius: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
.id-shadow {
|
.id-shadow {
|
||||||
box-shadow: 0 24px 70px rgb(22 26 43 / 12%);
|
box-shadow: 0 24px 70px rgb(22 26 43 / 12%);
|
||||||
}
|
}
|
||||||
|
|||||||
4
apps/frontend/app/icon.svg
Normal file
4
apps/frontend/app/icon.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<rect width="64" height="64" rx="18" fill="#111827"/>
|
||||||
|
<path d="M18 45V19h7l7 12 7-12h7v26h-7V31.5L34.8 38h-5.6L25 31.5V45h-7z" fill="#fff"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 213 B |
@@ -4,13 +4,18 @@ import './globals.css';
|
|||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'MVK ID',
|
title: 'MVK ID',
|
||||||
description: 'Единый аккаунт для сервисов Lendry'
|
description: 'Единый аккаунт для сервисов',
|
||||||
|
icons: {
|
||||||
|
icon: '/icon.svg',
|
||||||
|
shortcut: '/icon.svg',
|
||||||
|
apple: '/icon.svg'
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||||
return (
|
return (
|
||||||
<html lang="ru">
|
<html lang="ru" suppressHydrationWarning>
|
||||||
<body>
|
<body suppressHydrationWarning>
|
||||||
<Providers>{children}</Providers>
|
<Providers>{children}</Providers>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
207
apps/frontend/app/mini-apps/bot-create/content.tsx
Normal file
207
apps/frontend/app/mini-apps/bot-create/content.tsx
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { Check, Copy, Loader2, Bot, ArrowLeft } from 'lucide-react';
|
||||||
|
import { useToast } from '@/components/id/toast-provider';
|
||||||
|
import { usePublicSettings } from '@/components/id/public-settings-provider';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { createManagedBot, getApiErrorMessage } from '@/lib/api';
|
||||||
|
import { useMiniAppAuth } from '@/hooks/use-mini-app-auth';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
type Step = 'name' | 'username' | 'done';
|
||||||
|
|
||||||
|
interface BotCreateMiniAppContentProps {
|
||||||
|
onBack?: () => void;
|
||||||
|
onCreated?: (botId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BotCreateMiniAppContent({ onBack, onCreated }: BotCreateMiniAppContentProps = {}) {
|
||||||
|
const { projectName } = usePublicSettings();
|
||||||
|
const { effectiveToken, canUse, authReady, waitingForBridge, isLoading } = useMiniAppAuth();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
|
||||||
|
const [step, setStep] = useState<Step>('name');
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [createdBot, setCreatedBot] = useState<{ username: string; token: string; botId: string } | null>(null);
|
||||||
|
|
||||||
|
const canUseSession = canUse;
|
||||||
|
const usernamePreview = useMemo(() => `${username.replace(/_bot$/i, '').trim()}_bot`, [username]);
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
if (!canUseSession || !effectiveToken) return;
|
||||||
|
const trimmedName = name.trim();
|
||||||
|
const trimmedUsername = username.replace(/_bot$/i, '').trim();
|
||||||
|
if (!trimmedName) {
|
||||||
|
showToast('Укажите название бота');
|
||||||
|
setStep('name');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (trimmedUsername.length < 5) {
|
||||||
|
showToast('Username должен содержать минимум 5 символов');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCreating(true);
|
||||||
|
try {
|
||||||
|
const response = await createManagedBot({ name: trimmedName, username: trimmedUsername }, effectiveToken);
|
||||||
|
setCreatedBot({
|
||||||
|
username: response.bot?.username ?? usernamePreview,
|
||||||
|
token: response.token ?? '',
|
||||||
|
botId: response.bot?.id ?? ''
|
||||||
|
});
|
||||||
|
setStep('done');
|
||||||
|
showToast('Бот успешно создан');
|
||||||
|
} catch (error) {
|
||||||
|
showToast(getApiErrorMessage(error, 'Не удалось создать бота') ?? 'Ошибка');
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyToken() {
|
||||||
|
if (!createdBot?.token) return;
|
||||||
|
await navigator.clipboard.writeText(createdBot.token);
|
||||||
|
showToast('Токен скопирован');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((isLoading || waitingForBridge || !authReady) && !effectiveToken) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center p-6 text-center text-sm text-[#667085]">
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Загрузка...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canUseSession) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center p-6 text-center text-sm text-[#667085]">
|
||||||
|
Войдите в {projectName}, чтобы создать бота
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#17212b] p-4 text-white">
|
||||||
|
<div className="mx-auto max-w-md space-y-5 rounded-[24px] bg-[#242f3d] p-5 shadow-xl">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{onBack ? (
|
||||||
|
<Button type="button" variant="ghost" size="icon" className="h-9 w-9 shrink-0 text-[#8b93a7] hover:bg-[#17212b] hover:text-white" onClick={onBack}>
|
||||||
|
<ArrowLeft className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-[#3390ec]/20 text-[#3390ec]">
|
||||||
|
<Bot className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold">Создание бота</h1>
|
||||||
|
<p className="text-sm text-[#8b93a7]">Как в BotFather Telegram</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-xs text-[#8b93a7]">
|
||||||
|
{(['name', 'username', 'done'] as Step[]).map((item, index) => (
|
||||||
|
<div key={item} className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'flex h-6 w-6 items-center justify-center rounded-full',
|
||||||
|
step === item || (step === 'done' && item !== 'done') || (item === 'name' && step !== 'name')
|
||||||
|
? 'bg-[#3390ec] text-white'
|
||||||
|
: 'bg-[#17212b] text-[#8b93a7]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{step === 'done' && item !== 'done' ? <Check className="h-3.5 w-3.5" /> : index + 1}
|
||||||
|
</span>
|
||||||
|
{index < 2 ? <span className="h-px w-8 bg-[#2a3544]" /> : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{step === 'name' ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm leading-relaxed text-[#c5cad3]">
|
||||||
|
Alright, a new bot. How are we going to call it? Please choose a name for your bot.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-[#8b93a7]">Хорошо, новый бот. Как мы его назовём? Выберите название.</p>
|
||||||
|
<Input
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
placeholder="Например: Сервис уведомлений"
|
||||||
|
className="rounded-xl border-[#2a3544] bg-[#17212b] text-white placeholder:text-[#667085]"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Button className="w-full rounded-xl" disabled={!name.trim()} onClick={() => setStep('username')}>
|
||||||
|
Далее
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{step === 'username' ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm leading-relaxed text-[#c5cad3]">
|
||||||
|
Good. Now let's choose a username for your bot. It must end in `bot`.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-[#8b93a7]">Username должен заканчиваться на `_bot` (суффикс добавится автоматически).</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[#8b93a7]">@</span>
|
||||||
|
<Input
|
||||||
|
value={username}
|
||||||
|
onChange={(event) => setUsername(event.target.value.replace(/[^a-zA-Z0-9_]/g, ''))}
|
||||||
|
placeholder="notify_service"
|
||||||
|
className="rounded-xl border-[#2a3544] bg-[#17212b] text-white placeholder:text-[#667085]"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-[#8b93a7]">Будет: @{usernamePreview || 'your_bot'}</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="secondary" className="flex-1 rounded-xl bg-[#17212b] text-white hover:bg-[#1c2733]" onClick={() => setStep('name')}>
|
||||||
|
Назад
|
||||||
|
</Button>
|
||||||
|
<Button className="flex-1 rounded-xl" disabled={creating || username.replace(/_bot$/i, '').trim().length < 5} onClick={() => void handleCreate()}>
|
||||||
|
{creating ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||||
|
Создать бота
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{step === 'done' && createdBot ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-2xl bg-[#17212b] p-4">
|
||||||
|
<p className="text-sm text-[#8b93a7]">Done! Congratulations on your new bot.</p>
|
||||||
|
<p className="mt-2 text-base font-semibold">@{createdBot.username}</p>
|
||||||
|
<p className="mt-3 text-sm text-red-300">Сохраните токен — он больше не будет показан:</p>
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<Input value={createdBot.token} readOnly className="rounded-xl border-[#2a3544] bg-[#242f3d] font-mono text-xs text-white" />
|
||||||
|
<Button type="button" variant="secondary" className="rounded-xl bg-[#3390ec] text-white hover:bg-[#2b7fd4]" onClick={() => void copyToken()}>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className="w-full rounded-xl"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setStep('name');
|
||||||
|
setName('');
|
||||||
|
setUsername('');
|
||||||
|
setCreatedBot(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Создать ещё одного бота
|
||||||
|
</Button>
|
||||||
|
{onCreated && createdBot.botId ? (
|
||||||
|
<Button className="w-full rounded-xl" onClick={() => onCreated(createdBot.botId)}>
|
||||||
|
Настройки бота
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user