This commit is contained in:
sdarbinyan
2026-06-01 00:47:26 +04:00
parent 49f69f6af0
commit 4d8dc6b59c
22 changed files with 1266 additions and 353 deletions

View 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.

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

View File

@@ -1,144 +1,24 @@
bro we need to add another project, under another port. it must be the same as dexar
here are infos:
colors: #FD7300 #3AAA35 #E24B00
1. lavero.store
2. info@lavero.storre
3. logo - public\assets\images\lavero\lavero-logo.png
bro please read carefully.
this must be for all projects.
At first here is the API for only auth process:
https://users.vitanova.network:456/ping
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:
"ՆՇԱԹԵՐԹ
պետական գրանցման
մասին տեղեկությունների
«ԼԱՎԵՐՈ»
Սահմանափակ
պատասխանատվությամբ ընկերություն
Գրանցված է Հայաստանի Հանրապետության
արդարադատության նախարարության իրավաբանական
անձանց պետական ռեգիստրի գործակալության կողմից
Գրանցված է`
Աշխատակից՝
ՀՀ ԱՆ իրավաբանական անձանց պետական
ռեգիստրի գործակալություն
Ինքնաշխատ էլեկտրոնային գրանցում
Գրանցման ամսաթիվ՝
Գրանցման համար՝
ՀՎՀՀ՝
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-ից"
any questions?

View File

@@ -29,6 +29,12 @@
{{ 'auth.loginWithTelegram' | translate }}
</button>
@if (loginUrl()) {
<a class="bot-link" [href]="loginUrl()" target="_blank" rel="noopener noreferrer">
{{ loginUrl() }}
</a>
}
<div class="qr-section">
<p class="qr-hint">{{ 'auth.orScanQr' | translate }}</p>
@@ -57,12 +63,12 @@
</div>
}
@case ('error') {
<div class="qr-container">
<img [src]="'https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=' + encodedQrUrl()"
alt="QR Code"
width="180"
height="180"
loading="lazy" />
<div class="qr-container qr-error" (click)="refreshQr()">
<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 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"/>
</svg>
<span>{{ 'auth.qrError' | translate }}</span>
</div>
}
}

View File

@@ -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 {
margin-top: 20px;
@@ -158,6 +172,26 @@ h2 {
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;
}
}
}
}

View File

@@ -19,10 +19,11 @@ export class TelegramLoginComponent implements OnDestroy {
status = this.authService.status;
loginUrl = signal('');
qrToken = signal('');
webSessionID = signal('');
qrStatus = signal<'loading' | 'ready' | 'expired' | 'error'>('loading');
encodedQrUrl = computed(() => encodeURIComponent(this.loginUrl()));
private readonly pollIntervalMs = 5000;
private pollTimer?: ReturnType<typeof setInterval>;
constructor() {
@@ -45,12 +46,14 @@ export class TelegramLoginComponent implements OnDestroy {
}
openTelegramLogin(): void {
window.open(this.loginUrl(), '_blank');
const url = this.loginUrl();
if (!url) return;
window.open(url, '_blank');
if (!this.pollTimer) {
if (this.qrToken()) {
this.startPolling(this.qrToken());
} else {
this.startSessionPolling();
const webSessionID = this.webSessionID();
if (webSessionID) {
this.startPolling(webSessionID);
}
}
}
@@ -62,43 +65,25 @@ export class TelegramLoginComponent implements OnDestroy {
private initQrLogin(): void {
this.qrStatus.set('loading');
this.authService.createQrToken().subscribe({
this.loginUrl.set('');
this.webSessionID.set('');
this.authService.createWebSession().subscribe({
next: (res) => {
this.loginUrl.set(res.url);
this.qrToken.set(res.token);
this.webSessionID.set(res.webSessionID);
this.qrStatus.set('ready');
this.startPolling(res.token);
this.startPolling(res.webSessionID);
},
error: () => {
this.loginUrl.set(this.authService.getTelegramLoginUrl());
this.qrStatus.set('error');
this.startSessionPolling();
}
});
}
private startSessionPolling(): void {
private startPolling(webSessionID: string): void {
this.stopPolling();
let checks = 0;
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;
if (!webSessionID) return;
let checks = 0;
this.pollTimer = setInterval(() => {
@@ -109,28 +94,18 @@ export class TelegramLoginComponent implements OnDestroy {
return;
}
this.authService.pollQrToken(token).subscribe({
next: (res) => {
switch (res.status) {
case 'confirmed':
this.stopPolling();
if (res.session) {
this.syncCartAndComplete(res.session.sessionId);
} else {
this.authService.onTelegramLoginComplete();
}
break;
case 'expired':
this.stopPolling();
this.qrStatus.set('expired');
break;
this.authService.checkSessionOnce(webSessionID).subscribe({
next: (session) => {
if (session?.active) {
this.stopPolling();
this.syncCartAndComplete(session.sessionId);
}
},
error: () => {
// Network error — keep polling
}
});
}, 3000);
}, this.pollIntervalMs);
}
private syncCartAndComplete(sessionId: string): void {

View File

@@ -206,5 +206,6 @@ export const en: Translations = {
orScanQr: 'Or scan the QR code',
loginNote: 'You will be redirected back after login',
qrExpired: 'QR code expired. Click to refresh',
qrError: 'Could not create login session. Click to retry',
},
};

View File

@@ -206,5 +206,6 @@ export const hy: Translations = {
orScanQr: 'Կամ սքանավորեք QR կոդը',
loginNote: 'Մուտքից հետո դուք կվերաուղղվեք',
qrExpired: 'QR կոդը հնացել է։ Սեղմեք՝ թարմացնելու համար',
qrError: 'Չհաջողվեց ստեղծել մուտքի սեսիա։ Սեղմեք՝ կրկնելու համար',
},
};

View File

@@ -206,5 +206,6 @@ export const ru: Translations = {
orScanQr: 'Или отсканируйте QR-код',
loginNote: 'После входа вы будете перенаправлены обратно',
qrExpired: 'QR-код устарел. Нажмите, чтобы обновить',
qrError: 'Не удалось создать сессию входа. Нажмите, чтобы повторить',
},
};

View File

@@ -204,5 +204,6 @@ export interface Translations {
orScanQr: string;
loginNote: string;
qrExpired: string;
qrError: string;
};
}

View File

@@ -672,7 +672,7 @@ function respond<T>(body: T, delayMs = 150) {
}
// ─── Mock Auth State ───
let mockQrPollCount = 0;
const mockWebSessionChecks = new Map<string, number>();
// ─── The Interceptor ───
@@ -688,38 +688,39 @@ export const mockDataInterceptor: HttpInterceptorFn = (req, next) => {
return respond({ message: 'pong (mock)' });
}
// ── GET /auth/session
if (url.includes('/auth/session') && req.method === 'GET') {
return respond({ active: false }, 100);
// ── POST /users/sessions
if (url.endsWith('/users/sessions') && req.method === 'POST') {
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
if (url.includes('/auth/qr/create') && req.method === 'POST') {
const token = 'mock-qr-token-' + Date.now();
const botUsername = (environment as Record<string, unknown>)['telegramBot'] as string || 'DexarSupport_bot';
mockQrPollCount = 0;
return respond({
token,
url: `https://t.me/${botUsername}?start=qr_${token}`
}, 200);
}
// ── GET /users/sessions/:webSessionID
const userSessionMatch = url.match(/\/users\/sessions\/([^/?]+)$/);
if (userSessionMatch && req.method === 'GET') {
const webSessionID = decodeURIComponent(userSessionMatch[1]);
const checks = (mockWebSessionChecks.get(webSessionID) ?? 0) + 1;
mockWebSessionChecks.set(webSessionID, checks);
// ── GET /auth/qr/poll
if (url.includes('/auth/qr/poll') && req.method === 'GET') {
mockQrPollCount++;
// Simulate confirmed after 3 polls (~9 seconds)
if (mockQrPollCount >= 3) {
if (checks >= 3) {
return respond({
status: 'confirmed',
session: {
sessionId: 'mock-session-' + Date.now(),
active: true,
displayName: 'Telegram User',
expiresAt: new Date(Date.now() + 3600000).toISOString()
}
webSessionID,
status: true,
telegramUserID: 123456,
username: 'telegram_user',
displayName: 'Telegram User',
expiresAt: new Date(Date.now() + 3600000).toISOString()
}, 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)

View File

@@ -7,6 +7,11 @@ export interface AuthSession {
expiresAt: string;
}
export interface WebSessionStart {
webSessionID: string;
url: string;
}
export interface TelegramAuthData {
id: number;
first_name: string;
@@ -17,9 +22,4 @@ export interface TelegramAuthData {
hash: string;
}
export interface QrPollResponse {
status: 'pending' | 'confirmed' | 'expired';
session?: AuthSession;
}
export type AuthStatus = 'unknown' | 'checking' | 'authenticated' | 'expired' | 'unauthenticated';

View File

@@ -169,17 +169,6 @@
</svg>
{{ 'cart.loginWithTelegram' | translate }}
</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>

View File

@@ -802,29 +802,6 @@
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);
}
}
.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;
}
}
}
}
}

