Compare commits

...

159 Commits

Author SHA1 Message Date
lendry
7fc3ca7952 fix 2026-07-10 12:37:54 +03:00
lendry
f00f3d411d fix 2026-07-10 12:21:44 +03:00
lendry
a0966e7ba2 fix 2026-07-10 12:14:06 +03:00
lendry
a4b4577c55 fix 2026-07-10 10:37:22 +03:00
lendry
caf12e64f7 fix 2026-07-07 20:33:06 +03:00
lendry
e152442440 fix file size limit 2026-07-07 17:46:46 +03:00
lendry
f36a8d7456 fix file size limit 2026-07-07 17:36:01 +03:00
lendry
b90017aad0 fix file size limit 2026-07-07 17:04:59 +03:00
lendry
9a0cf54aa6 fix file size limit 2026-07-07 15:42:45 +03:00
lendry
ca2e30af04 fix family input 2026-07-07 15:32:36 +03:00
lendry
57925fb2c4 add push settings 2026-07-07 13:58:01 +03:00
lendry
1bd95fa99e add video 2026-07-07 12:46:43 +03:00
lendry
911e76f232 fix and update 2026-07-07 11:06:14 +03:00
lendry
f1821c2edc fix and update 2026-07-07 10:38:53 +03:00
lendry
12f46f572d fix and update 2026-07-07 10:29:07 +03:00
lendry
bd6cd0d798 fix and update 2026-07-07 10:14:37 +03:00
lendry
29306eb2ec fix and update 2026-07-07 09:01:49 +03:00
lendry
881e5d764b fix and update 2026-07-07 08:23:47 +03:00
lendry
cddb29fef6 fix and update 2026-07-07 08:14:26 +03:00
lendry
9ca5071f1a fix and update 2026-07-07 08:06:55 +03:00
lendry
28b04ada81 fix and update 2026-07-05 18:32:10 +03:00
lendry
adbd32fea0 fix and update 2026-07-05 18:28:25 +03:00
lendry
ef7f0c5380 fix and update 2026-07-02 16:40:04 +03:00
lendry
2f76b28339 fix and update 2026-07-02 16:19:06 +03:00
lendry
4306d0ce37 fix and update 2026-07-02 00:10:22 +03:00
lendry
bcdfbc3861 fix and update 2026-07-01 23:27:48 +03:00
lendry
2b88e028c6 fix and update 2026-07-01 22:46:04 +03:00
lendry
cb82544905 fix and update 2026-07-01 21:30:22 +03:00
lendry
2a488f2ab6 fix and update 2026-07-01 19:26:38 +03:00
lendry
7e54cec361 fix and update 2026-07-01 19:19:27 +03:00
lendry
f423f512f8 fix and update 2026-07-01 18:15:10 +03:00
lendry
322f8d6552 fix and update 2026-07-01 17:52:05 +03:00
lendry
06f1481787 fix and update 2026-07-01 17:28:53 +03:00
lendry
0c3c6d6d82 fix and update 2026-07-01 17:00:55 +03:00
lendry
607397fcf3 fix and update 2026-07-01 16:36:26 +03:00
lendry
ee8aaf9889 fix and update 2026-07-01 16:26:43 +03:00
lendry
f7c01a3963 fix and update 2026-07-01 15:52:17 +03:00
lendry
de4310239c fix and update 2026-07-01 15:23:38 +03:00
lendry
7d344fb82e fix and update 2026-07-01 14:58:30 +03:00
lendry
0c9b8e2629 fix and update 2026-07-01 14:34:55 +03:00
lendry
55deb5c152 fix and update 2026-07-01 14:03:57 +03:00
lendry
6929fb41fc fix and update 2026-07-01 12:14:50 +03:00
lendry
dce16af5a5 fix and update 2026-07-01 12:05:34 +03:00
lendry
f8f25c8289 fix and update 2026-07-01 11:32:53 +03:00
lendry
2c4b1fcc44 fix and update 2026-07-01 09:15:11 +03:00
lendry
7f5bb9838b fix and update 2026-07-01 07:59:13 +03:00
lendry
115fc140af fix and update 2026-07-01 00:26:16 +03:00
lendry
a0c1722a8d fix and update 2026-07-01 00:06:36 +03:00
lendry
deb213bd77 fix and update 2026-06-30 23:43:49 +03:00
lendry
4b2ade9354 fix and update 2026-06-30 23:18:53 +03:00
lendry
6ee3ffe0a5 fix and update 2026-06-30 19:47:26 +03:00
lendry
d41c9d1121 fix and update 2026-06-30 19:23:17 +03:00
lendry
2ea790d21d fix and update 2026-06-30 17:57:09 +03:00
lendry
521de7ea00 fix and update 2026-06-30 17:30:07 +03:00
lendry
ac9f405f43 fix and update 2026-06-30 17:21:27 +03:00
lendry
209036c036 fix and update 2026-06-30 17:01:19 +03:00
lendry
31251be877 fix and update 2026-06-30 16:42:48 +03:00
lendry
2eeb928a72 fix and update 2026-06-30 16:03:33 +03:00
lendry
69063c8fba fix and update 2026-06-30 15:36:07 +03:00
lendry
879875508f fix and update 2026-06-30 14:12:20 +03:00
lendry
2a88c87e94 fix and update 2026-06-30 13:48:50 +03:00
lendry
6a4bbd05b8 comment tauri docker compose 2026-06-30 12:47:14 +03:00
lendry
0b6e00205a comment tauri docker compose 2026-06-30 12:38:38 +03:00
lendry
46adf60ab8 fix and update 2026-06-30 11:38:41 +03:00
lendry
df4bbba133 fix and update 2026-06-30 11:28:10 +03:00
lendry
c082b087c5 fix and update 2026-06-30 11:00:43 +03:00
lendry
7f10b18336 fix and update 2026-06-30 10:36:21 +03:00
lendry
a76997986a fix and update 2026-06-30 10:05:57 +03:00
lendry
4e98f6bfab fix and update 2026-06-30 09:39:35 +03:00
lendry
250976ca08 fix and update 2026-06-30 09:11:55 +03:00
lendry
f1d6a5167f fix and update 2026-06-30 08:58:36 +03:00
lendry
40057b64c8 fix and update 2026-06-29 23:35:12 +03:00
lendry
40d388e0ed fix and update 2026-06-29 23:28:09 +03:00
lendry
0d43f9943f fix and update 2026-06-29 23:10:59 +03:00
lendry
57cb58347b fix and update 2026-06-29 22:51:25 +03:00
lendry
885b07d76b fix and update 2026-06-29 21:37:01 +03:00
lendry
4cd75cb0b1 fix and update 2026-06-29 21:36:32 +03:00
lendry
8369abb023 fix and update 2026-06-29 21:22:20 +03:00
lendry
7233e8b70a fix and update 2026-06-29 21:00:23 +03:00
lendry
69e6fced48 fix and update 2026-06-29 20:37:58 +03:00
lendry
8bbaf8b343 fix and update 2026-06-29 20:05:57 +03:00
lendry
115dc4e829 fix and update 2026-06-29 19:43:44 +03:00
lendry
d312e76abb fix and update 2026-06-29 19:26:52 +03:00
lendry
3a1bfb0903 fix and update 2026-06-29 18:38:53 +03:00
lendry
5a220917dc fix and update 2026-06-29 18:14:00 +03:00
lendry
71dfeda873 fix and update 2026-06-29 17:46:50 +03:00
lendry
2701c0e90b fix and update 2026-06-29 17:28:52 +03:00
lendry
e3c418d921 fix and update 2026-06-29 17:17:19 +03:00
lendry
3fd5509186 fix and update 2026-06-29 17:00:12 +03:00
lendry
6a7f1c5edf fix and update 2026-06-29 16:39:44 +03:00
lendry
10253fc76b fix and update 2026-06-29 16:23:40 +03:00
lendry
aebce54bd7 fix and update 2026-06-29 15:40:54 +03:00
lendry
e127df3d6d fix and update 2026-06-29 15:08:26 +03:00
lendry
01e4917acf fix and update 2026-06-29 15:02:01 +03:00
lendry
0df7240dc8 fix and update 2026-06-29 14:40:35 +03:00
lendry
75ccbe5fc4 fix and update 2026-06-29 12:17:25 +03:00
lendry
923a028cdd fix family screen adaptation 2026-06-27 23:27:32 +03:00
lendry
ee28a7b1db mobile adaptation 2026-06-27 00:41:03 +03:00
lendry
4b86c64cc4 Add for leave family 2026-06-27 00:15:36 +03:00
lendry
1a30e7e21c fix docker for tauri android build 2026-06-27 00:10:28 +03:00
lendry
886b7e9ade Add for leave family 2026-06-26 23:41:20 +03:00
lendry
f1bba24faa Complete offline Android APK build for isolated LAN servers.
Bundle AndroidX, Kotlin Gradle plugin, and SDK patch scripts so Gradle no longer needs Google Maven at runtime. Bake Gradle wrapper into Docker image during build when network is available.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 23:26:22 +03:00
lendry
73c292b3a5 Add full offline Android Gradle Plugin dependency cache 2026-06-26 23:05:44 +03:00
lendry
0a020d6857 Add full offline Android Gradle Plugin dependency cache 2026-06-26 22:44:18 +03:00
lendry
fd574b4972 Add full offline Android Gradle Plugin dependency cache 2026-06-26 22:33:03 +03:00
lendry
ea204a4d38 Add full offline Android Gradle Plugin dependency cache 2026-06-26 21:56:56 +03:00
lendry
07df6eacf1 Add full offline Android Gradle Plugin dependency cache 2026-06-26 20:55:41 +03:00
lendry
8805ec327f fix 2026-06-26 20:39:41 +03:00
lendry
65abf17421 fix 2026-06-26 20:15:13 +03:00
lendry
95ef9a9862 fix 2026-06-26 18:26:53 +03:00
lendry
3a5281cc58 update 2026-06-26 18:05:41 +03:00
lendry
4e853f8041 update 2026-06-26 17:49:22 +03:00
lendry
c23f35e732 fix docker for tauri app 2026-06-26 17:31:05 +03:00
lendry
3ab48d8537 fix docker for tauri app 2026-06-26 16:43:17 +03:00
lendry
f1068edc89 fix docker for tauri app 2026-06-26 16:29:12 +03:00
lendry
dd36818f80 fix docker for tauri app 2026-06-26 16:04:59 +03:00
lendry
ce8a326602 fix docker for tauri app 2026-06-26 15:52:39 +03:00
lendry
5385563f6e fix docker for tauri app 2026-06-26 15:08:02 +03:00
lendry
fcca318ea0 fix docker for tauri app 2026-06-26 14:57:00 +03:00
lendry
06d33b89d9 fix docker for tauri app 2026-06-26 14:51:40 +03:00
lendry
a8ad32c837 fix docker for tauri app 2026-06-26 14:22:30 +03:00
lendry
ef5262ac4a fix docker for tauri app 2026-06-26 14:10:38 +03:00
lendry
bede54cde6 fix docker for tauri app 2026-06-26 14:06:10 +03:00
lendry
3b05b7e4d4 global fix and add tauri app 2026-06-26 13:56:54 +03:00
lendry
aa228d84eb global fix and update bot Api 2026-06-26 13:01:52 +03:00
lendry
d3ea470d02 fix mini app bot father 2026-06-26 11:18:07 +03:00
lendry
7ed7cbdd16 fix mini app bot father 2026-06-26 11:08:10 +03:00
lendry
489b4d4a23 change public app name for oauth 2026-06-26 11:00:48 +03:00
lendry
b81c0cedbb add oauth app connected 2026-06-26 10:49:34 +03:00
lendry
d5e6b58955 fix document 500 error and fix users tabel for bot 2026-06-26 10:32:04 +03:00
lendry
ead3155ad8 fix document 500 error 2026-06-26 10:20:29 +03:00
lendry
dd4323ba51 fix oauth 2026-06-26 09:43:15 +03:00
lendry
c3b2eb4a50 add change redirect uri from oauth 2026-06-26 09:36:25 +03:00
lendry
971d10abf6 fix oauth 2026-06-26 09:20:06 +03:00
lendry
a15be4365c fix install sh 2026-06-26 09:02:43 +03:00
lendry
838a5ad923 fix install sh 2026-06-26 08:59:44 +03:00
lendry
72d6dcc145 fix install sh 2026-06-26 08:44:37 +03:00
lendry
0f1b360684 add custom cert install sh 2026-06-26 08:41:19 +03:00
lendry
4e78a81eb1 update and fix messanger 2026-06-26 00:16:17 +03:00
lendry
b0ea87e898 global update and global fix 2026-06-25 23:48:57 +03:00
lendry
3880c68d59 fix and update 2026-06-25 16:01:41 +03:00
lendry
ce58e6f4c1 rename sidebar (Админестрирование)(Админка) 2026-06-25 15:29:52 +03:00
lendry
f2108c7bdd rename sidebar (Админка)(Админестрирование) 2026-06-25 15:26:24 +03:00
lendry
1c55c871fc roles update 2026-06-25 15:14:50 +03:00
lendry
9671fe458b add cooldown notification repeat 2026-06-25 14:45:01 +03:00
lendry
6c63343fc7 update oauth 2026-06-25 14:40:05 +03:00
lendry
1796008a28 update oauth 2026-06-25 14:23:55 +03:00
lendry
c3e06e03cf add swagger link from docs 2026-06-25 13:51:53 +03:00
lendry
d8f97ee232 add rebuild docker install sh 2026-06-25 09:19:47 +03:00
lendry
933f7fb9e1 update 2026-06-25 08:31:36 +03:00
lendry
71b270fcb3 update 2026-06-25 07:23:34 +03:00
lendry
f2366a69a0 family update 2026-06-24 23:17:24 +03:00
lendry
9727cf3f35 more fix and update 2026-06-24 20:15:19 +03:00
lendry
dcab6557d3 fix ldap 2026-06-24 18:12:12 +03:00
lendry
21f2a1c227 fix idp on started 2026-06-24 17:53:58 +03:00
lendry
b6987f4aea fix idp on started 2026-06-24 17:34:55 +03:00
lendry
e60d55f6bd fix idp on started 2026-06-24 16:53:06 +03:00
lendry
d16eccb4c2 fix idp on started 2026-06-24 16:42:07 +03:00
lendry
36f30039ad fix idp on started 2026-06-24 16:21:29 +03:00
354 changed files with 52801 additions and 2572 deletions

