Compare commits
2 Commits
b0a744034b
...
1bec150822
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bec150822 | ||
|
|
4d8dc6b59c |
327
docs/TELEGRAM_USERAUTH_BACKEND.md
Normal file
327
docs/TELEGRAM_USERAUTH_BACKEND.md
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
# Telegram UserAuth Backend Contract
|
||||||
|
|
||||||
|
This document extracts the existing Telegram login flow into a repo-neutral contract for reuse in other projects.
|
||||||
|
|
||||||
|
The UI behavior, payloads, polling cadence, and session model stay the same. Only route names and cookie naming are generalized.
|
||||||
|
|
||||||
|
## Endpoint Renaming
|
||||||
|
|
||||||
|
| Current app contract | Reusable contract |
|
||||||
|
|---|---|
|
||||||
|
| `GET /auth/session` | `GET /userauth/session` |
|
||||||
|
| `POST /auth/qr/create` | `POST /userauth/qr/create` |
|
||||||
|
| `GET /auth/qr/poll?token=...` | `GET /userauth/qr/poll?token=...` |
|
||||||
|
| `POST /auth/qr/confirm` | `POST /userauth/qr/confirm` |
|
||||||
|
| `GET /auth/telegram/callback` | `GET /userauth/telegram/callback` |
|
||||||
|
| `POST /auth/logout` | `POST /userauth/logout` |
|
||||||
|
| `POST /websession/{sessionId}` | `POST /usersession/{sessionId}` |
|
||||||
|
| Cookie `dx_session` | Cookie `userauth_session` |
|
||||||
|
|
||||||
|
## Flow Summary
|
||||||
|
|
||||||
|
There are two supported flows.
|
||||||
|
|
||||||
|
### 1. Direct login from button
|
||||||
|
|
||||||
|
1. Frontend opens `https://t.me/{botUsername}?start=auth_{callbackUrl}`.
|
||||||
|
2. Telegram bot creates a session and sends the user a login button.
|
||||||
|
3. The button points to `GET /userauth/telegram/callback?token={sessionId}`.
|
||||||
|
4. Backend sets `userauth_session` cookie and redirects back to the storefront.
|
||||||
|
5. Frontend calls `GET /userauth/session` and becomes authenticated.
|
||||||
|
|
||||||
|
### 2. QR login from desktop
|
||||||
|
|
||||||
|
1. Frontend opens dialog.
|
||||||
|
2. Frontend calls `POST /userauth/qr/create`.
|
||||||
|
3. Backend returns `{ token, url }` where `url` is a Telegram deep link.
|
||||||
|
4. Frontend renders a QR from that URL.
|
||||||
|
5. User scans QR and bot calls `POST /userauth/qr/confirm`.
|
||||||
|
6. Frontend polls `GET /userauth/qr/poll?token=...` every 3 seconds.
|
||||||
|
7. When status becomes `confirmed`, backend returns session payload and sets the cookie.
|
||||||
|
8. Frontend syncs local cart using `POST /usersession/{sessionId}`.
|
||||||
|
|
||||||
|
## Session Shape
|
||||||
|
|
||||||
|
The frontend expects this exact response shape for the authenticated session.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sessionId": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"telegramUserId": 123456789,
|
||||||
|
"username": "ivan_petrov",
|
||||||
|
"displayName": "Ivan Petrov",
|
||||||
|
"active": true,
|
||||||
|
"expiresAt": "2026-05-21T14:30:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Required | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `sessionId` | string | yes | Session identifier used in cart sync |
|
||||||
|
| `telegramUserId` | number | yes | Telegram user ID |
|
||||||
|
| `username` | string or null | no | Telegram username |
|
||||||
|
| `displayName` | string | yes | User-facing full name |
|
||||||
|
| `active` | boolean | yes | `false` means expired session |
|
||||||
|
| `expiresAt` | ISO 8601 string | yes | Used by frontend refresh scheduling |
|
||||||
|
|
||||||
|
Recommended TTL:
|
||||||
|
|
||||||
|
- Session TTL: 24 hours
|
||||||
|
- QR token TTL: 5 minutes
|
||||||
|
|
||||||
|
## HTTP Contract
|
||||||
|
|
||||||
|
### `POST /userauth/qr/create`
|
||||||
|
|
||||||
|
Creates a one-time QR login token when the dialog opens.
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response `200`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"token": "dG9rZW4tYWJjMTIz",
|
||||||
|
"url": "https://t.me/userauth_bot?start=login_dG9rZW4tYWJjMTIz"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
- Generate a cryptographically secure token.
|
||||||
|
- Save token with status `pending`.
|
||||||
|
- Return a Telegram deep link in `url`.
|
||||||
|
- Rate limit to 5 requests per minute per IP.
|
||||||
|
|
||||||
|
### `GET /userauth/qr/poll?token={token}`
|
||||||
|
|
||||||
|
Called every 3 seconds until confirmation or expiration.
|
||||||
|
|
||||||
|
Possible responses:
|
||||||
|
|
||||||
|
Pending:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "status": "pending" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Confirmed:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "confirmed",
|
||||||
|
"session": {
|
||||||
|
"sessionId": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"telegramUserId": 123456789,
|
||||||
|
"username": "ivan_petrov",
|
||||||
|
"displayName": "Ivan Petrov",
|
||||||
|
"active": true,
|
||||||
|
"expiresAt": "2026-05-21T14:30:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Expired:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "status": "expired" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
- If confirmed, set cookie `userauth_session` in the response.
|
||||||
|
- Delete or invalidate the QR token after the first successful confirmed poll.
|
||||||
|
- If token is unknown or expired, return `status: "expired"`.
|
||||||
|
|
||||||
|
### `POST /userauth/qr/confirm`
|
||||||
|
|
||||||
|
Internal endpoint called by the Telegram bot after the user scans the QR code.
|
||||||
|
|
||||||
|
Required header:
|
||||||
|
|
||||||
|
```text
|
||||||
|
X-Bot-Secret: <shared secret between bot and backend>
|
||||||
|
```
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"token": "dG9rZW4tYWJjMTIz",
|
||||||
|
"telegram_user": {
|
||||||
|
"id": 123456789,
|
||||||
|
"first_name": "Ivan",
|
||||||
|
"last_name": "Petrov",
|
||||||
|
"username": "ivan_petrov"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response `200`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "status": "ok" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
- Validate `X-Bot-Secret`.
|
||||||
|
- Validate token exists and is still `pending`.
|
||||||
|
- Create a user session.
|
||||||
|
- Store session ID on the QR token.
|
||||||
|
- Mark QR token as `confirmed`.
|
||||||
|
|
||||||
|
### `GET /userauth/session`
|
||||||
|
|
||||||
|
Returns the currently active session based on the cookie.
|
||||||
|
|
||||||
|
Frontend behavior depends on this endpoint in two places:
|
||||||
|
|
||||||
|
- initial auth check on app startup
|
||||||
|
- fallback polling if QR token creation fails
|
||||||
|
|
||||||
|
Response `200`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sessionId": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"telegramUserId": 123456789,
|
||||||
|
"username": "ivan_petrov",
|
||||||
|
"displayName": "Ivan Petrov",
|
||||||
|
"active": true,
|
||||||
|
"expiresAt": "2026-05-21T14:30:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Error handling:
|
||||||
|
|
||||||
|
- Any non-200 response is treated by the frontend as unauthenticated.
|
||||||
|
|
||||||
|
### `GET /userauth/telegram/callback?token={sessionId}`
|
||||||
|
|
||||||
|
Used for direct Telegram login from the primary button flow.
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
- Read the `token` query param.
|
||||||
|
- Resolve it to a valid active session.
|
||||||
|
- Set cookie `userauth_session`.
|
||||||
|
- Redirect user to the storefront URL.
|
||||||
|
|
||||||
|
### `POST /userauth/logout`
|
||||||
|
|
||||||
|
Clears the backend session and expires the cookie.
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response `200`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "message": "ok" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### `POST /usersession/{sessionId}`
|
||||||
|
|
||||||
|
Synchronizes local cart immediately after successful login.
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"itemID": 123,
|
||||||
|
"quantity": 2,
|
||||||
|
"colour": "#ff0000",
|
||||||
|
"size": "XL",
|
||||||
|
"price": 1500
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- This payload is unchanged from the existing implementation.
|
||||||
|
- `price` is already discounted on the frontend side.
|
||||||
|
- The frontend skips the call if cart is empty.
|
||||||
|
|
||||||
|
## Telegram Deep Link Format
|
||||||
|
|
||||||
|
Direct login link format:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://t.me/{botUsername}?start=auth_{urlEncodedCallbackUrl}
|
||||||
|
```
|
||||||
|
|
||||||
|
QR login link format:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://t.me/{botUsername}?start=login_{qrToken}
|
||||||
|
```
|
||||||
|
|
||||||
|
Important limit:
|
||||||
|
|
||||||
|
- Telegram limits the `start` payload to 64 characters.
|
||||||
|
- A base64url encoding of 32 random bytes plus `login_` fits safely.
|
||||||
|
|
||||||
|
## Cookie Requirements
|
||||||
|
|
||||||
|
Use these cookie settings for the frontend to work correctly across site and API origins.
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|---|---|
|
||||||
|
| Name | `userauth_session` |
|
||||||
|
| Path | `/` |
|
||||||
|
| HttpOnly | `true` |
|
||||||
|
| Secure | `true` |
|
||||||
|
| SameSite | `None` |
|
||||||
|
| MaxAge | `86400` |
|
||||||
|
| Domain | your shared parent domain, for example `.example.com` |
|
||||||
|
|
||||||
|
## CORS Requirements
|
||||||
|
|
||||||
|
Because the frontend sends credentials, backend must return an explicit origin.
|
||||||
|
|
||||||
|
Required headers:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Access-Control-Allow-Origin: https://your-frontend.example
|
||||||
|
Access-Control-Allow-Credentials: true
|
||||||
|
Access-Control-Allow-Methods: GET, POST, OPTIONS
|
||||||
|
Access-Control-Allow-Headers: Content-Type
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not use `*` for `Access-Control-Allow-Origin` together with credentials.
|
||||||
|
|
||||||
|
## Frontend Runtime Expectations
|
||||||
|
|
||||||
|
The current dialog behavior is fixed and should be preserved by backend responses.
|
||||||
|
|
||||||
|
- QR polling interval: every 3 seconds
|
||||||
|
- QR expiration on frontend: after 100 checks
|
||||||
|
- If QR creation fails, frontend falls back to direct login URL and session polling
|
||||||
|
- After login, frontend closes the dialog and re-checks session
|
||||||
|
|
||||||
|
## Minimal Backend Checklist
|
||||||
|
|
||||||
|
- Implement all six `userauth` endpoints and the `usersession` sync endpoint.
|
||||||
|
- Store sessions for 24 hours.
|
||||||
|
- Store QR tokens for 5 minutes.
|
||||||
|
- Protect `POST /userauth/qr/confirm` with `X-Bot-Secret`.
|
||||||
|
- Set `userauth_session` cookie on confirmed QR poll and direct callback.
|
||||||
|
- Return the exact session JSON shape.
|
||||||
|
- Support credentialed CORS.
|
||||||
|
|
||||||
|
## Bot Checklist
|
||||||
|
|
||||||
|
- Handle `/start login_{token}` and call `POST /userauth/qr/confirm`.
|
||||||
|
- Handle `/start auth_{callbackUrl}` and provide a button that opens the callback URL.
|
||||||
|
- Send success and expiration messages back to the user.
|
||||||
|
- Share the same `X-Bot-Secret` value with backend.
|
||||||
551
docs/telegram-login-dialog.html
Normal file
551
docs/telegram-login-dialog.html
Normal file
@@ -0,0 +1,551 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Telegram Login Dialog</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-page: linear-gradient(135deg, #f4f7fb 0%, #e8eef4 100%);
|
||||||
|
--bg-card: #ffffff;
|
||||||
|
--bg-hover: #f0f0f0;
|
||||||
|
--text-primary: #1a1a1a;
|
||||||
|
--text-secondary: #666666;
|
||||||
|
--accent-color: #497671;
|
||||||
|
--accent-light: rgba(73, 118, 113, 0.1);
|
||||||
|
--telegram: #2aabee;
|
||||||
|
--telegram-hover: #229ed9;
|
||||||
|
--border: #e8e8e8;
|
||||||
|
--shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-page);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(320px, 448px) minmax(320px, 560px);
|
||||||
|
gap: 32px;
|
||||||
|
padding: 40px 32px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: rgba(255, 255, 255, 0.72);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.8);
|
||||||
|
border-radius: 28px;
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: 0 18px 50px rgba(38, 52, 73, 0.12);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info h1 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 32px;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info p {
|
||||||
|
margin: 0 0 18px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-switcher {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 20px 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-switcher button {
|
||||||
|
border: 1px solid #cfd8e3;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-switcher button.active {
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
background: var(--accent-light);
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #eef2f7;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-card strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-card code {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f3f7fb;
|
||||||
|
color: #21425f;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-card p {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-overlay {
|
||||||
|
position: relative;
|
||||||
|
min-height: 700px;
|
||||||
|
border-radius: 28px;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
animation: fadeIn 0.2s ease;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-dialog {
|
||||||
|
position: relative;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 32px 28px;
|
||||||
|
max-width: 400px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
animation: scaleIn 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
background: #e0e0e0;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-icon {
|
||||||
|
margin: 0 auto 16px;
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent-light);
|
||||||
|
color: var(--accent-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-dialog h2 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-desc {
|
||||||
|
margin: 0 0 24px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telegram-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px 24px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--telegram);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telegram-btn:hover {
|
||||||
|
background: var(--telegram-hover);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(42, 171, 238, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.telegram-btn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-section {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-hint {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-container {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 12px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-container img {
|
||||||
|
display: block;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-loading {
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 204px;
|
||||||
|
height: 204px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-loading .spinner,
|
||||||
|
.login-status .spinner {
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-loading .spinner {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 3px solid #e0e0e0;
|
||||||
|
border-top-color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-expired {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 204px;
|
||||||
|
height: 204px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #999;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-expired:hover {
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-expired span {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-note {
|
||||||
|
margin: 16px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-status {
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 16px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-status .spinner {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 2px solid #e0e0e0;
|
||||||
|
border-top-color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-content[data-state="checking"] .login-status {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-content[data-state="checking"] .action-block,
|
||||||
|
.dialog-content[data-state="loading"] .qr-ready,
|
||||||
|
.dialog-content[data-state="loading"] .qr-expired,
|
||||||
|
.dialog-content[data-state="expired"] .qr-ready,
|
||||||
|
.dialog-content[data-state="expired"] .qr-loading,
|
||||||
|
.dialog-content[data-state="error"] .qr-loading,
|
||||||
|
.dialog-content[data-state="error"] .qr-expired,
|
||||||
|
.dialog-content[data-state="checking"] .qr-section,
|
||||||
|
.dialog-content[data-state="checking"] .login-note {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-content[data-state="ready"] .qr-loading,
|
||||||
|
.dialog-content[data-state="ready"] .qr-expired,
|
||||||
|
.dialog-content[data-state="ready"] .qr-error,
|
||||||
|
.dialog-content[data-state="loading"] .qr-ready,
|
||||||
|
.dialog-content[data-state="loading"] .qr-expired,
|
||||||
|
.dialog-content[data-state="loading"] .qr-error,
|
||||||
|
.dialog-content[data-state="expired"] .qr-loading,
|
||||||
|
.dialog-content[data-state="expired"] .qr-ready,
|
||||||
|
.dialog-content[data-state="expired"] .qr-error,
|
||||||
|
.dialog-content[data-state="error"] .qr-loading,
|
||||||
|
.dialog-content[data-state="error"] .qr-expired {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-content[data-state="error"] .qr-ready {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata {
|
||||||
|
margin-top: 22px;
|
||||||
|
padding-top: 18px;
|
||||||
|
border-top: 1px solid #e9edf2;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata ul {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata li + li {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scaleIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.page {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
padding: 24px 16px 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-overlay {
|
||||||
|
min-height: 560px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.panel {
|
||||||
|
border-radius: 22px;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-dialog {
|
||||||
|
padding: 24px 20px;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-container img {
|
||||||
|
width: 140px;
|
||||||
|
height: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-loading,
|
||||||
|
.qr-expired {
|
||||||
|
width: 164px;
|
||||||
|
height: 164px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="page">
|
||||||
|
<section class="panel info">
|
||||||
|
<h1>Telegram Login Dialog</h1>
|
||||||
|
<p>
|
||||||
|
Standalone extraction of the current login popup: same layout, same visual states,
|
||||||
|
same QR flow, but with reusable neutral endpoint names for moving into a separate repo.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="state-switcher" aria-label="Dialog state switcher">
|
||||||
|
<button class="active" data-state-btn="ready" type="button">Ready</button>
|
||||||
|
<button data-state-btn="loading" type="button">QR Loading</button>
|
||||||
|
<button data-state-btn="checking" type="button">Checking</button>
|
||||||
|
<button data-state-btn="expired" type="button">Expired</button>
|
||||||
|
<button data-state-btn="error" type="button">Fallback</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="api-grid">
|
||||||
|
<div class="api-card">
|
||||||
|
<strong>Start QR session</strong>
|
||||||
|
<code>POST /userauth/qr/create</code>
|
||||||
|
<p>Returns a one-time token and Telegram deeplink for the QR image.</p>
|
||||||
|
</div>
|
||||||
|
<div class="api-card">
|
||||||
|
<strong>Poll QR confirmation</strong>
|
||||||
|
<code>GET /userauth/qr/poll?token=...</code>
|
||||||
|
<p>Returns pending, confirmed, or expired. On confirmed, also returns the user session.</p>
|
||||||
|
</div>
|
||||||
|
<div class="api-card">
|
||||||
|
<strong>Read current session</strong>
|
||||||
|
<code>GET /userauth/session</code>
|
||||||
|
<p>Cookie-based session lookup used for initial auth check and fallback polling.</p>
|
||||||
|
</div>
|
||||||
|
<div class="api-card">
|
||||||
|
<strong>Sync cart after login</strong>
|
||||||
|
<code>POST /usersession/{sessionId}</code>
|
||||||
|
<p>Existing cart payload is preserved. Only the namespace is generalized for reuse.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="metadata">
|
||||||
|
<strong>Behavior kept intact</strong>
|
||||||
|
<ul>
|
||||||
|
<li>Open Telegram directly from the primary button.</li>
|
||||||
|
<li>Show QR immediately and poll every 3 seconds.</li>
|
||||||
|
<li>Expire the QR after 100 checks and allow manual refresh.</li>
|
||||||
|
<li>Re-check cookie session if QR creation fails.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="login-overlay">
|
||||||
|
<div class="login-dialog">
|
||||||
|
<button class="close-btn" type="button" aria-label="Close dialog">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M18 6L6 18M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="dialog-content" data-state="ready" id="dialog-content">
|
||||||
|
<div class="login-icon">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Login required</h2>
|
||||||
|
<p class="login-desc">Please log in via Telegram to proceed with your order.</p>
|
||||||
|
|
||||||
|
<div class="login-status checking">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<span>Checking...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-block">
|
||||||
|
<button class="telegram-btn" type="button">
|
||||||
|
<svg class="tg-icon" width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"></path>
|
||||||
|
</svg>
|
||||||
|
Log in with Telegram
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="qr-section">
|
||||||
|
<p class="qr-hint">Or scan the QR code</p>
|
||||||
|
|
||||||
|
<div class="qr-container qr-loading">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="qr-container qr-ready">
|
||||||
|
<img
|
||||||
|
src="https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=https%3A%2F%2Ft.me%2Fuserauth_bot%3Fstart%3Dlogin_sample_token"
|
||||||
|
alt="QR Code"
|
||||||
|
width="180"
|
||||||
|
height="180"
|
||||||
|
loading="eager"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="qr-container qr-expired" role="button" tabindex="0" aria-label="Refresh QR code">
|
||||||
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M1 4v6h6M23 20v-6h-6"></path>
|
||||||
|
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"></path>
|
||||||
|
</svg>
|
||||||
|
<span>QR code expired. Click to refresh</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="login-note">You will be redirected back after login.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const content = document.getElementById('dialog-content');
|
||||||
|
const buttons = document.querySelectorAll('[data-state-btn]');
|
||||||
|
|
||||||
|
buttons.forEach((button) => {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
const state = button.getAttribute('data-state-btn');
|
||||||
|
content.setAttribute('data-state', state);
|
||||||
|
|
||||||
|
buttons.forEach((candidate) => candidate.classList.remove('active'));
|
||||||
|
button.classList.add('active');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,144 +1,24 @@
|
|||||||
bro we need to add another project, under another port. it must be the same as dexar
|
bro please read carefully.
|
||||||
here are infos:
|
this must be for all projects.
|
||||||
colors: #FD7300 #3AAA35 #E24B00
|
At first here is the API for only auth process:
|
||||||
1. lavero.store
|
https://users.vitanova.network:456/ping
|
||||||
2. info@lavero.storre
|
|
||||||
3. logo - public\assets\images\lavero\lavero-logo.png
|
and here are other stuff regarding it:
|
||||||
|
//Logout user by sessionID
|
||||||
|
r.DELETE("/users/sessions/:webSessionID", Logout)
|
||||||
|
|
||||||
|
//creates new session for user and send code for activation
|
||||||
|
r.POST("/users/sessions", newWebSession)
|
||||||
|
|
||||||
|
r.GET("/users/sessions/:webSessionID", getWebSession)
|
||||||
|
|
||||||
|
As you got all the info, keep all the structure of api above.
|
||||||
|
Now when the user clicks on login, we must show the QR and the link of the telegram bot, which we already have (btw sho me, so i see wheter it is true or not)
|
||||||
|
and add a query param "?start=GUID" and generate a guid there.
|
||||||
|
after we post it ad a websession, we have to get it like this " r.GET("/users/sessions/:webSessionID", getWebSession)" every 5 secs untill we get a status true
|
||||||
|
if we will be loged in, we have to keep that webSessionID in the cookies for an hour
|
||||||
|
1. if we open our website, we have to check the cookies and do a request for that websession
|
||||||
|
2. if we are not loged in, then we will loge in one more time
|
||||||
|
|
||||||
|
|
||||||
info for legal:
|
any questions?
|
||||||
"ՆՇԱԹԵՐԹ
|
|
||||||
պետական գրանցման
|
|
||||||
մասին տեղեկությունների
|
|
||||||
«ԼԱՎԵՐՈ»
|
|
||||||
Սահմանափակ
|
|
||||||
պատասխանատվությամբ ընկերություն
|
|
||||||
Գրանցված է Հայաստանի Հանրապետության
|
|
||||||
արդարադատության նախարարության իրավաբանական
|
|
||||||
անձանց պետական ռեգիստրի գործակալության կողմից
|
|
||||||
Գրանցված է`
|
|
||||||
Աշխատակից՝
|
|
||||||
ՀՀ ԱՆ իրավաբանական անձանց պետական
|
|
||||||
ռեգիստրի գործակալություն
|
|
||||||
Ինքնաշխատ էլեկտրոնային գրանցում
|
|
||||||
Գրանցման ամսաթիվ՝
|
|
||||||
Գրանցման համար՝
|
|
||||||
ՀՎՀՀ՝
|
|
||||||
18.05.2026
|
|
||||||
999.110.1583686
|
|
||||||
03590442
|
|
||||||
Պետական գրանցման մասին տեղեկությունների նշաթերթը էլեկտրոնային եղանակով ձևավորված է ՀՀ ԱՆ
|
|
||||||
իրավաբանական անձանց պետական ռեգիստրի տեղեկատվական համակարգի կողմից և հանդիսանում է
|
|
||||||
«ԼԱՎԵՐՈ» Սահմանափակ պատասխանատվությամբ ընկերություն կանոնադրության անբաժանելի մասը։
|
|
||||||
Սույն կանոնադրության գրանցումը հավաստվում է համակարգի կողմից գեներացված RBBE-3D66
|
|
||||||
EF54-3DAE ծածկագրով քաղվածքով, որի իսկությունը և արդիականությունը կարող է ստուգվել
|
|
||||||
փաստաթղթերի վավերականության ստուգման միասնական համակարգում (e-verify.am):
|
|
||||||
Սույն կանոնադրությունը կազմված է 2026-05-18 և բաղկացած է 4 թերթից:
|
|
||||||
Հ Ա Ս Տ Ա Տ Վ Ա Ծ Է
|
|
||||||
«ԼԱՎԵՐՈ»
|
|
||||||
սահմանափակ
|
|
||||||
պատասխանատվությամբ
|
|
||||||
ընկերության հիմնադիրների
|
|
||||||
2026-05-18 կայացրած
|
|
||||||
Հիմնադիր ժողովի որոշմամբ
|
|
||||||
արձանագրություն թիվ` 1
|
|
||||||
տնօրեն` ԳԵՎՈՐԳ ՄԱԹԵՎՈՍՅԱՆ
|
|
||||||
Գ Ր Ա Ն Ց Վ Ա Ծ Է
|
|
||||||
ՀԱՅԱՍՏԱՆԻ ՀԱՆՐԱՊԵՏՈՒԹՅԱՆ
|
|
||||||
ԱՐԴԱՐԱԴԱՏՈՒԹՅԱՆ ՆԱԽԱՐԱՐՈՒԹՅԱՆ
|
|
||||||
ԻՐԱՎԱԲԱՆԱԿԱՆ ԱՆՁԱՆՑ
|
|
||||||
ՊԵՏԱԿԱՆ ՌԵԳԻՍՏՐԻ ԳՈՐԾԱԿԱԼՈՒԹՅԱՆ
|
|
||||||
ԿՈՂՄԻՑ
|
|
||||||
Աշխատակից՝ ՀՀ ԻԱ Պետական ռեգիստրի էլ․
|
|
||||||
համակարգ
|
|
||||||
Գրանցման հ/հ՝ - 999.110.1583686
|
|
||||||
Գրանցման ամսաթիվ՝ 18.05.2026
|
|
||||||
Հարկ վճարողի հաշվառման
|
|
||||||
համար (ՀՎՀՀ)` 03590442
|
|
||||||
«ԼԱՎԵՐՈ»
|
|
||||||
ՍԱՀՄԱՆԱՓԱԿ ՊԱՏԱՍԽԱՆԱՏՎՈՒԹՅԱՄԲ ԸՆԿԵՐՈՒԹՅԱՆ
|
|
||||||
ԿԱՆՈՆԱԴՐՈՒԹՅՈՒՆ
|
|
||||||
2026
|
|
||||||
Էջ 1 4-ից
|
|
||||||
Սույն կանոնադրությունը կազմված է 2026-05-18 և բաղկացած է 4 թերթից:
|
|
||||||
1. ԸՆԴՀԱՆՈՒՐ ԴՐՈՒՅԹՆԵՐ
|
|
||||||
1.1. «ԼԱՎԵՐՈ» սահմանափակ պատասխանատվությամբ ընկերությունը (հետագայում` Ընկերություն)
|
|
||||||
համարվում է շահույթ ստանալու նպատակով հիմնադրված առևտրային կազմակերպություն
|
|
||||||
հանդիսացող իրավաբանական անձ, որի կանոնադրական կապիտալը բաժանված է սույն
|
|
||||||
կանոնադրությամբ սահմանված չափերով բաժնեմասերի:
|
|
||||||
1.2. Ընկերությունն իր գործունեության ընթացքում ղեկավարվում է Հայաստանի Հանրապետության
|
|
||||||
Քաղաքացիական Օրենսդրությամբ, այլ իրավական ակտերով և սույն կանոնադրությամբ:
|
|
||||||
1.3. Ընկերության ֆիրմային անվանումն է`
|
|
||||||
հայերեն լրիվ՝ «ԼԱՎԵՐՈ» սահմանափակ պատասխանատվությամբ ընկերություն
|
|
||||||
կրճատ՝ «ԼԱՎԵՐՈ» ՍՊԸ
|
|
||||||
ռուսերեն լրիվ՝ «ЛАВЕРО» Общество с Ограничеенной Ответственностью
|
|
||||||
կրճատ՝ «ЛАВЕРО» ՕՕՕ
|
|
||||||
անգլերեն լրիվ՝ «LAVERO» LIMITED LIABILITY COMPANY
|
|
||||||
կրճատ՝ «LAVERO» LLC
|
|
||||||
1.4. Ընկերության գտնվելու վայրը և իրավաբանական (փոստային) հասցեն է` ԿՈՏԱՅՔ, ԱԲՈՎՅԱՆ, ՎԵՐԻՆ
|
|
||||||
ՊՏՂՆԻ, 3-րդ փողոց, 28, Տ Փ/Դ՝ 2228:
|
|
||||||
2. ԸՆԿԵՐՈՒԹՅԱՆ ՄԱՍՆԱԿԻՑՆԵՐԸ, ՆՐԱՆՑ ԻՐԱՎՈՒՆՔՆԵՐՆ ՈՒ ՊԱՐՏԱԿԱՆՈՒԹՅՈՒՆՆԵՐԸ
|
|
||||||
2.1. Անձը համարվում է ընկերության մասնակից իրավաբանական անձանց պետական գրանցումն
|
|
||||||
իրականացնող մարմնի կողմից Ընկերության մասնակիցների գրանցամատյանում նրա որպես այդպիսին
|
|
||||||
գրանցվելու պահից:
|
|
||||||
2.2. Ընկերության կանոնադրական կապիտալում բաժնեմասը փոխանցելը.
|
|
||||||
2.2.1.Ընկերության մասնակիցն իրավունք ունի իր բաժնեմասը (դրա մասը) վաճառել կամ այլ` օրենքով
|
|
||||||
չարգելված ձևով, օտարել Ընկերության մեկ կամ մի քանի մասնակիցների, ինչպես նաև երրորդ անձանց:
|
|
||||||
Ընդ որում, մյուս մասնակիցներն ունեն այդ բաժնեմասը նույն գնով ձեռքբերման նախապատվության
|
|
||||||
իրավունք՝ Ընկերության կանոնադրական կապիտալում իրենց բաժնեմասերին համամասնորեն: Եթե
|
|
||||||
Ընկերության մյուս մասնակիցները չեն օգտագործել բաժնեմասը (դրա մասը) գնելու իրենց
|
|
||||||
նախապատվության իրավունքը կամ այն օգտագործել են մասամբ, ապա Ընկերությունն ունի այդ
|
|
||||||
բաժնեմասը նույն գնով ձեռքբերման նախապատվության իրավունք:
|
|
||||||
2.2.2.Երրորդ անձանց օտարման անհնարինության դեպքում մասնակցի պահանջով Ընկերությունն ինքը
|
|
||||||
պարտավոր է ձեռք բերել մասնակցի բաժնեմասը, այն իրացնել մեկ տարվա ընթացքում այլ
|
|
||||||
մասնակիցների կամ երրորդ անձանց կամ որոշում ընդունել բաժնեմասի չբաշխված մասի մարման
|
|
||||||
ճանապարհով Ընկերության կանոնադրական կապիտալի նվազեցման մասին:
|
|
||||||
2.2.3.Նախապատվության իրավունքի (այլ մասնակիցների կողմից վաճառվող բաժնեմասերի ձեռքբերման)
|
|
||||||
իրականացման ժամկետը սահմանվում է մեկ ամիս:
|
|
||||||
2.3. Ընկերությունից դուրս եկող մասնակցի բաժնեմասը դուրս գալու դիմումը ներկայացնելու պահից
|
|
||||||
փոխանցվում է ընկերությանը:
|
|
||||||
2.4. Ընկերության մասնակիցները կարող են տեղեկություններ ստանալ, ընկերության գործունեության մասին
|
|
||||||
գաղտնի փաստաթղթերից բացի, ծանոթանալ հաշվապահական հաշվառման, հաշվետվության,
|
|
||||||
ընկերության արտադրատնտեսական գործունեության այլ փաստաթղթերի հետ:
|
|
||||||
2.5. Ընկերության մասնակիցները պարտավոր են`– Ընկերության մասնակիցների ընդհանուր ժողովի սահմանված կարգի համաձայն կատարել
|
|
||||||
ներդրումներ Ընկերության կանոնադրական կապիտալում:
|
|
||||||
3. ԸՆԿԵՐՈՒԹՅԱՆ ԿԱՆՈՆԱԴՐԱԿԱՆ ԿԱՊԻՏԱԼԸ
|
|
||||||
3.1. Ընկերության կանոնադրական կապիտալը կազմում է 1000 դրամ:
|
|
||||||
Կանոնադրական կապիտալը սահմանում է պարտատերերի շահերը երաշխավորող ընկերության գույքի
|
|
||||||
նվազագույն չափը:
|
|
||||||
Ընկերության 100% բաժնեմասերը տեղաբաշխված են, լրիվ վճարված և պատկանում են սույն
|
|
||||||
կանոնադրության հավելվածում նշված մասնակցին (մասնակիցներին):
|
|
||||||
Էջ 2 4-ից
|
|
||||||
Սույն կանոնադրությունը կազմված է 2026-05-18 և բաղկացած է 4 թերթից:
|
|
||||||
4. ԸՆԿԵՐՈՒԹՅԱՆ ԿԱՌԱՎԱՐՈՒՄԸ
|
|
||||||
4.1. Ընկերության կառավարման մարմիններն են Ընկերության մասնակիցների ընդհանուր ժողովը և
|
|
||||||
Ընկերության գործադիր մարմինը՝ տնօրենը:
|
|
||||||
4.2. Ընկերության կառավարման բարձրագույն մարմինը Ընկերության մասնակիցների ընդհանուր ժողովն է,
|
|
||||||
որն ունի Ընկերության կառավարման և գործունեության ցանկացած հարցի վերջնական լուծման
|
|
||||||
իրավունք:
|
|
||||||
4.3. Ընկերության մասնակիցների հերթական ընդհանուր ժողովը հրավիրում է Ընկերության տնօրենի կողմից
|
|
||||||
տարեկան մեկ անգամ ֆինանսական տարվա ավարտից ոչ շուտ, քան երկու ամիս ոչ ուշ, քան չորս ամիս
|
|
||||||
անց: Ընկերությունը պարտավոր է ամեն տարի գումարել մասնակիցների հերթական ընդհանուր ժողովը`
|
|
||||||
Ընկերության գործունեության տարեկան արդյունքները հաստատելու համար:
|
|
||||||
4.4. Ընկերության ընթացիկ գործունեության ղեկավարումն իրականացնում է տնօրենը, որն ընտրվում է
|
|
||||||
Ընկերության մասնակիցների ընդհանուր ժողովի կողմից:
|
|
||||||
Տնօրենը իրավունք ունի առանց լիազորության գործարքներ կատարել Ընկերության անունից, եթե
|
|
||||||
գործարքի գումարը չի գերազանցում ընկերության զուտ ակտիվների մեծության 25%-ից:
|
|
||||||
Էջ 3 4-ից
|
|
||||||
Սույն կանոնադրությունը կազմված է 2026-05-18 և բաղկացած է 4 թերթից:
|
|
||||||
«ԼԱՎԵՐՈ»
|
|
||||||
ՍԱՀՄԱՆԱՓԱԿ ՊԱՏԱՍԽԱՆԱՏՎՈՒԹՅԱՄԲ ԸՆԿԵՐՈՒԹՅԱՆ
|
|
||||||
մասնակիցների ցուցակ
|
|
||||||
Մասնակցի քաղաքացիությունը, անունը, ազգանունը (պետությունը
|
|
||||||
որտեղ հիմնադրվել է, անվանումը) անձնագրի(գրանցման) տվյալները
|
|
||||||
հասցեն (գտնվելու վայրը)
|
|
||||||
Բաժնեմասի
|
|
||||||
չափը - %
|
|
||||||
Բաժնեմասը
|
|
||||||
ՀՀ դրամով
|
|
||||||
ԳԵՎՈՐԳ ՄԱԹԵՎՈՍՅԱՆ ՀԱՄԼԵՏԻ
|
|
||||||
Քաղաքացիություն՝ ՀԱՅԱՍՏԱՆ
|
|
||||||
ՀԾՀ` 2723060837
|
|
||||||
ՀԱՅԱՍՏԱՆ ԿՈՏԱՅՔ ԱԲՈՎՅԱՆ ՎԵՐԻՆ ՊՏՂՆԻ 3 Փ. Տ 28 2228
|
|
||||||
100.0 % 1000.0
|
|
||||||
Էջ 4 4-ից"
|
|
||||||
|
|||||||
@@ -29,6 +29,12 @@
|
|||||||
{{ 'auth.loginWithTelegram' | translate }}
|
{{ 'auth.loginWithTelegram' | translate }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
@if (loginUrl()) {
|
||||||
|
<a class="bot-link" [href]="loginUrl()" target="_blank" rel="noopener noreferrer">
|
||||||
|
{{ loginUrl() }}
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="qr-section">
|
<div class="qr-section">
|
||||||
<p class="qr-hint">{{ 'auth.orScanQr' | translate }}</p>
|
<p class="qr-hint">{{ 'auth.orScanQr' | translate }}</p>
|
||||||
|
|
||||||
@@ -57,12 +63,12 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@case ('error') {
|
@case ('error') {
|
||||||
<div class="qr-container">
|
<div class="qr-container qr-error" (click)="refreshQr()">
|
||||||
<img [src]="'https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=' + encodedQrUrl()"
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
alt="QR Code"
|
<path d="M1 4v6h6M23 20v-6h-6"/>
|
||||||
width="180"
|
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/>
|
||||||
height="180"
|
</svg>
|
||||||
loading="lazy" />
|
<span>{{ 'auth.qrError' | translate }}</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,6 +102,20 @@ h2 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bot-link {
|
||||||
|
display: block;
|
||||||
|
margin-top: 10px;
|
||||||
|
color: var(--accent-color, #497671);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.35;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.qr-section {
|
.qr-section {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
|
|
||||||
@@ -158,6 +172,26 @@ h2 {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.qr-error {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 204px;
|
||||||
|
height: 204px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary, #999);
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--accent-color, #497671);
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,10 +19,11 @@ export class TelegramLoginComponent implements OnDestroy {
|
|||||||
status = this.authService.status;
|
status = this.authService.status;
|
||||||
|
|
||||||
loginUrl = signal('');
|
loginUrl = signal('');
|
||||||
qrToken = signal('');
|
webSessionID = signal('');
|
||||||
qrStatus = signal<'loading' | 'ready' | 'expired' | 'error'>('loading');
|
qrStatus = signal<'loading' | 'ready' | 'expired' | 'error'>('loading');
|
||||||
encodedQrUrl = computed(() => encodeURIComponent(this.loginUrl()));
|
encodedQrUrl = computed(() => encodeURIComponent(this.loginUrl()));
|
||||||
|
|
||||||
|
private readonly pollIntervalMs = 5000;
|
||||||
private pollTimer?: ReturnType<typeof setInterval>;
|
private pollTimer?: ReturnType<typeof setInterval>;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -45,12 +46,14 @@ export class TelegramLoginComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
openTelegramLogin(): void {
|
openTelegramLogin(): void {
|
||||||
window.open(this.loginUrl(), '_blank');
|
const url = this.loginUrl();
|
||||||
|
if (!url) return;
|
||||||
|
|
||||||
|
window.open(url, '_blank');
|
||||||
if (!this.pollTimer) {
|
if (!this.pollTimer) {
|
||||||
if (this.qrToken()) {
|
const webSessionID = this.webSessionID();
|
||||||
this.startPolling(this.qrToken());
|
if (webSessionID) {
|
||||||
} else {
|
this.startPolling(webSessionID);
|
||||||
this.startSessionPolling();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -62,43 +65,25 @@ export class TelegramLoginComponent implements OnDestroy {
|
|||||||
|
|
||||||
private initQrLogin(): void {
|
private initQrLogin(): void {
|
||||||
this.qrStatus.set('loading');
|
this.qrStatus.set('loading');
|
||||||
this.authService.createQrToken().subscribe({
|
this.loginUrl.set('');
|
||||||
|
this.webSessionID.set('');
|
||||||
|
|
||||||
|
this.authService.createWebSession().subscribe({
|
||||||
next: (res) => {
|
next: (res) => {
|
||||||
this.loginUrl.set(res.url);
|
this.loginUrl.set(res.url);
|
||||||
this.qrToken.set(res.token);
|
this.webSessionID.set(res.webSessionID);
|
||||||
this.qrStatus.set('ready');
|
this.qrStatus.set('ready');
|
||||||
this.startPolling(res.token);
|
this.startPolling(res.webSessionID);
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
this.loginUrl.set(this.authService.getTelegramLoginUrl());
|
|
||||||
this.qrStatus.set('error');
|
this.qrStatus.set('error');
|
||||||
this.startSessionPolling();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private startSessionPolling(): void {
|
private startPolling(webSessionID: string): void {
|
||||||
this.stopPolling();
|
this.stopPolling();
|
||||||
let checks = 0;
|
if (!webSessionID) return;
|
||||||
this.pollTimer = setInterval(() => {
|
|
||||||
checks++;
|
|
||||||
if (checks > 100) {
|
|
||||||
this.stopPolling();
|
|
||||||
this.qrStatus.set('expired');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.authService.checkSessionOnce().subscribe(session => {
|
|
||||||
if (session && session.active) {
|
|
||||||
this.stopPolling();
|
|
||||||
this.syncCartAndComplete(session.sessionId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
|
|
||||||
private startPolling(token: string): void {
|
|
||||||
this.stopPolling();
|
|
||||||
if (!token) return;
|
|
||||||
|
|
||||||
let checks = 0;
|
let checks = 0;
|
||||||
this.pollTimer = setInterval(() => {
|
this.pollTimer = setInterval(() => {
|
||||||
@@ -109,28 +94,18 @@ export class TelegramLoginComponent implements OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.authService.pollQrToken(token).subscribe({
|
this.authService.checkSessionOnce(webSessionID).subscribe({
|
||||||
next: (res) => {
|
next: (session) => {
|
||||||
switch (res.status) {
|
if (session?.active) {
|
||||||
case 'confirmed':
|
this.stopPolling();
|
||||||
this.stopPolling();
|
this.syncCartAndComplete(session.sessionId);
|
||||||
if (res.session) {
|
|
||||||
this.syncCartAndComplete(res.session.sessionId);
|
|
||||||
} else {
|
|
||||||
this.authService.onTelegramLoginComplete();
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'expired':
|
|
||||||
this.stopPolling();
|
|
||||||
this.qrStatus.set('expired');
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
// Network error — keep polling
|
// Network error — keep polling
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, 3000);
|
}, this.pollIntervalMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
private syncCartAndComplete(sessionId: string): void {
|
private syncCartAndComplete(sessionId: string): void {
|
||||||
|
|||||||
@@ -206,5 +206,6 @@ export const en: Translations = {
|
|||||||
orScanQr: 'Or scan the QR code',
|
orScanQr: 'Or scan the QR code',
|
||||||
loginNote: 'You will be redirected back after login',
|
loginNote: 'You will be redirected back after login',
|
||||||
qrExpired: 'QR code expired. Click to refresh',
|
qrExpired: 'QR code expired. Click to refresh',
|
||||||
|
qrError: 'Could not create login session. Click to retry',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -206,5 +206,6 @@ export const hy: Translations = {
|
|||||||
orScanQr: 'Կամ սքանավորեք QR կոդը',
|
orScanQr: 'Կամ սքանավորեք QR կոդը',
|
||||||
loginNote: 'Մուտքից հետո դուք կվերաուղղվեք',
|
loginNote: 'Մուտքից հետո դուք կվերաուղղվեք',
|
||||||
qrExpired: 'QR կոդը հնացել է։ Սեղմեք՝ թարմացնելու համար',
|
qrExpired: 'QR կոդը հնացել է։ Սեղմեք՝ թարմացնելու համար',
|
||||||
|
qrError: 'Չհաջողվեց ստեղծել մուտքի սեսիա։ Սեղմեք՝ կրկնելու համար',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -206,5 +206,6 @@ export const ru: Translations = {
|
|||||||
orScanQr: 'Или отсканируйте QR-код',
|
orScanQr: 'Или отсканируйте QR-код',
|
||||||
loginNote: 'После входа вы будете перенаправлены обратно',
|
loginNote: 'После входа вы будете перенаправлены обратно',
|
||||||
qrExpired: 'QR-код устарел. Нажмите, чтобы обновить',
|
qrExpired: 'QR-код устарел. Нажмите, чтобы обновить',
|
||||||
|
qrError: 'Не удалось создать сессию входа. Нажмите, чтобы повторить',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -204,5 +204,6 @@ export interface Translations {
|
|||||||
orScanQr: string;
|
orScanQr: string;
|
||||||
loginNote: string;
|
loginNote: string;
|
||||||
qrExpired: string;
|
qrExpired: string;
|
||||||
|
qrError: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -672,7 +672,7 @@ function respond<T>(body: T, delayMs = 150) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Mock Auth State ───
|
// ─── Mock Auth State ───
|
||||||
let mockQrPollCount = 0;
|
const mockWebSessionChecks = new Map<string, number>();
|
||||||
|
|
||||||
// ─── The Interceptor ───
|
// ─── The Interceptor ───
|
||||||
|
|
||||||
@@ -688,38 +688,39 @@ export const mockDataInterceptor: HttpInterceptorFn = (req, next) => {
|
|||||||
return respond({ message: 'pong (mock)' });
|
return respond({ message: 'pong (mock)' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── GET /auth/session
|
// ── POST /users/sessions
|
||||||
if (url.includes('/auth/session') && req.method === 'GET') {
|
if (url.endsWith('/users/sessions') && req.method === 'POST') {
|
||||||
return respond({ active: false }, 100);
|
const body = req.body as { webSessionID?: string } | null;
|
||||||
|
const webSessionID = body?.webSessionID || req.headers.get('WebSessionID') || 'mock-web-session';
|
||||||
|
mockWebSessionChecks.set(webSessionID, 0);
|
||||||
|
return respond({ webSessionID, status: false }, 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── POST /auth/qr/create
|
// ── GET /users/sessions/:webSessionID
|
||||||
if (url.includes('/auth/qr/create') && req.method === 'POST') {
|
const userSessionMatch = url.match(/\/users\/sessions\/([^/?]+)$/);
|
||||||
const token = 'mock-qr-token-' + Date.now();
|
if (userSessionMatch && req.method === 'GET') {
|
||||||
const botUsername = (environment as Record<string, unknown>)['telegramBot'] as string || 'DexarSupport_bot';
|
const webSessionID = decodeURIComponent(userSessionMatch[1]);
|
||||||
mockQrPollCount = 0;
|
const checks = (mockWebSessionChecks.get(webSessionID) ?? 0) + 1;
|
||||||
return respond({
|
mockWebSessionChecks.set(webSessionID, checks);
|
||||||
token,
|
|
||||||
url: `https://t.me/${botUsername}?start=qr_${token}`
|
|
||||||
}, 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── GET /auth/qr/poll
|
if (checks >= 3) {
|
||||||
if (url.includes('/auth/qr/poll') && req.method === 'GET') {
|
|
||||||
mockQrPollCount++;
|
|
||||||
// Simulate confirmed after 3 polls (~9 seconds)
|
|
||||||
if (mockQrPollCount >= 3) {
|
|
||||||
return respond({
|
return respond({
|
||||||
status: 'confirmed',
|
webSessionID,
|
||||||
session: {
|
status: true,
|
||||||
sessionId: 'mock-session-' + Date.now(),
|
telegramUserID: 123456,
|
||||||
active: true,
|
username: 'telegram_user',
|
||||||
displayName: 'Telegram User',
|
displayName: 'Telegram User',
|
||||||
expiresAt: new Date(Date.now() + 3600000).toISOString()
|
expiresAt: new Date(Date.now() + 3600000).toISOString()
|
||||||
}
|
|
||||||
}, 200);
|
}, 200);
|
||||||
}
|
}
|
||||||
return respond({ status: 'pending' }, 200);
|
|
||||||
|
return respond({ webSessionID, status: false }, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DELETE /users/sessions/:webSessionID
|
||||||
|
if (userSessionMatch && req.method === 'DELETE') {
|
||||||
|
mockWebSessionChecks.delete(decodeURIComponent(userSessionMatch[1]));
|
||||||
|
return respond({ status: true }, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── GET /category (all categories flat list)
|
// ── GET /category (all categories flat list)
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ export interface AuthSession {
|
|||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WebSessionStart {
|
||||||
|
webSessionID: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TelegramAuthData {
|
export interface TelegramAuthData {
|
||||||
id: number;
|
id: number;
|
||||||
first_name: string;
|
first_name: string;
|
||||||
@@ -17,9 +22,4 @@ export interface TelegramAuthData {
|
|||||||
hash: string;
|
hash: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QrPollResponse {
|
|
||||||
status: 'pending' | 'confirmed' | 'expired';
|
|
||||||
session?: AuthSession;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AuthStatus = 'unknown' | 'checking' | 'authenticated' | 'expired' | 'unauthenticated';
|
export type AuthStatus = 'unknown' | 'checking' | 'authenticated' | 'expired' | 'unauthenticated';
|
||||||
|
|||||||
@@ -169,17 +169,6 @@
|
|||||||
</svg>
|
</svg>
|
||||||
{{ 'cart.loginWithTelegram' | translate }}
|
{{ 'cart.loginWithTelegram' | translate }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="login-gate-qr">
|
|
||||||
<p class="qr-hint">{{ 'cart.orScanQr' | translate }}</p>
|
|
||||||
<div class="qr-wrapper">
|
|
||||||
<img [src]="'https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=' + loginUrl()"
|
|
||||||
alt="QR Code"
|
|
||||||
width="150"
|
|
||||||
height="150"
|
|
||||||
loading="lazy" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -802,29 +802,6 @@
|
|||||||
box-shadow: 0 4px 12px rgba(42, 171, 238, 0.3);
|
box-shadow: 0 4px 12px rgba(42, 171, 238, 0.3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-gate-qr {
|
|
||||||
margin-top: 14px;
|
|
||||||
|
|
||||||
.qr-hint {
|
|
||||||
margin: 0 0 8px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qr-wrapper {
|
|
||||||
display: inline-flex;
|
|
||||||
padding: 10px;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 10px;
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
|
|
||||||
img {
|
|
||||||
display: block;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -967,29 +944,6 @@
|
|||||||
box-shadow: 0 4px 12px rgba(42, 171, 238, 0.3);
|
box-shadow: 0 4px 12px rgba(42, 171, 238, 0.3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-gate-qr {
|
|
||||||
margin-top: 14px;
|
|
||||||
|
|
||||||
.qr-hint {
|
|
||||||
margin: 0 0 8px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: #9ca3af;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qr-wrapper {
|
|
||||||
display: inline-flex;
|
|
||||||
padding: 10px;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
|
|
||||||
img {
|
|
||||||
display: block;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ export class CartComponent implements OnDestroy {
|
|||||||
private authService = inject(AuthService);
|
private authService = inject(AuthService);
|
||||||
|
|
||||||
isAuthenticated = this.authService.isAuthenticated;
|
isAuthenticated = this.authService.isAuthenticated;
|
||||||
loginUrl = signal('');
|
|
||||||
|
|
||||||
// Swipe state
|
// Swipe state
|
||||||
swipedItemId = signal<number | null>(null);
|
swipedItemId = signal<number | null>(null);
|
||||||
@@ -69,7 +68,6 @@ export class CartComponent implements OnDestroy {
|
|||||||
this.items = this.cartService.items;
|
this.items = this.cartService.items;
|
||||||
this.itemCount = this.cartService.itemCount;
|
this.itemCount = this.cartService.itemCount;
|
||||||
this.totalPrice = this.cartService.totalPrice;
|
this.totalPrice = this.cartService.totalPrice;
|
||||||
this.loginUrl.set(this.authService.getTelegramLoginUrl());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
requestLogin(): void {
|
requestLogin(): void {
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { Injectable, signal, computed } from '@angular/core';
|
import { Injectable, signal, computed } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { Observable, of, catchError, map, tap } from 'rxjs';
|
import { Observable, of, catchError, map, tap } from 'rxjs';
|
||||||
import { AuthSession, AuthStatus, QrPollResponse } from '../models/auth.model';
|
import { AuthSession, AuthStatus, WebSessionStart } from '../models/auth.model';
|
||||||
import { environment } from '../../environments/environment';
|
import { environment } from '../../environments/environment';
|
||||||
|
|
||||||
|
const WEB_SESSION_COOKIE = 'webSessionID';
|
||||||
|
const WEB_SESSION_COOKIE_MAX_AGE_SECONDS = 60 * 60;
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
@@ -24,52 +27,45 @@ export class AuthService {
|
|||||||
readonly displayName = computed(() => this.sessionSignal()?.displayName ?? null);
|
readonly displayName = computed(() => this.sessionSignal()?.displayName ?? null);
|
||||||
|
|
||||||
private readonly apiUrl = environment.apiUrl;
|
private readonly apiUrl = environment.apiUrl;
|
||||||
private sessionCheckTimer?: ReturnType<typeof setInterval>;
|
private readonly authApiUrl = environment.authApiUrl;
|
||||||
|
private sessionCheckTimer?: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
constructor(private http: HttpClient) {
|
constructor(private http: HttpClient) {
|
||||||
// On init, check existing session via cookie
|
// On init, check existing session via cookie
|
||||||
this.checkSession();
|
this.checkSession();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Check the current webSessionID cookie against the auth backend. */
|
||||||
* Check current session status with backend.
|
|
||||||
* The backend reads the session cookie and returns the session info.
|
|
||||||
*/
|
|
||||||
checkSession(): void {
|
checkSession(): void {
|
||||||
|
const webSessionID = this.getStoredWebSessionID();
|
||||||
|
|
||||||
|
if (!webSessionID) {
|
||||||
|
this.clearAuthState('unauthenticated');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.statusSignal.set('checking');
|
this.statusSignal.set('checking');
|
||||||
|
|
||||||
this.http.get<AuthSession>(`${this.apiUrl}/auth/session`, {
|
this.checkSessionOnce(webSessionID).subscribe(session => {
|
||||||
withCredentials: true
|
if (!session?.active) {
|
||||||
}).pipe(
|
this.clearAuthState('unauthenticated');
|
||||||
catchError(() => {
|
|
||||||
this.statusSignal.set('unauthenticated');
|
|
||||||
this.sessionSignal.set(null);
|
|
||||||
return of(null);
|
|
||||||
})
|
|
||||||
).subscribe(session => {
|
|
||||||
if (session && session.active) {
|
|
||||||
this.sessionSignal.set(session);
|
|
||||||
this.statusSignal.set('authenticated');
|
|
||||||
this.scheduleSessionRefresh(session.expiresAt);
|
|
||||||
} else if (session && !session.active) {
|
|
||||||
this.sessionSignal.set(null);
|
|
||||||
this.statusSignal.set('expired');
|
|
||||||
} else {
|
|
||||||
this.statusSignal.set('unauthenticated');
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check session without updating internal state (for polling) */
|
/** Check session without updating internal state (for polling) */
|
||||||
checkSessionOnce(): Observable<AuthSession | null> {
|
checkSessionOnce(webSessionID = this.getStoredWebSessionID()): Observable<AuthSession | null> {
|
||||||
return this.http.get<AuthSession>(`${this.apiUrl}/auth/session`, {
|
if (!webSessionID) {
|
||||||
withCredentials: true
|
return of(null);
|
||||||
}).pipe(
|
}
|
||||||
|
|
||||||
|
return this.http.get<Record<string, unknown>>(
|
||||||
|
`${this.authApiUrl}/users/sessions/${encodeURIComponent(webSessionID)}`
|
||||||
|
).pipe(
|
||||||
|
map(response => this.normalizeWebSession(response, webSessionID)),
|
||||||
tap(session => {
|
tap(session => {
|
||||||
if (session && session.active) {
|
if (session?.active) {
|
||||||
this.sessionSignal.set(session);
|
this.activateSession(session);
|
||||||
this.statusSignal.set('authenticated');
|
|
||||||
this.scheduleSessionRefresh(session.expiresAt);
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
catchError(() => of(null))
|
catchError(() => of(null))
|
||||||
@@ -78,19 +74,19 @@ export class AuthService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Called after user completes Telegram login.
|
* Called after user completes Telegram login.
|
||||||
* The callback URL from Telegram will hit our backend which sets the cookie.
|
|
||||||
* Then we re-check the session.
|
|
||||||
*/
|
*/
|
||||||
onTelegramLoginComplete(): void {
|
onTelegramLoginComplete(): void {
|
||||||
this.checkSession();
|
|
||||||
this.hideLogin();
|
this.hideLogin();
|
||||||
|
|
||||||
|
if (!this.isAuthenticated()) {
|
||||||
|
this.checkSession();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Generate the Telegram login URL for bot-based auth */
|
/** Generate the Telegram login URL for bot-based auth */
|
||||||
getTelegramLoginUrl(): string {
|
getTelegramLoginUrl(webSessionID = this.generateGuid()): string {
|
||||||
const botUsername = (environment as Record<string, unknown>)['telegramBot'] as string || 'DexarSupport_bot';
|
const botUsername = (environment as Record<string, unknown>)['telegramBot'] as string || 'DexarSupport_bot';
|
||||||
const callbackUrl = encodeURIComponent(`${this.apiUrl}/auth/telegram/callback`);
|
return `https://t.me/${botUsername}?start=${encodeURIComponent(webSessionID)}`;
|
||||||
return `https://t.me/${botUsername}?start=auth_${callbackUrl}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get QR code data URL for Telegram login */
|
/** Get QR code data URL for Telegram login */
|
||||||
@@ -98,23 +94,22 @@ export class AuthService {
|
|||||||
return this.getTelegramLoginUrl();
|
return this.getTelegramLoginUrl();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create a one-time QR login token via backend */
|
/** Create a backend web session and return the Telegram start link for it. */
|
||||||
createQrToken(): Observable<{ token: string; url: string }> {
|
createWebSession(): Observable<WebSessionStart> {
|
||||||
return this.http.post<{ token: string; url: string }>(
|
const webSessionID = this.generateGuid();
|
||||||
`${this.apiUrl}/auth/qr/create`,
|
|
||||||
{},
|
|
||||||
{ withCredentials: true }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Poll the QR token status (pending → confirmed / expired) */
|
return this.http.post<Record<string, unknown>>(
|
||||||
pollQrToken(token: string): Observable<QrPollResponse> {
|
`${this.authApiUrl}/users/sessions`,
|
||||||
return this.http.get<QrPollResponse>(
|
{ webSessionID },
|
||||||
`${this.apiUrl}/auth/qr/poll`,
|
{ headers: { WebSessionID: webSessionID } }
|
||||||
{
|
).pipe(
|
||||||
params: { token },
|
map(response => {
|
||||||
withCredentials: true,
|
const responseWebSessionID = this.extractSessionId(response, webSessionID);
|
||||||
}
|
return {
|
||||||
|
webSessionID: responseWebSessionID,
|
||||||
|
url: this.getTelegramLoginUrl(responseWebSessionID),
|
||||||
|
};
|
||||||
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,17 +133,36 @@ export class AuthService {
|
|||||||
|
|
||||||
/** Logout — clears session on backend and locally */
|
/** Logout — clears session on backend and locally */
|
||||||
logout(): void {
|
logout(): void {
|
||||||
this.http.post(`${this.apiUrl}/auth/logout`, {}, {
|
const webSessionID = this.sessionSignal()?.sessionId || this.getStoredWebSessionID();
|
||||||
withCredentials: true
|
|
||||||
|
if (!webSessionID) {
|
||||||
|
this.clearAuthState('unauthenticated');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.http.delete(`${this.authApiUrl}/users/sessions/${encodeURIComponent(webSessionID)}`, {
|
||||||
|
headers: { WebSessionID: webSessionID }
|
||||||
}).pipe(
|
}).pipe(
|
||||||
catchError(() => of(null))
|
catchError(() => of(null))
|
||||||
).subscribe(() => {
|
).subscribe(() => {
|
||||||
this.sessionSignal.set(null);
|
this.clearAuthState('unauthenticated');
|
||||||
this.statusSignal.set('unauthenticated');
|
|
||||||
this.clearSessionRefresh();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private activateSession(session: AuthSession): void {
|
||||||
|
this.sessionSignal.set(session);
|
||||||
|
this.statusSignal.set('authenticated');
|
||||||
|
this.setStoredWebSessionID(session.sessionId);
|
||||||
|
this.scheduleSessionRefresh(session.expiresAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearAuthState(status: AuthStatus): void {
|
||||||
|
this.sessionSignal.set(null);
|
||||||
|
this.statusSignal.set(status);
|
||||||
|
this.clearStoredWebSessionID();
|
||||||
|
this.clearSessionRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
/** Schedule a session re-check before it expires */
|
/** Schedule a session re-check before it expires */
|
||||||
private scheduleSessionRefresh(expiresAt: string): void {
|
private scheduleSessionRefresh(expiresAt: string): void {
|
||||||
this.clearSessionRefresh();
|
this.clearSessionRefresh();
|
||||||
@@ -156,7 +170,9 @@ export class AuthService {
|
|||||||
const expiresMs = new Date(expiresAt).getTime();
|
const expiresMs = new Date(expiresAt).getTime();
|
||||||
const nowMs = Date.now();
|
const nowMs = Date.now();
|
||||||
// Re-check 60 seconds before expiry, minimum 30s from now
|
// Re-check 60 seconds before expiry, minimum 30s from now
|
||||||
const refreshIn = Math.max(expiresMs - nowMs - 60_000, 30_000);
|
const refreshIn = Number.isFinite(expiresMs)
|
||||||
|
? Math.max(expiresMs - nowMs - 60_000, 30_000)
|
||||||
|
: WEB_SESSION_COOKIE_MAX_AGE_SECONDS * 1000;
|
||||||
|
|
||||||
this.sessionCheckTimer = setTimeout(() => {
|
this.sessionCheckTimer = setTimeout(() => {
|
||||||
this.checkSession();
|
this.checkSession();
|
||||||
@@ -169,4 +185,176 @@ export class AuthService {
|
|||||||
this.sessionCheckTimer = undefined;
|
this.sessionCheckTimer = undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private normalizeWebSession(response: Record<string, unknown> | null, fallbackSessionId: string): AuthSession | null {
|
||||||
|
if (!response) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = this.asRecord(this.readFirst(response, ['user', 'User', 'telegramUser', 'TelegramUser'])) ?? response;
|
||||||
|
const status = this.readFirst(response, [
|
||||||
|
'status',
|
||||||
|
'Status',
|
||||||
|
'active',
|
||||||
|
'Active',
|
||||||
|
'loggedIn',
|
||||||
|
'LoggedIn',
|
||||||
|
'isLoggedIn',
|
||||||
|
'IsLoggedIn',
|
||||||
|
'authenticated',
|
||||||
|
'Authenticated'
|
||||||
|
]);
|
||||||
|
const active = this.isActiveStatus(status);
|
||||||
|
const sessionId = this.extractSessionId(response, fallbackSessionId);
|
||||||
|
const username = this.readString(this.readFirst(user, ['username', 'Username']))
|
||||||
|
?? this.readString(this.readFirst(response, ['username', 'Username']));
|
||||||
|
const firstName = this.readString(this.readFirst(user, ['firstName', 'first_name', 'FirstName', 'First_name']));
|
||||||
|
const lastName = this.readString(this.readFirst(user, ['lastName', 'last_name', 'LastName', 'Last_name']));
|
||||||
|
const fullName = [firstName, lastName].filter(Boolean).join(' ');
|
||||||
|
const explicitDisplayName = this.readString(this.readFirst(response, ['displayName', 'DisplayName', 'name', 'Name']))
|
||||||
|
?? this.readString(this.readFirst(user, ['displayName', 'DisplayName', 'name', 'Name']))
|
||||||
|
const displayName = explicitDisplayName ?? username ?? (fullName || 'Telegram User');
|
||||||
|
const telegramUserId = this.readNumber(this.readFirst(user, ['telegramUserId', 'telegramUserID', 'TelegramUserID', 'id', 'ID']))
|
||||||
|
?? this.readNumber(this.readFirst(response, ['telegramUserId', 'telegramUserID', 'TelegramUserID', 'userID', 'UserID']))
|
||||||
|
?? 0;
|
||||||
|
const expiresAt = this.readString(this.readFirst(response, ['expiresAt', 'ExpiresAt', 'expires', 'Expires']))
|
||||||
|
?? new Date(Date.now() + WEB_SESSION_COOKIE_MAX_AGE_SECONDS * 1000).toISOString();
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
telegramUserId,
|
||||||
|
username,
|
||||||
|
displayName,
|
||||||
|
active,
|
||||||
|
expiresAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractSessionId(response: Record<string, unknown> | null, fallbackSessionId: string): string {
|
||||||
|
if (!response) {
|
||||||
|
return fallbackSessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.readString(this.readFirst(response, [
|
||||||
|
'webSessionID',
|
||||||
|
'WebSessionID',
|
||||||
|
'webSessionId',
|
||||||
|
'sessionID',
|
||||||
|
'SessionID',
|
||||||
|
'sessionId',
|
||||||
|
'id',
|
||||||
|
'ID'
|
||||||
|
])) ?? fallbackSessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readFirst(source: Record<string, unknown>, keys: string[]): unknown {
|
||||||
|
for (const key of keys) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(source, key)) {
|
||||||
|
return source[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readString(value: unknown): string | null {
|
||||||
|
if (typeof value === 'string' && value.trim()) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'number' || typeof value === 'bigint') {
|
||||||
|
return value.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readNumber(value: unknown): number | null {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
|
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isActiveStatus(status: unknown): boolean {
|
||||||
|
if (status === true || status === 1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof status !== 'string') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['true', '1', 'active', 'authenticated', 'confirmed', 'success', 'logged_in'].includes(status.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateGuid(): string {
|
||||||
|
if (globalThis.crypto?.randomUUID) {
|
||||||
|
return globalThis.crypto.randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = new Uint8Array(16);
|
||||||
|
if (globalThis.crypto?.getRandomValues) {
|
||||||
|
globalThis.crypto.getRandomValues(bytes);
|
||||||
|
} else {
|
||||||
|
for (let index = 0; index < bytes.length; index++) {
|
||||||
|
bytes[index] = Math.floor(Math.random() * 256);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||||
|
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||||
|
|
||||||
|
const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0'));
|
||||||
|
return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex.slice(6, 8).join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10, 16).join('')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getStoredWebSessionID(): string | null {
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookie = document.cookie
|
||||||
|
.split('; ')
|
||||||
|
.find(row => row.startsWith(`${WEB_SESSION_COOKIE}=`));
|
||||||
|
|
||||||
|
if (!cookie) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(cookie.substring(WEB_SESSION_COOKIE.length + 1));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setStoredWebSessionID(webSessionID: string): void {
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const secure = typeof window !== 'undefined' && window.location.protocol === 'https:' ? '; Secure' : '';
|
||||||
|
document.cookie = `${WEB_SESSION_COOKIE}=${encodeURIComponent(webSessionID)}; Max-Age=${WEB_SESSION_COOKIE_MAX_AGE_SECONDS}; Path=/; SameSite=Lax${secure}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearStoredWebSessionID(): void {
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.cookie = `${WEB_SESSION_COOKIE}=; Max-Age=0; Path=/; SameSite=Lax`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ export const environment = {
|
|||||||
brandFullName: 'Lavero Store',
|
brandFullName: 'Lavero Store',
|
||||||
theme: 'lavero',
|
theme: 'lavero',
|
||||||
apiUrl: '/api',
|
apiUrl: '/api',
|
||||||
|
authApiUrl: 'https://users.vitanova.network:456',
|
||||||
logo: '/assets/images/lavero/lavero-logo.png',
|
logo: '/assets/images/lavero/lavero-logo.png',
|
||||||
contactEmail: 'info@lavero.store',
|
contactEmail: 'info@lavero.store',
|
||||||
supportEmail: 'info@lavero.store',
|
supportEmail: 'info@lavero.store',
|
||||||
domain: 'lovero.store',
|
domain: 'lovero.store',
|
||||||
telegram: '@laveromarket',
|
telegram: '@laveromarket',
|
||||||
telegramBot: 'laveroSupportbot',
|
telegramBot: 'myAMLKYCBOT',
|
||||||
phones: {
|
phones: {
|
||||||
russia: '+374 96 601607',
|
russia: '+374 96 601607',
|
||||||
support: '+374 96 601607'
|
support: '+374 96 601607'
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ export const environment = {
|
|||||||
brandFullName: 'Lavero Store',
|
brandFullName: 'Lavero Store',
|
||||||
theme: 'lavero',
|
theme: 'lavero',
|
||||||
apiUrl: '/api',
|
apiUrl: '/api',
|
||||||
|
authApiUrl: 'https://users.vitanova.network:456',
|
||||||
logo: '/assets/images/lavero/lavero-logo.png',
|
logo: '/assets/images/lavero/lavero-logo.png',
|
||||||
contactEmail: 'info@lavero.store',
|
contactEmail: 'info@lavero.store',
|
||||||
supportEmail: 'info@lavero.store',
|
supportEmail: 'info@lavero.store',
|
||||||
domain: 'lovero.store',
|
domain: 'lovero.store',
|
||||||
telegram: '@laveromarket',
|
telegram: '@laveromarket',
|
||||||
telegramBot: 'laveroSupportbot',
|
telegramBot: 'myAMLKYCBOT',
|
||||||
phones: {
|
phones: {
|
||||||
russia: '+374 96 601607',
|
russia: '+374 96 601607',
|
||||||
support: '+374 96 601607'
|
support: '+374 96 601607'
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ export const environment = {
|
|||||||
brandFullName: 'novo Market',
|
brandFullName: 'novo Market',
|
||||||
theme: 'novo',
|
theme: 'novo',
|
||||||
apiUrl: '/api',
|
apiUrl: '/api',
|
||||||
|
authApiUrl: 'https://users.vitanova.network:456',
|
||||||
logo: '/assets/images/novo-logo.svg',
|
logo: '/assets/images/novo-logo.svg',
|
||||||
contactEmail: 'info@novo.market',
|
contactEmail: 'info@novo.market',
|
||||||
supportEmail: 'info@novo.market',
|
supportEmail: 'info@novo.market',
|
||||||
domain: 'novo.market',
|
domain: 'novo.market',
|
||||||
telegram: '@novomarket',
|
telegram: '@novomarket',
|
||||||
telegramBot: 'novoSupportbot',
|
telegramBot: 'myAMLKYCBOT',
|
||||||
phones: {
|
phones: {
|
||||||
russia: '+7 916 109 10 32',
|
russia: '+7 916 109 10 32',
|
||||||
support: '+7 916 109 10 32'
|
support: '+7 916 109 10 32'
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ export const environment = {
|
|||||||
brandFullName: 'novo Market',
|
brandFullName: 'novo Market',
|
||||||
theme: 'novo',
|
theme: 'novo',
|
||||||
apiUrl: '/api',
|
apiUrl: '/api',
|
||||||
|
authApiUrl: 'https://users.vitanova.network:456',
|
||||||
logo: '/assets/images/novo-logo.svg',
|
logo: '/assets/images/novo-logo.svg',
|
||||||
contactEmail: 'info@novo.market',
|
contactEmail: 'info@novo.market',
|
||||||
supportEmail: 'info@novo.market',
|
supportEmail: 'info@novo.market',
|
||||||
domain: 'novo.market',
|
domain: 'novo.market',
|
||||||
telegram: '@novomarket',
|
telegram: '@novomarket',
|
||||||
telegramBot: 'novoSupportbot',
|
telegramBot: 'myAMLKYCBOT',
|
||||||
phones: {
|
phones: {
|
||||||
russia: '+7 916 109 10 32',
|
russia: '+7 916 109 10 32',
|
||||||
support: '+7 916 109 10 32'
|
support: '+7 916 109 10 32'
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ export const environment = {
|
|||||||
brandFullName: 'Dexar Market',
|
brandFullName: 'Dexar Market',
|
||||||
theme: 'dexar',
|
theme: 'dexar',
|
||||||
apiUrl: 'https://api.dexarmarket.ru:445',
|
apiUrl: 'https://api.dexarmarket.ru:445',
|
||||||
|
authApiUrl: 'https://users.vitanova.network:456',
|
||||||
logo: '/assets/images/dexar-logo.svg',
|
logo: '/assets/images/dexar-logo.svg',
|
||||||
contactEmail: 'info@dexarmarket.ru',
|
contactEmail: 'info@dexarmarket.ru',
|
||||||
supportEmail: 'info@dexarmarket.ru',
|
supportEmail: 'info@dexarmarket.ru',
|
||||||
domain: 'dexarmarket.ru',
|
domain: 'dexarmarket.ru',
|
||||||
telegram: '@dexarmarket',
|
telegram: '@dexarmarket',
|
||||||
telegramBot: 'DexarSupport_bot',
|
telegramBot: 'myAMLKYCBOT',
|
||||||
phones: {
|
phones: {
|
||||||
russia: '+7 (926) 459-31-57',
|
russia: '+7 (926) 459-31-57',
|
||||||
armenia: '+374 94 86 18 16',
|
armenia: '+374 94 86 18 16',
|
||||||
|
|||||||
@@ -6,12 +6,13 @@ export const environment = {
|
|||||||
brandFullName: 'Dexar Market',
|
brandFullName: 'Dexar Market',
|
||||||
theme: 'dexar',
|
theme: 'dexar',
|
||||||
apiUrl: '/api',
|
apiUrl: '/api',
|
||||||
|
authApiUrl: 'https://users.vitanova.network:456',
|
||||||
logo: '/assets/images/dexar-logo.svg',
|
logo: '/assets/images/dexar-logo.svg',
|
||||||
contactEmail: 'info@dexarmarket.ru',
|
contactEmail: 'info@dexarmarket.ru',
|
||||||
supportEmail: 'info@dexarmarket.ru',
|
supportEmail: 'info@dexarmarket.ru',
|
||||||
domain: 'dexarmarket.ru',
|
domain: 'dexarmarket.ru',
|
||||||
telegram: '@dexarmarket',
|
telegram: '@dexarmarket',
|
||||||
telegramBot: 'DexarSupport_bot',
|
telegramBot: 'myAMLKYCBOT',
|
||||||
phones: {
|
phones: {
|
||||||
russia: '+7 (926) 459-31-57',
|
russia: '+7 (926) 459-31-57',
|
||||||
armenia: '+374 94 86 18 16',
|
armenia: '+374 94 86 18 16',
|
||||||
|
|||||||
Reference in New Issue
Block a user