View File

@@ -33,7 +33,6 @@ export class CartComponent implements OnDestroy {
private authService = inject(AuthService);
isAuthenticated = this.authService.isAuthenticated;
loginUrl = signal('');
// Swipe state
swipedItemId = signal<number | null>(null);
@@ -69,7 +68,6 @@ export class CartComponent implements OnDestroy {
this.items = this.cartService.items;
this.itemCount = this.cartService.itemCount;
this.totalPrice = this.cartService.totalPrice;
this.loginUrl.set(this.authService.getTelegramLoginUrl());
}
requestLogin(): void {

View File

@@ -1,9 +1,12 @@
import { Injectable, signal, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';
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';
const WEB_SESSION_COOKIE = 'webSessionID';
const WEB_SESSION_COOKIE_MAX_AGE_SECONDS = 60 * 60;
@Injectable({
providedIn: 'root'
})
@@ -24,52 +27,45 @@ export class AuthService {
readonly displayName = computed(() => this.sessionSignal()?.displayName ?? null);
private readonly apiUrl = environment.apiUrl;
private sessionCheckTimer?: ReturnType<typeof setInterval>;
private readonly authApiUrl = environment.authApiUrl;
private sessionCheckTimer?: ReturnType<typeof setTimeout>;
constructor(private http: HttpClient) {
// On init, check existing session via cookie
this.checkSession();
}
/**
* Check current session status with backend.
* The backend reads the session cookie and returns the session info.
*/
/** Check the current webSessionID cookie against the auth backend. */
checkSession(): void {
const webSessionID = this.getStoredWebSessionID();
if (!webSessionID) {
this.clearAuthState('unauthenticated');
return;
}
this.statusSignal.set('checking');
this.http.get<AuthSession>(`${this.apiUrl}/auth/session`, {
withCredentials: true
}).pipe(
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');
this.checkSessionOnce(webSessionID).subscribe(session => {
if (!session?.active) {
this.clearAuthState('unauthenticated');
}
});
}
/** Check session without updating internal state (for polling) */
checkSessionOnce(): Observable<AuthSession | null> {
return this.http.get<AuthSession>(`${this.apiUrl}/auth/session`, {
withCredentials: true
}).pipe(
checkSessionOnce(webSessionID = this.getStoredWebSessionID()): Observable<AuthSession | null> {
if (!webSessionID) {
return of(null);
}
return this.http.get<Record<string, unknown>>(
`${this.authApiUrl}/users/sessions/${encodeURIComponent(webSessionID)}`
).pipe(
map(response => this.normalizeWebSession(response, webSessionID)),
tap(session => {
if (session && session.active) {
this.sessionSignal.set(session);
this.statusSignal.set('authenticated');
this.scheduleSessionRefresh(session.expiresAt);
if (session?.active) {
this.activateSession(session);
}
}),
catchError(() => of(null))
@@ -78,19 +74,19 @@ export class AuthService {
/**
* 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 {
this.checkSession();
this.hideLogin();
if (!this.isAuthenticated()) {
this.checkSession();
}
}
/** 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 callbackUrl = encodeURIComponent(`${this.apiUrl}/auth/telegram/callback`);
return `https://t.me/${botUsername}?start=auth_${callbackUrl}`;
return `https://t.me/${botUsername}?start=${encodeURIComponent(webSessionID)}`;
}
/** Get QR code data URL for Telegram login */
@@ -98,23 +94,22 @@ export class AuthService {
return this.getTelegramLoginUrl();
}
/** Create a one-time QR login token via backend */
createQrToken(): Observable<{ token: string; url: string }> {
return this.http.post<{ token: string; url: string }>(
`${this.apiUrl}/auth/qr/create`,
{},
{ withCredentials: true }
);
}
/** Create a backend web session and return the Telegram start link for it. */
createWebSession(): Observable<WebSessionStart> {
const webSessionID = this.generateGuid();
/** Poll the QR token status (pending → confirmed / expired) */
pollQrToken(token: string): Observable<QrPollResponse> {
return this.http.get<QrPollResponse>(
`${this.apiUrl}/auth/qr/poll`,
{
params: { token },
withCredentials: true,
}
return this.http.post<Record<string, unknown>>(
`${this.authApiUrl}/users/sessions`,
{ webSessionID },
{ headers: { WebSessionID: webSessionID } }
).pipe(
map(response => {
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(): void {
this.http.post(`${this.apiUrl}/auth/logout`, {}, {
withCredentials: true
const webSessionID = this.sessionSignal()?.sessionId || this.getStoredWebSessionID();
if (!webSessionID) {
this.clearAuthState('unauthenticated');
return;
}
this.http.delete(`${this.authApiUrl}/users/sessions/${encodeURIComponent(webSessionID)}`, {
headers: { WebSessionID: webSessionID }
}).pipe(
catchError(() => of(null))
).subscribe(() => {
this.sessionSignal.set(null);
this.statusSignal.set('unauthenticated');
this.clearSessionRefresh();
this.clearAuthState('unauthenticated');
});
}
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 */
private scheduleSessionRefresh(expiresAt: string): void {
this.clearSessionRefresh();
@@ -156,7 +170,9 @@ export class AuthService {
const expiresMs = new Date(expiresAt).getTime();
const nowMs = Date.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.checkSession();
@@ -169,4 +185,176 @@ export class AuthService {
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`;
}
}

View File

@@ -5,12 +5,13 @@ export const environment = {
brandFullName: 'Lavero Store',
theme: 'lavero',
apiUrl: '/api',
authApiUrl: 'https://users.vitanova.network:456',
logo: '/assets/images/lavero/lavero-logo.png',
contactEmail: 'info@lavero.store',
supportEmail: 'info@lavero.store',
domain: 'lovero.store',
telegram: '@laveromarket',
telegramBot: 'laveroSupportbot',
telegramBot: 'myAMLKYCBOT',
phones: {
russia: '+7 916 109 10 32',
support: '+7 916 109 10 32'

View File

@@ -5,12 +5,13 @@ export const environment = {
brandFullName: 'Lavero Store',
theme: 'lavero',
apiUrl: '/api',
authApiUrl: 'https://users.vitanova.network:456',
logo: '/assets/images/lavero/lavero-logo.png',
contactEmail: 'info@lavero.store',
supportEmail: 'info@lavero.store',
domain: 'lovero.store',
telegram: '@laveromarket',
telegramBot: 'laveroSupportbot',
telegramBot: 'myAMLKYCBOT',
phones: {
russia: '+7 916 109 10 32',
support: '+7 916 109 10 32'

View File

@@ -5,12 +5,13 @@ export const environment = {
brandFullName: 'novo Market',
theme: 'novo',
apiUrl: '/api',
authApiUrl: 'https://users.vitanova.network:456',
logo: '/assets/images/novo-logo.svg',
contactEmail: 'info@novo.market',
supportEmail: 'info@novo.market',
domain: 'novo.market',
telegram: '@novomarket',
telegramBot: 'novoSupportbot',
telegramBot: 'myAMLKYCBOT',
phones: {
russia: '+7 916 109 10 32',
support: '+7 916 109 10 32'

View File

@@ -5,12 +5,13 @@ export const environment = {
brandFullName: 'novo Market',
theme: 'novo',
apiUrl: '/api',
authApiUrl: 'https://users.vitanova.network:456',
logo: '/assets/images/novo-logo.svg',
contactEmail: 'info@novo.market',
supportEmail: 'info@novo.market',
domain: 'novo.market',
telegram: '@novomarket',
telegramBot: 'novoSupportbot',
telegramBot: 'myAMLKYCBOT',
phones: {
russia: '+7 916 109 10 32',
support: '+7 916 109 10 32'

View File

@@ -5,12 +5,13 @@ export const environment = {
brandFullName: 'Dexar Market',
theme: 'dexar',
apiUrl: 'https://api.dexarmarket.ru:445',
authApiUrl: 'https://users.vitanova.network:456',
logo: '/assets/images/dexar-logo.svg',
contactEmail: 'info@dexarmarket.ru',
supportEmail: 'info@dexarmarket.ru',
domain: 'dexarmarket.ru',
telegram: '@dexarmarket',
telegramBot: 'DexarSupport_bot',
telegramBot: 'myAMLKYCBOT',
phones: {
russia: '+7 (926) 459-31-57',
armenia: '+374 94 86 18 16',

View File

@@ -6,12 +6,13 @@ export const environment = {
brandFullName: 'Dexar Market',
theme: 'dexar',
apiUrl: '/api',
authApiUrl: 'https://users.vitanova.network:456',
logo: '/assets/images/dexar-logo.svg',
contactEmail: 'info@dexarmarket.ru',
supportEmail: 'info@dexarmarket.ru',
domain: 'dexarmarket.ru',
telegram: '@dexarmarket',
telegramBot: 'DexarSupport_bot',
telegramBot: 'myAMLKYCBOT',
phones: {
russia: '+7 (926) 459-31-57',
armenia: '+374 94 86 18 16',