View File

@@ -10,3 +10,11 @@ apps/sso-core/.env
coverage
*.log
*.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

View File

@@ -4,26 +4,47 @@
# Режим: local | intranet | production
INSTALL_MODE=local
# Nginx: auto (Windows → Docker) | host (Linux) | docker
NGINX_MODE=auto
# Nginx: docker (контейнер lendry-id-nginx на 80/443) | host (только с --nginx-mode host)
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
# Домены без протокола (install.sh подставит PUBLIC_* URL)
DOMAIN_API=
DOMAIN_FRONTEND=
# Папка со своими сертификатами (для SSL_TYPE=custom); install.sh копирует файлы в nginx/certs/custom/
SSL_CERT_DIR=
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=
# Пусто = WebSocket на DOMAIN_API по пути /ws
DOMAIN_WS=
DOMAIN_MINIO=
DOMAIN_MINIO_CONSOLE=
# Публичные URL (генерируются install.sh)
PUBLIC_API_URL=http://localhost:3000
PUBLIC_FRONTEND_URL=http://localhost:3002
# Публичные URL (генерируются install.sh; split-domain: API и SSO отдельно)
PUBLIC_API_URL=https://api.idpmvk.lpr
PUBLIC_FRONTEND_URL=https://sso.idpmvk.lpr
PUBLIC_DOCS_URL=http://localhost:3003
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)
USE_NGINX_SSL=false
@@ -53,4 +74,6 @@ DATA_ENCRYPTION_KEY=change-me-data-encryption-key-32-chars-min
# NPM registry (опционально)
NPM_REGISTRY=https://registry.npmjs.org
# Android APK (tauri_app): keystore создаётся автоматически в tauri_app/.secrets/ при ./install.sh --build-apk
COMPOSE_PROJECT_NAME=lendry-id

5
.gitignore vendored
View File

@@ -16,3 +16,8 @@ coverage
generated
*.tsbuildinfo
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/

View File

@@ -1,5 +1,3 @@
# syntax=docker/dockerfile:1.4
FROM node:24-alpine
WORKDIR /app

View File

@@ -20,11 +20,13 @@
"@nestjs/swagger": "^11.2.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
"cookie-parser": "^1.4.7",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2"
},
"devDependencies": {
"@nestjs/cli": "^11.0.14",
"@types/cookie-parser": "^1.4.10",
"@types/node": "^24.10.1",
"typescript": "^5.9.3"
}

View File

@@ -14,13 +14,21 @@ import { ProfileController } from './controllers/profile.controller';
import { DocumentsController } from './controllers/documents.controller';
import { AddressesController } from './controllers/addresses.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 { FamilyController } from './controllers/family.controller';
import { ChatController } from './controllers/chat.controller';
import { NotificationsController } from './controllers/notifications.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 { AdminGuard, SuperAdminGuard } from './guards/admin.guard';
import { AdminGuard, RbacManageGuard, SuperAdminGuard } from './guards/admin.guard';
import { FedcmCookieInterceptor } from './interceptors/fedcm-cookie.interceptor';
@Module({
imports: [
@@ -34,7 +42,7 @@ import { AdminGuard, SuperAdminGuard } from './guards/admin.guard';
useFactory: (config: ConfigService) => ({
transport: Transport.GRPC,
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: [
join(__dirname, '../../../shared/proto/auth.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/media.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],
providers: [CoreGrpcService, AdminGuard, SuperAdminGuard]
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, RbacManageGuard, SuperAdminGuard, FedcmCookieInterceptor]
})
export class AppModule {}

View 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)
};
}

View 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 }));
}
}

View 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
})
);
}
}

View File

@@ -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 { firstValueFrom } from 'rxjs';
import { map } from 'rxjs';
import { CoreGrpcService } from '../core-grpc.service';
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';
@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')
@ApiOperation({ summary: 'Обновить профиль пользователя', description: 'Обновляет основные и резервные контакты пользователя.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@@ -66,4 +76,170 @@ export class AdminController {
setSuperAdmin(@Param('userId') userId: string, @Body() dto: SetSuperAdminDto, @CurrentAdmin() admin: AdminRequestUser) {
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
})
);
}
}

View File

@@ -1,12 +1,21 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
import { Body, Controller, ForbiddenException, Get, Headers, Param, Post, Req } from '@nestjs/common';
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 { QrSessionDto, WebAuthnDto } from '../dto/identity.dto';
import { resolveAuthorizedPayload } from '../session-auth';
import { resolveFrontendUrl } from '../lib/oauth-issuer';
@ApiTags('Биометрия и QR-вход')
@Controller('auth/advanced')
export class AdvancedAuthController {
constructor(private readonly core: CoreGrpcService) {}
constructor(
private readonly core: CoreGrpcService,
private readonly jwt: JwtService
) {}
@Post('webauthn/register/challenge')
@ApiOperation({ summary: 'Challenge регистрации WebAuthn', description: 'Создает challenge для регистрации лица/отпечатка. Endpoint готов для подключения настоящего WebAuthn attestation.' })
@@ -28,15 +37,55 @@ export class AdvancedAuthController {
@ApiOperation({ summary: 'Создать QR-сессию', description: 'Создает временную QR-сессию для входа с другого устройства.' })
@ApiBody({ type: QrSessionDto })
@ApiResponse({ status: 201, description: 'QR-сессия создана' })
createQr(@Body() dto: QrSessionDto) {
return this.core.advancedAuth.CreateQrSession(dto);
createQr(@Body() dto: QrSessionDto, @Req() req: Request) {
return this.core.advancedAuth.CreateQrSession(
enrichAuthClientMeta(req, {
deviceName: dto.deviceName,
fingerprint: dto.fingerprint,
deviceType: dto.deviceType ?? 'WEB'
})
);
}
@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-сессии' })
@ApiResponse({ status: 200, description: 'Статус QR-сессии получен' })
pollQr(@Param('sessionId') sessionId: string) {
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 });
}
}

View 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);
}
}

View File

@@ -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 { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Request } from 'express';
import { firstValueFrom } from 'rxjs';
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 { FedcmCookieInterceptor } from '../interceptors/fedcm-cookie.interceptor';
@ApiTags('Аутентификация')
@Controller('auth')
@UseInterceptors(FedcmCookieInterceptor)
export class AuthController {
constructor(
private readonly core: CoreGrpcService,
@@ -24,8 +28,8 @@ export class AuthController {
@Post('login')
@ApiOperation({ summary: 'Вход по почте, телефону или логину', description: 'Возвращает JWT и refresh token. Если PIN включен, сессия создается в ограниченном режиме.' })
@ApiBody({ type: LoginDto })
login(@Body() dto: LoginDto) {
return this.core.auth.Login(dto);
login(@Body() dto: LoginDto, @Req() req: Request) {
return this.core.auth.Login(enrichAuthClientMeta(req, dto));
}
@Post('identify')
@@ -38,29 +42,29 @@ export class AuthController {
@Post('otp/send')
@ApiOperation({ summary: 'Отправить OTP для входа', description: 'Passwordless-first вход: пользователь вводит почту или телефон, сервер создает 6-значный код и пишет его в console.log.' })
@ApiBody({ type: PasswordlessOtpDto })
sendOtp(@Body() dto: PasswordlessOtpDto) {
return this.core.auth.SendOtp({ recipient: dto.recipient, channel: dto.channel });
sendOtp(@Body() dto: PasswordlessOtpDto, @Req() req: Request) {
return this.core.auth.SendOtp({ recipient: dto.recipient, channel: dto.channel, ipAddress: enrichAuthClientMeta(req, {}).ipAddress });
}
@Post('otp/verify')
@ApiOperation({ summary: 'Проверить OTP для входа', description: 'Если пользователь не существует, создает его. Если пароль не задан, сразу возвращает JWT. Если пароль задан, возвращает requiresPassword=true и tempAuthToken.' })
@ApiBody({ type: PasswordlessVerifyDto })
verifyOtp(@Body() dto: PasswordlessVerifyDto) {
return this.core.auth.VerifyOtp(dto);
verifyOtp(@Body() dto: PasswordlessVerifyDto, @Req() req: Request) {
return this.core.auth.VerifyOtp(enrichAuthClientMeta(req, dto));
}
@Post('login/password')
@ApiOperation({ summary: 'Войти по паролю', description: 'Identifier-first парольный шаг. Принимает login+password, либо tempAuthToken+password для совместимости, затем выдает JWT.' })
@ApiBody({ type: PasswordLoginDto })
loginWithPassword(@Body() dto: PasswordLoginDto) {
return this.core.auth.LoginWithPassword(dto);
loginWithPassword(@Body() dto: PasswordLoginDto, @Req() req: Request) {
return this.core.auth.LoginWithPassword(enrichAuthClientMeta(req, dto));
}
@Post('ldap/login')
@ApiOperation({ summary: 'Войти через LDAP/LDAPS', description: 'Аутентификация через корпоративный LDAP-сервер. Требует включённой настройки LDAP_ENABLED.' })
@ApiBody({ type: LdapLoginDto })
loginWithLdap(@Body() dto: LdapLoginDto) {
return this.core.auth.LoginWithLdap(dto);
loginWithLdap(@Body() dto: LdapLoginDto, @Req() req: Request) {
return this.core.auth.LoginWithLdap(enrichAuthClientMeta(req, dto));
}
@Post('pin/verify')
@@ -70,6 +74,20 @@ export class AuthController {
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')
@ApiOperation({ summary: 'Обновить access token', description: 'Обновляет JWT по refresh token. Если сессия заблокирована PIN-кодом, возвращает requiresPin=true без выхода из аккаунта.' })
@ApiBody({ type: RefreshSessionDto })

View 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 }))
);
}
}

View File

@@ -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 { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { firstValueFrom } from 'rxjs';
@@ -6,9 +6,16 @@ import { map } from 'rxjs/operators';
import { CoreGrpcService } from '../core-grpc.service';
import { getAuthorizedUserId } from '../document-access';
import {
AddChatRoomMemberDto,
CreateChatRoomDto,
CreateE2EChatRoomDto,
EditChatMessageDto,
ForwardMessagesDto,
MarkRoomReadDto,
SendChatMessageDto,
SetMessagePinnedDto,
SetRoomNotificationsMutedDto,
ToggleMessageReactionDto,
UpdateChatRoomDto,
VotePollDto
} 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')
@ApiOperation({ summary: 'Настройки чата' })
async updateRoom(
@@ -66,11 +84,34 @@ export class ChatController {
userId,
roomId,
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')
@ApiOperation({ summary: 'Сообщения чата' })
async listMessages(
@@ -113,7 +154,8 @@ export class ChatController {
storageKey: dto.storageKey,
mimeType: dto.mimeType,
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);
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 }));
}
}

View File

@@ -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 { JwtService } from '@nestjs/jwt';
import { firstValueFrom } from 'rxjs';
import { map } from 'rxjs';
import { CoreGrpcService } from '../core-grpc.service';
import { assertDocumentsReadAccess, assertDocumentsWriteAccess } from '../document-access';
import { CreateDocumentDto, UpdateDocumentDto } from '../dto/documents.dto';
@@ -43,14 +42,10 @@ export class DocumentsController {
@ApiParam({ name: 'userId', description: 'ID пользователя' })
async list(@Headers('authorization') authorization: string | undefined, @Param('userId') userId: string) {
await assertDocumentsReadAccess(this.jwt, this.core, authorization, userId);
return firstValueFrom(
this.core.documents.ListDocuments({ userId }).pipe(
map((response) => {
const payload = response as { documents?: unknown[] };
return { documents: payload.documents ?? [] };
})
)
);
const result = (await firstValueFrom(this.core.documents.ListDocuments({ userId }))) as {
documents?: unknown[];
};
return { documents: result.documents ?? [] };
}
@Get(':documentId')

View File

@@ -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 { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
import { firstValueFrom } from 'rxjs';
import { map } from 'rxjs/operators';
import { CoreGrpcService } from '../core-grpc.service';
import { getAuthorizedUserId } from '../document-access';
import { AddFamilyMemberDto, CreateFamilyGroupDto, LeaveFamilyGroupDto, RespondFamilyInviteDto, SendFamilyInviteDto, TransferFamilyOwnershipDto, UpdateFamilyGroupDto } from '../dto/identity.dto';
@ApiTags('Семья')
@ApiBearerAuth()
@@ -16,8 +16,9 @@ export class FamilyController {
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')
@@ -33,8 +34,12 @@ export class FamilyController {
@Get('users/:userId/groups')
@ApiOperation({ summary: 'Список семей пользователя' })
private async auth(authorization?: string) {
async listGroups(
@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) {
throw new ForbiddenException('Можно просматривать только свои семьи');
}
@@ -50,8 +55,12 @@ export class FamilyController {
@Get('groups/:groupId')
@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 }));
}
@@ -91,6 +100,51 @@ export class FamilyController {
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')
@ApiOperation({ summary: 'Пригласить участника в семью' })
async sendInvite(
@@ -99,7 +153,14 @@ export class FamilyController {
@Body() dto: SendFamilyInviteDto
) {
const requesterId = await this.auth(authorization);
return firstValueFrom(
this.core.family.SendFamilyInvite({
requesterId,
groupId,
target: dto.target,
inviteeUserId: dto.inviteeUserId
})
);
}
@Get('invites')
@@ -126,5 +187,17 @@ export class FamilyController {
const userId = await this.auth(authorization);
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 }));
}
}

View 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>`);
}
}

View File

@@ -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')
export class HealthController {
constructor(private readonly core: CoreGrpcService) {}
// Liveness-проба. ВАЖНО: проверяет только то, что HTTP-сервер api-gateway
// поднят, и НЕ зависит от sso-core. Иначе временная недоступность gRPC
// помечает весь api-gateway как unhealthy в Docker healthcheck, что роняет
// зависимые сервисы и приводит к 502 на всех /idp-api/* маршрутах.
@Get()
check() {
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'
});
}
}
}

View File

@@ -1,7 +1,8 @@
import { Body, Controller, Get, Headers, Param, Post, Query, Res } from '@nestjs/common';
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
import { BadRequestException, Body, Controller, ForbiddenException, Get, Headers, Param, Post, Query, Res, UploadedFile, UseInterceptors } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
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 { CoreGrpcService } from '../core-grpc.service';
import { getAuthorizedUserId } from '../document-access';
@@ -44,6 +45,49 @@ export class MediaController {
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')
@ApiBearerAuth()
@ApiOperation({ summary: 'Получить URL для загрузки аватара', description: 'Возвращает presigned URL MinIO для загрузки изображения аватара.' })
@@ -73,7 +117,7 @@ export class MediaController {
@Post('documents/:documentId/photo/upload-url')
@ApiBearerAuth()
@ApiOperation({ summary: 'Получить URL для фото документа', description: 'Presigned URL для загрузки скана/фото документа в MinIO.' })
@ApiOperation({ summary: 'Получить URL для файла документа', description: 'Presigned URL для загрузки скана, фото или файла документа в MinIO.' })
@ApiBody({ type: DocumentPhotoUploadDto })
async createDocumentPhotoUploadUrl(
@Headers('authorization') authorization: string | undefined,
@@ -81,22 +125,31 @@ export class MediaController {
@Body() dto: DocumentPhotoUploadDto
) {
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')
@ApiBearerAuth()
@ApiOperation({ summary: 'Получить ссылку на фото документа', description: 'Временная ссылка на просмотр фото документа (15 минут).' })
@ApiOperation({ summary: 'Получить ссылку на файл документа', description: 'Временная ссылка на просмотр файла документа (15 минут).' })
@ApiParam({ name: 'userId', description: 'ID владельца документа' })
@ApiQuery({ name: 'storageKey', description: 'Ключ объекта в MinIO' })
@ApiQuery({ name: 'fileName', required: false, description: 'Имя файла для скачивания' })
async getDocumentPhotoUrl(
@Headers('authorization') authorization: string | undefined,
@Param('userId') userId: string,
@Query('storageKey') storageKey: string
@Query('storageKey') storageKey: string,
@Query('fileName') fileName?: string
) {
const requesterId = await this.authUserId(authorization);
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 })
);
}
@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 }));
}
}

View File

@@ -5,7 +5,7 @@ import { firstValueFrom } from 'rxjs';
import { map } from 'rxjs/operators';
import { CoreGrpcService } from '../core-grpc.service';
import { getAuthorizedUserId } from '../document-access';
import { MarkNotificationReadDto } from '../dto/notifications.dto';
import { MarkNotificationReadDto, RegisterPushTokenDto, UnregisterPushTokenDto } from '../dto/notifications.dto';
@ApiTags('Уведомления')
@ApiBearerAuth()
@@ -40,8 +40,12 @@ export class NotificationsController {
@Get('unread-count')
@ApiOperation({ summary: 'Количество непрочитанных уведомлений' })
async unreadCount(@Headers('authorization') authorization: string | undefined) {
const userId = await getAuthorizedUserId(this.jwt, this.core, authorization);
async unreadCount(
@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 }));
}
@@ -79,4 +83,36 @@ export class NotificationsController {
const userId = await getAuthorizedUserId(this.jwt, this.core, authorization);
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
})
);
}
}

View File

@@ -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 { firstValueFrom } from 'rxjs';
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 { 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')
@Controller('oauth')
export class OAuthController {
constructor(private readonly core: CoreGrpcService) {}
constructor(
private readonly core: CoreGrpcService,
private readonly jwt: JwtService
) {}
@Get('authorize')
@ApiOperation({ summary: 'OAuth авторизация', description: 'Создает authorization_code и возвращает redirectUrl для OAuth клиента. Consent считается подтвержденным для переданного userId.' })
@ApiQuery({ name: 'userId', description: 'ID пользователя' })
@ApiQuery({ name: 'clientId', description: 'OAuth client_id' })
@ApiQuery({ name: 'redirectUri', description: 'redirect_uri' })
@ApiQuery({ name: 'scope', description: 'Scopes через пробел' })
@ApiResponse({ status: 200, description: 'Authorization code создан' })
authorize(@Query() query: OAuthAuthorizeQueryDto) {
return this.core.oauth.Authorize(query);
@UsePipes(oauthValidationPipe)
@ApiOperation({
summary: 'OAuth / OIDC авторизация',
description:
'Поддерживает стандартные query-параметры RFC 6749 / OIDC (client_id, redirect_uri, response_type, code_challenge) и legacy camelCase. ' +
'Без userId и без Bearer-токена перенаправляет на страницу подтверждения доступа.'
})
@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')
@ApiOperation({ summary: 'Выдать OAuth токены', description: 'Поддерживает grant_type=authorization_code и grant_type=refresh_token.' })
@ApiBody({ type: OAuthTokenDto })
@UsePipes(oauthValidationPipe)
@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 токены выданы' })
token(@Body() dto: OAuthTokenDto) {
return this.core.oauth.Token(dto);
async token(@Body() body: OAuthTokenIncomingDto, @Headers('authorization') authorization?: string) {
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')
@ApiBearerAuth()
@ApiOperation({ summary: 'OAuth userinfo', description: 'Возвращает профиль пользователя по OAuth access token.' })
@ApiResponse({ status: 200, description: 'Профиль пользователя получен' })
userInfo(@Headers('authorization') authorization?: string) {
return this.core.oauth.UserInfo({ accessToken: extractBearerToken(authorization) });
async userInfo(@Headers('authorization') authorization?: string) {
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);
}
}

View File

@@ -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 { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
import { CoreGrpcService } from '../core-grpc.service';
import { getAuthorizedUserId } from '../document-access';
import { CoreGrpcService } from '../core-grpc.service';
import {
ChangePasswordDto,
PasswordVerificationDto,
SendPasswordVerificationOtpDto,
SetPasswordDto,
UpdateAvatarDto,
UpdateContactsDto,
UpdateProfileDto
} from '../dto/profile.dto';
@ApiTags('Профиль и биометрия')
@ApiBearerAuth()
@Controller('profile/users/:userId')
@@ -60,24 +67,94 @@ export class ProfileController {
}
@Post('password')
}
@ApiOperation({ summary: 'Установить пароль', description: 'Устанавливает пароль для аккаунта без ранее заданного пароля.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiBody({ type: SetPasswordDto })
@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 });
}
@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')
@ApiOperation({
@ApiBody({ type: UpdateAvatarDto })
summary: 'Запросить удаление своего профиля',
description:
'Планирует удаление аккаунта через период ожидания ACCOUNT_DELETE_GRACE_DAYS. До истечения срока пользователь может отменить удаление.'
})
@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) {
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 });
}
}

View File

@@ -3,8 +3,22 @@ import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiTags } from '@nestjs
import { map } from 'rxjs';
import { CoreGrpcService } from '../core-grpc.service';
import { CurrentAdmin } from '../decorators/current-admin.decorator';
import { AssignUserRoleDto, CreateOAuthClientDto, CreateRoleDto, UpdateOAuthClientDto } from '../dto/rbac.dto';
import { AdminGuard, AdminRequestUser, assertAdminPermission, SuperAdminGuard } from '../guards/admin.guard';
import {
AssignUserPermissionDto,
AssignUserRoleDto,
CreateOAuthClientDto,
CreateRoleDto,
UpdateOAuthClientDto,
UpdateRoleDto
} from '../dto/rbac.dto';
import {
AdminGuard,
AdminRequestUser,
assertAdminAnyPermission,
assertAdminPermission,
RbacManageGuard,
SuperAdminGuard
} from '../guards/admin.guard';
@ApiTags('RBAC и OAuth')
@ApiBearerAuth()
@@ -30,7 +44,7 @@ export class RbacController {
}
@Get('permissions')
@UseGuards(SuperAdminGuard)
@UseGuards(RbacManageGuard)
@ApiOperation({ summary: 'Список прав', description: 'Возвращает все доступные permissions.' })
listPermissions() {
return this.core.rbac.ListPermissions({});
@@ -39,28 +53,45 @@ export class RbacController {
@Get('oauth-scopes')
@ApiOperation({ summary: 'OAuth scopes', description: 'Возвращает доступные scopes для OAuth-приложений.' })
listOAuthScopes(@CurrentAdmin() admin: AdminRequestUser) {
assertAdminPermission(admin, 'canManageOAuth');
assertAdminAnyPermission(admin, 'canViewOAuth', 'canManageOAuth');
return this.core.rbac.ListOAuthScopes({});
}
@Get('oauth-clients')
@ApiOperation({ summary: 'OAuth приложения', description: 'Возвращает OAuth-клиенты и доступные scopes.' })
listOAuthClients(@CurrentAdmin() admin: AdminRequestUser) {
assertAdminPermission(admin, 'canManageOAuth');
return this.core.rbac.ListOAuthClients({});
assertAdminPermission(admin, 'canViewOAuth');
return this.core.rbac.ListOAuthClients({ actorUserId: admin.id });
}
@Post('roles')
@UseGuards(SuperAdminGuard)
@ApiOperation({ summary: 'Создать роль', description: 'Создаёт новую роль с набором прав. Только супер-администратор.' })
@UseGuards(RbacManageGuard)
@ApiOperation({ summary: 'Создать роль', description: 'Создаёт новую роль с набором прав.' })
@ApiBody({ type: CreateRoleDto })
createRole(@Body() dto: CreateRoleDto, @CurrentAdmin() admin: AdminRequestUser) {
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')
@UseGuards(SuperAdminGuard)
@ApiOperation({ summary: 'Назначить роль пользователю', description: 'Только супер-администратор может назначать роли.' })
@UseGuards(RbacManageGuard)
@ApiOperation({ summary: 'Назначить роль пользователю' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiBody({ type: AssignUserRoleDto })
assignRole(@Param('userId') userId: string, @Body() dto: AssignUserRoleDto, @CurrentAdmin() admin: AdminRequestUser) {
@@ -68,14 +99,36 @@ export class RbacController {
}
@Delete('users/:userId/roles/:roleSlug')
@UseGuards(SuperAdminGuard)
@ApiOperation({ summary: 'Снять роль с пользователя', description: 'Только супер-администратор может снимать роли.' })
@UseGuards(RbacManageGuard)
@ApiOperation({ summary: 'Снять роль с пользователя' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiParam({ name: 'roleSlug', description: 'Slug роли' })
removeRole(@Param('userId') userId: string, @Param('roleSlug') roleSlug: string, @CurrentAdmin() admin: AdminRequestUser) {
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')
@ApiOperation({ summary: 'Создать OAuth-приложение', description: 'Создаёт OAuth2-клиент и возвращает client secret один раз.' })
@ApiBody({ type: CreateOAuthClientDto })
@@ -93,6 +146,14 @@ export class RbacController {
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')
@ApiOperation({ summary: 'Перевыпустить client secret', description: 'Генерирует новый secret для confidential-клиента.' })
@ApiParam({ name: 'clientId', description: 'Client ID приложения' })

View File

@@ -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 { firstValueFrom } from 'rxjs';
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('Безопасность')
@ApiBearerAuth()
@Controller('security')
export class SecurityController {
constructor(private readonly core: CoreGrpcService) {}
constructor(
private readonly core: CoreGrpcService,
private readonly jwt: JwtService
) {}
@Get('users/:userId/devices')
@ApiOperation({ summary: 'Активные устройства', description: 'Показывает устройства пользователя и связанные активные сессии.' })
@ApiOperation({ summary: 'Активные устройства', description: 'Показывает устройства пользователя и связанные активные сессии. Текущее устройство не отображается.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiResponse({ status: 200, description: 'Список устройств получен' })
listDevices(@Param('userId') userId: string) {
return this.core.security.ListActiveDevices({ userId });
async listDevices(@Param('userId') userId: string, @Headers('authorization') authorization?: string) {
const payload = await resolveAuthorizedPayload(this.jwt, this.core, authorization);
return this.core.security.ListActiveDevices({ userId, exceptSessionId: payload.sessionId });
}
@Get('users/:userId/sessions')
@ApiOperation({ summary: 'Активные сессии', description: 'Возвращает ACTIVE и LOCKED сессии пользователя для управления устройствами.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@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 });
}
@@ -29,8 +38,40 @@ export class SecurityController {
@ApiOperation({ summary: 'История входов', description: 'Показывает последние попытки входа и причины отказов.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiResponse({ status: 200, description: 'История входов получена' })
listHistory(@Param('userId') userId: string) {
return this.core.security.ListSignInHistory({ userId });
listHistory(@Param('userId') userId: string, @Headers('authorization') authorization?: string) {
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')
@@ -102,10 +143,27 @@ export class SecurityController {
}
@Post('users/:userId/revoke-all-sessions')
@ApiOperation({ summary: 'Выйти везде', description: 'Отзывает все активные и PIN-заблокированные сессии пользователя.' })
@ApiOperation({ summary: 'Выйти везде', description: 'Отзывает все активные и PIN-заблокированные сессии пользователя, кроме текущей.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })
@ApiResponse({ status: 201, description: 'Все сессии отозваны' })
revokeAll(@Param('userId') userId: string) {
return this.core.security.RevokeAllSessions({ userId });
@ApiResponse({ status: 201, description: 'Остальные сессии отозваны' })
async revokeAll(@Param('userId') userId: string, @Headers('authorization') authorization?: string) {
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 }));
}
}

View File

@@ -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 { map } from 'rxjs';
import { CoreGrpcService } from '../core-grpc.service';
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';
const settingsWritePipe = new ValidationPipe({
@@ -97,6 +97,24 @@ export class SettingsController {
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')
@ApiOperation({ summary: 'Связанные внешние аккаунты', description: 'Возвращает LinkedAccount записи пользователя для Google/Yandex и других провайдеров.' })
@ApiParam({ name: 'userId', description: 'ID пользователя' })

View 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);
}
}

View 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);
}
}

View File

@@ -15,12 +15,15 @@ export class CoreGrpcService implements OnModuleInit {
documents!: Record<string, GrpcMethod>;
addresses!: Record<string, GrpcMethod>;
oauth!: Record<string, GrpcMethod>;
fedcm!: Record<string, GrpcMethod>;
otp!: Record<string, GrpcMethod>;
advancedAuth!: Record<string, GrpcMethod>;
notifications!: Record<string, GrpcMethod>;
chat!: Record<string, GrpcMethod>;
family!: Record<string, GrpcMethod>;
media!: Record<string, GrpcMethod>;
bot!: Record<string, GrpcMethod>;
appRelease!: Record<string, GrpcMethod>;
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.addresses = this.client.getService<Record<string, GrpcMethod>>('AddressesService');
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.advancedAuth = this.client.getService<Record<string, GrpcMethod>>('AdvancedAuthService');
this.family = this.client.getService<Record<string, GrpcMethod>>('FamilyService');
this.notifications = this.client.getService<Record<string, GrpcMethod>>('NotificationsService');
this.chat = this.client.getService<Record<string, GrpcMethod>>('ChatService');
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');
}
}

View File

@@ -10,9 +10,19 @@ interface RequesterProfile {
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);
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 };
return { id: profile.id, isSuperAdmin: profile.isSuperAdmin, canViewUserDocuments: profile.canViewUserDocuments };
}
@@ -46,7 +56,12 @@ export async function assertDocumentsWriteAccess(
return requester.id;
}
export async function getAuthorizedUserId(jwt: JwtService, core: CoreGrpcService, authorization?: string) {
const requester = await getRequesterProfile(jwt, core, authorization);
export async function getAuthorizedUserId(
jwt: JwtService,
core: CoreGrpcService,
authorization?: string,
touchActivity = true
) {
const requester = await getRequesterProfile(jwt, core, authorization, touchActivity);
return requester.id;
}

View File

@@ -58,3 +58,44 @@ export class SetSuperAdminDto {
@IsBoolean({ message: '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;
}

View 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;
}

View File

@@ -150,6 +150,41 @@ export class VerifyPinDto {
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 {
@ApiProperty({ description: 'Refresh token текущей сессии' })
@IsString({ message: 'Refresh token должен быть строкой' })

View 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;
}

View File

@@ -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 {
@IsString()
@@ -11,6 +11,11 @@ export class CreateChatRoomDto {
memberUserIds?: string[];
}
export class CreateE2EChatRoomDto {
@IsString()
peerUserId!: string;
}
export class UpdateChatRoomDto {
@IsOptional()
@IsString()
@@ -20,11 +25,15 @@ export class UpdateChatRoomDto {
@IsOptional()
@IsBoolean()
notificationsMuted?: boolean;
@IsOptional()
@IsBoolean()
pinned?: boolean;
}
export class SendChatMessageDto {
@IsString()
@IsIn(['TEXT', 'IMAGE', 'AUDIO', 'VOICE', 'FILE', 'EMOJI', 'POLL'])
@IsIn(['TEXT', 'IMAGE', 'AUDIO', 'VOICE', 'VIDEO', 'VIDEO_NOTE', 'FILE', 'EMOJI', 'POLL', 'LOCATION'])
type!: string;
@IsOptional()
@@ -54,6 +63,10 @@ export class SendChatMessageDto {
allowsMultiple?: boolean;
isAnonymous?: boolean;
};
@IsOptional()
@IsBoolean()
isEncrypted?: boolean;
}
export class VotePollDto {
@@ -66,3 +79,38 @@ export class SetRoomNotificationsMutedDto {
@IsBoolean()
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[];
}

View File

@@ -97,6 +97,16 @@ export class QrSessionDto {
@ApiProperty({ description: 'Название устройства', example: 'Chrome на Windows' })
@IsString({ message: 'Название устройства должно быть строкой' })
deviceName!: string;
@ApiPropertyOptional({ description: 'Отпечаток устройства браузера' })
@IsOptional()
@IsString({ message: 'Отпечаток устройства должен быть строкой' })
fingerprint?: string;
@ApiPropertyOptional({ description: 'Тип устройства', example: 'WEB' })
@IsOptional()
@IsString({ message: 'Тип устройства должен быть строкой' })
deviceType?: string;
}
export class CreateFamilyGroupDto {
@@ -127,10 +137,15 @@ export class AddFamilyMemberDto {
}
export class SendFamilyInviteDto {
@ApiProperty({ description: 'Email, телефон или логин приглашаемого' })
@ApiPropertyOptional({ description: 'Email, телефон или логин (legacy)' })
@IsOptional()
@IsString({ message: 'Укажите контакт приглашаемого' })
@IsNotEmpty({ message: 'Укажите контакт приглашаемого' })
target!: string;
target?: string;
@ApiPropertyOptional({ description: 'ID пользователя из результатов поиска' })
@IsOptional()
@IsString({ message: 'ID пользователя должен быть строкой' })
inviteeUserId?: string;
}
export class RespondFamilyInviteDto {
@@ -138,3 +153,18 @@ export class RespondFamilyInviteDto {
@IsBoolean({ message: 'Укажите accept: true или false' })
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;
}

View File

@@ -2,6 +2,21 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsOptional, IsString } from 'class-validator';
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 {
@ApiProperty({ description: 'MIME-тип изображения', example: 'image/jpeg', enum: IMAGE_TYPES })
@IsString({ message: 'Укажите MIME-тип изображения' })
@@ -16,10 +31,17 @@ export class ConfirmAvatarDto {
}
export class DocumentPhotoUploadDto {
@ApiProperty({ description: 'MIME-тип изображения', example: 'image/jpeg', enum: IMAGE_TYPES })
@IsString({ message: 'Укажите MIME-тип изображения' })
@IsIn([...IMAGE_TYPES], { message: 'Допустимы только JPEG, PNG, WEBP или GIF' })
@ApiProperty({ description: 'MIME-тип файла', example: 'application/pdf', enum: DOCUMENT_ATTACHMENT_TYPES })
@IsString({ message: 'Укажите MIME-тип файла' })
@IsIn([...DOCUMENT_ATTACHMENT_TYPES], {
message: 'Допустимы изображения, PDF, Word, Excel, PowerPoint, TXT и ODT'
})
contentType!: string;
@ApiPropertyOptional({ description: 'Имя файла для определения расширения', example: 'passport.pdf' })
@IsOptional()
@IsString({ message: 'Имя файла должно быть строкой' })
fileName?: string;
}
export class ChatMediaUploadDto {

View File

@@ -1 +1,28 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
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;
}

View 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;
}

View File

@@ -40,6 +40,11 @@ export class UpdateProfileDto {
@IsOptional()
@IsString({ message: 'Пол должен быть строкой' })
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 {
@@ -71,6 +76,41 @@ export class SetPasswordDto {
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 {
@ApiProperty({ description: 'ID пользователя' })
@IsString({ message: 'ID пользователя должен быть строкой' })

View File

@@ -33,6 +33,30 @@ export class AssignUserRoleDto {
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 {
@ApiProperty({ description: 'Название приложения', example: 'Lendry Docs' })
@IsString({ message: 'Название должно быть строкой' })

View File

@@ -23,3 +23,11 @@ export class VerifySecurityPinDto extends PinDto {
@IsString({ message: 'ID сессии должен быть строкой' })
sessionId!: string;
}
export class TotpCodeDto {
@ApiProperty({ description: '6-значный код из приложения-аутентификатора', example: '123456' })
@IsString({ message: 'Код должен быть строкой' })
@Length(6, 6, { message: 'Код должен содержать 6 цифр' })
@Matches(/^\d+$/, { message: 'Код должен содержать только цифры' })
code!: string;
}

View File

@@ -1,5 +1,5 @@
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 {
@ApiProperty({ description: 'Ключ настройки', example: 'PIN_LOCK_TIMEOUT_MINUTES' })
@@ -43,6 +43,17 @@ export class UpsertSocialProviderDto {
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 {
@ApiProperty({ description: 'Название провайдера', example: 'google' })
@IsString({ message: 'Название провайдера должно быть строкой' })

View File

@@ -1,8 +1,16 @@
import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common';
import { status as GrpcStatus } from '@grpc/grpc-js';
import { applyFedcmCorsHeaders } from './lib/fedcm-cors';
interface HttpResponseLike {
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 {
@@ -19,6 +27,8 @@ function grpcToHttp(code?: number): number {
return 404;
case GrpcStatus.ALREADY_EXISTS:
return 409;
case GrpcStatus.RESOURCE_EXHAUSTED:
return 429;
default:
return 500;
}
@@ -27,7 +37,16 @@ function grpcToHttp(code?: number): number {
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
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) {
const statusCode = exception.getStatus();

View File

@@ -9,10 +9,15 @@ export interface AdminRequestUser {
isSuperAdmin: boolean;
canAccessAdmin: boolean;
canManageRoles: boolean;
canViewOAuth: boolean;
canManageOAuth: boolean;
canManageAllOAuth: boolean;
canManageUsers: boolean;
canManageAllUsers: boolean;
canViewUsers: boolean;
canManageSettings: boolean;
canVerifyUsers: boolean;
canModerateChats: boolean;
roles: string[];
permissions: string[];
}
@@ -48,10 +53,15 @@ export class AdminGuard implements CanActivate {
isSuperAdmin: profile.isSuperAdmin,
canAccessAdmin: Boolean(profile.canAccessAdmin),
canManageRoles: Boolean(profile.canManageRoles),
canViewOAuth: Boolean(profile.canViewOAuth),
canManageOAuth: Boolean(profile.canManageOAuth),
canManageAllOAuth: Boolean(profile.canManageAllOAuth),
canManageUsers: Boolean(profile.canManageUsers),
canManageAllUsers: Boolean(profile.canManageAllUsers),
canManageSettings: Boolean(profile.canManageSettings),
canViewUsers: Boolean(profile.canViewUsers),
canVerifyUsers: Boolean(profile.canVerifyUsers),
canModerateChats: Boolean(profile.canModerateChats),
roles: profile.roles ?? [],
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()
export class SuperAdminGuard implements CanActivate {
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]) {
throw new ForbiddenException('Недостаточно прав для выполнения действия');
}
}
export function assertAdminAnyPermission(user: AdminRequestUser | undefined, ...permissions: AdminPermissionKey[]) {
if (!user || !permissions.some((permission) => user[permission])) {
throw new ForbiddenException('Недостаточно прав для выполнения действия');
}
}

View File

@@ -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;
})
);
}
}

View 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 };

View 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
});
}

View 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');
}

View 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 };
}

View 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'
]
};
}

View 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 ?? '')
}))
};
}

View 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;
}

View File

@@ -1,11 +1,15 @@
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import cookieParser from 'cookie-parser';
import { AppModule } from './app.module';
import { AllExceptionsFilter } from './grpc-exception.filter';
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.useGlobalPipes(
new ValidationPipe({
@@ -18,14 +22,15 @@ async function bootstrap() {
const config = new DocumentBuilder()
.setTitle('Lendry ID API')
.setDescription('REST API для единого входа, безопасности, RBAC и администрирования Lendry ID.')
.setDescription('REST API для единого входа, безопасности, RBAC и администрирования.')
.setVersion('0.1.0')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('docs', app, document, {
yamlDocumentUrl: '/openapi.yaml',
swaggerOptions: { persistAuthorization: true },
customSiteTitle: 'Документация Lendry ID API'
customSiteTitle: 'Документация API'
});
await app.listen(process.env.PORT ? Number(process.env.PORT) : 3000);

View File

@@ -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) {
return;
}
@@ -37,7 +41,7 @@ export async function assertSessionUnlocked(core: CoreGrpcService, payload: Acce
core.auth.ValidateSession({
userId: payload.sub,
sessionId: payload.sessionId,
touchActivity: true
touchActivity
})
)) as { requiresPin: boolean; sessionId: string };
@@ -54,9 +58,10 @@ export async function assertSessionUnlocked(core: CoreGrpcService, payload: Acce
export async function resolveAuthorizedPayload(
jwt: JwtService,
core: CoreGrpcService,
authorization?: string
authorization?: string,
touchActivity = true
): Promise<AccessTokenPayload> {
const payload = await verifyAccessToken(jwt, authorization);
await assertSessionUnlocked(core, payload);
await assertSessionUnlocked(core, payload, touchActivity);
return payload;
}

View File

@@ -1,5 +1,3 @@
# syntax=docker/dockerfile:1.4
FROM node:24-alpine
WORKDIR /app

View File

@@ -27,6 +27,31 @@ body {
color: var(--foreground);
font-family: var(--font-sans);
-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 {

View 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>
);
}

View File

@@ -1,7 +1,10 @@
import type { DocBlock } from '@/lib/docs-pages';
import { OAuthCodeTabs } from '@/components/oauth-code-tabs';
import { OneTapCodeTabs } from '@/components/one-tap-code-tabs';
import { OneTapButtonBuilder } from '@/components/one-tap-button-builder';
import { AuthLoginCodeTabs } from '@/components/auth-code-tabs';
import { AuthLdapCodeTabs } from '@/components/auth-ldap-code-tabs';
import { BotCodeTabs } from '@/components/bot-code-tabs';
import { ApiReferenceSection } from '@/components/api-endpoint-card';
import { CodeBlock } from '@/components/code-block';
import { cn } from '@/lib/utils';
@@ -66,10 +69,16 @@ export function DocBlockRenderer({ block }: { block: DocBlock }) {
);
case 'oauth-examples':
return <OAuthCodeTabs />;
case 'one-tap-examples':
return <OneTapCodeTabs />;
case 'one-tap-builder':
return <OneTapButtonBuilder />;
case 'auth-login-examples':
return <AuthLoginCodeTabs />;
case 'auth-ldap-examples':
return <AuthLdapCodeTabs />;
case 'bot-examples':
return <BotCodeTabs />;
case 'api-reference':
return <ApiReferenceSection />;
default:

View File

@@ -1,7 +1,7 @@
'use client';
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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { docNavigation, groupDocNavigation } from '@/lib/navigation';
@@ -16,10 +16,22 @@ const highlights = [
},
{
icon: Code2,
title: 'OAuth 2.0',
description: 'Примеры интеграции на JavaScript, Python, PHP, Go и других языках.',
title: 'OAuth 2.0 / OIDC',
description: 'Стандартный OpenID Connect: Discovery, client_id, PKCE, form-urlencoded token. Примеры для PHP без доработки OidcProvider.',
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,
title: 'Безопасность',

View File

@@ -27,7 +27,7 @@ export function DocsHeader() {
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" asChild className="hidden md:inline-flex">
<a href={`${apiUrl}/api`} target="_blank" rel="noreferrer">
<a href={`${apiUrl}/docs`} target="_blank" rel="noreferrer">
Swagger
<ExternalLink className="h-3.5 w-3.5" />
</a>

View File

@@ -1,8 +1,30 @@
'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';
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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -17,9 +17,11 @@ export const apiReference: ApiTagGroup[] = [
endpoints: [
{ method: 'POST', path: '/auth/register', summary: 'Регистрация пользователя', description: 'Первый пользователь получает isSuperAdmin.' },
{ method: 'POST', path: '/auth/login', summary: 'Вход по почте, телефону или логину' },
{ method: 'POST', path: '/auth/identify', summary: 'Проверить способ входа (identifier-first)' },
{ method: 'POST', path: '/auth/otp/send', summary: 'Отправить OTP для passwordless-входа' },
{ method: 'POST', path: '/auth/otp/verify', summary: роверить OTP' },
{ method: 'POST', path: '/auth/identify', summary: 'Проверить способ входа (identifier-first)', description: 'Возвращает isTotpEnabled, otpChannels, methods.' },
{ method: 'POST', path: '/auth/totp/begin', summary: 'Начать вход по TOTP', description: 'Challenge для Google Authenticator вместо SMS/email 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/ldap/login', summary: 'Войти через LDAP/LDAPS' },
{ 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: [
{ method: 'GET', path: '/oauth/authorize', summary: 'Создать authorization code' },
{ method: 'POST', path: '/oauth/token', summary: 'Выдать OAuth токены (code / refresh_token)' },
{ method: 'GET', path: '/oauth/userinfo', summary: 'Профиль по OAuth access token', auth: true }
{
method: 'GET',
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}/contacts', summary: 'Обновить контакты', auth: true },
{ method: 'POST', path: '/profile/users/{userId}/password', summary: 'Установить пароль', auth: true },
{ method: 'POST', path: '/profile/users/{userId}/self-delete', summary: 'Удалить свой профиль', auth: true }
{
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: [
{ 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}/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}/revoke-all-sessions', summary: 'Выйти везде', auth: true }
]
@@ -86,15 +207,83 @@ export const apiReference: ApiTagGroup[] = [
endpoints: [
{ method: 'POST', path: '/family/groups', summary: 'Создать семейную группу', auth: true },
{ method: 'GET', path: '/family/users/{userId}/groups', summary: 'Список семей пользователя', auth: true },
{ method: 'POST', path: '/family/groups/{groupId}/invites', summary: ригласить участника', auth: true }
{ 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: 'Чат',
endpoints: [
{ method: 'GET', path: '/chat/groups/{groupId}/rooms', summary: 'Список чатов семьи', auth: true },
{ method: 'POST', path: '/chat/rooms/{roomId}/messages', summary: 'Отправить сообщение', auth: true },
{ method: 'GET', path: '/chat/rooms/{roomId}/messages', summary: 'Сообщения чата', auth: true }
{
method: 'GET',
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 }
]
},
{
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: 'Уведомления',
endpoints: [

View 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

View File

@@ -9,10 +9,12 @@ export const docNavigation: DocNavItem[] = [
{ slug: 'architecture', title: 'Архитектура', group: 'Введение' },
{ slug: 'deployment', 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: 'sessions', title: 'Сессии и PIN', group: 'Безопасность' },
{ slug: 'sessions', title: 'Сессии, PIN и удаление аккаунта', group: 'Безопасность' },
{ slug: 'family-chat', title: 'Семья и чат', group: 'Функции' },
{ slug: 'bot-api', title: 'Telegram Bot API', group: 'Интеграция' },
{ slug: 'api-reference', title: 'Справочник API', group: 'Справочник' }
];

View File

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

View 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();
}

View File

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

View 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"`
}
];
}

View 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]
);
}

View File

@@ -1,5 +1,36 @@
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;

View File

@@ -1,5 +1,3 @@
# syntax=docker/dockerfile:1.4
FROM node:24-alpine
WORKDIR /app
@@ -7,6 +5,8 @@ WORKDIR /app
ARG NPM_REGISTRY=https://registry.npmjs.org
ARG NEXT_PUBLIC_API_URL=http://localhost:3000
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 apps/sso-core/package.json ./apps/sso-core/
@@ -34,6 +34,8 @@ ENV PORT="3000"
ENV HOSTNAME="0.0.0.0"
ENV NEXT_PUBLIC_API_URL="${NEXT_PUBLIC_API_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

View 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>
);
}

View File

@@ -1,8 +1,10 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { Copy, KeyRound, Loader2, LockKeyhole, Plus, RefreshCw } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Copy, ExternalLink, Loader2, LockKeyhole, Plus, UserRound } from 'lucide-react';
import { AdminShell } from '@/components/id/admin-shell';
import { OAuthClientDetailDialog } from '@/components/id/oauth-client-detail-dialog';
import { useAuth } from '@/components/id/auth-provider';
import { useToast } from '@/components/id/toast-provider';
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 { Input } from '@/components/ui/input';
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() {
const { token } = useAuth();
const router = useRouter();
const { token, user } = useAuth();
const { showToast } = useToast();
const [clients, setClients] = useState<OAuthClient[]>([]);
const [scopes, setScopes] = useState<OAuthScope[]>([]);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = 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 [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 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 () => {
if (!token) return;
if (!token || !user?.canViewOAuth) return;
setLoading(true);
try {
const [clientsResponse, scopesResponse] = await Promise.all([
apiFetch<{ clients: OAuthClient[] }>('/admin/rbac/oauth-clients', {}, token),
apiFetch<{ scopes: OAuthScope[] }>('/admin/rbac/oauth-scopes', {}, token)
]);
const clientsResponse = await apiFetch<{ clients: OAuthClient[] }>('/admin/rbac/oauth-clients', {}, token);
setClients(clientsResponse.clients ?? []);
if (user.canManageOAuth) {
const scopesResponse = await apiFetch<{ scopes: OAuthScope[] }>('/admin/rbac/oauth-scopes', {}, token);
setScopes(scopesResponse.scopes ?? []);
}
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось загрузить OAuth-приложения');
} finally {
setLoading(false);
}
}, [showToast, token]);
}, [showToast, token, user?.canManageOAuth, user?.canViewOAuth]);
useEffect(() => {
void loadPublicSettings();
}, [loadPublicSettings]);
useEffect(() => {
if (!user) return;
if (!user.canViewOAuth && !user.canManageOAuth) {
router.replace(getAdminLandingPath(user));
return;
}
void loadData();
}, [loadData]);
}, [loadData, router, user]);
async function handleCreate() {
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) {
if (!token) return;
try {
@@ -98,12 +156,26 @@ export default function AdminOAuthPage() {
body: JSON.stringify({ isActive: !client.isActive })
}, token);
showToast(client.isActive ? 'Приложение отключено' : 'Приложение включено');
setSelectedClient((current) => (current?.id === client.id ? { ...client, isActive: !client.isActive } : current));
await loadData();
} catch (error) {
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) {
void navigator.clipboard.writeText(value);
showToast(`${label} скопирован`);
@@ -114,9 +186,25 @@ export default function AdminOAuthPage() {
<div className="mb-6 flex items-center justify-between gap-4">
<div>
<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>
<Button onClick={() => setDialogOpen(true)}>
<Button onClick={() => setDialogOpen(true)} disabled={!user?.canManageOAuth}>
<Plus className="h-4 w-4" />
Новое приложение
</Button>
@@ -129,56 +217,60 @@ export default function AdminOAuthPage() {
</div>
) : (
<div className="grid gap-4 md:grid-cols-2">
{clients.map((client) => (
<Card key={client.id} className={client.isActive ? '' : 'opacity-70'}>
{clients.map((client) => {
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>
<LockKeyhole className="h-6 w-6" />
<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>
<CardContent className="space-y-3">
<div className="rounded-2xl bg-[#f4f5f8] p-4 text-sm">
<div className="mb-2 flex items-center justify-between gap-2">
<span className="font-medium">Client ID</span>
<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 className="mb-1 font-medium">Authorization URL</div>
<code className="line-clamp-2 break-all text-xs text-[#667085]">{authorizePreview}</code>
</div>
<p className="text-xs text-[#667085]">Нажмите на карточку все endpoints и данные для интеграции</p>
</CardContent>
</Card>
))}
);
})}
{!clients.length ? <p className="text-[#667085]">OAuth-приложения ещё не созданы</p> : null}
</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}>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>

View File

@@ -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() {
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>
);
}

View File

@@ -1,7 +1,7 @@
'use client';
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 { useAuth } from '@/components/id/auth-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 { 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() {
const { token, user } = useAuth();
const { showToast } = useToast();
const [roles, setRoles] = useState<AdminRole[]>([]);
const [permissions, setPermissions] = useState<AdminPermission[]>([]);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const [saving, setSaving] = 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(() => {
if (!token || !user?.canManageRoles) return;
@@ -35,30 +51,74 @@ export default function AdminRbacPage() {
.finally(() => setLoading(false));
}, [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;
setCreating(true);
setSaving(true);
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', {
method: 'POST',
body: JSON.stringify(form)
}, token);
showToast('Роль создана');
}
setDialogOpen(false);
setForm({ slug: '', name: '', description: '', permissionSlugs: [] });
const response = await apiFetch<{ roles: AdminRole[] }>('/admin/rbac/roles', {}, token);
setRoles(response.roles ?? []);
setForm(emptyForm);
setEditingRole(null);
await reloadRoles();
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось создать роль');
showToast(error instanceof Error ? error.message : 'Не удалось сохранить роль');
} 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) {
return (
<AdminShell active="/admin/rbac">
<p className="text-[#667085]">Управление ролями доступно только супер-администратору.</p>
<p className="text-[#667085]">Управление ролями доступно пользователям с правом rbac.manage.</p>
</AdminShell>
);
}
@@ -68,9 +128,9 @@ export default function AdminRbacPage() {
<div className="mb-6 flex items-center justify-between gap-4">
<div>
<h2 className="text-2xl font-medium">Роли и права</h2>
<p className="text-sm text-[#667085]">Только супер-администратор может создавать роли и назначать их пользователям</p>
<p className="text-sm text-[#667085]">Редактируйте роли, назначайте права всем пользователям или отдельным аккаунтам</p>
</div>
<Button onClick={() => setDialogOpen(true)}>
<Button onClick={openCreateDialog}>
<Plus className="h-4 w-4" />
Создать роль
</Button>
@@ -86,17 +146,37 @@ export default function AdminRbacPage() {
{roles.map((role) => (
<Card key={role.id} className="bg-[#f8f9fb] shadow-none">
<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>
<CardDescription>{role.permissions.length} прав · {role.slug}</CardDescription>
<CardDescription>
{role.permissions.length} прав · {role.slug}
{role.isSystem ? ' · системная' : ''}
{role.isDefault ? ' · по умолчанию' : ''}
</CardDescription>
</CardHeader>
<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 className="font-medium">{permission.name}</div>
<div className="text-xs text-[#667085]">{permission.slug}</div>
</div>
))}
))
) : (
<p className="text-sm text-[#667085]">Права не назначены</p>
)}
</CardContent>
</Card>
))}
@@ -106,10 +186,14 @@ export default function AdminRbacPage() {
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Новая роль</DialogTitle>
<DialogTitle>{editingRole ? `Редактирование: ${editingRole.name}` : 'Новая роль'}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{!editingRole ? (
<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.description} onChange={(event) => setForm((current) => ({ ...current, description: event.target.value }))} placeholder="Описание" />
<div className="space-y-2">
@@ -132,8 +216,8 @@ export default function AdminRbacPage() {
</label>
))}
</div>
<Button className="w-full" disabled={creating} onClick={() => void handleCreateRole()}>
{creating ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Создать роль'}
<Button className="w-full" disabled={saving || !form.name.trim() || (!editingRole && !form.slug.trim())} onClick={() => void handleSaveRole()}>
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : editingRole ? 'Сохранить изменения' : 'Создать роль'}
</Button>
</div>
</DialogContent>

View 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>
);
}

View File

@@ -1,19 +1,41 @@
'use client';
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 { AdminShell } from '@/components/id/admin-shell';
import { usePublicSettings } from '@/components/id/public-settings-provider';
import { useToast } from '@/components/id/toast-provider';
import { Button } from '@/components/ui/button';
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 { 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) {
return ['true', '1', 'yes'].includes(value.trim().toLowerCase());
}
const GROUP_LABELS: Record<string, string> = {
...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() {
const { token, user } = useAuth();
@@ -22,7 +44,8 @@ export default function AdminSettingsPage() {
const [settings, setSettings] = useState<SystemSetting[]>([]);
const [providers, setProviders] = useState<SocialProvider[]>([]);
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(() => {
if (!token || !user?.canManageSettings) return;
@@ -41,6 +64,8 @@ export default function AdminSettingsPage() {
const groupedSettings = useMemo(() => {
const groups = new Map<string, SystemSetting[]>();
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 group = meta?.group ?? 'other';
const list = groups.get(group) ?? [];
@@ -50,10 +75,16 @@ export default function AdminSettingsPage() {
return groups;
}, [settings]);
async function saveSetting(setting: SystemSetting) {
async function saveSettingsGroup(groupKey: string, groupSettings: SystemSetting[]) {
if (!token) return;
setSavingKey(setting.key);
const visibleSettings = groupSettings.filter((setting) => isSettingVisible(setting, settings));
if (visibleSettings.length === 0) return;
setSavingGroup(groupKey);
try {
let needsPublicRefresh = false;
for (const setting of visibleSettings) {
const updated = await apiFetch<SystemSetting>(
'/admin/settings',
{
@@ -63,14 +94,18 @@ export default function AdminSettingsPage() {
token
);
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();
}
showToast('Настройка сохранена');
showToast(`Блок «${GROUP_LABELS[groupKey] ?? groupKey}» сохранён`);
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось сохранить настройку');
showToast(error instanceof Error ? error.message : 'Не удалось сохранить настройки блока');
} finally {
setSavingKey(null);
setSavingGroup(null);
}
}
@@ -89,10 +124,14 @@ export default function AdminSettingsPage() {
async function toggleProvider(provider: SocialProvider) {
if (!token) return;
try {
const updated = await apiFetch<SocialProvider>('/admin/settings/oauth/providers', {
const updated = await apiFetch<SocialProvider>(
'/admin/settings/oauth/providers',
{
method: 'PUT',
body: JSON.stringify({ ...toProviderPayload(provider), isEnabled: !provider.isEnabled })
}, token);
},
token
);
setProviders((current) => current.map((item) => (item.providerName === updated.providerName ? updated : item)));
showToast(updated.isEnabled ? 'Провайдер включён' : 'Провайдер отключён');
} 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) {
return (
<AdminShell active="/admin/settings">
@@ -112,7 +167,9 @@ export default function AdminSettingsPage() {
<AdminShell active="/admin/settings">
<div className="mb-6">
<h2 className="text-2xl font-medium">Глобальные настройки</h2>
<p className="mt-2 text-[#667085]">Параметры хранятся в SystemSetting и применяются сервисами без релиза frontend.</p>
<p className="mt-2 text-[#667085]">
Параметры сгруппированы по блокам измените нужные поля и сохраните весь блок одной кнопкой.
</p>
</div>
{loading ? (
@@ -122,91 +179,110 @@ export default function AdminSettingsPage() {
</div>
) : (
<>
{[...groupedSettings.entries()].map(([groupKey, groupSettings]) => (
<section key={groupKey} className="mb-10">
<h3 className="mb-4 text-xl font-medium">{SYSTEM_SETTING_GROUPS[groupKey] ?? 'Прочие настройки'}</h3>
<div className="space-y-3">
{groupSettings.map((setting) => {
const meta = getSettingMeta(setting.key);
const label = meta?.label ?? setting.key;
const type = meta?.type ?? 'text';
{[...groupedSettings.entries()].map(([groupKey, groupSettings]) => {
const visibleSettings = groupSettings.filter((setting) => isSettingVisible(setting, settings));
const saving = savingGroup === groupKey;
return (
<div key={setting.key} className="rounded-[24px] bg-[#f4f5f8] p-5">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 font-semibold">
<Settings2 className="h-4 w-4 shrink-0" />
{label}
</div>
<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');
}}
<section key={groupKey} className="mb-10">
<div className="rounded-[24px] bg-[#f4f5f8] p-5">
<div className="mb-4 flex flex-col gap-3 border-b border-[#e0e3ea] pb-4 md:flex-row md:items-center md:justify-between">
<h3 className="text-xl font-medium">{SYSTEM_SETTING_GROUPS[groupKey] ?? 'Прочие настройки'}</h3>
<Button
disabled={saving || visibleSettings.length === 0}
onClick={() => void saveSettingsGroup(groupKey, groupSettings)}
>
{parseBoolean(setting.value) ? <ToggleRight className="h-9 w-9 text-green-600" /> : <ToggleLeft className="h-9 w-9 text-[#a8adbc]" />}
</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" />}
Сохранить
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
Сохранить блок
</Button>
</div>
<div>
{visibleSettings.map((setting) => (
<SettingsFieldRow
key={setting.key}
setting={setting}
onChange={(value) => updateSettingValue(setting.key, value)}
/>
))}
</div>
</div>
);
})}
</div>
</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">
<h3 className="text-xl font-medium">OAuth Social Providers</h3>
<div className="mt-4 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) => (
<h3 className="mb-4 text-xl font-medium">OAuth Social Providers</h3>
<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) => (
<div key={provider.providerName} className="rounded-[24px] bg-[#f4f5f8] p-5">
<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)}>
{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>
</div>
<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
className="mt-4 bg-white"
value={provider.clientId}
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
className="mt-3"
variant="secondary"
onClick={() =>
void apiFetch('/admin/settings/oauth/providers', { method: 'PUT', body: JSON.stringify(toProviderPayload(provider)) }, token)
.then(() => showToast('Провайдер сохранён'))
.catch((error) => showToast(error instanceof Error ? error.message : 'Ошибка сохранения'))
}
disabled={savingOAuthProvider === provider.providerName}
onClick={() => void saveOAuthProvider(provider)}
>
Сохранить провайдера
{savingOAuthProvider === provider.providerName ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Save className="h-4 w-4" />
)}
Сохранить блок
</Button>
</div>
))}

View File

@@ -1,16 +1,27 @@
'use client';
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 { 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 {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { AdminRole, AdminUser, apiFetch } from '@/lib/api';
import { Table, TableBody, TableCell, TableContainer, TableHead, TableHeader, TableRow } from '@/components/ui/table';
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> = {
ACTIVE: 'Активен',
@@ -19,24 +30,139 @@ const statusLabels: Record<string, string> = {
};
const roleLabels: Record<string, string> = {
user: 'Пользователь',
bot: 'Бот',
admin: 'Администратор',
moderator: 'Модератор',
manager: 'Менеджер',
'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() {
const router = useRouter();
const { token, user: currentUser } = useAuth();
const { showToast } = useToast();
const [users, setUsers] = useState<AdminUser[]>([]);
const [roles, setRoles] = useState<AdminRole[]>([]);
const [permissions, setPermissions] = useState<AdminPermission[]>([]);
const [search, setSearch] = useState('');
const [loading, setLoading] = useState(true);
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null);
const [password, setPassword] = useState('');
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 [inspectorUser, setInspectorUser] = useState<AdminUser | null>(null);
const [verificationIcon, setVerificationIcon] = useState(DEFAULT_VERIFICATION_ICON);
const loadUsers = useCallback(async () => {
if (!token) return;
@@ -45,24 +171,47 @@ export default function AdminUsersPage() {
const response = await apiFetch<{ users: AdminUser[] }>(`/admin/users${search ? `?search=${encodeURIComponent(search)}` : ''}`, {}, token);
setUsers(response.users ?? []);
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось загрузить пользователей');
const message = getApiErrorMessage(error, 'Не удалось загрузить пользователей');
if (message) showToast(message);
} finally {
setLoading(false);
}
}, [search, showToast, token]);
useEffect(() => {
if (!currentUser) return;
if (!currentUser.canViewUsers && !currentUser.canManageUsers) {
router.replace(getAdminLandingPath(currentUser));
}
}, [currentUser, router]);
useEffect(() => {
if (!currentUser?.canViewUsers && !currentUser?.canManageUsers) return;
void loadUsers();
}, [loadUsers]);
}, [currentUser?.canManageUsers, currentUser?.canViewUsers, loadUsers]);
useEffect(() => {
if (!token || !currentUser?.canManageRoles) return;
apiFetch<{ roles: AdminRole[] }>('/admin/rbac/roles', {}, token)
.then((response) => setRoles(response.roles ?? []))
Promise.all([
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);
}, [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) {
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() {
if (!token || !selectedUser || password.length < 8) return;
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) {
if (user.isBot) {
return roleLabels.bot;
}
const userRoles = user.roles ?? [];
const labels = user.isSuperAdmin ? ['Супер-админ', ...userRoles.map((role) => roleLabels[role] ?? role)] : userRoles.map((role) => roleLabels[role] ?? role);
if (!labels.length) return 'Пользователь';
return labels.join(', ');
const extraRoles = userRoles.filter((role) => role !== DEFAULT_USER_ROLE && role !== 'bot');
const direct = user.directPermissions ?? [];
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 (
@@ -163,7 +440,7 @@ export default function AdminUsersPage() {
</div>
</div>
<div className="overflow-hidden rounded-[24px] border border-[#eceef4] bg-white">
<TableContainer className="rounded-[24px] border border-[#eceef4] bg-white">
{loading ? (
<div className="flex items-center justify-center gap-2 py-16 text-[#667085]">
<Loader2 className="h-5 w-5 animate-spin" />
@@ -182,9 +459,19 @@ export default function AdminUsersPage() {
</TableHeader>
<TableBody>
{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>
<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>
</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>
</TableCell>
<TableCell>
<div className="flex justify-end gap-2">
{currentUser?.canManageUsers ? (
<>
<Button
variant="secondary"
size="icon"
aria-label="Сбросить пароль"
disabled={actionLoading}
onClick={() => {
<div className="flex justify-end" onClick={(event) => event.stopPropagation()}>
<UserActionsMenu
user={user}
currentUser={currentUser}
actionLoading={actionLoading}
onOpenInspector={() => setInspectorUser(user)}
onResetPassword={() => {
setSelectedUser(user);
setPassword('');
setDialog('password');
}}
>
<KeyRound className="h-4 w-4" />
</Button>
<Button variant="secondary" size="icon" aria-label="Заблокировать" disabled={actionLoading || user.status === 'SUSPENDED'} onClick={() => void handleSuspend(user)}>
<Ban className="h-4 w-4" />
</Button>
</>
) : 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={() => {
onSuspend={() => void handleSuspend(user)}
onUnsuspend={() => void handleUnsuspend(user)}
onOpenDocuments={() => setDocumentsUser(user)}
onDisableTotp={() => void handleAdminDisableTotp(user)}
onOpenVerification={() => openVerificationDialog(user)}
onOpenRoles={() => {
setSelectedUser(user);
setDialog('roles');
}}
>
<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}
onToggleSuperAdmin={() => void handleToggleSuperAdmin(user)}
/>
</div>
</TableCell>
</TableRow>
@@ -257,7 +509,7 @@ export default function AdminUsersPage() {
</TableBody>
</Table>
)}
</div>
</TableContainer>
<Dialog open={dialog === 'password'} onOpenChange={(open) => !open && setDialog(null)}>
<DialogContent>
@@ -281,10 +533,14 @@ export default function AdminUsersPage() {
<div className="space-y-2">
{(selectedUser?.roles ?? []).map((role) => (
<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>
)}
</div>
))}
{!(selectedUser?.roles ?? []).length ? <p className="text-sm text-[#667085]">Роли не назначены</p> : null}
@@ -304,6 +560,80 @@ export default function AdminUsersPage() {
</Button>
))}
</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>
</Dialog>
@@ -318,6 +648,16 @@ export default function AdminUsersPage() {
token={token}
/>
) : null}
<UserInspectorDialog
user={inspectorUser}
token={token}
canModerateChats={Boolean(currentUser?.canModerateChats || currentUser?.isSuperAdmin)}
open={Boolean(inspectorUser)}
onOpenChange={(open) => {
if (!open) setInspectorUser(null);
}}
/>
</AdminShell>
);
}

File diff suppressed because it is too large Load Diff

View 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>
);
}

View File

@@ -3,33 +3,61 @@
import Link from 'next/link';
import { FormEvent, useState } from 'react';
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 { 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 { 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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
type Step = 'contact' | 'otp' | 'pin';
export default function RegisterPage() {
const router = useRouter();
const { sendLoginOtp } = useAuth();
const { sendLoginOtp, verifyLoginOtp, completePin, logout } = useAuth();
const { showToast } = useToast();
const [authTab, setAuthTab] = useState<'email' | 'phone'>('email');
const [email, setEmail] = useState('');
const [phoneNumber, setPhoneNumber] = useState('');
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);
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();
setIsSubmitting(true);
try {
const recipient = authTab === 'email' ? email.trim() : toE164(country, phoneNumber);
await sendLoginOtp(recipient);
showToast('Код отправлен. Подтвердите его на экране входа.');
router.push('/auth/login');
const identifier = getIdentifier();
setRecipient(identifier);
const response = await sendLoginOtp(identifier);
setMaskedTarget(response.maskedTarget);
setOtp('');
setStep('otp');
showToast('Код отправлен. Введите его ниже.');
} catch (error) {
showToast(error instanceof Error ? error.message : 'Не удалось отправить код');
} 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 (
<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">
<div className="flex flex-col items-center text-center">
<BrandLogo size="lg" variant="light" />
<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>
<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">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="email">Почта</TabsTrigger>
@@ -69,9 +147,62 @@ export default function RegisterPage() {
{isSubmitting ? 'Отправляем...' : 'Получить код'}
</Button>
</form>
) : null}
{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 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]">
<Link href="/auth/login">У меня уже есть ID</Link>
</Button>
) : null}
</section>
</main>
);

View 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>
);
}

View File

@@ -2,9 +2,10 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
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 { 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 { useAuth } from '@/components/id/auth-provider';
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 { AvatarDisplay, AvatarUpload } from '@/components/id/avatar-upload';
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 {
getDocumentType,
indexDocumentsByType,
parseAttachmentMeta,
parseDocumentPhotos,
parseMetadata,
type DocumentTypeCode
} 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({
icon: Icon,
@@ -64,43 +80,80 @@ function Row({
export default function DataPage() {
const router = useRouter();
const { user, token, refreshProfile, logout } = useAuth();
const { user, token, applyUserPatch } = useAuth();
const { isReady, isPinLocked } = useRequireAuth();
const { showToast } = useToast();
const userId = user?.id;
const [displayName, setDisplayName] = useState('');
const [email, setEmail] = useState('');
const [backupEmail, setBackupEmail] = useState('');
const [phone, setPhone] = 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 [isSaving, setIsSaving] = useState(false);
const [activeDocumentType, setActiveDocumentType] = useState<DocumentTypeCode | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [deletionStatus, setDeletionStatus] = useState<AccountDeletionStatus | null>(null);
const [cancellingDeletion, setCancellingDeletion] = useState(false);
const loadDocuments = useCallback(async () => {
if (!user || !token || isPinLocked) return;
if (!userId || !token || isPinLocked) return;
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 ?? []);
} catch (error) {
const message = getApiErrorMessage(error, 'Не удалось загрузить документы');
if (message) showToast(message);
}
}, [isPinLocked, showToast, token, user]);
}, [isPinLocked, showToast, token, userId]);
useEffect(() => {
if (!user) return;
setDisplayName(user.displayName);
setEmail(user.email ?? '');
setBackupEmail(user.backupEmail ?? '');
setPhone(user.phone ?? '');
setBackupPhone(user.backupPhone ?? '');
const parsedPhone = parseE164Phone(user.phone ?? '');
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]);
useEffect(() => {
if (isReady && user && !isPinLocked) void loadDocuments();
}, [isPinLocked, isReady, loadDocuments, user]);
if (!isReady || !userId || !token || isPinLocked) return;
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]);
@@ -116,14 +169,20 @@ export default function DataPage() {
try {
await apiFetch(`/profile/users/${user.id}`, {
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);
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 (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 (backupPhone.trim() && backupPhone.trim() !== (user.backupPhone ?? '')) contacts.backupPhone = backupPhone.trim();
if (nextBackupPhone && nextBackupPhone !== (user.backupPhone ?? '')) contacts.backupPhone = nextBackupPhone;
if (Object.keys(contacts).length > 0) {
await apiFetch(`/profile/users/${user.id}/contacts`, {
@@ -132,7 +191,13 @@ export default function DataPage() {
}, 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('Профиль обновлён');
} catch (error) {
const message = getApiErrorMessage(error, 'Не удалось обновить профиль');
@@ -146,23 +211,54 @@ export default function DataPage() {
if (!user || !token) return;
setIsDeleting(true);
try {
await softDeleteProfile(user.id, token);
const response = await requestAccountDeletion(user.id, token);
setDeletionStatus(response);
setDeleteDialogOpen(false);
showToast('Профиль удалён');
logout();
showToast(
response.effectiveAt
? `Удаление запланировано на ${formatDeletionDate(response.effectiveAt)}`
: 'Удаление аккаунта запланировано'
);
} catch (error) {
const message = getApiErrorMessage(error, 'Не удалось удалить профиль');
const message = getApiErrorMessage(error, 'Не удалось запланировать удаление профиля');
if (message) showToast(message);
} finally {
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 (
<IdShell active="/data">
<div className="mb-8 flex items-center gap-4 rounded-[24px] border border-[#20212b] p-4">
{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" />
)}
@@ -177,11 +273,31 @@ export default function DataPage() {
<p className="mt-1 text-sm text-[#667085]">Эти данные помогают быстрее входить в сервисы и восстанавливать доступ.</p>
<div className="mt-5 grid gap-3 sm:grid-cols-2">
<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={backupEmail} onChange={(event) => setBackupEmail(event.target.value)} />
<Input placeholder="Основной телефон" value={phone} onChange={(event) => setPhone(event.target.value)} />
<Input placeholder="Резервный телефон" value={backupPhone} onChange={(event) => setBackupPhone(event.target.value)} />
<PhoneInput
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>
<Button className="mt-4" onClick={updateProfile} disabled={isSaving}>
{isSaving ? 'Сохраняем...' : 'Обновить профиль'}
@@ -208,14 +324,22 @@ export default function DataPage() {
) : (
documents.map((document) => {
const config = getDocumentType(document.type);
const photoKeys = parseDocumentPhotos(parseMetadata(document.metadataJson));
const attachmentMeta = parseAttachmentMeta(parseMetadata(document.metadataJson));
return (
<div key={document.id}>
<Row
key={document.id}
icon={FileText}
title={config?.label ?? document.type}
text={document.number}
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">
<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">
<Row
icon={Trash2}
title="Удалить профиль"
text="Аккаунт будет деактивирован, вход станет невозможен"
text="Аккаунт будет удалён после периода ожидания (настраивается администратором)"
onClick={() => setDeleteDialogOpen(true)}
destructive
/>
</div>
)}
</section>
{activeDocumentType && user ? (
<DocumentFormDialog
<DocumentDialog
open={Boolean(activeDocumentType)}
onOpenChange={(open) => {
if (!open) setActiveDocumentType(null);
@@ -265,9 +422,10 @@ export default function DataPage() {
<DialogTitle>Удалить профиль?</DialogTitle>
</DialogHeader>
<p className="text-sm leading-relaxed text-[#667085]">
Ваш аккаунт будет помечен как удалённый. Вы сразу выйдете из системы и больше не сможете войти с текущими
данными. Почта, телефон и логин будут освобождены для новой регистрации. Административные роли будут сняты.
Запись в базе сохранится в архивном виде.
Аккаунт не удалится сразу. После подтверждения начнётся период ожидания (по умолчанию 30 дней срок
задаётся в настройках администратора). По истечении срока профиль будет окончательно удалён: контакты и
логин освободятся, семьи и чаты будут удалены или покинууты, сессии завершены. До этого момента удаление
можно отменить на этой странице.
</p>
<div className="mt-6 flex gap-3">
<Button variant="secondary" className="flex-1" onClick={() => setDeleteDialogOpen(false)} disabled={isDeleting}>
@@ -280,7 +438,7 @@ export default function DataPage() {
Удаляем...
</>
) : (
'Удалить профиль'
'Запланировать удаление'
)}
</Button>
</div>

View File

@@ -3,7 +3,8 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
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 { IdShell } from '@/components/id/shell';
import { useToast } from '@/components/id/toast-provider';
@@ -11,6 +12,9 @@ import {
DOCUMENT_CATEGORIES,
DOCUMENT_TYPES,
getDocumentType,
parseAttachmentMeta,
parseDocumentPhotos,
parseMetadata,
QUICK_DOCUMENT_TYPES,
indexDocumentsByType,
type DocumentTypeCode
@@ -52,7 +56,7 @@ export default function DocumentsPage() {
return (
<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>
<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) => {
const Icon = item.icon;
const existing = documentsByType.get(item.type);
const photoKeys = existing ? parseDocumentPhotos(parseMetadata(existing.metadataJson)) : [];
const attachmentMeta = existing ? parseAttachmentMeta(parseMetadata(existing.metadataJson)) : {};
return (
<div key={item.type} className="border-b border-white/70 last:border-b-0">
<button
key={item.type}
type="button"
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)}>
<Icon className="h-5 w-5" />
@@ -109,6 +115,12 @@ export default function DocumentsPage() {
</div>
{existing ? <ChevronRight className="h-5 w-5 text-[#a8adbc]" /> : <Plus className="h-5 w-5 text-[#a8adbc]" />}
</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>
@@ -116,7 +128,7 @@ export default function DocumentsPage() {
))}
{activeType ? (
<DocumentFormDialog
<DocumentDialog
open={Boolean(activeType)}
onOpenChange={(open) => {
if (!open) setActiveType(null);

View 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);
}

View 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);
}

View 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');
}

View 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&amp;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>
);
}

View 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);
}

View 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);
}

View File

@@ -1,14 +1,26 @@
'use client';
import { use } from 'react';
import { IdShell } from '@/components/id/shell';
import { use, useEffect } from 'react';
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 }> }) {
const { groupId } = use(params);
return (
<IdShell active="/family" wide>
<FamilyGroupView groupId={groupId} />
<IdShell active="/family" fullBleed>
<FamilyGroupPageContent groupId={groupId} />
</IdShell>
);
}

View File

@@ -9,35 +9,44 @@ import { useToast } from '@/components/id/toast-provider';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
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() {
const router = useRouter();
const { user, token } = useAuth();
const { isReady, isPinLocked } = useRequireAuth();
const { user, token, isLoading, isPinLocked } = useAuth();
const { isReady } = useRequireAuth();
const { showToast } = useToast();
const [groups, setGroups] = useState<FamilyGroup[]>([]);
const [name, setName] = useState('Моя семья');
const [name, setName] = useState('');
const [creating, setCreating] = useState(false);
useEffect(() => {
if (!user || !token || isPinLocked) return;
fetchFamilyGroups(user.id, token)
if (user?.displayName) {
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 ?? []))
.catch((error) => {
const message = getApiErrorMessage(error, 'Не удалось загрузить семью');
if (message) showToast(message);
});
}, [isPinLocked, showToast, token, user]);
}, [isLoading, isPinLocked, showToast, token, user]);
async function createGroup() {
if (!user || !token) return;
const accessToken = getAccessToken() ?? token?.trim() ?? null;
if (!user || !accessToken) return;
setCreating(true);
try {
const group = await apiFetch<FamilyGroup>('/family/groups', {
method: 'POST',
body: JSON.stringify({ ownerId: user.id, name })
}, token);
}, accessToken);
router.push(`/family/${group.id}`);
} catch (error) {
const message = getApiErrorMessage(error, 'Не удалось создать семью');
@@ -47,7 +56,7 @@ export default function FamilyPage() {
}
}
if (!isReady) {
if (!isReady || isLoading) {
return (
<IdShell active="/family">
<div className="py-20 text-center text-[#667085]">Загрузка...</div>
@@ -58,12 +67,22 @@ export default function FamilyPage() {
return (
<IdShell active="/family" wide>
<p className="text-sm text-[#667085]">Семья</p>
<h1 className="text-4xl font-medium tracking-tight">Семейный доступ</h1>
<p className="mt-2 text-[#667085]">Приглашайте близких, общайтесь в чатах и управляйте семейной группой.</p>
<h1 className="text-2xl font-medium tracking-tight sm:text-4xl">Семейный доступ</h1>
<p className="mt-2 text-sm text-[#667085] sm:text-base">Приглашайте близких, общайтесь в чатах и управляйте семейной группой.</p>
<div className="mt-8 flex gap-3">
<Input value={name} onChange={(event) => setName(event.target.value)} placeholder="Название семьи" />
<Button onClick={() => void createGroup()} disabled={creating}>
<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="Название семьи"
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" />Создать</>)}
</Button>
</div>

View File

@@ -1,5 +1,6 @@
@import "tailwindcss";
@import "leaflet/dist/leaflet.css";
@import "react-day-picker/style.css";
:root {
--background: #ffffff;
@@ -14,6 +15,29 @@
* {
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 {
@@ -21,6 +45,35 @@ body {
background: var(--background);
color: var(--foreground);
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 {
@@ -40,6 +93,20 @@ input {
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 {
box-shadow: 0 24px 70px rgb(22 26 43 / 12%);
}

View 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

View File

@@ -4,13 +4,18 @@ import './globals.css';
export const metadata: Metadata = {
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 }>) {
return (
<html lang="ru">
<body>
<html lang="ru" suppressHydrationWarning>
<body suppressHydrationWarning>
<Providers>{children}</Providers>
</body>
</html>

View 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&apos;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